package main // route-proxy's tools (issue 774): the web front's command line. Each answers from what the proxy keeps on the bus // (route-proxy_routes and route-proxy_unrouted, written on change by the proxy itself), in one call, for every // machine running a proxy: which sites are served and where they go, whether their certificates are good, what the // last request through each got back, and which names were asked for and are served nowhere. import ( "encoding/json" "fmt" "sort" "strings" "time" stdio "git.novox.be/novox/mesh-sdk/go" "route-proxy/internal/routestate" ) // The tools' names, as the manifest declares them. const ( RoutesTool = "route_proxy_routes" RouteTool = "route_proxy_route" UnroutedTool = "route_proxy_unrouted" ) // reader is one of the module's states, as stdio.KeptState reads it. type reader interface { Keys() ([]string, error) Get(key string) (*stdio.StateEntry, error) } // front is what the tools read: the routes and the refusals, and the clock a certificate's days left are counted by. type front struct { routes, unrouted reader now func() time.Time } // RouteLine is one served name in the list: where it goes, how long its certificate holds, what it last answered. type RouteLine struct { Name string `json:"name"` Proxy string `json:"proxy"` Reach string `json:"reach"` Targets []string `json:"targets"` // Reachable is yes when the last answer upstream gave was below 500, no when it was 500 or more or none came, // unknown before any request. Reachable string `json:"reachable"` // Certificate is its outcome, issuer and expiry in words; ExpiresInDays is the whole days until not-after. Certificate string `json:"certificate"` ExpiresInDays *int `json:"expires_in_days,omitempty"` LastAnswer *routestate.Answer `json:"last_answer,omitempty"` } func (f front) tools() []stdio.Tool { node := map[string]any{"type": "string", "description": "only the proxy on this machine; every machine when left out"} return []stdio.Tool{ {Name: RoutesTool, Description: "Every site (web name, host name) the mesh's web front serves through route-proxy, the reverse proxy " + "on the public machine: for each route the module and node it forwards to, its TLS certificate's issuer and " + "expiry (days left), and the last answer upstream gave (status, time, error), so is X reachable and is its " + "certificate good is answered without curl. Read from the proxy's routes state on the bus. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{"node": node}}, Run: func(args map[string]any) (any, error) { at, _ := args["node"].(string) return f.list(at) }}, {Name: RouteTool, Description: "One site the mesh's web front (route-proxy) is asked about, in full: every rule it is served by " + "(path, module, node, target, refusal, redirect, authentication), its certificate (issuer, not-after, last " + "renewal and its outcome, or why it failed) and the last upstream answer. A name with no route says so, and " + "when it was last asked for. Answers is X reachable, why does this site not load. Read-only.", Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{ "name": map[string]any{"type": "string", "description": "the site's name, such as app.example.org"}, "node": node}}, Run: func(args map[string]any) (any, error) { name, _ := args["name"].(string) at, _ := args["node"].(string) return f.one(name, at) }}, {Name: UnroutedTool, Description: "Names the mesh's web front (route-proxy) answered \"no route for\": a site asked for in a request or " + "a TLS handshake that no route serves, newest first, with when it was last asked and how often. Only names under a " + "domain the proxy serves are kept by name, a few new ones a minute; every other refusal (a scan) is counted per " + "proxy, the last minute's count and the total since its start. A site that should be served and is missing " + "its route shows here. Kept a week. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{"node": node}}, Run: func(args map[string]any) (any, error) { at, _ := args["node"].(string) return f.refusals(at) }}, } } // read is every value of a state, of node's proxy alone when node is named, decoded into a fresh T each. func read[T any](state reader, node string) ([]T, error) { keys, err := state.Keys() if err != nil { return nil, err } var out []T for _, key := range keys { at, _ := routestate.Split(key) if node != "" && at != node { continue } entry, err := state.Get(key) if err != nil { return nil, err } if entry == nil { continue // deleted between the list and the read } var v T if err := json.Unmarshal(entry.Value, &v); err != nil { return nil, fmt.Errorf("%s holds what is not a route-proxy record: %w", key, err) } out = append(out, v) } return out, nil } func (f front) list(node string) (map[string]any, error) { routes, err := read[routestate.Route](f.routes, node) if err != nil { return nil, err } lines := make([]RouteLine, 0, len(routes)) unreachable, expiring := 0, 0 for _, r := range routes { line := f.line(r) if line.Reachable == "no" { unreachable++ } if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == routestate.Failed { expiring++ } lines = append(lines, line) } sort.Slice(lines, func(i, j int) bool { if lines[i].Name != lines[j].Name { return lines[i].Name < lines[j].Name } return lines[i].Proxy < lines[j].Proxy }) return map[string]any{"routes": lines, "served": len(lines), "unreachable": unreachable, "certificates_failing_or_within_14_days": expiring}, nil } func (f front) line(r routestate.Route) RouteLine { line := RouteLine{Name: r.Name, Proxy: r.Proxy, Reach: r.Reach, LastAnswer: r.LastAnswer, Reachable: "unknown", Targets: []string{}} for _, t := range r.Targets { line.Targets = append(line.Targets, describe(t)) } if a := r.LastAnswer; a != nil { line.Reachable = "yes" if a.Error != "" || a.Status >= 500 || a.Status == 0 { line.Reachable = "no" } } c := r.Certificate words := []string{} if c.Outcome != "" { words = append(words, c.Outcome) } if c.Issuer != "" { words = append(words, "by "+c.Issuer) } if c.NotAfter != nil { days := int(c.NotAfter.Sub(f.now()).Hours() / 24) line.ExpiresInDays = &days words = append(words, "until "+c.NotAfter.UTC().Format("2006-01-02")) } if c.Error != "" { words = append(words, "("+c.Error+")") } if len(words) == 0 { words = append(words, "none seen yet") } line.Certificate = c.Authority + ": " + strings.Join(words, " ") return line } // describe is one rule in a few words: path → module@node at target, or what it answers with. func describe(t routestate.Target) string { path := t.Path if path == "" { path = "/" } var what string switch { case t.Deny: what = "refused" case t.Redirect != "": what = "redirected to " + t.Redirect default: what = fmt.Sprintf("%s@%s at %s", t.Module, t.Node, t.Target) } if t.Sealed != "" { what += " (authentication cannot be checked: " + t.Sealed + ")" } else if t.Auth { what += " (authenticated)" } return path + " → " + what } func (f front) one(name, node string) (map[string]any, error) { name = strings.ToLower(strings.TrimSpace(name)) if name == "" { return nil, fmt.Errorf("name is the site to read, such as app.example.org") } routes, err := read[routestate.Route](f.routes, node) if err != nil { return nil, err } var found []routestate.Route for _, r := range routes { if r.Name == name { found = append(found, r) } } if len(found) > 0 { out := map[string]any{"name": name, "routed": true, "routes": found} if len(found) == 1 { out["reachable"] = f.line(found[0]).Reachable } return out, nil } asked, _, err := f.asked(node) if err != nil { return nil, err } for _, a := range asked { if a.Name == name { return map[string]any{"name": name, "routed": false, "asked": a, "says": fmt.Sprintf("no proxy serves %s; the proxy on %s answered \"no route\" for it, last at %s", name, a.Proxy, a.AskedAt.UTC().Format(time.RFC3339))}, nil } } return map[string]any{"name": name, "routed": false, "says": fmt.Sprintf("no proxy serves %s, and none was asked for it this week", name)}, nil } func (f front) refusals(node string) (map[string]any, error) { asked, others, err := f.asked(node) if err != nil { return nil, err } sort.Slice(asked, func(i, j int) bool { return asked[i].AskedAt.After(asked[j].AskedAt) }) if asked == nil { asked = []routestate.Unrouted{} } return map[string]any{"unrouted": asked, "names": len(asked), "other_refusals": others}, nil } // asked is the names refused and kept by name, and each proxy's count of the refusals not kept by name. func (f front) asked(node string) ([]routestate.Unrouted, []routestate.Other, error) { keys, err := f.unrouted.Keys() if err != nil { return nil, nil, err } names, others := keySet{}, keySet{} for _, key := range keys { if _, name := routestate.Split(key); name == routestate.OtherName { others[key] = true } else { names[key] = true } } asked, err := read[routestate.Unrouted](only{f.unrouted, names}, node) if err != nil { return nil, nil, err } counted, err := read[routestate.Other](only{f.unrouted, others}, node) if counted == nil { counted = []routestate.Other{} } return asked, counted, err } // keySet is a set of keys; only is a state narrowed to them. type keySet map[string]bool type only struct { reader keys keySet } func (o only) Keys() ([]string, error) { out := make([]string, 0, len(o.keys)) for k := range o.keys { out = append(out, k) } sort.Strings(out) return out, nil }