From 373565c2f7208f6d600fc60e7dae9566798b533d Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 09:24:53 +0100 Subject: [PATCH 1/4] Serve route-proxy's routes from the seat mesh-provisioner's plans alone and drop its received routes file, since a route is a provision and the file is the deprecated path (issue 774, research 072 row 34; needs the mesh-sdk pin carrying plan entries' given and Conn.Read) --- modules/route-proxy/README.md | 33 +++-- .../cmd/route-provider/provider.go | 16 +- .../cmd/route-provider/provider_test.go | 4 +- modules/route-proxy/cmd/route-proxy/bus.go | 98 +++++++----- modules/route-proxy/cmd/route-proxy/main.go | 112 ++++---------- modules/route-proxy/cmd/route-proxy/plans.go | 123 +++++++++++++++ .../cmd/route-proxy/policy_test.go | 12 +- .../route-proxy/cmd/route-proxy/reach_test.go | 140 +++++++++++++----- .../cmd/route-proxy/routes_test.go | 69 ++++----- .../internal/routestate/routestate.go | 7 + modules/route-proxy/module.json | 11 -- 11 files changed, 396 insertions(+), 229 deletions(-) create mode 100644 modules/route-proxy/cmd/route-proxy/plans.go diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index 2767eca..ae9ca8d 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -5,26 +5,31 @@ The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08 ## What it is -**A route is a grant.** A module that must be reachable declares `requires: ["route"]` and -contributes the name it wants and the port it listens on; this module provides `route`, receives -every consumer that asked as a file, and forwards by the `Host` header of each request to -`http://:` — where `at` is where the mesh says that consumer's machine is (empty means -this machine, reached over loopback). +**A route is a provision** (issue 774, research 072 row 34; the operator, 2026-10-31: "there is only 1 path, the other +is simply deprecated/invalid"). A module that must be reachable declares `requires: ["route"]` and contributes the +name it wants and the port it listens on; this module provides `route`. The controller composes each consumer's plan, +and the seat `mesh-provisioner` keeps it in its state `plans`: the plan's entry for the route names this proxy as +its provider and carries what the consumer contributes, composed (`given`: its names joined to the node's domains, its +endpoint's port, its policy), and where the consumer's machine is on the private network (`consumer-at`). The proxy +follows the plans on the bus and forwards by the `Host` header of each request to `http://:`, or +over loopback for a consumer on its own machine. -The control plane does not write the proxy's configuration. It delivers the facts — the -contributions file at `receives.route` — and the proxy turns them into whatever it runs. That -boundary is why swapping this proxy for another costs nothing to the modules that publish through -it. +The controller does not write the proxy's configuration. It composes the facts into the plans and the proxy turns them +into whatever it runs. That boundary is why swapping this proxy for another costs nothing to the modules that publish +through it. **There is no received file and no second source:** a route no plan names is not served, and a module +declaring `receives.route` is refused by the catalogue check (mesh-catalog `checks/credentialform`), but for the +migration scaffolding still listed there. | field | value | why | |---|---|---| -| `provides` | `route` (scope `mesh`) | a module on any node may require it | +| `provides` | `route` (scope `mesh`, answered by the helper) | a module on any node may require it | | `serves.route` | `{}` | a route hands back a name, not a credential — the consumer already knows the name it asked for | -| `receives.route` | `…/routes/mesh.json` | where the mesh writes every contribution; the proxy reads it as `$ROUTES` and re-reads on change | +| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions` | the plans are where the routes are; the helper reads both | | `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) | -No provisioner: the proxy neither mints a credential for anybody nor answers a provision other -than `route`. It reads the file the mesh writes, and its own broker credential is for its tools. +Until the bus answers, the proxy routes nothing and says so on its log; it refuses to start without its broker +credential (`MESH_BROKER_FILE`). Who is inside the private network, and so may be served an internal-only name, is +still read from its membership (novox/hq ADR 0167). ## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) @@ -77,7 +82,7 @@ traffic. | env | default | meaning | |---|---|---| -| `ROUTES` | `/routes/mesh.json` | the contributions file the mesh writes (`receives.route`) | +| `MESH_BROKER_FILE` | *(required)* | the proxy's broker credential: the plans it routes from, and its membership | | `LISTEN` | `:80` | HTTP, and the ACME HTTP-01 challenge | | `TLS_LISTEN` | `:443` | HTTPS; unset to serve plain HTTP only | | `ACME_CACHE` | `/acme` | where issued certificates persist; required when `TLS_LISTEN` is set, so a restart does not re-order | diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go index 7eee671..4e0a27c 100644 --- a/modules/route-proxy/cmd/route-provider/provider.go +++ b/modules/route-proxy/cmd/route-provider/provider.go @@ -7,18 +7,20 @@ package main // **It composes nothing.** A route's public name is the label its consumer chose joined to the node's public domain, // which only the controller knows (the domain is placement), so the controller composes it and carries it in the // consumer's plan entry (`names`); the helper copies it into the entry's values unchanged. There is no credential for -// a route and nothing to make: the traefik files stay the TypeScript adapter's, written from the contributions the -// mesh still gives it. +// a route and nothing to make. The proxy itself serves the same plans' entries (cmd/route-proxy plans.go), the one +// source of routes (issue 774, research 072 row 34). import ( "git.novox.be/novox/mesh-sdk/go/provider" + + "route-proxy/internal/routestate" ) -// ProvisionName is what route-proxy provides, as its manifest says. -const ProvisionName = "route" - -// ModuleName is this module. -const ModuleName = "route-proxy" +// ProvisionName is what route-proxy provides, as its manifest says, and ModuleName this module. +const ( + ProvisionName = routestate.Provision + ModuleName = routestate.Module +) // newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it // (nothing of its own), with no credential, and nothing to make or retire for a consumer. diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go index 1c36b22..378832b 100644 --- a/modules/route-proxy/cmd/route-provider/provider_test.go +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -108,6 +108,8 @@ func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { // the public ingress does not change. Everything of the manifest but its build, what it provides and the state it // keeps is pinned by this digest; changing the ingress means changing it here, on purpose. Re-pinned in R4d (novox/hq // ADR 0480): the program is an archive mounted into the trust image, the container and its files are otherwise as before. +// Re-pinned for issue 774 (research 072 row 34): the received routes file, its directory, mount and ROUTES went, since +// the proxy serves from the seat's plans alone; the ingress, its certificates and its files are otherwise as before. func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -133,7 +135,7 @@ func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { t.Fatal(err) } sum := sha256.Sum256(pinned) - if got := hex.EncodeToString(sum[:]); got != "85cb955e5a4b2d8826f50aa76433064a576e8b15b71537032eb93c7388c0ee28" { + if got := hex.EncodeToString(sum[:]); got != "e96702560003ae7f27490d27fd2353f8c6680d8e16864e15fdc0a833108bc2e5" { t.Fatalf("route-proxy's own manifest changed: digest %s", got) } // The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked diff --git a/modules/route-proxy/cmd/route-proxy/bus.go b/modules/route-proxy/cmd/route-proxy/bus.go index 9dbcc27..b872183 100644 --- a/modules/route-proxy/cmd/route-proxy/bus.go +++ b/modules/route-proxy/cmd/route-proxy/bus.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "log" "strings" @@ -10,9 +9,11 @@ import ( "github.com/nats-io/nats.go" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/provisions" "route-proxy/internal/routestate" ) @@ -27,28 +28,28 @@ func keptStates() []announce.State { } } -// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167). +// What the mesh says this proxy routes, and who is inside the mesh, read on the bus. // -// **The proxy is told, not left to work it out.** Its membership carries the routes it is given — -// the same contributions its file is written from — and every machine's address on the private -// network, which is who may be served an internal name. Read once at connect and followed, so a -// route added or a machine joining reaches a running proxy without a restart. +// **The routes are the seat mesh-provisioner's plans, alone** (issue 774, research 072 row 34): every plan's entry +// naming this proxy as a route's provider, followed, so a route added or withdrawn reaches a running proxy without a +// restart. **Who is inside** is this proxy's membership (novox/hq ADR 0160, ADR 0167): every machine's address on the +// private network, which is who may be served an internal name; until it is read, an internal name is served to this +// machine alone. -// followMembership connects with the credential in path and applies every membership the mesh -// issues this proxy. It retries the first connection for as long as it takes: a proxy that started -// before the bus keeps serving the file, and takes the bus when it answers. -func followMembership(path string, held *table, fromBus *atomic.Bool) { +// follow connects with the credential in path, follows the plans and the membership, and retries the connection for as +// long as it takes: until the bus answers, the proxy routes nothing, and says so. +func follow(path string, held *table) { for { - err := followOnce(path, held, fromBus) + err := followOnce(path, held) if err == nil { return } - log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err) + log.Printf("cannot follow the plans that say what this proxy routes, so it routes what it last read: %v", err) time.Sleep(30 * time.Second) } } -func followOnce(path string, held *table, fromBus *atomic.Bool) error { +func followOnce(path string, held *table) error { account, err := process.ReadAccount(path) if err != nil { return err @@ -65,42 +66,63 @@ func followOnce(path string, held *table, fromBus *atomic.Bool) error { } else { held.ledger.attach(account.Node, conn.State(routestate.RoutesState), conn.State(routestate.UnroutedState)) } - subject := membership.Subject(account.Node, account.Module) - err = conn.FollowMembership(func(issued membership.Membership) { - if took := applyMembership(issued, held); took && !fromBus.Swap(true) { - log.Printf("routes now come from this proxy's membership on %s", subject) - } - }, func(err error) { log.Print(err) }) - if err != nil { + if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node), held); err != nil { conn.Close() return err } + subject := membership.Subject(account.Node, account.Module) + err = conn.FollowMembership(func(issued membership.Membership) { + applyMembership(issued, held) + }, func(err error) { log.Print(err) }) + if err != nil { + log.Printf("cannot follow this proxy's membership on %s, so an internal name is served to this machine "+ + "alone: %v", subject, err) + } return nil } -// applyMembership serves what a membership says, and says whether it said anything about routes. -// -// A membership with no routes in it is one from a controller older than ADR 0167, and the file stays -// the source rather than every route being withdrawn because a field was absent. +// planWatch is the seat's plans as this proxy watches them: every current key, then every change. +type planWatch interface { + Watch(key string, handle func(stdio.StateChange) error) error +} + +// followPlans serves what the plans give this proxy: the whole table once every current plan is read, then again on +// every change that alters it. A table built from part of the plans would withdraw every route not yet read. +func followPlans(plans planWatch, given *planned, held *table) error { + var read atomic.Bool // every current plan is read: from here, each change is served as it comes + err := plans.Watch("", func(c stdio.StateChange) error { + var value []byte + if c.Op != "delete" { + value = c.Value + } + if changed := given.take(c.Key, value); changed && read.Load() { + serve(given, held) + } + return nil + }) + if err != nil { + return fmt.Errorf("watching %s: %w", provisions.PlansRead, err) + } + read.Store(true) + serve(given, held) + return nil +} + +// serve replaces what the proxy serves with what the plans give it. +func serve(given *planned, held *table) { + routes, public := routesOf(given.contributions()) + held.set(routes, public) + log.Printf("serving %d route(s) from the seat's plans: %s", len(routes), strings.Join(held.names(), ", ")) +} + +// applyMembership takes who is inside the mesh from a membership, and says whether it could read it. func applyMembership(issued membership.Membership, held *table) bool { - raw, carries := issued.Receives["route"] - if !carries { - return false - } - var contributions []contribution - if err := json.Unmarshal(raw, &contributions); err != nil { - log.Printf("the routes in this proxy's membership are not contributions, keeping what is served: %v", err) - return false - } inside, err := sourcesOf(issued.Mesh) if err != nil { - log.Printf("the mesh in this proxy's membership is unreadable, keeping what is served: %v", err) + log.Printf("the mesh in this proxy's membership is unreadable, keeping who is inside as it was: %v", err) return false } - routes, public := routesOf(contributions) - held.set(routes, public) held.setInside(inside) - log.Printf("serving %d route(s) from the membership, internal names to %d machine(s): %s", - len(routes), len(inside), strings.Join(held.names(), ", ")) + log.Printf("internal names are served to %d machine(s)", len(inside)) return true } diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 5288f55..905ce80 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -1,13 +1,14 @@ // A reverse proxy, in the form the mesh expects one. // -// **A route is a grant** (novox/hq ADR 0007, 08-connectivity §3). A module that must be reachable -// declares it requires `route` and contributes the name it wants; the proxy provides `route` and -// receives every consumer that asked. It is the mirror of a database grant: there the consumer +// **A route is a provision** (novox/hq ADR 0007, 08-connectivity §3; issue 774, research 072 row 34). A +// module that must be reachable declares it requires `route` and contributes the name it wants; the +// proxy provides `route` and serves every consumer whose plan, in the seat mesh-provisioner's state +// `plans`, names it as the route's provider. It is the mirror of a database grant: there the consumer // supplies a name and receives credentials, here it supplies a target and receives a name. // -// **It is an example, not part of the control plane.** The control plane decides and never touches -// a machine. A real deployment runs whatever proxy it likes — the contract is the file, not this -// program. What lives here is that contract, written as something that runs so it can be read +// **It is an example, not part of the controller.** The controller decides and never touches a +// machine. A real deployment runs whatever proxy it likes — the contract is the plan's entry, not +// this program. What lives here is that contract, written as something that runs so it can be read // rather than described. // // **A route also carries what a request arriving at it may do** (novox/hq ADR 0108). The grant used @@ -16,11 +17,9 @@ // an open middleware surface recreates the thing being replaced and is far harder to narrow later // than a closed one is to widen. // -// What it is given, written by the host from an ordinary declaration: -// -// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is -// -// Each contribution's values carry the name and port as before, and optionally: +// What it is given, in each plan's entry for the route: every contribution the consumer makes, +// composed by the controller, and where the consumer's machine is on the private network (plans.go). +// Each contribution's values carry the name and port, and optionally: // // path the path prefix this rule is scoped to; absent means every path // priority which rule wins where two match; higher first, and the order is total @@ -35,8 +34,8 @@ // outright rather than served unprotected, and a secret that cannot be read makes the route refuse // rather than open — a gate that cannot check is not a gate that opens. // -// It re-reads on change rather than being restarted, for the same reason the provisioner does: -// a route arriving or leaving is an ordinary event and must not drop the connections of every +// It follows the plans on the bus rather than being restarted, for the same reason the provisioner +// does: a route arriving or leaving is an ordinary event and must not drop the connections of every // other workload. package main @@ -48,7 +47,6 @@ import ( "crypto/tls" "crypto/x509" "encoding/hex" - "encoding/json" "fmt" "log" "net" @@ -61,7 +59,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "golang.org/x/crypto/acme" @@ -119,11 +116,8 @@ func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy { } } -// what the mesh writes: the contributions file, one entry per consumer. -type given struct { - Given []contribution `json:"given"` -} - +// contribution is one route a consumer asked for: the module, its machine, where that machine is, and what it +// contributes, composed by the mesh (its plan's entry for the route, plans.go). type contribution struct { From string `json:"from"` Node string `json:"node"` @@ -183,9 +177,9 @@ type rule struct { from, node string } -// table is what the proxy is currently serving, replaced whole whenever the file changes. +// table is what the proxy is currently serving, replaced whole whenever the plans change what they give it. // -// Replaced rather than merged: the file is the whole truth about who has a route, so merging +// Replaced rather than merged: the plans are the whole truth about who has a route, so merging // would keep serving a name whose module was unassigned — which is the stale-route fault // 08-connectivity lists as open, reintroduced one level down. // @@ -356,7 +350,7 @@ func (t *table) routed(host string) bool { // // **The mesh has two name spaces and two authorities** (novox/hq 03-DESIGN/01-to-be/08-connectivity // §2): a public name is certified by a public CA, an internal one by the mesh's own. This is -// composed only from `to` and `public`, which routesFrom already builds correctly — a host never +// composed only from `to` and `public`, which routesOf already builds correctly — a host never // lands in both a route's own `name` and only its `internal-name`, so nothing new has to be // tracked to tell the two apart. func (t *table) eligibleForInternalACME(host string) bool { @@ -432,50 +426,21 @@ func main() { } func run() error { - path := strings.TrimSpace(os.Getenv("ROUTES")) - if path == "" { - return fmt.Errorf("ROUTES is not set, so this proxy does not know what it is routing") - } listen := strings.TrimSpace(os.Getenv("LISTEN")) if listen == "" { listen = ":80" } + // **The seat's plans are the one source of routes** (issue 774, research 072 row 34). Without the bus this proxy + // knows no route, so it refuses to start rather than serving an empty table and saying nothing. + credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")) + if credential == "" { + return fmt.Errorf("MESH_BROKER_FILE is not set, so this proxy cannot read the plans that say what it routes") + } held := newTable() held.ledger = newLedger() go held.ledger.loop() - // **The bus first, the file until it has spoken** (novox/hq ADR 0167). The membership carries - // the routes and who the mesh is; the file carries the routes alone, so while the proxy reads - // it an internal name is served to this machine and to nobody else — refused, never opened. - fromBus := &atomic.Bool{} - if credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); credential != "" { - go followMembership(credential, held, fromBus) - } else { - log.Printf("MESH_BROKER_FILE is not set: routes come from %s alone, and a name that is only "+ - "internal is served to this machine alone", path) - } - read := func() { - if fromBus.Load() { - return - } - routes, public, err := routesFrom(path) - if err != nil { - // Kept serving what it had. A file being rewritten is momentarily unreadable, and - // dropping every route because one read landed mid-write would turn an ordinary - // event into an outage. - log.Printf("cannot read %s, keeping what is already served: %v", path, err) - return - } - held.set(routes, public) - log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) - } - read() - - go func() { - for range time.Tick(2 * time.Second) { - read() - } - }() + go follow(credential, held) // TLS is opt-in. A proxy with no `TLS_LISTEN` serves plain HTTP exactly as before — which is // what an internal-only mesh wants, and what the mesh's own certificate authority already @@ -881,30 +846,13 @@ func boolByte(b bool) byte { return 0 } -// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and -// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached -// only through `internal-name` never appears there. +// routesOf turns the routes the plans give into host → the rules for that host, and which of those hosts is a public +// name — the second is `name`, ACME-eligible; a host reached only through `internal-name` never appears there. // -// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches -// decides which names the mesh composes, so an endpoint that reaches only the private network -// arrives with an `internal-name` and no `name`. That is a whole route, not a malformed one: it is -// served under its internal name and certified by the internal authority. Only a route with -// neither name has nothing to be served under (novox/hq issue 191). -func routesFrom(path string) (map[string][]rule, map[string]bool, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, nil, err - } - var said given - if err := json.Unmarshal(raw, &said); err != nil { - return nil, nil, err - } - routes, public := routesOf(said.Given) - return routes, public, nil -} - -// routesOf turns what the mesh gave into host → the rules for that host, and which hosts are public -// names — the same whether the contributions came in the file or in the membership. +// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches decides which names the +// mesh composes, so an endpoint that reaches only the private network arrives with an `internal-name` and no `name`. +// That is a whole route, not a malformed one: it is served under its internal name and certified by the internal +// authority. Only a route with neither name has nothing to be served under (novox/hq issue 191). func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) { out := map[string][]rule{} public := map[string]bool{} diff --git a/modules/route-proxy/cmd/route-proxy/plans.go b/modules/route-proxy/cmd/route-proxy/plans.go new file mode 100644 index 0000000..d42ea31 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/plans.go @@ -0,0 +1,123 @@ +package main + +// The routes this proxy serves, read from the seat mesh-provisioner's plans and nothing else (issue 774; research 072 +// row 34, the operator on 2026-10-31: "there is only 1 path, the other is simply deprecated/invalid"). +// +// **A route is a provision.** A module that must be reachable requires `route`; the controller composes its plan on +// its node, and the plan's entry for the route names this proxy as its provider and carries what the module +// contributes, composed as the received file used to carry it (its names joined to the node's domains, its endpoint's +// port, its policy), and where the module's machine is on the private network. The proxy follows the plans on the bus +// and serves every entry naming it. There is no file and no second source: a route the plans do not name is not +// served. + +import ( + "fmt" + "log" + "sort" + "sync" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/provisions" + + "route-proxy/internal/routestate" +) + +// planned is the routes the seat's plans give this proxy on its node, by plan key, kept so a plan that cannot be read +// leaves what it gave served (a bind missed would read as a route withdrawn). +type planned struct { + node string + mu sync.Mutex + by map[string][]contribution +} + +func newPlanned(node string) *planned { + return &planned{node: node, by: map[string][]contribution{}} +} + +// take is one change of a plan's key: its value, or nil when the key was deleted. It answers whether what is served +// changed, and says on the log what it could not serve. +func (p *planned) take(key string, value []byte) bool { + p.mu.Lock() + defer p.mu.Unlock() + if value == nil { + _, had := p.by[key] + delete(p.by, key) + return had + } + gave, problems, err := routesOfPlan(p.node, key, value) + if err != nil { + log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err) + return false + } + for _, problem := range problems { + log.Print(problem) + } + if len(gave) == 0 { + _, had := p.by[key] + delete(p.by, key) + return had + } + p.by[key] = gave + return true +} + +// contributions is every route the plans give this proxy, in the order of their plans' keys. +func (p *planned) contributions() []contribution { + p.mu.Lock() + defer p.mu.Unlock() + keys := make([]string, 0, len(p.by)) + for k := range p.by { + keys = append(keys, k) + } + sort.Strings(keys) + var out []contribution + for _, k := range keys { + out = append(out, p.by[k]...) + } + return out +} + +// routesOfPlan is the routes one plan gives the proxy on node: each contribution of each entry naming this proxy on +// node as the route's provider, reached on its own machine over loopback and on another at the address the plan says. +// A plan of another module, or one routing elsewhere, gives none. Problems are what the plan names and the proxy +// cannot serve, said rather than skipped silently: an entry carrying nothing to serve (a controller before the plans +// carried it), or a consumer elsewhere with no address on the private network. +func routesOfPlan(node, key string, value []byte) ([]contribution, []string, error) { + plan, err := provisions.DecodePlan(value) + if err != nil { + return nil, nil, err + } + if plan.Node+"."+plan.Module != key { + return nil, nil, fmt.Errorf("it is the plan of %s.%s, under the key %s", plan.Node, plan.Module, key) + } + var out []contribution + var problems []string + for _, e := range plan.Plan.Provisions { + if !routesHere(node, e) { + continue + } + if len(e.Given) == 0 { + problems = append(problems, fmt.Sprintf("the plan %s names this proxy for its route and gives nothing "+ + "to serve; it is not served", key)) + continue + } + at := "" + if plan.Node != node { + if e.ConsumerAt == "" { + problems = append(problems, fmt.Sprintf("the plan %s routes %s on %s here and says no address on "+ + "the private network for it; it is not served", key, plan.Module, plan.Node)) + continue + } + at = e.ConsumerAt + } + for _, g := range e.Given { + out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Values: g.Values}) + } + } + return out, problems, nil +} + +// routesHere is whether a plan's entry is a route this proxy on node provides. +func routesHere(node string, e catalogue.PlanProvision) bool { + return e.Provision == routestate.Provision && e.Provider == node && e.ProviderModule == routestate.Module +} diff --git a/modules/route-proxy/cmd/route-proxy/policy_test.go b/modules/route-proxy/cmd/route-proxy/policy_test.go index 192af8d..0f2b364 100644 --- a/modules/route-proxy/cmd/route-proxy/policy_test.go +++ b/modules/route-proxy/cmd/route-proxy/policy_test.go @@ -57,11 +57,7 @@ func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response func proxyFor(t *testing.T, routesJSON string) string { t.Helper() - path := filepath.Join(t.TempDir(), "routes.json") - if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil { - t.Fatal(err) - } - routes, public, err := routesFrom(path) + routes, public, err := routesFrom(routesJSON) if err != nil { t.Fatal(err) } @@ -153,11 +149,7 @@ func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"$2a$10$abcdefghijklmnopqrstuv"}}]}`, } for _, body := range inline { - path := filepath.Join(t.TempDir(), "routes.json") - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - routes, _, err := routesFrom(path) + routes, _, err := routesFrom(body) if err != nil { t.Fatal(err) } diff --git a/modules/route-proxy/cmd/route-proxy/reach_test.go b/modules/route-proxy/cmd/route-proxy/reach_test.go index d008d64..5e70b2a 100644 --- a/modules/route-proxy/cmd/route-proxy/reach_test.go +++ b/modules/route-proxy/cmd/route-proxy/reach_test.go @@ -9,9 +9,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/membership" ) @@ -26,12 +28,12 @@ func behind(t *testing.T, mesh ...string) *table { at, _ := url.Parse(workload.URL) host, port, _ := net.SplitHostPort(at.Host) - routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[ + routes, public, err := routesFrom(fmt.Sprintf(`{"given":[ {"from":"app","node":"anchor","at":%q, "values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}}, {"from":"admin","node":"anchor","at":%q, "values":{"internal-name":"admin.anchor.internal","port":%s}} - ]}`, host, port, host, port))) + ]}`, host, port, host, port)) if err != nil { t.Fatal(err) } @@ -165,42 +167,114 @@ func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) { } } -// What the mesh issues is what is served: the routes in the membership, internal names to the -// machines it names (novox/hq ADR 0167). -func TestAMembershipIsServedAsIssued(t *testing.T) { +// fakePlans is the seat's plans as a watch hands them: every current key, then each change after Watch returned. +type fakePlans struct { + current map[string]string + handle func(stdio.StateChange) error +} + +func (f *fakePlans) Watch(_ string, handle func(stdio.StateChange) error) error { + f.handle = handle + for k, v := range f.current { + if err := handle(stdio.StateChange{Key: k, Op: "put", Value: json.RawMessage(v), Current: true}); err != nil { + return err + } + } + return nil +} + +// plan is a plan of the seat mesh-provisioner for module on node, its entries as given. +func plan(node, module, entries string) string { + return fmt.Sprintf(`{"v":1,"node":%q,"module":%q,"plan":{"templates":{"source":"","digest":""},"files":null,`+ + `"provisions":[%s]}}`, node, module, entries) +} + +// What the plans give is what is served, and nothing else (issue 774, research 072 row 34): each entry naming this +// proxy on its node as the route's provider, every contribution of it, reached over loopback on this machine and at +// the plan's address on another; an entry routing elsewhere, giving nothing or naming no address is not served. A +// change is served as it comes, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The +// membership says only who is inside: an internal-only name is served to the machines it names. +func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) { + const gitea = `{"provision":"route","provider":"anchor","provider-module":"route-proxy","consumer-at":"10.10.0.7",` + + `"given":[{"local":"web","values":{"name":"git.example","internal-name":"git.anchor.internal","port":3000}},` + + `{"local":"api","values":{"internal-name":"git-api.anchor.internal","port":3001}}]}` + plans := &fakePlans{current: map[string]string{ + "laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+ + `"provider-module":"postgres","given":[{"values":{"name":"gitea"}}]}`), + "anchor.blog": plan("anchor", "blog", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+ + `"consumer-at":"10.10.0.1","given":[{"values":{"name":"blog.example","port":2368}}]}`), + "anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+ + `"given":[{"values":{"name":"other.example","port":80}}]}`), + "anchor.older": plan("anchor", "older", `{"provision":"route","provider":"anchor","provider-module":"route-proxy"}`), + "laptop.lost": plan("laptop", "lost", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+ + `"given":[{"values":{"name":"lost.example","port":80}}]}`), + }} held := newTable() - took := applyMembership(membership.Membership{ - Receives: map[string]json.RawMessage{"route": json.RawMessage(`[ - {"from":"admin","node":"anchor","at":"anchor.internal", - "values":{"internal-name":"admin.anchor.internal","port":8080}}]`)}, - Mesh: []string{"10.10.0.7"}, - }, held) - if !took { - t.Fatal("a membership carrying routes was not applied") + if err := followPlans(plans, newPlanned("anchor"), held); err != nil { + t.Fatal(err) + } + served := func() map[string]string { + out := map[string]string{} + held.mu.RLock() + defer held.mu.RUnlock() + for host, rules := range held.to { + out[host] = rules[0].target + } + return out + } + want := map[string]string{"git.example": "http://10.10.0.7:3000", "git.anchor.internal": "http://10.10.0.7:3000", + "git-api.anchor.internal": "http://10.10.0.7:3001", "blog.example": "http://127.0.0.1:2368"} + if got := served(); !reflect.DeepEqual(got, want) { + t.Fatalf("serving %v, want %v", got, want) + } + if !held.eligibleForACME("git.example") || held.eligibleForACME("git-api.anchor.internal") { + t.Error("a public name is not certified publicly, or an internal-only one is") + } + for _, row := range []struct { + name, key, value string + served []string + withdrawn []string + }{ + {"a route arrives", "anchor.new", plan("anchor", "new", `{"provision":"route","provider":"anchor",`+ + `"provider-module":"route-proxy","given":[{"values":{"name":"new.example","port":81}}]}`), + []string{"new.example"}, nil}, + {"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil}, + {"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"}}, + } { + change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)} + if row.value == "" { + change = stdio.StateChange{Key: row.key, Op: "delete"} + } + if err := plans.handle(change); err != nil { + t.Fatal(err) + } + got := served() + for _, host := range row.served { + if _, ok := got[host]; !ok { + t.Errorf("%s: %s is not served (%v)", row.name, host, got) + } + } + for _, host := range row.withdrawn { + if _, ok := got[host]; ok { + t.Errorf("%s: %s is still served", row.name, host) + } + } + } + + held = behind(t) + if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound { + t.Errorf("before a membership, a machine of the mesh was served an internal-only name: %d", code) + } + if !applyMembership(membership.Membership{Mesh: []string{"10.10.0.7"}}, held) { + t.Fatal("a membership naming the mesh was not applied") } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { t.Error("a machine the membership names was refused the internal-only route") } - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound { - t.Errorf("a machine the membership does not name was served the internal-only route: %d", code) - } -} - -// A membership that says nothing about routes is one from a controller that does not issue them, -// and changes nothing: the file stays the source rather than every route being withdrawn. -func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) { - held := behind(t, "10.10.0.7") - before := held.names() - if applyMembership(membership.Membership{Mesh: []string{"10.10.0.7"}}, held) { - t.Error("a membership without routes was taken as the source of routes") - } - if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") { - t.Errorf("a membership without routes changed what is served: %v, was %v", got, before) - } - if applyMembership(membership.Membership{ - Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)}, - Mesh: []string{"not-an-address"}, - }, held) { + if applyMembership(membership.Membership{Mesh: []string{"not-an-address"}}, held) { t.Error("a membership whose mesh cannot be read was applied") } + if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { + t.Error("an unreadable membership changed who is inside") + } } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index a8447f4..a29095e 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -2,23 +2,26 @@ package main import ( "context" + "encoding/json" "fmt" "io" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" ) -func write(t *testing.T, body string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "routes.json") - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) +// routesFrom is the table a test's routes make, written as `{"given":[…]}`: the contributions the plans +// give the proxy (plans.go), each as routesOfPlan makes it. +func routesFrom(body string) (map[string][]rule, map[string]bool, error) { + var said struct { + Given []contribution `json:"given"` } - return path + if err := json.Unmarshal([]byte(body), &said); err != nil { + return nil, nil, err + } + routes, public := routesOf(said.Given) + return routes, public, nil } // plain is the table an ordinary set of routes makes: one host, one target, no policy. @@ -51,9 +54,9 @@ func targetOf(routes map[string][]rule, host string) string { // A route is a grant: the consumer supplies a target, and where that machine is comes from the // mesh rather than from a naming convention the proxy has to know. func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[ + routes, _, err := routesFrom(`{"contributions":1,"requirement":"route","given":[ {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -68,10 +71,10 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { // target — the same convenience a predecessor proxy gave for reaching a service over the VPN // without a public TLS round trip. func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) { - routes, public, err := routesFrom(write(t, `{"given":[ + routes, public, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","at":"anchor.internal", "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -94,10 +97,10 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) { // one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing — // skipping it left every internal-only module unreachable by name (novox/hq issue 191). func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) { - routes, public, err := routesFrom(write(t, `{"given":[ + routes, public, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","at":"anchor.internal", "values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -123,9 +126,9 @@ func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) { // A route with no internal-name composed gets no second host — the ordinary case, unchanged. func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","values":{"name":"app.example","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -137,9 +140,9 @@ func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) { // A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the // only thing that works when there is no private network. func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","values":{"name":"app.example","port":9000}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -151,10 +154,10 @@ func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { // A route may name a target reached over https, for a backend that terminates its own TLS — the // shape Mailu's webmail front needs, which this proxy reaches as a plain workload otherwise. func TestARouteMayTargetHttps(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(`{"given":[ {"from":"mail","node":"anchor","at":"anchor.internal", "values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -319,10 +322,10 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { // public CA can validate a private name, and asking anyway would only spend the account's rate // limit on an order that can never succeed. func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) { - routes, public, err := routesFrom(write(t, `{"given":[ + routes, public, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","at":"anchor.internal", "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -341,10 +344,10 @@ func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) { // A certificate is asked of the *internal* authority only for a name that is routed here and is // not a route's own public name — the internal-network alias, never the route it accompanies. func TestTheInternalAuthorityOnlyCertifiesInternalOnlyAliases(t *testing.T) { - routes, public, err := routesFrom(write(t, `{"given":[ + routes, public, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","at":"anchor.internal", "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -385,10 +388,10 @@ func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { // default refuses them long before the workload is reached. It is configuration beside `insecure`, // not a fifth policy — novox/hq ADR 0108 closed that set at four. func TestARouteMayLimitTheBodyItCarries(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(`{"given":[ {"from":"registry","node":"anchor","at":"anchor.internal", "values":{"name":"images.example","port":5000,"max-request-body":21474836480}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -403,9 +406,9 @@ func TestARouteMayLimitTheBodyItCarries(t *testing.T) { // Saying nothing leaves the route unlimited, which is what every route already got. func TestARouteThatSaysNothingCarriesAnySize(t *testing.T) { - routes, _, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(`{"given":[ {"from":"app","node":"anchor","at":"anchor.internal","values":{"name":"app.example","port":8080}} - ]}`)) + ]}`) if err != nil { t.Fatal(err) } @@ -427,10 +430,10 @@ func TestABodyOverTheLimitIsRefusedAndOneUnderItIsCarried(t *testing.T) { at := strings.TrimPrefix(workload.URL, "http://") host, port, _ := strings.Cut(at, ":") - routes, _, err := routesFrom(write(t, `{"given":[ - {"from":"registry","node":"anchor","at":"`+host+`", - "values":{"name":"images.example","port":`+port+`,"max-request-body":8}} - ]}`)) + routes, _, err := routesFrom(`{"given":[ + {"from":"registry","node":"anchor","at":"` + host + `", + "values":{"name":"images.example","port":` + port + `,"max-request-body":8}} + ]}`) if err != nil { t.Fatal(err) } @@ -491,7 +494,7 @@ func TestAContributionThatCannotBeServedIsSkipped(t *testing.T) { {"a zero limit", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":0}}`, ""}, {"a fractional limit", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":1.5}}`, ""}, } { - routes, public, err := routesFrom(write(t, `{"given":[`+row.given+`]}`)) + routes, public, err := routesFrom(`{"given":[` + row.given + `]}`) if err != nil { t.Fatal(err) } diff --git a/modules/route-proxy/internal/routestate/routestate.go b/modules/route-proxy/internal/routestate/routestate.go index 44dd82d..fa7ac98 100644 --- a/modules/route-proxy/internal/routestate/routestate.go +++ b/modules/route-proxy/internal/routestate/routestate.go @@ -14,6 +14,13 @@ import ( "time" ) +// Module is route-proxy, and Provision what it provides: what a plan's entry names when this proxy is a route's provider +// (both halves read the seat mesh-provisioner's plans for it, issue 774). +const ( + Module = "route-proxy" + Provision = "route" +) + // 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. diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 1a42825..daddb91 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -16,9 +16,6 @@ "serves": { "route": {} }, - "receives": { - "route": "${dir:routes-dir}/mesh.json" - }, "requires": [ "internal-acme-ca" ], @@ -73,12 +70,6 @@ "mode": "0700", "place": "mesh" }, - { - "id": "routes-dir", - "type": "directory", - "path": "/var/lib/route-proxy/routes", - "mode": "0700" - }, { "id": "acme-cache", "type": "directory", @@ -167,14 +158,12 @@ "${dir:state}/internal-acme.env" ], "volumes": [ - "${dir:routes-dir}:/routes:ro", "${dir:acme-cache}:/acme", "${dir:ca-dir}:/ca:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro", "${dir:state}/route-proxy:/opt/mesh:ro" ], "env": { - "ROUTES": "/routes/mesh.json", "LISTEN": ":80", "TLS_LISTEN": ":443", "ACME_CACHE": "/acme", From bf395f88eea6ffb94090ebb1be13d81ad52221bf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 09:44:00 +0100 Subject: [PATCH 2/4] 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 0000000..99b8d46 --- /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 0000000..5095e0e --- /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 0000000..9111045 --- /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 0000000..1a10c98 --- /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 0000000..af55a78 --- /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 0000000..e1fce95 --- /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 +} From fa56e384293f8b6fbe1f64476a072df1c67ee743 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 09:44:07 +0100 Subject: [PATCH 3/4] Serve route-proxy's routes through its own contract: decode each plan entry's opaque contribution with it, say each name served, withdrawn or refused on the bus, and take who is inside from the seat mesh-placement, since the membership went with the assignments stream (issue 774, research 072 rows 33 and 34) --- modules/route-proxy/README.md | 31 +++- .../route-proxy/cmd/route-provider/main.go | 4 +- .../cmd/route-provider/provider.go | 6 +- .../route-proxy/cmd/route-provider/tools.go | 40 ++-- .../cmd/route-provider/tools_test.go | 24 +-- modules/route-proxy/cmd/route-proxy/bus.go | 134 ++++++++++---- modules/route-proxy/cmd/route-proxy/ledger.go | 66 +++---- .../cmd/route-proxy/ledger_test.go | 28 +-- modules/route-proxy/cmd/route-proxy/main.go | 174 +++++------------- modules/route-proxy/cmd/route-proxy/plans.go | 84 ++++++--- .../route-proxy/cmd/route-proxy/reach_test.go | 109 ++++++++--- .../cmd/route-proxy/routes_test.go | 22 ++- modules/route-proxy/go.mod | 1 + .../internal/routestate/routestate.go | 158 ---------------- modules/route-proxy/module.json | 6 + 15 files changed, 410 insertions(+), 477 deletions(-) delete mode 100644 modules/route-proxy/internal/routestate/routestate.go diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index ae9ca8d..f5ddb81 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -9,10 +9,27 @@ The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08 is simply deprecated/invalid"). A module that must be reachable declares `requires: ["route"]` and contributes the name it wants and the port it listens on; this module provides `route`. The controller composes each consumer's plan, and the seat `mesh-provisioner` keeps it in its state `plans`: the plan's entry for the route names this proxy as -its provider and carries what the consumer contributes, composed (`given`: its names joined to the node's domains, its -endpoint's port, its policy), and where the consumer's machine is on the private network (`consumer-at`). The proxy -follows the plans on the bus and forwards by the `Host` header of each request to `http://:`, or -over loopback for a consumer on its own machine. +its provider and carries what the consumer contributes, composed (its names joined to the node's domains, its +endpoint's port, its policy), and where the consumer's machine is on the private network, as one opaque payload +(`contributed`) the seat never reads. The proxy follows the plans on the bus, reads each contribution with its own +contract (below), and forwards by the `Host` header of each request to `http://:`, or over loopback for a +consumer on its own machine. + +## Its contract: a package of its own + +**route-proxy defines its own contract, and other modules use it** (the operator, 2026-10-31). It is the Go module +`git.novox.be/novox/mesh-controller/modules/route-proxy/contract`, in `contract/` here with its own `go.mod`, standard +library only, released by tags `modules/route-proxy/contract/vX.Y.Z`, the way mesh-sdk is released (`go/vX.Y.Z`); this +module depends on it by its version like any other. It holds: + +- **the route a consumer asks** (`Request`, read strictly by `DecodeRequest`: no field it does not name, a name of + either kind, a port where the rule forwards, http or https, a positive body limit, a secret's file never a + credential) and its JSON schema (`route.schema.json`, `Schema`), the form of a manifest's `contributes.route`; +- **the events this proxy says** on `mesh.mod.route-proxy.event.`: `route.served` and `route.withdrawn` as the + names it serves change, `route.refused` with why for a route a plan gives it and it does not serve (`Event`); +- **what it keeps on the bus**, its states `routes` and `unrouted`, their keys and records (`Route`, `Unrouted`, + `Other`, `Key`, `Split`); +- **its tools' names and arguments** (`ToolRoutes`, `ToolRoute`, `ToolUnrouted`, `RoutesArgs`, `RouteArgs`). The controller does not write the proxy's configuration. It composes the facts into the plans and the proxy turns them into whatever it runs. That boundary is why swapping this proxy for another costs nothing to the modules that publish @@ -24,12 +41,14 @@ migration scaffolding still listed there. |---|---|---| | `provides` | `route` (scope `mesh`, answered by the helper) | a module on any node may require it | | `serves.route` | `{}` | a route hands back a name, not a credential — the consumer already knows the name it asked for | -| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions` | the plans are where the routes are; the helper reads both | +| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions`, `mesh-delivery-assigner.placement` | the plans are where the routes are; the helper reads both; the placement says who is inside | +| `emits` | `route.served`, `route.withdrawn`, `route.refused` | what it serves, said as it changes (the contract's `Event`) | | `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) | Until the bus answers, the proxy routes nothing and says so on its log; it refuses to start without its broker credential (`MESH_BROKER_FILE`). Who is inside the private network, and so may be served an internal-only name, is -still read from its membership (novox/hq ADR 0167). +every node's address on it as the seat mesh-placement decides it (mesh-sdk `delivery.InsideAddresses`); until that +is read, an internal-only name is served to this machine alone. ## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) diff --git a/modules/route-proxy/cmd/route-provider/main.go b/modules/route-proxy/cmd/route-provider/main.go index a00a709..b1eecda 100644 --- a/modules/route-proxy/cmd/route-provider/main.go +++ b/modules/route-proxy/cmd/route-provider/main.go @@ -14,7 +14,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/provider" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) func say(format string, args ...any) { @@ -36,7 +36,7 @@ func main() { } }() } - tools := front{routes: stdio.State(routestate.RoutesState), unrouted: stdio.State(routestate.UnroutedState), + tools := front{routes: stdio.State(contract.RoutesState), unrouted: stdio.State(contract.UnroutedState), now: time.Now}.tools() if err := stdio.Serve("", tools); err != nil { say("%v", err) diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go index 4e0a27c..39973d4 100644 --- a/modules/route-proxy/cmd/route-provider/provider.go +++ b/modules/route-proxy/cmd/route-provider/provider.go @@ -13,13 +13,13 @@ package main import ( "git.novox.be/novox/mesh-sdk/go/provider" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // ProvisionName is what route-proxy provides, as its manifest says, and ModuleName this module. const ( - ProvisionName = routestate.Provision - ModuleName = routestate.Module + ProvisionName = contract.Provision + ModuleName = contract.Module ) // newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it diff --git a/modules/route-proxy/cmd/route-provider/tools.go b/modules/route-proxy/cmd/route-provider/tools.go index c01a0ba..6129a4b 100644 --- a/modules/route-proxy/cmd/route-provider/tools.go +++ b/modules/route-proxy/cmd/route-provider/tools.go @@ -14,14 +14,14 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // The tools' names, as the manifest declares them. const ( - RoutesTool = "route_proxy_routes" - RouteTool = "route_proxy_route" - UnroutedTool = "route_proxy_unrouted" + RoutesTool = contract.ToolRoutes + RouteTool = contract.ToolRoute + UnroutedTool = contract.ToolUnrouted ) // reader is one of the module's states, as stdio.KeptState reads it. @@ -46,9 +46,9 @@ type RouteLine struct { // 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"` + Certificate string `json:"certificate"` + ExpiresInDays *int `json:"expires_in_days,omitempty"` + LastAnswer *contract.Answer `json:"last_answer,omitempty"` } func (f front) tools() []stdio.Tool { @@ -99,7 +99,7 @@ func read[T any](state reader, node string) ([]T, error) { } var out []T for _, key := range keys { - at, _ := routestate.Split(key) + at, _ := contract.Split(key) if node != "" && at != node { continue } @@ -120,7 +120,7 @@ func read[T any](state reader, node string) ([]T, error) { } func (f front) list(node string) (map[string]any, error) { - routes, err := read[routestate.Route](f.routes, node) + routes, err := read[contract.Route](f.routes, node) if err != nil { return nil, err } @@ -131,7 +131,7 @@ func (f front) list(node string) (map[string]any, error) { if line.Reachable == "no" { unreachable++ } - if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == routestate.Failed { + if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == contract.Failed { expiring++ } lines = append(lines, line) @@ -146,7 +146,7 @@ func (f front) list(node string) (map[string]any, error) { "certificates_failing_or_within_14_days": expiring}, nil } -func (f front) line(r routestate.Route) RouteLine { +func (f front) line(r contract.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 { @@ -182,7 +182,7 @@ func (f front) line(r routestate.Route) RouteLine { } // describe is one rule in a few words: path → module@node at target, or what it answers with. -func describe(t routestate.Target) string { +func describe(t contract.Target) string { path := t.Path if path == "" { path = "/" @@ -209,11 +209,11 @@ func (f front) one(name, node string) (map[string]any, error) { 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) + routes, err := read[contract.Route](f.routes, node) if err != nil { return nil, err } - var found []routestate.Route + var found []contract.Route for _, r := range routes { if r.Name == name { found = append(found, r) @@ -248,32 +248,32 @@ func (f front) refusals(node string) (map[string]any, error) { } sort.Slice(asked, func(i, j int) bool { return asked[i].AskedAt.After(asked[j].AskedAt) }) if asked == nil { - asked = []routestate.Unrouted{} + asked = []contract.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) { +func (f front) asked(node string) ([]contract.Unrouted, []contract.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 { + if _, name := contract.Split(key); name == contract.OtherName { others[key] = true } else { names[key] = true } } - asked, err := read[routestate.Unrouted](only{f.unrouted, names}, node) + asked, err := read[contract.Unrouted](only{f.unrouted, names}, node) if err != nil { return nil, nil, err } - counted, err := read[routestate.Other](only{f.unrouted, others}, node) + counted, err := read[contract.Other](only{f.unrouted, others}, node) if counted == nil { - counted = []routestate.Other{} + counted = []contract.Other{} } return asked, counted, err } diff --git a/modules/route-proxy/cmd/route-provider/tools_test.go b/modules/route-proxy/cmd/route-provider/tools_test.go index 3194e75..da91dfe 100644 --- a/modules/route-proxy/cmd/route-provider/tools_test.go +++ b/modules/route-proxy/cmd/route-provider/tools_test.go @@ -9,7 +9,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // held is a state as the bus holds it, read-only. @@ -40,19 +40,19 @@ func TestTheWebFrontsToolsAnswerFromTheBus(t *testing.T) { now := time.Date(2026, 10, 30, 21, 0, 0, 0, time.UTC) expires := now.Add(30 * 24 * time.Hour) routes := held{ - "anchor.grafana.example": routestate.Route{Name: "grafana.example", Proxy: "anchor", Reach: routestate.ReachPublic, - Targets: []routestate.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}}, - Certificate: routestate.Certificate{Authority: routestate.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: routestate.Issued}, - LastAnswer: &routestate.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}}, - "anchor.drive.example": routestate.Route{Name: "drive.example", Proxy: "anchor", Reach: routestate.ReachPublic, - Targets: []routestate.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}}, - Certificate: routestate.Certificate{Authority: routestate.ReachPublic}, - LastAnswer: &routestate.Answer{Status: 200, At: now}}, + "anchor.grafana.example": contract.Route{Name: "grafana.example", Proxy: "anchor", Reach: contract.ReachPublic, + Targets: []contract.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}}, + Certificate: contract.Certificate{Authority: contract.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: contract.Issued}, + LastAnswer: &contract.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}}, + "anchor.drive.example": contract.Route{Name: "drive.example", Proxy: "anchor", Reach: contract.ReachPublic, + Targets: []contract.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}}, + Certificate: contract.Certificate{Authority: contract.ReachPublic}, + LastAnswer: &contract.Answer{Status: 200, At: now}}, } unrouted := held{ - "anchor.old.example": routestate.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3}, - "anchor.wiki.example": routestate.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1}, - "anchor._other": routestate.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900}, + "anchor.old.example": contract.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3}, + "anchor.wiki.example": contract.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1}, + "anchor._other": contract.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900}, } tools := map[string]stdio.Tool{} for _, tool := range (front{routes: routes, unrouted: unrouted, now: func() time.Time { return now }}).tools() { diff --git a/modules/route-proxy/cmd/route-proxy/bus.go b/modules/route-proxy/cmd/route-proxy/bus.go index b872183..ff34c07 100644 --- a/modules/route-proxy/cmd/route-proxy/bus.go +++ b/modules/route-proxy/cmd/route-proxy/bus.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log" + "sort" "strings" "sync/atomic" "time" @@ -11,20 +12,21 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/provisions" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // keptStates is every state route-proxy keeps, as both its halves write them: the routes and the refusals this // proxy writes, readable by others, and the bound entries the provider helper writes. func keptStates() []announce.State { return []announce.State{ - {Name: routestate.BoundState}, - {Name: routestate.RoutesState, Readable: true}, - {Name: routestate.UnroutedState, Readable: true}, + {Name: contract.BoundState}, + {Name: contract.RoutesState, Readable: true}, + {Name: contract.UnroutedState, Readable: true}, } } @@ -32,12 +34,13 @@ func keptStates() []announce.State { // // **The routes are the seat mesh-provisioner's plans, alone** (issue 774, research 072 row 34): every plan's entry // naming this proxy as a route's provider, followed, so a route added or withdrawn reaches a running proxy without a -// restart. **Who is inside** is this proxy's membership (novox/hq ADR 0160, ADR 0167): every machine's address on the -// private network, which is who may be served an internal name; until it is read, an internal name is served to this -// machine alone. +// restart, and said on the bus as it changes (contract.EventServed, EventWithdrawn, EventRefused). **Who is inside** +// is every node's address on the private network as the seat mesh-placement decides it (mesh-sdk +// delivery.InsideAddresses), which is who may be served an internal name; until it is read, an internal name is +// served to this machine alone. -// follow connects with the credential in path, follows the plans and the membership, and retries the connection for as -// long as it takes: until the bus answers, the proxy routes nothing, and says so. +// follow connects with the credential in path, follows the plans and the placement, and retries the connection for +// as long as it takes: until the bus answers, the proxy routes nothing, and says so. func follow(path string, held *table) { for { err := followOnce(path, held) @@ -64,39 +67,40 @@ func followOnce(path string, held *table) error { if err := conn.Keeps(keptStates()...); err != nil { log.Printf("cannot say on the bus what this proxy keeps, so it keeps nothing there: %v", err) } else { - held.ledger.attach(account.Node, conn.State(routestate.RoutesState), conn.State(routestate.UnroutedState)) + held.ledger.attach(account.Node, conn.State(contract.RoutesState), conn.State(contract.UnroutedState)) } - if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node), held); err != nil { + say := func(eventType string, e contract.Event) { + if err := conn.Emit(eventType, e); err != nil { + log.Printf("cannot say %s of %q on the bus: %v", eventType, e.Name, err) + } + } + // Who is inside first, so the first table is served to the machines it is meant for. + if err := followInside(conn.Read(delivery.PlacementRead), held); err != nil { + log.Printf("cannot follow %s, so an internal name is served to this machine alone: %v", delivery.PlacementRead, err) + } + if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node, say), held, say); err != nil { conn.Close() return err } - subject := membership.Subject(account.Node, account.Module) - err = conn.FollowMembership(func(issued membership.Membership) { - applyMembership(issued, held) - }, func(err error) { log.Print(err) }) - if err != nil { - log.Printf("cannot follow this proxy's membership on %s, so an internal name is served to this machine "+ - "alone: %v", subject, err) - } return nil } -// planWatch is the seat's plans as this proxy watches them: every current key, then every change. +// planWatch is a state this proxy watches: every current key, then every change. type planWatch interface { Watch(key string, handle func(stdio.StateChange) error) error } // followPlans serves what the plans give this proxy: the whole table once every current plan is read, then again on // every change that alters it. A table built from part of the plans would withdraw every route not yet read. -func followPlans(plans planWatch, given *planned, held *table) error { +func followPlans(plans planWatch, given *planned, held *table, say func(string, contract.Event)) error { var read atomic.Bool // every current plan is read: from here, each change is served as it comes err := plans.Watch("", func(c stdio.StateChange) error { var value []byte - if c.Op != "delete" { + if c.Op != "delete" && c.Op != "purge" { value = c.Value } if changed := given.take(c.Key, value); changed && read.Load() { - serve(given, held) + serve(given, held, say) } return nil }) @@ -104,25 +108,87 @@ func followPlans(plans planWatch, given *planned, held *table) error { return fmt.Errorf("watching %s: %w", provisions.PlansRead, err) } read.Store(true) - serve(given, held) + serve(given, held, say) return nil } -// serve replaces what the proxy serves with what the plans give it. -func serve(given *planned, held *table) { +// serve replaces what the proxy serves with what the plans give it, and says each name it now serves or serves no +// more. +func serve(given *planned, held *table, say func(string, contract.Event)) { + before := held.served() routes, public := routesOf(given.contributions()) held.set(routes, public) + after := held.served() + for _, e := range servedChanges(given.node, before, after) { + say(e.kind, e.Event) + } log.Printf("serving %d route(s) from the seat's plans: %s", len(routes), strings.Join(held.names(), ", ")) } -// applyMembership takes who is inside the mesh from a membership, and says whether it could read it. -func applyMembership(issued membership.Membership, held *table) bool { - inside, err := sourcesOf(issued.Mesh) +// change is one event serve says. +type change struct { + kind string + contract.Event +} + +// servedChanges is what changed between two tables' names, each name once, in order: a name now served, or now +// served at another reach or by another module, is served; a name no longer served is withdrawn. +func servedChanges(proxy string, before, after map[string]contract.Event) []change { + var out []change + for _, name := range sortedNames(after) { + if was, ok := before[name]; !ok || was != after[name] { + e := after[name] + e.Proxy = proxy + out = append(out, change{contract.EventServed, e}) + } + } + for _, name := range sortedNames(before) { + if _, ok := after[name]; !ok { + out = append(out, change{contract.EventWithdrawn, contract.Event{Proxy: proxy, Name: name}}) + } + } + return out +} + +func sortedNames(m map[string]contract.Event) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// followInside follows the seat mesh-placement's state and serves an internal name to every node's address on the +// private network it decides, each time it changes (mesh-sdk delivery.InsideAddresses; research 072 rows 33 and 34: +// the membership that carried them went with the assignments stream). A key that does not read keeps who is inside +// as it was. +func followInside(placement planWatch, held *table) error { + keys := delivery.NewPlacementKeys() + var read atomic.Bool + apply := func() { + inside, err := sourcesOf(delivery.InsideAddresses(keys.All())) + if err != nil { + log.Printf("the placement says an address that is not one, so who is inside stays as it was: %v", err) + return + } + held.setInside(inside) + log.Printf("internal names are served to %d machine(s)", len(inside)) + } + err := placement.Watch("", func(c stdio.StateChange) error { + if _, err := keys.Put(c.Key, c.Value, c.Revision, c.Op == "delete" || c.Op == "purge"); err != nil { + log.Printf("the placement's key %s does not read, so who is inside stays as it was: %v", c.Key, err) + return nil + } + if read.Load() { + apply() + } + return nil + }) if err != nil { - log.Printf("the mesh in this proxy's membership is unreadable, keeping who is inside as it was: %v", err) - return false + return err } - held.setInside(inside) - log.Printf("internal names are served to %d machine(s)", len(inside)) - return true + read.Store(true) + apply() + return nil } diff --git a/modules/route-proxy/cmd/route-proxy/ledger.go b/modules/route-proxy/cmd/route-proxy/ledger.go index fce3713..30aecb1 100644 --- a/modules/route-proxy/cmd/route-proxy/ledger.go +++ b/modules/route-proxy/cmd/route-proxy/ledger.go @@ -17,7 +17,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // What this proxy says on the bus about the names it serves and the names it refuses (issue 774). @@ -55,9 +55,9 @@ type ledger struct { node string routes, unrouted kvStore - served map[string]*routestate.Route - previous map[string]routestate.Route // what the bus held for this machine when it was reached - asked map[string]*routestate.Unrouted + served map[string]*contract.Route + previous map[string]contract.Route // what the bus held for this machine when it was reached + asked map[string]*contract.Unrouted answerWritten map[string]time.Time askedWritten map[string]time.Time @@ -69,7 +69,7 @@ type ledger struct { domains map[string]bool newMinute time.Time newInMinute int - other routestate.Other + other contract.Other otherMinute time.Time otherInMinute int otherDirty bool @@ -81,9 +81,9 @@ type ledger struct { func newLedger() *ledger { return &ledger{ now: time.Now, - served: map[string]*routestate.Route{}, - previous: map[string]routestate.Route{}, - asked: map[string]*routestate.Unrouted{}, + served: map[string]*contract.Route{}, + previous: map[string]contract.Route{}, + asked: map[string]*contract.Unrouted{}, answerWritten: map[string]time.Time{}, askedWritten: map[string]time.Time{}, dirtyRoutes: map[string]bool{}, @@ -113,12 +113,12 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) { if l == nil { return } - before := map[string]routestate.Route{} + before := map[string]contract.Route{} if keys, err := routes.Keys(); err != nil { log.Printf("cannot read back what this proxy said it serves: %v", err) } else { for _, key := range keys { - at, name := routestate.Split(key) + at, name := contract.Split(key) if at != node { continue } @@ -126,7 +126,7 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) { if err != nil || entry == nil { continue } - var r routestate.Route + var r contract.Route if json.Unmarshal(entry.Value, &r) == nil { before[name] = r } @@ -152,7 +152,7 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) { } // keepWhatWasKnown carries a certificate and a last answer read back from the bus onto a route that knows none yet. -func keepWhatWasKnown(r *routestate.Route, before routestate.Route) { +func keepWhatWasKnown(r *contract.Route, before contract.Route) { if r.Certificate.Outcome == "" && r.Certificate.NotAfter == nil && before.Certificate.Authority == r.Certificate.Authority { r.Certificate = before.Certificate } @@ -170,14 +170,14 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) { l.mu.Lock() defer l.mu.Unlock() for name, rules := range routes { - reach := routestate.ReachInternal + reach := contract.ReachInternal if public[name] { - reach = routestate.ReachPublic + reach = contract.ReachPublic } targets := targetsOf(rules) r := l.served[name] if r == nil { - r = &routestate.Route{Name: name} + r = &contract.Route{Name: name} if before, ok := l.previous[name]; ok { r.Certificate.Authority = reach keepWhatWasKnown(r, before) @@ -190,7 +190,7 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) { l.dirtyRoutes[name] = true } if r.Certificate.Authority != reach { - r.Certificate = routestate.Certificate{Authority: reach} + r.Certificate = contract.Certificate{Authority: reach} l.dirtyRoutes[name] = true } if _, was := l.asked[name]; was { @@ -200,7 +200,7 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) { } l.domains = map[string]bool{} for name := range routes { - if d := routestate.Registrable(name); d != "" { + if d := contract.Registrable(name); d != "" { l.domains[d] = true } } @@ -214,10 +214,10 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) { l.wake() } -func targetsOf(rules []rule) []routestate.Target { - out := make([]routestate.Target, 0, len(rules)) +func targetsOf(rules []rule) []contract.Target { + out := make([]contract.Target, 0, len(rules)) for _, r := range rules { - out = append(out, routestate.Target{Path: r.path, Module: r.from, Node: r.node, Target: r.target, + out = append(out, contract.Target{Path: r.path, Module: r.from, Node: r.node, Target: r.target, Deny: r.policy.deny, Redirect: r.policy.redirectTo, Auth: len(r.policy.users) > 0 || r.policy.sealed != "", Sealed: r.policy.sealed}) } @@ -241,7 +241,7 @@ func (l *ledger) answered(name, path string, status int, failed error) { why = failed.Error() } before := r.LastAnswer - r.LastAnswer = &routestate.Answer{Status: status, At: now, Error: why, Path: path} + r.LastAnswer = &contract.Answer{Status: status, At: now, Error: why, Path: path} if before != nil && before.Status == status && before.Error == why && now.Sub(l.answerWritten[name]) < refreshAfter { return } @@ -268,11 +268,11 @@ func (l *ledger) refused(name string, internalOnly, handshake bool) { now := l.now() a := l.asked[name] if a == nil { - if !routestate.Keyable(name) || !l.domains[routestate.Registrable(name)] || !l.mayAddName(now) { + if !contract.Keyable(name) || !l.domains[contract.Registrable(name)] || !l.mayAddName(now) { l.countOther(now) return } - a = &routestate.Unrouted{Name: name} + a = &contract.Unrouted{Name: name} l.asked[name] = a } a.AskedAt, a.InternalOnly, a.Handshake = now, internalOnly, handshake @@ -345,7 +345,7 @@ func (l *ledger) certified(name string, leaf *x509.Certificate) { notAfter := leaf.NotAfter.UTC() r.Certificate.Issuer, r.Certificate.NotAfter = issuerOf(leaf), ¬After r.Certificate.RenewedAt, r.Certificate.At = &now, &now - r.Certificate.Outcome, r.Certificate.Error = routestate.Issued, "" + r.Certificate.Outcome, r.Certificate.Error = contract.Issued, "" l.dirtyRoutes[name] = true l.wake() } @@ -362,13 +362,13 @@ func (l *ledger) handedOut(name string, leaf *x509.Certificate) { return } notAfter := leaf.NotAfter.UTC() - if r.Certificate.NotAfter != nil && r.Certificate.NotAfter.Equal(notAfter) && r.Certificate.Outcome != routestate.Failed { + if r.Certificate.NotAfter != nil && r.Certificate.NotAfter.Equal(notAfter) && r.Certificate.Outcome != contract.Failed { return } now := l.now() r.Certificate.Issuer, r.Certificate.NotAfter, r.Certificate.At = issuerOf(leaf), ¬After, &now - if r.Certificate.Outcome != routestate.Issued || r.Certificate.Error != "" { - r.Certificate.Outcome, r.Certificate.Error = routestate.Cached, "" + if r.Certificate.Outcome != contract.Issued || r.Certificate.Error != "" { + r.Certificate.Outcome, r.Certificate.Error = contract.Cached, "" } l.dirtyRoutes[name] = true l.wake() @@ -386,11 +386,11 @@ func (l *ledger) certificateFailed(name string, failed error) { return } now := l.now() - same := r.Certificate.Outcome == routestate.Failed && r.Certificate.Error == failed.Error() + same := r.Certificate.Outcome == contract.Failed && r.Certificate.Error == failed.Error() if same && r.Certificate.At != nil && now.Sub(*r.Certificate.At) < refreshAfter { return } - r.Certificate.Outcome, r.Certificate.Error, r.Certificate.At = routestate.Failed, failed.Error(), &now + r.Certificate.Outcome, r.Certificate.Error, r.Certificate.At = contract.Failed, failed.Error(), &now l.dirtyRoutes[name] = true l.wake() } @@ -423,7 +423,7 @@ func (l *ledger) flush() { l.forgetOldestAsked() var puts []put for name := range l.dirtyRoutes { - p := put{store: l.routes, key: routestate.Key(l.node, name), route: true} + p := put{store: l.routes, key: contract.Key(l.node, name), route: true} if r := l.served[name]; r != nil { c := *r c.Proxy = l.node @@ -432,7 +432,7 @@ func (l *ledger) flush() { puts = append(puts, p) } for name := range l.dirtyAsked { - p := put{store: l.unrouted, key: routestate.Key(l.node, name)} + p := put{store: l.unrouted, key: contract.Key(l.node, name)} if a := l.asked[name]; a != nil { c := *a c.Proxy = l.node @@ -443,7 +443,7 @@ func (l *ledger) flush() { if l.otherDirty { o := l.other o.Proxy = l.node - puts = append(puts, put{store: l.unrouted, key: routestate.Key(l.node, routestate.OtherName), value: o}) + puts = append(puts, put{store: l.unrouted, key: contract.Key(l.node, contract.OtherName), value: o}) } l.dirtyRoutes, l.dirtyAsked, l.otherDirty = map[string]bool{}, map[string]bool{}, false l.mu.Unlock() @@ -466,7 +466,7 @@ func (l *ledger) flush() { log.Printf("cannot write %s to the bus, kept for the next change: %v", p.key, err) } l.routesFailing = true - _, name := routestate.Split(p.key) + _, name := contract.Split(p.key) l.dirtyRoutes[name] = true default: if !l.askFailed { diff --git a/modules/route-proxy/cmd/route-proxy/ledger_test.go b/modules/route-proxy/cmd/route-proxy/ledger_test.go index fba0dd6..91c0111 100644 --- a/modules/route-proxy/cmd/route-proxy/ledger_test.go +++ b/modules/route-proxy/cmd/route-proxy/ledger_test.go @@ -25,7 +25,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // fakeState is a state on the bus, in memory, counting what was written to it. @@ -68,13 +68,13 @@ func (f *fakeState) Get(key string) (*stdio.StateEntry, error) { return &stdio.StateEntry{Key: key, Value: v}, nil } -func (f *fakeState) route(t *testing.T, key string) *routestate.Route { +func (f *fakeState) route(t *testing.T, key string) *contract.Route { t.Helper() raw, ok := f.values[key] if !ok { return nil } - var r routestate.Route + var r contract.Route if err := json.Unmarshal(raw, &r); err != nil { t.Fatal(err) } @@ -117,8 +117,8 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { }() _, port, _ := net.SplitHostPort(gone) contributions := []contribution{ - {From: "grafana", Node: "anchor", At: "127.0.0.1", Values: map[string]any{"name": "grafana.example", "port": mustFloat(t, port)}}, - {From: "drive", Node: "home-server", At: "10.0.0.2", Values: map[string]any{"internal-name": "drive.internal", "port": 8080.0}}, + {From: "grafana", Node: "anchor", At: "127.0.0.1", Route: contract.Request{Name: "grafana.example", Port: int(mustFloat(t, port))}}, + {From: "drive", Node: "home-server", At: "10.0.0.2", Route: contract.Request{InternalName: "drive.internal", Port: 8080}}, } for _, row := range []struct { @@ -130,12 +130,12 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { func(t *testing.T, held *table) {}, func(t *testing.T, routes, _ *fakeState) { r := routes.route(t, "anchor.grafana.example") - if r == nil || r.Proxy != "anchor" || r.Reach != routestate.ReachPublic || len(r.Targets) != 1 || + if r == nil || r.Proxy != "anchor" || r.Reach != contract.ReachPublic || len(r.Targets) != 1 || r.Targets[0].Module != "grafana" || r.Targets[0].Node != "anchor" || r.Targets[0].Target != "http://127.0.0.1:"+port { t.Fatalf("grafana's route is not on the bus as served: %+v", r) } - if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != routestate.ReachInternal || - d.Certificate.Authority != routestate.ReachInternal { + if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != contract.ReachInternal || + d.Certificate.Authority != contract.ReachInternal { t.Fatalf("an internal-only name is not on the bus as internal: %+v", d) } }}, @@ -172,7 +172,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { req.RemoteAddr = "192.0.2.7:51000" handler(held).ServeHTTP(httptest.NewRecorder(), req) held.ledger.flush() - var a routestate.Unrouted + var a contract.Unrouted raw := held.ledger.unrouted.(*fakeState).values["anchor.wiki.grafana.example"] if err := json.Unmarshal(raw, &a); err != nil || a.Times != 1 || a.AskedAt.IsZero() { t.Fatalf("the refused name is not on the bus as asked: %s", raw) @@ -181,7 +181,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { t.Fatalf("who asked is on the bus: %s", raw) } routes, public := routesOf(append(contributions, contribution{From: "grafana", Node: "anchor", - Values: map[string]any{"name": "wiki.grafana.example", "port": 3000.0}})) + Route: contract.Request{Name: "wiki.grafana.example", Port: 3000}})) held.set(routes, public) }, func(t *testing.T, _, unrouted *fakeState) { @@ -197,7 +197,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { _, _ = pick(&tls.ClientHelloInfo{ServerName: "forgotten.grafana.example"}) }, func(t *testing.T, _, unrouted *fakeState) { - var a routestate.Unrouted + var a contract.Unrouted if err := json.Unmarshal(unrouted.values["anchor.forgotten.grafana.example"], &a); err != nil || !a.Handshake { t.Fatalf("a handshake refused for its name is not on the bus: %v", unrouted.values) } @@ -232,7 +232,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { held.ledger.refused("w.scan.test", false, false) }, func(t *testing.T, _, unrouted *fakeState) { - var o routestate.Other + var o contract.Other if err := json.Unmarshal(unrouted.values["anchor._other"], &o); err != nil || o.LastMinute != 2 || o.SinceStart != 78 { t.Fatalf("the refusals not kept by name are not counted on the bus: %s", unrouted.values["anchor._other"]) } @@ -247,7 +247,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { }, func(t *testing.T, routes, _ *fakeState) { c := routes.route(t, "anchor.grafana.example").Certificate - if c.Outcome != routestate.Issued || c.Issuer != "Let's Encrypt R11" || c.NotAfter == nil || + if c.Outcome != contract.Issued || c.Issuer != "Let's Encrypt R11" || c.NotAfter == nil || !c.NotAfter.Equal(notAfter) || c.RenewedAt == nil { t.Fatalf("the issued certificate is not on the bus: %+v", c) } @@ -261,7 +261,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { }, func(t *testing.T, routes, _ *fakeState) { c := routes.route(t, "anchor.grafana.example").Certificate - if c.Outcome != routestate.Failed || c.Error != "acme: urn:ietf:params:acme:error:rateLimited" { + if c.Outcome != contract.Failed || c.Error != "acme: urn:ietf:params:acme:error:rateLimited" { t.Fatalf("the failed certificate is not on the bus: %+v", c) } }}, diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 905ce80..9203ba4 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -64,6 +64,8 @@ import ( "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/bcrypt" + + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // Where public certificates come from when nothing says otherwise. @@ -116,15 +118,16 @@ func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy { } } -// contribution is one route a consumer asked for: the module, its machine, where that machine is, and what it -// contributes, composed by the mesh (its plan's entry for the route, plans.go). +// contribution is one route a consumer asked for: the module, its machine, where that machine is, the local name of +// its contribution, and the route, composed by the mesh and read with route-proxy's contract (plans.go). type contribution struct { From string `json:"from"` Node string `json:"node"` // At is where that machine is on the private network. The mesh knows it; a proxy that had to // build it from the node name would be a naming convention copied into every provider. - At string `json:"at"` - Values map[string]any `json:"values"` + At string `json:"at"` + Local string `json:"local,omitempty"` + Route contract.Request `json:"route"` } // policy is what a rule does with a request that matched it. @@ -407,6 +410,24 @@ func (t *table) namesSeenFrom(remote string) []string { return out } +// served is every name served, as an event says it: how far it reaches and the module whose rule serves it first. +func (t *table) served() map[string]contract.Event { + t.mu.RLock() + defer t.mu.RUnlock() + out := make(map[string]contract.Event, len(t.to)) + for name, rules := range t.to { + e := contract.Event{Name: name, Reach: contract.ReachInternal} + if t.public[name] { + e.Reach = contract.ReachPublic + } + if len(rules) > 0 { + e.Module, e.Node = rules[0].from, rules[0].node + } + out[name] = e + } + return out +} + func (t *table) names() []string { t.mu.RLock() defer t.mu.RUnlock() @@ -857,38 +878,15 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) out := map[string][]rule{} public := map[string]bool{} for _, c := range contributions { - name, _ := c.Values["name"].(string) - name = strings.TrimSpace(name) - internal, _ := c.Values["internal-name"].(string) - internal = strings.TrimSpace(internal) - if name == "" && internal == "" { - log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node) - continue - } - // What the route is called in a log line: its public name when it has one. - called := name - if called == "" { - called = internal - } - - made := rule{path: asPath(c.Values["path"])} - if p, ok := asWhole(c.Values["priority"]); ok { - made.priority = p - } - made.policy.deny, _ = c.Values["deny"].(bool) - made.policy.redirectTo, _ = c.Values["redirect"].(string) - - if named, carried := c.Values["auth"].(string); carried && strings.TrimSpace(named) != "" { - // **A declaration names a secret; it never holds one** (ADR 0108). Refused rather than - // tolerated, and the whole rule is dropped rather than served unprotected — the - // rejected option cannot come back by accident, which is the failure this check exists - // to make impossible. - if looksLikeACredential(named) { - log.Printf("%s on %s declared route %q with a credential in the declaration rather "+ - "than the name of a secret; the whole route is refused (novox/hq ADR 0108)", - c.From, c.Node, called) - continue - } + // The contract admitted the route whole (contract.Request.Validate): it names a host, a rule that forwards + // gives a port, its scheme is http or https, its body limit is whole and positive, and its auth names a + // secret's file rather than holding a credential (novox/hq ADR 0108). + r := c.Route + name := strings.TrimSpace(r.Name) + internal := strings.TrimSpace(r.InternalName) + made := rule{path: r.Prefix(), priority: r.Priority} + made.policy.deny, made.policy.redirectTo = r.Deny, r.Redirect + if named := strings.TrimSpace(r.Auth); named != "" { users, err := usersFrom(named) if err != nil { // Fail closed: the rule is kept so the name stays routed and answers, and it @@ -900,13 +898,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) } // Only a rule that actually proxies needs somewhere to send the request. - if !made.policy.deny && made.policy.redirectTo == "" { - port, ok := asPort(c.Values["port"]) - if !ok { - log.Printf("%s on %s asked for route %q and gave no usable port; skipped", - c.From, c.Node, called) - continue - } + if r.Forwards() { // Where the mesh says that machine is. Empty means it is this one — a workload beside // the proxy is ordinary, and reaching it over loopback is both correct and the only // thing that works when there is no private network. @@ -916,32 +908,12 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) } // http unless the contribution says otherwise. A backend that terminates its own TLS // with a certificate this proxy has no reason to trust — Mailu's webmail front is the - // first of these — is the reason `insecure` exists, and it stays the exception: every - // other target the mesh hands this proxy is a plain workload on the private network. - scheme, _ := c.Values["scheme"].(string) - scheme = strings.ToLower(strings.TrimSpace(scheme)) - if scheme == "" { - scheme = "http" + // first of these — is the reason `insecure` exists, and it stays the exception. + made.insecure = r.Insecure + if r.MaxRequestBody != nil { + made.maxRequestBody = *r.MaxRequestBody } - if scheme != "http" && scheme != "https" { - log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+ - "nor https; skipped", c.From, c.Node, called, scheme) - continue - } - made.insecure, _ = c.Values["insecure"].(bool) - // A limit this proxy cannot read is a route it does not serve, named like a port that - // is not a port. Serving it without the limit would carry exactly what the module said - // not to carry, and report success doing it. - if asked, said := c.Values["max-request-body"]; said { - bytes, whole := asWhole(asked) - if !whole || bytes <= 0 { - log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+ - "not a whole positive number of bytes; skipped", c.From, c.Node, called, asked) - continue - } - made.maxRequestBody = int64(bytes) - } - made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port) + made.target = fmt.Sprintf("%s://%s:%d", r.SchemeOrDefault(), at, r.Port) } made.from, made.node = c.From, c.Node @@ -955,9 +927,8 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) // answered both for one route, as a convenience (reaching a service over the VPN without a // public TLS round trip), not as an access boundary; composing it here restores exactly // that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR - // 0056's internalDomain half) — the same "nothing to join a label to" case the public name - // already has. And the only name, when the endpoint reaches no further than the private - // network. + // 0056's internalDomain half). And the only name, when the endpoint reaches no further than + // the private network. if internal != "" { out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made) } @@ -965,50 +936,6 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) return out, public } -// asWhole is any whole number the mesh wrote, whatever its magnitude. -// -// **Not asPort.** Priority was read with the port reader first, which caps at 65535 — so a rule -// declared at a priority above that silently became priority 0 and stopped shadowing the route it -// exists to shadow. The one real rule this has to reproduce is declared at 100000, so the bug was -// exactly load-bearing. A priority is an ordering, not a port: it has no range. -func asWhole(v any) (int, bool) { - switch n := v.(type) { - case float64: - // JSON makes a float of every number, so a non-integral one was not meant as a priority. - if n != float64(int(n)) { - return 0, false - } - return int(n), true - case int: - return n, true - } - return 0, false -} - -// asPath is the path prefix a rule is scoped to, or "" for every path. -func asPath(v any) string { - p, _ := v.(string) - p = strings.TrimSpace(p) - if p == "" { - return "" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - return p -} - -// looksLikeACredential is the check that keeps a secret out of a declaration. -// -// It errs towards refusing: a value holding a `:` (the htpasswd separator) or opening with a bcrypt -// identifier is a credential, not a path, and no filesystem path the mesh writes needs either. A -// false refusal is a loud log and a route that does not serve; a false accept is a credential -// committed to a declaration, which is the thing being prevented. -func looksLikeACredential(v string) bool { - v = strings.TrimSpace(v) - return strings.Contains(v, ":") || strings.HasPrefix(v, "$2") -} - // usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is. func usersFrom(path string) (map[string]string, error) { raw, err := os.ReadFile(path) @@ -1033,23 +960,6 @@ func usersFrom(path string) (map[string]string, error) { return users, nil } -// asPort accepts what JSON makes of a number, which is a float even when it was written 8080. -func asPort(v any) (int, bool) { - switch n := v.(type) { - case float64: - if n < 1 || n > 65535 { - return 0, false - } - return int(n), true - case int: - if n < 1 || n > 65535 { - return 0, false - } - return n, true - } - return 0, false -} - // towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and // -For**, set from the request this proxy received. A backend that builds its own addresses — a forge // writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this diff --git a/modules/route-proxy/cmd/route-proxy/plans.go b/modules/route-proxy/cmd/route-proxy/plans.go index d42ea31..d2623b8 100644 --- a/modules/route-proxy/cmd/route-proxy/plans.go +++ b/modules/route-proxy/cmd/route-proxy/plans.go @@ -3,12 +3,13 @@ package main // The routes this proxy serves, read from the seat mesh-provisioner's plans and nothing else (issue 774; research 072 // row 34, the operator on 2026-10-31: "there is only 1 path, the other is simply deprecated/invalid"). // -// **A route is a provision.** A module that must be reachable requires `route`; the controller composes its plan on -// its node, and the plan's entry for the route names this proxy as its provider and carries what the module -// contributes, composed as the received file used to carry it (its names joined to the node's domains, its endpoint's -// port, its policy), and where the module's machine is on the private network. The proxy follows the plans on the bus +// **A route is a provision, in route-proxy's own contract.** A module that must be reachable requires `route`; the +// controller composes its plan on its node, and the plan's entry for the route names this proxy as its provider and +// carries what the module contributes, composed (its names joined to the node's domains, its endpoint's port), and +// where the module's machine is on the private network, as one opaque payload the seat never reads (mesh-sdk +// catalogue.PlanProvision.Contributed). The proxy decodes each contribution with its contract (contract.DecodeRequest) // and serves every entry naming it. There is no file and no second source: a route the plans do not name is not -// served. +// served, and a route the contract refuses is said refused (contract.EventRefused), never served half. import ( "fmt" @@ -19,23 +20,28 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/provisions" - "route-proxy/internal/routestate" + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // planned is the routes the seat's plans give this proxy on its node, by plan key, kept so a plan that cannot be read // leaves what it gave served (a bind missed would read as a route withdrawn). type planned struct { node string + // said is where a refusal is told: the bus's events, nothing in a test that does not ask. + said func(eventType string, e contract.Event) mu sync.Mutex by map[string][]contribution } -func newPlanned(node string) *planned { - return &planned{node: node, by: map[string][]contribution{}} +func newPlanned(node string, said func(string, contract.Event)) *planned { + if said == nil { + said = func(string, contract.Event) {} + } + return &planned{node: node, said: said, by: map[string][]contribution{}} } // take is one change of a plan's key: its value, or nil when the key was deleted. It answers whether what is served -// changed, and says on the log what it could not serve. +// changed, and says on the log and the bus what it could not serve. func (p *planned) take(key string, value []byte) bool { p.mu.Lock() defer p.mu.Unlock() @@ -44,13 +50,14 @@ func (p *planned) take(key string, value []byte) bool { delete(p.by, key) return had } - gave, problems, err := routesOfPlan(p.node, key, value) + gave, refused, err := routesOfPlan(p.node, key, value) if err != nil { log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err) return false } - for _, problem := range problems { - log.Print(problem) + for _, r := range refused { + log.Printf("the plan %s gives %s on %s a route this proxy does not serve: %s", key, r.Module, r.Node, r.Why) + p.said(contract.EventRefused, r) } if len(gave) == 0 { _, had := p.by[key] @@ -77,12 +84,12 @@ func (p *planned) contributions() []contribution { return out } -// routesOfPlan is the routes one plan gives the proxy on node: each contribution of each entry naming this proxy on -// node as the route's provider, reached on its own machine over loopback and on another at the address the plan says. -// A plan of another module, or one routing elsewhere, gives none. Problems are what the plan names and the proxy -// cannot serve, said rather than skipped silently: an entry carrying nothing to serve (a controller before the plans -// carried it), or a consumer elsewhere with no address on the private network. -func routesOfPlan(node, key string, value []byte) ([]contribution, []string, error) { +// routesOfPlan is the routes one plan gives the proxy on node: each contribution the plan's entries naming this proxy +// on node as the route's provider carry, decoded with the contract, reached on its own machine over loopback and on +// another at the address the plan says. A plan of another module, or one routing elsewhere, gives none. Refused is +// what the plan names and the proxy does not serve, each with why: an entry carrying nothing to serve, a payload or a +// route the contract does not read, or a consumer elsewhere with no address on the private network. +func routesOfPlan(node, key string, value []byte) ([]contribution, []contract.Event, error) { plan, err := provisions.DecodePlan(value) if err != nil { return nil, nil, err @@ -91,33 +98,50 @@ func routesOfPlan(node, key string, value []byte) ([]contribution, []string, err return nil, nil, fmt.Errorf("it is the plan of %s.%s, under the key %s", plan.Node, plan.Module, key) } var out []contribution - var problems []string + var refused []contract.Event + refuse := func(local, why string) { + refused = append(refused, contract.Event{Proxy: node, Module: plan.Module, Node: plan.Node, Local: local, Why: why}) + } + named, carried := false, false for _, e := range plan.Plan.Provisions { if !routesHere(node, e) { continue } - if len(e.Given) == 0 { - problems = append(problems, fmt.Sprintf("the plan %s names this proxy for its route and gives nothing "+ - "to serve; it is not served", key)) + named = true + // What the module contributes is on the requirement's first entry alone, however many local names it has. + if len(e.Contributed) == 0 { + continue + } + carried = true + given, err := catalogue.DecodeContributed(e.Contributed) + if err != nil { + refuse("", err.Error()) continue } at := "" if plan.Node != node { - if e.ConsumerAt == "" { - problems = append(problems, fmt.Sprintf("the plan %s routes %s on %s here and says no address on "+ - "the private network for it; it is not served", key, plan.Module, plan.Node)) + if given.At == "" { + refuse("", fmt.Sprintf("it runs on %s and its plan says no address on the private network for it", plan.Node)) continue } - at = e.ConsumerAt + at = given.At } - for _, g := range e.Given { - out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Values: g.Values}) + for _, one := range given.Each { + route, err := contract.DecodeRequest(one.Values) + if err != nil { + refuse(one.Local, err.Error()) + continue + } + out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Local: one.Local, Route: route}) } } - return out, problems, nil + if named && !carried { + refuse("", "its plan names this proxy for its route and carries nothing to serve") + } + return out, refused, nil } // routesHere is whether a plan's entry is a route this proxy on node provides. func routesHere(node string, e catalogue.PlanProvision) bool { - return e.Provision == routestate.Provision && e.Provider == node && e.ProviderModule == routestate.Module + return e.Provision == contract.Provision && e.Provider == node && e.ProviderModule == contract.Module } diff --git a/modules/route-proxy/cmd/route-proxy/reach_test.go b/modules/route-proxy/cmd/route-proxy/reach_test.go index 5e70b2a..25372c1 100644 --- a/modules/route-proxy/cmd/route-proxy/reach_test.go +++ b/modules/route-proxy/cmd/route-proxy/reach_test.go @@ -10,11 +10,15 @@ import ( "net/http/httptest" "net/url" "reflect" + "slices" + "sort" "strings" "testing" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/membership" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) // behind is a workload the proxy can send to, and a table routing one public name and one @@ -189,28 +193,42 @@ func plan(node, module, entries string) string { `"provisions":[%s]}}`, node, module, entries) } +// contributed is a plan entry's opaque payload: the consumer's machine at at, and each contribution by local name. +func contributed(at string, each ...string) string { + var parts []string + for i := 0; i+1 < len(each); i += 2 { + parts = append(parts, fmt.Sprintf(`{"local":%q,"values":%s}`, each[i], each[i+1])) + } + return fmt.Sprintf(`"contributed":{"v":1,"at":%q,"each":[%s]}`, at, strings.Join(parts, ",")) +} + // What the plans give is what is served, and nothing else (issue 774, research 072 row 34): each entry naming this -// proxy on its node as the route's provider, every contribution of it, reached over loopback on this machine and at -// the plan's address on another; an entry routing elsewhere, giving nothing or naming no address is not served. A -// change is served as it comes, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The -// membership says only who is inside: an internal-only name is served to the machines it names. -func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) { - const gitea = `{"provision":"route","provider":"anchor","provider-module":"route-proxy","consumer-at":"10.10.0.7",` + - `"given":[{"local":"web","values":{"name":"git.example","internal-name":"git.anchor.internal","port":3000}},` + - `{"local":"api","values":{"internal-name":"git-api.anchor.internal","port":3001}}]}` +// proxy on its node as the route's provider, every contribution of it read with route-proxy's contract, reached over +// loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming +// no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and +// said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The +// placement says only who is inside: an internal-only name is served to the machines it decides addresses for. +func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) { + const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",` + gitea := route + contributed("10.10.0.7", + "web", `{"name":"git.example","internal-name":"git.anchor.internal","port":3000}`, + "api", `{"internal-name":"git-api.anchor.internal","port":3001}`, + "refused", `{"name":"bad.example","port":3002,"host":"x"}`) + `}` plans := &fakePlans{current: map[string]string{ "laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+ - `"provider-module":"postgres","given":[{"values":{"name":"gitea"}}]}`), - "anchor.blog": plan("anchor", "blog", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+ - `"consumer-at":"10.10.0.1","given":[{"values":{"name":"blog.example","port":2368}}]}`), + `"provider-module":"postgres",`+contributed("10.10.0.7", "", `{"name":"gitea"}`)+`}`), + "anchor.blog": plan("anchor", "blog", route+contributed("10.10.0.1", "", `{"name":"blog.example","port":2368}`)+`}`), "anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+ - `"given":[{"values":{"name":"other.example","port":80}}]}`), - "anchor.older": plan("anchor", "older", `{"provision":"route","provider":"anchor","provider-module":"route-proxy"}`), - "laptop.lost": plan("laptop", "lost", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+ - `"given":[{"values":{"name":"lost.example","port":80}}]}`), + contributed("", "", `{"name":"other.example","port":80}`)+`}`), + "anchor.older": plan("anchor", "older", route+`"local":""}`), + "laptop.lost": plan("laptop", "lost", route+contributed("", "", `{"name":"lost.example","port":80}`)+`}`), }} held := newTable() - if err := followPlans(plans, newPlanned("anchor"), held); err != nil { + var said []string + say := func(kind string, e contract.Event) { + said = append(said, kind+" "+e.Proxy+" "+e.Name+" "+e.Module+" "+e.Local+" "+e.Why) + } + if err := followPlans(plans, newPlanned("anchor", say), held, say); err != nil { t.Fatal(err) } served := func() map[string]string { @@ -230,17 +248,36 @@ func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) { if !held.eligibleForACME("git.example") || held.eligibleForACME("git-api.anchor.internal") { t.Error("a public name is not certified publicly, or an internal-only one is") } + // Each refusal said with why, and each name served said once. + sort.Strings(said) + for _, w := range []string{ + "route.refused anchor gitea refused not a route this contract reads: json: unknown field \"host\"", + "route.refused anchor lost it runs on laptop and its plan says no address on the private network for it", + "route.refused anchor older its plan names this proxy for its route and carries nothing to serve", + "route.served anchor blog.example blog ", + "route.served anchor git-api.anchor.internal gitea ", + } { + if !slices.Contains(said, w) { + t.Errorf("not said %q, said:\n%s", w, strings.Join(said, "\n")) + } + } + if len(said) != 7 { + t.Errorf("said %d events, want 3 refusals and 4 names served:\n%s", len(said), strings.Join(said, "\n")) + } for _, row := range []struct { name, key, value string served []string withdrawn []string + say []string }{ - {"a route arrives", "anchor.new", plan("anchor", "new", `{"provision":"route","provider":"anchor",`+ - `"provider-module":"route-proxy","given":[{"values":{"name":"new.example","port":81}}]}`), - []string{"new.example"}, nil}, - {"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil}, - {"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"}}, + {"a route arrives", "anchor.new", plan("anchor", "new", route+contributed("", "", `{"name":"new.example","port":81}`)+`}`), + []string{"new.example"}, nil, []string{"route.served anchor new.example new "}}, + {"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil, nil}, + {"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"}, + []string{"route.withdrawn anchor git-api.anchor.internal ", "route.withdrawn anchor git.anchor.internal ", + "route.withdrawn anchor git.example "}}, } { + said = nil change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)} if row.value == "" { change = stdio.StateChange{Key: row.key, Op: "delete"} @@ -259,22 +296,36 @@ func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) { t.Errorf("%s: %s is still served", row.name, host) } } + if !slices.Equal(said, row.say) { + t.Errorf("%s: said %q, want %q", row.name, said, row.say) + } } held = behind(t) if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound { - t.Errorf("before a membership, a machine of the mesh was served an internal-only name: %d", code) + t.Errorf("before the placement is read, a machine of the mesh was served an internal-only name: %d", code) } - if !applyMembership(membership.Membership{Mesh: []string{"10.10.0.7"}}, held) { - t.Fatal("a membership naming the mesh was not applied") + laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}}) + if err != nil { + t.Fatal(err) + } + placement := &fakePlans{current: map[string]string{}} + for k, v := range laptop { + placement.current[k] = string(v) + } + if err := followInside(placement, held); err != nil { + t.Fatal(err) } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("a machine the membership names was refused the internal-only route") + t.Error("a machine the placement decides an address for was refused the internal-only route") } - if applyMembership(membership.Membership{Mesh: []string{"not-an-address"}}, held) { - t.Error("a membership whose mesh cannot be read was applied") + if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound { + t.Error("a machine the placement does not name was served the internal-only route") + } + if err := placement.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil { + t.Fatal(err) } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("an unreadable membership changed who is inside") + t.Error("an unreadable placement key changed who is inside") } } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index a29095e..836dbb5 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -9,18 +9,32 @@ import ( "net/http/httptest" "strings" "testing" + + "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" ) -// routesFrom is the table a test's routes make, written as `{"given":[…]}`: the contributions the plans -// give the proxy (plans.go), each as routesOfPlan makes it. +// routesFrom is the table a test's routes make, written as `{"given":[{"from","node","at","values"}…]}`: the +// contributions the plans give the proxy, each read with route-proxy's contract as routesOfPlan reads it, and one the +// contract refuses left out, as routesOfPlan leaves it out. func routesFrom(body string) (map[string][]rule, map[string]bool, error) { var said struct { - Given []contribution `json:"given"` + Given []struct { + From, Node, At string + Values json.RawMessage + } `json:"given"` } if err := json.Unmarshal([]byte(body), &said); err != nil { return nil, nil, err } - routes, public := routesOf(said.Given) + var given []contribution + for _, g := range said.Given { + route, err := contract.DecodeRequest(g.Values) + if err != nil { + continue + } + given = append(given, contribution{From: g.From, Node: g.Node, At: g.At, Route: route}) + } + routes, public := routesOf(given) return routes, public, nil } diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod index c826c27..dd604f6 100644 --- a/modules/route-proxy/go.mod +++ b/modules/route-proxy/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 github.com/klauspost/compress v1.20.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/route-proxy/internal/routestate/routestate.go b/modules/route-proxy/internal/routestate/routestate.go deleted file mode 100644 index fa7ac98..0000000 --- a/modules/route-proxy/internal/routestate/routestate.go +++ /dev/null @@ -1,158 +0,0 @@ -// Package routestate is 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 shape both halves of route-proxy agree on. The proxy writes it, on change, -// in its states `routes` and `unrouted`, announced readable; the module's tools 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. -package routestate - -import ( - "net" - "regexp" - "strings" - "time" -) - -// Module is route-proxy, and Provision what it provides: what a plan's entry names when this proxy is a route's provider -// (both halves read the seat mesh-provisioner's plans for it, issue 774). -const ( - Module = "route-proxy" - Provision = "route" -) - -// 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 -} diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index daddb91..67e2052 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -245,6 +245,11 @@ "route_proxy_route", "route_proxy_unrouted" ], + "emits": [ + "route.served", + "route.withdrawn", + "route.refused" + ], "replaces": { "route_proxy_routes": [ "curl -I" @@ -257,6 +262,7 @@ ] }, "reads": [ + "mesh-delivery-assigner.placement", "mesh-provisioner.plans", "mesh-provisioner.provisions" ] From 26dea32f5f4300b4ce418672165523cf9b8fd2ef Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 10:27:05 +0100 Subject: [PATCH 4/4] Pin route-proxy at the group's mesh-sdk head acbf80a and its contract at the published tag v0.1.0, so it builds and reads the contributed payload; refresh its manifest digest for the events its contract names (issue 774) --- modules/route-proxy/cmd/route-provider/provider_test.go | 3 ++- modules/route-proxy/go.mod | 2 +- modules/route-proxy/go.sum | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go index 378832b..34b143b 100644 --- a/modules/route-proxy/cmd/route-provider/provider_test.go +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -130,12 +130,13 @@ func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { for _, k := range []string{"build", "provides", "state", "reads", "tools", "replaces"} { delete(m, k) } + // Refreshed on purpose for the events its contract names (`emits`: route.served, route.withdrawn, route.refused). pinned, err := json.Marshal(m) // sorted keys, compact if err != nil { t.Fatal(err) } sum := sha256.Sum256(pinned) - if got := hex.EncodeToString(sum[:]); got != "e96702560003ae7f27490d27fd2353f8c6680d8e16864e15fdc0a833108bc2e5" { + if got := hex.EncodeToString(sum[:]); got != "3a44c56cc021be4008f521d16fa02e1c0f0d2eedab2f4d0b60f7d3177269e542" { t.Fatalf("route-proxy's own manifest changed: digest %s", got) } // The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod index dd604f6..8be9309 100644 --- a/modules/route-proxy/go.mod +++ b/modules/route-proxy/go.mod @@ -3,7 +3,7 @@ module route-proxy go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum index 286ed6e..6fbcb8c 100644 --- a/modules/route-proxy/go.sum +++ b/modules/route-proxy/go.sum @@ -1,5 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68 h1:2rMJX0RMIBaN5NDIaEZVwbj5MFism1O3bRayEz+WlOs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:89jE1oBK+l7gFJjz6wx8mUD3KxrqSDa7pUTlQkzC1do= +git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0/go.mod h1:Yr+Xp89kpMiKH6kA06EU+d9I2CyzrK9yMX7J3pSv0hE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=