From efe745f2be4ccc9c7d3dde6d4a387ea2b8f2cdca Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 09:44:00 +0100 Subject: [PATCH] Publish route-proxy's contract as a Go module of its own: the route a consumer asks, its schema, the events the proxy says, what it keeps on the bus and its tools' arguments, so other modules use the contract by its version and the provisioner carries a route unread (issue 774, research 072 row 34) --- modules/route-proxy/contract/contract.go | 77 +++++++++ modules/route-proxy/contract/contract_test.go | 85 ++++++++++ modules/route-proxy/contract/go.mod | 3 + modules/route-proxy/contract/request.go | 128 +++++++++++++++ .../route-proxy/contract/route.schema.json | 28 ++++ modules/route-proxy/contract/state.go | 152 ++++++++++++++++++ 6 files changed, 473 insertions(+) create mode 100644 modules/route-proxy/contract/contract.go create mode 100644 modules/route-proxy/contract/contract_test.go create mode 100644 modules/route-proxy/contract/go.mod create mode 100644 modules/route-proxy/contract/request.go create mode 100644 modules/route-proxy/contract/route.schema.json create mode 100644 modules/route-proxy/contract/state.go 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 +}