diff --git a/modules/route-proxy/contract/contract.go b/modules/route-proxy/contract/contract.go new file mode 100644 index 00000000..99b8d46b --- /dev/null +++ b/modules/route-proxy/contract/contract.go @@ -0,0 +1,77 @@ +// Package contract is route-proxy's contract for the provision `route`: what a consumer asks a route with, what the +// proxy keeps on the bus about what it serves, the events it says, and its tools' arguments (issue 774; research 072 +// row 34; the operator, 2026-10-31: "the route-proxy should define its own contract for the seat (with the message +// contracts of course) which other modules can use"). +// +// **Published by the repository that holds route-proxy, as its own Go module**, versioned by tags of the form +// `modules/route-proxy/contract/vX.Y.Z`, the subdirectory form Go's module system expects, the same way mesh-sdk is +// published (`go/vX.Y.Z`). A module that asks a route, reads what the proxy serves or hears its events imports this +// package; nothing imports the repository. Standard library only, so a consumer takes nothing else with it. +// +// **How a route travels.** A module that must be reachable requires `route` and says, in its manifest's +// `contributes.route`, one Request, or several by local name; Schema is that object's JSON schema. The controller +// composes each (its label joined to the node's domains into `name` and `internal-name`, its endpoint's port into +// `port`) and the seat mesh-provisioner carries the result in the consumer's plan entry as an opaque payload +// (mesh-sdk catalogue.PlanProvision.Contributed, in the generic catalogue.ContributedForm). Neither the seat nor its +// client knows a field of a route: route-proxy decodes each contribution with DecodeRequest, and refuses, saying why +// (EventRefused), what this contract does not admit. +// +// **Two name spaces, two authorities** (novox/hq ADR 0056, ADR 0138). `name` is public: served to anyone and +// certified by the public ACME directory (Let's Encrypt). `internal-name` is served only inside the private network +// and certified by the mesh's own authority. A route carries either, or both. +package contract + +// Module is the module that provides a route, and Provision what it provides: what a plan's entry names when +// route-proxy is a route's provider. +const ( + Module = "route-proxy" + Provision = "route" +) + +// Version is the version of the Request form this package reads and writes. +const Version = 1 + +// The events route-proxy says (its manifest's `emits`), on mesh.mod.route-proxy.event., each with an Event. +const ( + // EventServed: a name is now served by this proxy (it was not, or it is now served from other rules). + EventServed = "route.served" + // EventWithdrawn: a name this proxy served is served no more, because no plan gives it any longer. + EventWithdrawn = "route.withdrawn" + // EventRefused: a plan gives this proxy a route it does not serve, and Why says why. + EventRefused = "route.refused" +) + +// Event is the body of every event route-proxy says: the proxy's machine, the name (empty for a refusal that named +// none), how far it reaches, and who asked it: the consumer module, its machine and the local name of its +// contribution. Why is set on a refusal alone. +type Event struct { + Proxy string `json:"proxy"` + Name string `json:"name,omitempty"` + Reach string `json:"reach,omitempty"` + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` + Local string `json:"local,omitempty"` + Why string `json:"why,omitempty"` +} + +// The tools route-proxy serves (its manifest's `tools`), each answering from its states RoutesState and UnroutedState. +const ( + // ToolRoutes lists every name served, with RoutesArgs. + ToolRoutes = "route_proxy_routes" + // ToolRoute is one name in full, with RouteArgs. + ToolRoute = "route_proxy_route" + // ToolUnrouted lists the names asked for and not served, with RoutesArgs. + ToolUnrouted = "route_proxy_unrouted" +) + +// RoutesArgs are ToolRoutes' and ToolUnrouted's arguments: Node narrows to the proxy on one machine, every machine +// when empty. +type RoutesArgs struct { + Node string `json:"node,omitempty"` +} + +// RouteArgs are ToolRoute's arguments: the name, such as app.example.org, and optionally the machine whose proxy. +type RouteArgs struct { + Name string `json:"name"` + Node string `json:"node,omitempty"` +} diff --git a/modules/route-proxy/contract/contract_test.go b/modules/route-proxy/contract/contract_test.go new file mode 100644 index 00000000..5095e0ea --- /dev/null +++ b/modules/route-proxy/contract/contract_test.go @@ -0,0 +1,85 @@ +package contract + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" +) + +func limit(n int64) *int64 { return &n } + +// A route is read strictly and whole (issue 774): what the contract names is admitted, what it does not, or a route +// it cannot serve, is refused naming why; and the published schema names exactly the fields the decoder reads. +func TestARouteIsReadStrictly(t *testing.T) { + for _, c := range []struct { + raw, refused string + want Request + }{ + {raw: `{"name":"app.example.org","internal-name":"app.anchor.internal","label":"app","endpoint":"web","port":8080}`, + want: Request{Name: "app.example.org", InternalName: "app.anchor.internal", Label: "app", Endpoint: "web", Port: 8080}}, + {raw: `{"internal-name":"app.anchor.internal","port":443,"scheme":"https","insecure":true,"max-request-body":1048576}`, + want: Request{InternalName: "app.anchor.internal", Port: 443, Scheme: "https", Insecure: true, MaxRequestBody: limit(1048576)}}, + {raw: `{"name":"git.example.org","path":"/api/internal","deny":true,"priority":100000}`, + want: Request{Name: "git.example.org", Path: "/api/internal", Deny: true, Priority: 100000}}, + {raw: `{"name":"old.example.org","redirect":"https://new.example.org"}`, + want: Request{Name: "old.example.org", Redirect: "https://new.example.org"}}, + {raw: `{"name":"admin.example.org","port":80,"auth":"/run/secrets/admin"}`, + want: Request{Name: "admin.example.org", Port: 80, Auth: "/run/secrets/admin"}}, + {raw: `{"label":"app","port":8080}`, refused: "names nothing"}, + {raw: `{"name":"app.example.org"}`, refused: "gives no port"}, + {raw: `{"name":"app.example.org","port":70000}`, refused: "not a port"}, + {raw: `{"name":"app.example.org","port":80.5}`, refused: "not a route"}, + {raw: `{"name":"app.example.org","port":"80"}`, refused: "not a route"}, + {raw: `{"name":"app.example.org","port":80,"scheme":"ftp"}`, refused: "neither http nor https"}, + {raw: `{"name":"app.example.org","port":80,"max-request-body":-1}`, refused: "max-request-body"}, + {raw: `{"name":"app.example.org","port":80,"max-request-body":0}`, refused: "max-request-body"}, + {raw: `{"name":"app.example.org","port":80,"auth":"admin:$2y$05$abc"}`, refused: "credential"}, + {raw: `{"name":"app.example.org","port":80,"host":"x"}`, refused: "unknown field \"host\""}, + {raw: `{"name":"app.example.org","port":80} {}`, refused: "more than one"}, + } { + got, err := DecodeRequest([]byte(c.raw)) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("DecodeRequest(%s) = %+v, %v; want refused for %q", c.raw, got, err, c.refused) + } + continue + } + if err != nil || !reflect.DeepEqual(got, c.want) { + t.Errorf("DecodeRequest(%s) = %+v, %v; want %+v", c.raw, got, err, c.want) + continue + } + // What it encodes, it reads back the same. + if raw, err := got.Encode(); err != nil { + t.Errorf("Encode(%+v): %v", got, err) + } else if again, err := DecodeRequest(raw); err != nil || !reflect.DeepEqual(again, got) { + t.Errorf("Encode then DecodeRequest of %+v = %+v, %v", got, again, err) + } + } + if (Request{Path: "api"}).Prefix() != "/api" || (Request{}).Prefix() != "" || (Request{Scheme: " HTTPS"}).SchemeOrDefault() != "https" || + (Request{}).SchemeOrDefault() != "http" || (Request{Redirect: "x"}).Forwards() { + t.Error("Prefix, SchemeOrDefault or Forwards says otherwise than the contract") + } + + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + AdditionalProperties bool `json:"additionalProperties"` + } + if err := json.Unmarshal(Schema, &schema); err != nil || schema.AdditionalProperties { + t.Fatalf("the schema is not a closed object: %v", err) + } + var tags []string + for f := range reflect.TypeFor[Request]().Fields() { + tags = append(tags, strings.Split(f.Tag.Get("json"), ",")[0]) + } + var said []string + for name := range schema.Properties { + said = append(said, name) + } + slices.Sort(tags) + slices.Sort(said) + if !slices.Equal(tags, said) { + t.Fatalf("the schema names %v, the decoder reads %v", said, tags) + } +} diff --git a/modules/route-proxy/contract/go.mod b/modules/route-proxy/contract/go.mod new file mode 100644 index 00000000..91110457 --- /dev/null +++ b/modules/route-proxy/contract/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-controller/modules/route-proxy/contract + +go 1.26.0 diff --git a/modules/route-proxy/contract/request.go b/modules/route-proxy/contract/request.go new file mode 100644 index 00000000..1a10c98b --- /dev/null +++ b/modules/route-proxy/contract/request.go @@ -0,0 +1,128 @@ +package contract + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Request is one route a consumer asks for, as its manifest's `contributes.route` says it and the controller composes +// it: the object Schema describes. Every field is optional to the decoder; Validate says which a route needs. +type Request struct { + // Name is the public name, served to anyone and certified by the public ACME directory; InternalName the name on + // the private network, served inside it alone and certified by the mesh's own authority. The controller composes + // both from Label and the node's domains, as far as the endpoint reaches (novox/hq ADR 0138); a manifest writes a + // label, never a domain. + Name string `json:"name,omitempty"` + InternalName string `json:"internal-name,omitempty"` + Label string `json:"label,omitempty"` + // Endpoint names the manifest's endpoint the route serves; the controller fills Port from it. + Endpoint string `json:"endpoint,omitempty"` + Port int `json:"port,omitempty"` + // Path is the path prefix the rule is scoped to, every path when empty; Priority which rule wins where two match, + // higher first. + Path string `json:"path,omitempty"` + Priority int `json:"priority,omitempty"` + // Scheme is how the consumer is reached, http (the default) or https; Insecure skips verifying its certificate, + // for a backend that terminates TLS with its own. + Scheme string `json:"scheme,omitempty"` + Insecure bool `json:"insecure,omitempty"` + // MaxRequestBody is the largest body, in bytes, the route carries; no limit when absent. Said, it is whole and + // positive: a limit the proxy cannot keep is a route it does not serve. + MaxRequestBody *int64 `json:"max-request-body,omitempty"` + // The policy of the rule, a closed set (novox/hq ADR 0108): Deny refuses every request, Redirect answers with a + // permanent redirect to that address, and Auth names the file of the secret a request must present, never the + // secret itself. + Deny bool `json:"deny,omitempty"` + Redirect string `json:"redirect,omitempty"` + Auth string `json:"auth,omitempty"` +} + +// Schema is Request's JSON schema, published with this package for what checks a manifest's `contributes.route`. +// +//go:embed route.schema.json +var Schema []byte + +// DecodeRequest reads one contribution's object strictly: no field this contract does not name, one document, and +// a route Validate admits. What it refuses, it refuses whole, naming why: a route is never served half. +func DecodeRequest(raw []byte) (Request, error) { + var r Request + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&r); err != nil { + return Request{}, fmt.Errorf("not a route this contract reads: %w", err) + } + if dec.More() { + return Request{}, errors.New("not a route this contract reads: more than one document") + } + if err := r.Validate(); err != nil { + return Request{}, err + } + return r, nil +} + +// Encode is r as a contribution's object, refused where DecodeRequest would refuse it. +func (r Request) Encode() ([]byte, error) { + if err := r.Validate(); err != nil { + return nil, err + } + return json.Marshal(r) +} + +// Validate says what makes r no route: no name of either kind; a port outside 1-65535, or none where the rule +// forwards (neither Deny nor Redirect); a scheme other than http or https; a body limit that is not positive; and a +// credential written where a secret's file is to be named (novox/hq ADR 0108: refused rather than tolerated). +func (r Request) Validate() error { + if strings.TrimSpace(r.Name) == "" && strings.TrimSpace(r.InternalName) == "" { + return errors.New("the route names nothing to be served under: neither name nor internal-name") + } + if r.Port < 0 || r.Port > 65535 { + return fmt.Errorf("the route's port %d is not a port", r.Port) + } + if r.Forwards() && r.Port == 0 { + return errors.New("the route forwards and gives no port") + } + switch strings.ToLower(strings.TrimSpace(r.Scheme)) { + case "", "http", "https": + default: + return fmt.Errorf("the route's scheme %q is neither http nor https", r.Scheme) + } + if r.MaxRequestBody != nil && *r.MaxRequestBody <= 0 { + return fmt.Errorf("the route's max-request-body %d is not a positive number of bytes", *r.MaxRequestBody) + } + if LooksLikeACredential(r.Auth) { + return errors.New("the route's auth holds a credential rather than the name of a secret's file (novox/hq ADR 0108)") + } + return nil +} + +// Forwards is whether the rule sends a request on to the consumer, rather than answering it itself. +func (r Request) Forwards() bool { return !r.Deny && r.Redirect == "" } + +// Prefix is the path prefix the rule is scoped to, beginning with a slash, or "" for every path. +func (r Request) Prefix() string { + p := strings.TrimSpace(r.Path) + if p != "" && !strings.HasPrefix(p, "/") { + p = "/" + p + } + return p +} + +// SchemeOrDefault is how the consumer is reached: the scheme, lower-cased, http when none is said. +func (r Request) SchemeOrDefault() string { + if s := strings.ToLower(strings.TrimSpace(r.Scheme)); s != "" { + return s + } + return "http" +} + +// LooksLikeACredential is the check that keeps a secret out of a declaration: a value holding a `:` (the htpasswd +// separator) or opening with a bcrypt identifier is a credential, not a path, and no path the mesh writes needs +// either. It errs towards refusing. +func LooksLikeACredential(v string) bool { + v = strings.TrimSpace(v) + return strings.Contains(v, ":") || strings.HasPrefix(v, "$2") +} diff --git a/modules/route-proxy/contract/route.schema.json b/modules/route-proxy/contract/route.schema.json new file mode 100644 index 00000000..af55a78d --- /dev/null +++ b/modules/route-proxy/contract/route.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.novox.be/novox/mesh-controller/modules/route-proxy/contract/route.schema.json", + "title": "route", + "description": "One route a consumer asks route-proxy for, as a manifest's contributes.route says it (one, or several by local name) and the controller composes it. Version 1 of route-proxy's contract.", + "type": "object", + "additionalProperties": false, + "anyOf": [ + {"required": ["name"]}, + {"required": ["internal-name"]}, + {"required": ["label"]} + ], + "properties": { + "name": {"type": "string", "description": "the public name, served to anyone and certified by the public ACME directory; composed from label"}, + "internal-name": {"type": "string", "description": "the name on the private network, served inside it alone and certified by the mesh's own authority; composed from label"}, + "label": {"type": "string", "description": "the subdomain the module chooses; joined to the node's domains by the controller"}, + "endpoint": {"type": "string", "description": "the manifest's endpoint the route serves; the controller fills port from it"}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "path": {"type": "string", "description": "the path prefix the rule is scoped to; every path when absent"}, + "priority": {"type": "integer", "description": "which rule wins where two match, higher first"}, + "scheme": {"enum": ["http", "https"], "description": "how the consumer is reached; http when absent"}, + "insecure": {"type": "boolean", "description": "skip verifying the consumer's certificate over https"}, + "max-request-body": {"type": "integer", "minimum": 1, "description": "the largest body, in bytes, the route carries"}, + "deny": {"type": "boolean", "description": "refuse every request matching the rule"}, + "redirect": {"type": "string", "description": "answer with a permanent redirect to this address"}, + "auth": {"type": "string", "description": "the file of the secret a request must present, never the secret itself"} + } +} diff --git a/modules/route-proxy/contract/state.go b/modules/route-proxy/contract/state.go new file mode 100644 index 00000000..e1fce955 --- /dev/null +++ b/modules/route-proxy/contract/state.go @@ -0,0 +1,152 @@ +package contract + +// What the web front keeps on the bus about the names it serves, and about the names it was asked for and does not +// serve (issue 774). The proxy writes it, on change, in its states `routes` and `unrouted`, announced readable; the +// module's tools, and any module that reads them, read it from there, so the question "is this site reachable, and +// is its certificate good" is answered from the bus rather than with curl. +// +// A key is `.`: the machine whose proxy answers, then the name as asked, lower-cased. A node's name holds +// no dot, so the first dot splits them. + +import ( + "net" + "regexp" + "strings" + "time" +) + +// The states route-proxy keeps, by their local names (the buckets are route-proxy_). +const ( + // RoutesState holds one key per served name: where it forwards, its certificate, the last answer it got. + RoutesState = "routes" + // UnroutedState holds one key per name the proxy answered "no route for" with, and when it was last asked. + UnroutedState = "unrouted" + // BoundState is the provider helper's bucket of consumer entries (mesh-sdk go/provider). + BoundState = "bound" +) + +// The words a route's reach and a certificate's authority are said in. +const ( + ReachPublic = "public" + ReachInternal = "internal" +) + +// The words a certificate's outcome is said in. +const ( + // Issued: the authority issued it while this proxy ran, at RenewedAt. + Issued = "issued" + // Cached: this proxy served it from what it kept, and saw no issuance of it since it started. + Cached = "cached" + // Failed: the last attempt to have one failed, Error says why. + Failed = "failed" +) + +// Target is one rule a name is served by: what a request matching it reaches, or what it is answered with. +type Target struct { + // Path is the path prefix the rule is scoped to; empty is every path. + Path string `json:"path,omitempty"` + // Module and Node are the consumer that contributed the route and the machine it runs on. + Module string `json:"module,omitempty"` + Node string `json:"node,omitempty"` + // Target is where the request is forwarded, scheme://address:port; empty for a rule that answers itself. + Target string `json:"target,omitempty"` + Deny bool `json:"deny,omitempty"` + Redirect string `json:"redirect,omitempty"` + // Auth says the rule asks for credentials; Sealed says why it cannot check them, and so refuses everything. + Auth bool `json:"auth,omitempty"` + Sealed string `json:"sealed,omitempty"` +} + +// Certificate is what this proxy knows of a name's certificate. +type Certificate struct { + // Authority is which issuer certifies the name: public (the ACME directory the module names) or internal (the + // mesh's own). + Authority string `json:"authority"` + Issuer string `json:"issuer,omitempty"` + NotAfter *time.Time `json:"not_after,omitempty"` + // RenewedAt is when an authority last issued it to this proxy. + RenewedAt *time.Time `json:"renewed_at,omitempty"` + // Outcome is issued, cached or failed; empty until a handshake or an issuance said anything. + Outcome string `json:"outcome,omitempty"` + Error string `json:"error,omitempty"` + At *time.Time `json:"at,omitempty"` +} + +// Answer is the last answer the proxy got from upstream for a name: the status it carried back, or the error that +// stood in for one (a 502). +type Answer struct { + Status int `json:"status"` + At time.Time `json:"at"` + Error string `json:"error,omitempty"` + Path string `json:"path,omitempty"` +} + +// Route is one served name, the value of a key of RoutesState. +type Route struct { + Name string `json:"name"` + // Proxy is the machine whose proxy serves it. + Proxy string `json:"proxy"` + // Reach is public (a route's own name) or internal (only its internal-name, served inside the private network). + Reach string `json:"reach"` + Targets []Target `json:"targets"` + Certificate Certificate `json:"certificate"` + LastAnswer *Answer `json:"last_answer,omitempty"` +} + +// Unrouted is one name the proxy answered "no route for", the value of a key of UnroutedState. +type Unrouted struct { + Name string `json:"name"` + Proxy string `json:"proxy"` + // AskedAt is when the name was last asked for. Who asked is not kept here: the journal keeps it, for the + // machine's intrusion prevention. + AskedAt time.Time `json:"asked_at"` + // Times is how often it was asked since the proxy last started. + Times int `json:"times"` + // InternalOnly says the name is served, but only inside the private network, and was asked from outside. + InternalOnly bool `json:"internal_only,omitempty"` + // Handshake says it was asked in a TLS handshake (the name a client sent), not in an HTTP request. + Handshake bool `json:"handshake,omitempty"` +} + +// OtherName is the name of the one key per proxy that counts the refusals not kept by name: names under no domain +// the proxy serves (what a scan sends), and names past the limit of new names a minute. Its key is +// `._other`; no host name begins with an underscore. +const OtherName = "_other" + +// Other is the value of a proxy's OtherName key. +type Other struct { + Proxy string `json:"proxy"` + // Minute is the last whole minute counted, and LastMinute how many refusals it held. + Minute time.Time `json:"minute"` + LastMinute int `json:"last_minute"` + // SinceStart is every refusal not kept by name since the proxy started. + SinceStart int `json:"since_start"` +} + +// Registrable is the domain a name is registered under, as its last two labels; "" for an address or a single +// label. Two labels is wrong for a public suffix of two (co.uk); it only decides which refused names are kept by name. +func Registrable(name string) string { + if net.ParseIP(strings.Trim(name, "[]")) != nil { + return "" + } + labels := strings.Split(name, ".") + if len(labels) < 2 { + return "" + } + return strings.Join(labels[len(labels)-2:], ".") +} + +var keyable = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_.-]{0,251}[a-z0-9_])?$`) + +// Keyable says whether a name can be a key's name: what a host name or an address is. A name asked for in a request +// is whatever a client sent; one no key could hold is no site anybody runs. +func Keyable(name string) bool { return keyable.MatchString(name) && !strings.Contains(name, "..") } + +// Key is the key of name served or asked at node's proxy. +func Key(node, name string) string { return node + "." + name } + +// Split is a key's node and name. +func Split(key string) (node, name string) { + node, name, _ = strings.Cut(key, ".") + return node, name +}