mesh/delivery merged: source.main moved to novox/mesh-controller@159db37618c2
mesh/check being carried to b7c3868980f3 on 159db37618c2 | from a4426190b0c7
Review of ddae6729: a scanner's random Host or SNI names each became a key, bounded only by the
bucket's lifetime and size, and every failed write was retried at each change and logged each time.
A refused name is now kept by name only under the registrable domain of a name the proxy serves, and
at most five new names a minute; every other refusal is counted in memory and written once a minute
under <node>._other (last minute, since start), which route_proxy_unrouted shows. A refused write of
that state is dropped and logged once until a write succeeds; trimming memory to 4096 names moved
from the request to the writer. The visitor's address is no longer kept: the journal has it.
Quick: drop unrouted keys from strangers by a fixed allow-list. Thorough: bound by the proxy's own
domains, by rate, and by never retrying. Chose thorough: the scan is constant on a public machine.
297 lines
9.7 KiB
Go
297 lines
9.7 KiB
Go
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
|
|
}
|