From e1d63500f07863c01ed652851c3b53151ded1b16 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 02:43:19 +0200 Subject: [PATCH 01/51] A route is a grant, and a provider is told where its consumer is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit novox/hq 08-connectivity §3, built. The mirror of a database grant: there the consumer supplies a name and receives credentials; here it supplies a target and receives a name. Nothing new in the vocabulary — a route is a provision like any other. One field was missing and it is the one that matters for anything reaching back: a contribution now carries where the mesh says that machine is. A reverse proxy is told to send traffic to a consumer and has to open a connection, so without it every provider implementing a provision would have to know how the mesh names machines — a convention leaking into every module. The proxy itself is an example, not part of the control plane: the contract is the file, not this program. It replaces its table whole rather than merging, because the file is the whole truth about who has a route and merging would keep serving a name whose module was unassigned — the stale-route fault 08-connectivity lists as open, reintroduced one level down. A name it does not serve is refused by saying which it does: a route withdrawn and a name that never existed are different things. --- .../route-proxy/cmd/route-proxy/Dockerfile | 14 ++ modules/route-proxy/cmd/route-proxy/main.go | 214 ++++++++++++++++++ .../cmd/route-proxy/routes_test.go | 143 ++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 modules/route-proxy/cmd/route-proxy/Dockerfile create mode 100644 modules/route-proxy/cmd/route-proxy/main.go create mode 100644 modules/route-proxy/cmd/route-proxy/routes_test.go diff --git a/modules/route-proxy/cmd/route-proxy/Dockerfile b/modules/route-proxy/cmd/route-proxy/Dockerfile new file mode 100644 index 0000000..c807c3d --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/Dockerfile @@ -0,0 +1,14 @@ +# The route proxy, as a module ships one. +# +# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched +# by digest and run on a machine, so everything in it is something a person would have to audit. +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy + +FROM scratch +COPY --from=build /mesh-route-proxy /mesh-route-proxy +ENTRYPOINT ["/mesh-route-proxy"] diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go new file mode 100644 index 0000000..f50d322 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -0,0 +1,214 @@ +// 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 +// 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 +// rather than described. +// +// 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 +// +// 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 +// other workload. +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" +) + +// what the mesh writes: the contributions file, one entry per consumer. +type given struct { + Given []contribution `json:"given"` +} + +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"` +} + +// table is what the proxy is currently serving, replaced whole whenever the file changes. +// +// Replaced rather than merged: the file is 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. +type table struct { + mu sync.RWMutex + to map[string]*httputil.ReverseProxy + targets map[string]string +} + +func (t *table) set(routes map[string]string) { + made := map[string]*httputil.ReverseProxy{} + for name, target := range routes { + where, err := url.Parse(target) + if err != nil { + log.Printf("route %s points at %q, which is not a URL: %v", name, target, err) + continue + } + made[name] = httputil.NewSingleHostReverseProxy(where) + } + t.mu.Lock() + t.to, t.targets = made, routes + t.mu.Unlock() +} + +func (t *table) find(host string) (*httputil.ReverseProxy, bool) { + // The port is not part of the name. A request to app.example:8080 is for app.example. + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + t.mu.RLock() + defer t.mu.RUnlock() + p, ok := t.to[strings.ToLower(host)] + return p, ok +} + +func (t *table) names() []string { + t.mu.RLock() + defer t.mu.RUnlock() + out := make([]string, 0, len(t.targets)) + for name := range t.targets { + out = append(out, name) + } + sort.Strings(out) + return out +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "mesh-route-proxy: "+err.Error()) + os.Exit(1) + } +} + +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" + } + + held := newTable() + read := func() { + routes, 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) + log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) + } + read() + + go func() { + for range time.Tick(2 * time.Second) { + read() + } + }() + + return http.ListenAndServe(listen, handler(held)) +} + +// newTable is an empty routing table. +func newTable() *table { + return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}} +} + +// handler is the proxy itself, separated so it can be driven by a test without a listener. +func handler(held *table) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxy, known := held.find(r.Host) + if !known { + // **Named, not a bare 404.** A route that was withdrawn and a name that never existed + // are different things, and a proxy that says only "not found" makes an operator go + // and read the mesh to tell them apart. What it is serving is the answer to both. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n", + r.Host, strings.Join(held.names(), ", ")) + return + } + proxy.ServeHTTP(w, r) + }) +} + +// routesFrom reads what the mesh wrote and turns it into name → target. +func routesFrom(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var said given + if err := json.Unmarshal(raw, &said); err != nil { + return nil, err + } + + out := map[string]string{} + for _, c := range said.Given { + name, _ := c.Values["name"].(string) + if name == "" { + log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node) + continue + } + 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, name) + continue + } + // 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. + at := c.At + if at == "" { + at = "127.0.0.1" + } + out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port) + } + return out, 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 +} diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go new file mode 100644 index 0000000..628ed7b --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "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) + } + return path +} + +// 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":[ + {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + // Lower-cased, because a Host header is not case-sensitive and a route that only answers the + // spelling in the manifest answers half the requests made to it. + if routes["app.example"] != "http://laptop.internal:8080" { + t.Fatalf("the route does not point at the consumer: %v", routes) + } +} + +// 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":[ + {"from":"app","node":"anchor","values":{"name":"app.example","port":9000}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if routes["app.example"] != "http://127.0.0.1:9000" { + t.Fatalf("a workload on this machine was not reachable: %v", routes) + } +} + +// Skipped rather than served wrongly. A route with no port would proxy to :0. +func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { + routes, err := routesFrom(write(t, `{"given":[ + {"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}}, + {"from":"b","node":"n","at":"n.internal","values":{"port":8080}}, + {"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 1 || routes["fine.example"] == "" { + t.Fatalf("an unusable contribution was served: %v", routes) + } +} + +// End to end through the proxy itself: a request for the name reaches the workload, and a name +// nobody asked for is refused in a way that says what IS served. +func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { + workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("the workload")) + })) + defer workload.Close() + target := strings.TrimPrefix(workload.URL, "http://") + host, port, _ := strings.Cut(target, ":") + + held := newTable() + held.set(map[string]string{"app.example": "http://" + host + ":" + port}) + + proxy := httptest.NewServer(handler(held)) + defer proxy.Close() + + asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil) + if err != nil { + t.Fatal(err) + } + asked.Host = "app.example" + answer, err := http.DefaultClient.Do(asked) + if err != nil { + t.Fatal(err) + } + defer answer.Body.Close() + if answer.StatusCode != http.StatusOK { + t.Fatalf("a request for a served name got %d", answer.StatusCode) + } + + // And a name that is not served says which are — a route withdrawn and a name that never + // existed are different things, and a bare 404 makes an operator go and read the mesh. + other, _ := http.NewRequest(http.MethodGet, proxy.URL, nil) + other.Host = "nobody.example" + refused, err := http.DefaultClient.Do(other) + if err != nil { + t.Fatal(err) + } + defer refused.Body.Close() + if refused.StatusCode != http.StatusNotFound { + t.Fatalf("a name nobody asked for got %d", refused.StatusCode) + } + body := make([]byte, 256) + n, _ := refused.Body.Read(body) + if !strings.Contains(string(body[:n]), "app.example") { + t.Fatalf("the refusal does not say what is served: %s", body[:n]) + } +} + +// The file is the whole truth about who has a route, so the table replaces rather than merges. +// +// Merging would keep serving a name whose module was unassigned — the stale-route fault +// 08-connectivity lists as open, reintroduced one level down. A stale public name pointing at +// nothing fails more visibly than a stale grant, which is exactly why it must not survive. +func TestWithdrawingARouteStopsServingIt(t *testing.T) { + held := newTable() + held.set(map[string]string{ + "going.example": "http://a.internal:80", + "staying.example": "http://b.internal:80", + }) + held.set(map[string]string{"staying.example": "http://b.internal:80"}) + + if _, still := held.find("going.example"); still { + t.Fatal("a route whose module was unassigned is still served") + } + if _, kept := held.find("staying.example"); !kept { + t.Fatal("withdrawing one route took another with it") + } +} + +// A Host header carries a port and the name does not. +func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { + held := newTable() + held.set(map[string]string{"app.example": "http://a.internal:8080"}) + if _, found := held.find("app.example:8080"); !found { + t.Fatal("a request to app.example:8080 did not find the route for app.example") + } +} -- 2.54.0 From 0f21ae2ddec79ac06862cb21af7f9c21bb94dbb8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 19:35:34 +0200 Subject: [PATCH 02/51] The proxy can obtain a public certificate, and asks staging by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work breakdown 1.4. The mesh's own authority certifies internal names and always did; a name reachable from outside needs one the world already trusts, and there was no ACME anywhere in this repository. Uses acme/autocert from x/crypto, which was already a dependency — one indirect addition (x/net, for idna) and no new direct one. Three things worth more than the feature: **Staging is the default** (novox/hq 04-ISSUES/004). Production issuance is rate-limited per domain and per account and does not replenish quickly. Defaulting to production would leave the safe path depending on remembering to opt out, on exactly the work most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces itself on the first request rather than a fortnight later. **A certificate is only asked for on a name the mesh routes here.** Without that policy, anything that can reach the port and send a name triggers an order for it — a scan becomes a stream of failed orders against the account's rate limit, and the proxy looks healthy throughout. What it may certify is what it was told to serve. **A private issuer is trusted by naming a file, never by skipping verification.** Skip would still apply on the day this points at a public issuer, and nothing would say so. TLS is opt-in: without TLS_LISTEN the proxy serves plain HTTP exactly as before, which is what an internal-only mesh wants. With it and no cache, it refuses rather than defaulting — every restart would otherwise order new certificates, silently, until the rate limit says it does not. --- modules/route-proxy/cmd/route-proxy/main.go | 102 +++++++++++++++++- .../cmd/route-proxy/routes_test.go | 55 ++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index f50d322..6ae5440 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -20,6 +20,9 @@ package main import ( + "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "log" @@ -32,8 +35,48 @@ import ( "strings" "sync" "time" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" ) +// Where public certificates come from when nothing says otherwise. +// +// **Staging, deliberately** (novox/hq 04-ISSUES/004). Production issuance is rate-limited per +// domain and per account, the quota does not replenish quickly, and exhausting it removes the +// ability to issue a certificate somebody actually needs. Defaulting to production would leave +// the safe path depending on remembering to opt out of it, on exactly the work most likely to +// iterate — standing up a node, changing how names resolve. +// +// A staging certificate is trusted by no browser, so the mistake announces itself on the first +// request rather than a fortnight later at the rate limit. +const stagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory" + +// issuer is the ACME directory to ask. The lab points this at its own issuer; a node serving real +// traffic points it at production, and says so explicitly. +func issuer() string { + if named := strings.TrimSpace(os.Getenv("ACME_DIRECTORY")); named != "" { + return named + } + return stagingDirectory +} + +// onlyWhatTheMeshSaid refuses to obtain a certificate for a name this proxy was not given. +// +// **The policy that stops a quota from being spent by accident.** Without it, anything that can +// reach port 443 and send a name triggers an issuance attempt for it — so a scan, or one +// misconfigured client, becomes a stream of failed orders against the account's rate limit. What +// this proxy may certify is exactly what the mesh told it to route, which is already the answer +// to what it may serve. +func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { + return func(_ context.Context, host string) error { + if _, known := held.find(host); known { + return nil + } + return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) + } +} + // what the mesh writes: the contributions file, one entry per consumer. type given struct { Given []contribution `json:"given"` @@ -134,7 +177,64 @@ func run() error { } }() - return http.ListenAndServe(listen, handler(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 + // covers for names inside it (novox/hq 08-connectivity). This is for names reachable from + // outside, where the authority has to be one the world already trusts. + secure := strings.TrimSpace(os.Getenv("TLS_LISTEN")) + if secure == "" { + return http.ListenAndServe(listen, handler(held)) + } + + cache := strings.TrimSpace(os.Getenv("ACME_CACHE")) + if cache == "" { + // Refused rather than defaulted. Without somewhere durable to keep them, every restart + // orders new certificates — which works, silently, until the rate limit says it does not. + return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " + + "to persist, or every restart orders them again") + } + client := &acme.Client{DirectoryURL: issuer()} + // An issuer that is not one of the public ones serves its own API over TLS with a certificate + // nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and + // names a file, rather than the client being told to skip verification: *skip* would also + // apply on the day this points at a public issuer, and nothing would say so. + if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" { + pem, err := os.ReadFile(bundle) + if err != nil { + return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("%s holds no certificate this can trust", bundle) + } + client.HTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, + } + } + manager := &autocert.Manager{ + Cache: autocert.DirCache(cache), + Prompt: autocert.AcceptTOS, + HostPolicy: onlyWhatTheMeshSaid(held), + Client: client, + } + log.Printf("issuing from %s, for whatever the mesh routes here", issuer()) + + // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge + // must be answered *at the name being certified*, which is why issuance happens on the node + // that is publicly reachable rather than wherever the workload runs. + go func() { + if err := http.ListenAndServe(listen, manager.HTTPHandler(handler(held))); err != nil { + log.Printf("plain HTTP stopped: %v", err) + } + }() + + server := &http.Server{ + Addr: secure, + Handler: handler(held), + TLSConfig: manager.TLSConfig(), + } + return server.ListenAndServeTLS("", "") } // newTable is an empty routing table. diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index 628ed7b..6740569 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "net/http/httptest" "os" @@ -141,3 +142,57 @@ func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { t.Fatal("a request to app.example:8080 did not find the route for app.example") } } + +// Defends novox/hq 04-ISSUES/004: issuance targets staging unless something says otherwise. +// +// The failure this guards is not a broken proxy. It is a working one that quietly spends a +// production quota which does not replenish for a week, on exactly the work most likely to +// iterate. +func TestTheIssuerIsStagingUnlessNamed(t *testing.T) { + t.Setenv("ACME_DIRECTORY", "") + if got := issuer(); !strings.Contains(got, "staging") { + t.Fatalf("with nothing set the issuer is %q, and a default that spends production quota "+ + "is a default nobody chose", got) + } + + t.Setenv("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory") + if got := issuer(); strings.Contains(got, "staging") { + t.Fatalf("an issuer was named explicitly and %q was used instead", got) + } +} + +// A certificate is only ever asked for on a name the mesh routes here. +// +// **Without this, anything that can reach the port spends the quota.** A scan sending arbitrary +// names, or one misconfigured client, becomes a stream of failed orders against the account's +// rate limit — and the proxy would look healthy throughout. +func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { + held := newTable() + held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + policy := onlyWhatTheMeshSaid(held) + + if err := policy(context.Background(), "photos.example"); err != nil { + t.Errorf("a name the mesh routes here was refused a certificate: %v", err) + } + for _, name := range []string{"unknown.example", "", "photos.example.evil"} { + if err := policy(context.Background(), name); err == nil { + t.Errorf("a certificate would be ordered for %q, which the mesh never mentioned", name) + } + } +} + +// A route withdrawn stops being certifiable, without the proxy restarting. +func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { + held := newTable() + held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + policy := onlyWhatTheMeshSaid(held) + if err := policy(context.Background(), "photos.example"); err != nil { + t.Fatal(err) + } + + held.set(nil) + if err := policy(context.Background(), "photos.example"); err == nil { + t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " + + "once rather than what is served now") + } +} -- 2.54.0 From 1fba76c768209e92c276f57d3ec44ab3412c9920 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 10 Sep 2026 00:22:06 +0200 Subject: [PATCH 03/51] route-proxy: an empty ACME_CA_BUNDLE means the system trust store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piece A of ADR 0056 (selectable issuer). A provider that serves an empty root — public-acme, whose root already ships in the OS trust store — leaves route-proxy's CA bundle file existing but empty, because the mesh writes it unconditionally from ${bound:acme-ca:root}. Read that as "trust the system roots", the same as an unset bundle, instead of failing with "holds no certificate this can trust". A bundle that holds bytes but no parseable certificate is still refused. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/route-proxy/cmd/route-proxy/main.go | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 6ae5440..3235d18 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -203,13 +203,21 @@ func run() error { if err != nil { return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pem) { - return fmt.Errorf("%s holds no certificate this can trust", bundle) - } - client.HTTPClient = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, + // An empty bundle means the issuer's root is already in the system trust store — a public + // authority whose root ships with the OS, pointed at by a provider that serves an empty + // root (novox/hq ADR 0056). The mesh always writes the bundle file, so it exists and holds + // nothing; that is the signal to fall back to the system roots, the same as if nothing had + // named a bundle at all. A file that holds bytes but no certificate is still a + // misconfiguration and is refused, because there the operator meant to trust something. + if strings.TrimSpace(string(pem)) != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("%s holds no certificate this can trust", bundle) + } + client.HTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, + } } } manager := &autocert.Manager{ -- 2.54.0 From fae039eba6d01ad08accf690fb8c28fb4206fd6e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 10 Sep 2026 21:02:00 +0200 Subject: [PATCH 04/51] route-proxy: an ACME account belongs to the authority that issued it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autocert keeps its account key at one fixed name, `acme_account+key`, in whatever directory it is given, and reuses it for ever. That is right while the authority stays the same and silently wrong the moment it does not. Re-initialising the internal CA makes a new authority with a new root: it has never heard of the account in the cache, rejects every use of it, and autocert has no path back from that. Nothing is re-registered, no order ever reaches the CA, and issuance stops with nothing saying why — until somebody guesses that deleting the cache directory by hand is the answer. Name the directory after the authority instead of sharing one between all of them: a digest of the ACME directory URL and the root this proxy was told to verify it with. A re-initialised CA has a new root, the mesh delivers it as a changed bundle, the proxy restarts on that file and lands in a directory with no account in it, so autocert registers afresh and orders again. The healing is that "is this account still valid" never has to be asked — an account is only ever found where it is still valid, which needs no error codes, no probe at startup and no network call that can itself fail. It closes a latent one of the same shape: pointing ACME_DIRECTORY at production after testing against staging reused the staging account, because the cache had no idea the two were different. Trailing whitespace around the delivered root is not a new authority — the mesh writes that file, and a newline coming or going must not throw away an account. Old directories are left on disk, unused: they hold the only copy of certificates that may still be valid, and this program is not the thing that should decide a certificate is finished with. A proxy already holding certificates orders them once more on the first start after this, because its account moves. Free against the lab's own CA and against staging; one issuance per name against a public authority. novox/hq ADR 0056 Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .../cmd/route-proxy/authority_test.go | 73 +++++++++++++++++++ modules/route-proxy/cmd/route-proxy/main.go | 48 ++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 modules/route-proxy/cmd/route-proxy/authority_test.go diff --git a/modules/route-proxy/cmd/route-proxy/authority_test.go b/modules/route-proxy/cmd/route-proxy/authority_test.go new file mode 100644 index 0000000..0f70a27 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/authority_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +// The lab's first root, and the one a re-initialised CA generates in its place. +const ( + firstRoot = "-----BEGIN CERTIFICATE-----\nMIIBeFIRST\n-----END CERTIFICATE-----\n" + secondRoot = "-----BEGIN CERTIFICATE-----\nMIIBeSECOND\n-----END CERTIFICATE-----\n" +) + +// A re-initialised CA does not need somebody to delete the cache by hand. +// +// autocert keeps its account key at one fixed name and reuses it for ever. When an internal CA is +// re-initialised it has never heard of that account, rejects every use of it, and autocert has no +// path back: nothing is re-registered, no order reaches the CA, and issuance stops with nothing +// saying why. Naming the cache after the authority means the account is only ever found where it is +// still valid — the new root lands in a directory with no account in it, and autocert registers. +func TestANewCARootMeansANewAccountCache(t *testing.T) { + const directory = "https://anchor.internal/acme/acme/directory" + before := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(firstRoot)) + after := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(secondRoot)) + if before == after { + t.Fatalf("a re-initialised CA reuses the account it was rejected for: %s", before) + } +} + +// And the SAME authority keeps the account it registered, restart after restart. +// +// This is the whole reason ACME_CACHE is required in the first place: an account and its +// certificates that did not persist would be re-ordered on every restart, which works silently until +// a rate limit says it does not. Whitespace around the delivered root is not a new authority — the +// mesh writes that file, and a trailing newline coming or going must not throw away an account. +func TestTheSameAuthorityKeepsItsAccount(t *testing.T) { + const directory = "https://anchor.internal/acme/acme/directory" + first := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(firstRoot)) + again := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte("\n"+firstRoot+"\n\n")) + if first != again { + t.Errorf("the same authority was given two caches, so every restart orders again:\n%s\n%s", + first, again) + } +} + +// Staging and production are different authorities, and were sharing one account. +// +// The latent fault of the same shape: pointing ACME_DIRECTORY at production after testing against +// staging reused the staging account, because the cache had no idea they were different. +func TestStagingAndProductionDoNotShareAnAccount(t *testing.T) { + staging := forThisAuthority("/acme", stagingDirectory, nil) + production := forThisAuthority("/acme", "https://acme-v02.api.letsencrypt.org/directory", nil) + if staging == production { + t.Errorf("two issuers share one account: %s", staging) + } +} + +// It stays inside the directory the mesh gave it, and is a plain name. +// +// The mesh owns ACME_CACHE and mounts it; a name derived from a certificate that escaped it — or +// that carried a separator out of the PEM — would put an account somewhere nothing persists. +func TestTheAccountCacheStaysWhereTheMeshPutIt(t *testing.T) { + const cache = "/var/lib/route-proxy/acme" + got := forThisAuthority(cache, "https://anchor.internal/acme/acme/directory", []byte(firstRoot)) + if !strings.HasPrefix(got, cache+"/") { + t.Fatalf("the account cache is not under %s: %s", cache, got) + } + name := strings.TrimPrefix(got, cache+"/") + if name != filepath.Base(got) || strings.ContainsAny(name, "/.") { + t.Errorf("the account cache is not a plain name: %q", name) + } +} diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 3235d18..397d65f 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -20,9 +20,12 @@ package main import ( + "bytes" "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "fmt" "log" @@ -31,6 +34,7 @@ import ( "net/http/httputil" "net/url" "os" + "path/filepath" "sort" "strings" "sync" @@ -198,20 +202,22 @@ func run() error { // nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and // names a file, rather than the client being told to skip verification: *skip* would also // apply on the day this points at a public issuer, and nothing would say so. + var root []byte if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" { - pem, err := os.ReadFile(bundle) + read, err := os.ReadFile(bundle) if err != nil { return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) } + root = read // An empty bundle means the issuer's root is already in the system trust store — a public // authority whose root ships with the OS, pointed at by a provider that serves an empty // root (novox/hq ADR 0056). The mesh always writes the bundle file, so it exists and holds // nothing; that is the signal to fall back to the system roots, the same as if nothing had // named a bundle at all. A file that holds bytes but no certificate is still a // misconfiguration and is refused, because there the operator meant to trust something. - if strings.TrimSpace(string(pem)) != "" { + if strings.TrimSpace(string(root)) != "" { pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pem) { + if !pool.AppendCertsFromPEM(root) { return fmt.Errorf("%s holds no certificate this can trust", bundle) } client.HTTPClient = &http.Client{ @@ -220,13 +226,16 @@ func run() error { } } } + // Where this authority's account and certificates are kept. Per authority, not per proxy — see + // forThisAuthority, which is what makes a re-initialised CA heal itself. + mine := forThisAuthority(cache, issuer(), root) manager := &autocert.Manager{ - Cache: autocert.DirCache(cache), + Cache: autocert.DirCache(mine), Prompt: autocert.AcceptTOS, HostPolicy: onlyWhatTheMeshSaid(held), Client: client, } - log.Printf("issuing from %s, for whatever the mesh routes here", issuer()) + log.Printf("issuing from %s into %s, for whatever the mesh routes here", issuer(), mine) // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge // must be answered *at the name being certified*, which is why issuance happens on the node @@ -245,6 +254,35 @@ func run() error { return server.ListenAndServeTLS("", "") } +// forThisAuthority is where one ACME authority's account and certificates are kept. +// +// **A cached ACME account belongs to the authority that issued it, and nothing in the cache says +// so** (novox/hq ADR 0056). autocert keeps its account key at one fixed name — `acme_account+key` — +// in whatever directory it is given, and reuses it for ever. That is right while the authority stays +// the same and silently wrong the moment it does not: an internal CA that is re-initialised is a new +// authority with a new root, it has never heard of the account in the cache, and every attempt to +// use it is rejected. autocert has no path back from that. Nothing is retried, nothing is +// re-registered, no order ever reaches the CA — issuance simply stops, with no error anybody sees, +// until a person deletes the directory by hand and finds out that was the answer. +// +// So the directory is named after the authority instead of being shared by all of them. The name is +// a digest of the two things that identify one: the directory URL, and the root this proxy was told +// to verify it with. Re-initialising the CA produces a new root; the mesh delivers it as a changed +// bundle; this proxy restarts on that file and lands in a directory with no account in it, so +// autocert registers afresh and orders again. **The healing is that the question "is this account +// still valid" never has to be asked** — an account is only ever found where it is still valid. +// +// It also fixes a latent one of the same shape: pointing ACME_DIRECTORY at production after testing +// against staging reused the staging account, because the cache had no idea they were different. +// +// The old directories stay on disk, unused. Left rather than deleted: they are the only copy of +// certificates that may still be valid, and this program is not the thing that should decide a +// certificate is finished with. +func forThisAuthority(cache, directory string, root []byte) string { + sum := sha256.Sum256([]byte(directory + "\x00" + string(bytes.TrimSpace(root)))) + return filepath.Join(cache, hex.EncodeToString(sum[:])[:16]) +} + // newTable is an empty routing table. func newTable() *table { return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}} -- 2.54.0 From e45c81e2408ceae72b9085597b8e15695f255064 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 11 Sep 2026 00:09:56 +0200 Subject: [PATCH 05/51] The routing record is 0066, not 0056 0056 is 'the authority is the control plane, not a database'. A citation pointing at the wrong decision is worse than none: it reads as corroboration. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/route-proxy/cmd/route-proxy/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 397d65f..2962a85 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -211,7 +211,7 @@ func run() error { root = read // An empty bundle means the issuer's root is already in the system trust store — a public // authority whose root ships with the OS, pointed at by a provider that serves an empty - // root (novox/hq ADR 0056). The mesh always writes the bundle file, so it exists and holds + // root (novox/hq ADR 0066). The mesh always writes the bundle file, so it exists and holds // nothing; that is the signal to fall back to the system roots, the same as if nothing had // named a bundle at all. A file that holds bytes but no certificate is still a // misconfiguration and is refused, because there the operator meant to trust something. @@ -257,7 +257,7 @@ func run() error { // forThisAuthority is where one ACME authority's account and certificates are kept. // // **A cached ACME account belongs to the authority that issued it, and nothing in the cache says -// so** (novox/hq ADR 0056). autocert keeps its account key at one fixed name — `acme_account+key` — +// so** (novox/hq ADR 0066). autocert keeps its account key at one fixed name — `acme_account+key` — // in whatever directory it is given, and reuses it for ever. That is right while the authority stays // the same and silently wrong the moment it does not: an internal CA that is re-initialised is a new // authority with a new root, it has never heard of the account in the cache, and every attempt to -- 2.54.0 From cd4bd5083cf2b7aedf560d6c077b04383760e8ab Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 14:00:57 +0200 Subject: [PATCH 06/51] route-proxy: a route carries the policy applied to a request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements novox/hq ADR 0108, closing issue 116. The proxy's request path was a host lookup and a forward, so it applied nothing — while the ingress it replaces relies on four things it had none of. Path scoping came first because it is a prerequisite, not a sibling. The table mapped a host to one target, so a host could not be routed two ways, and the refusal this issue turns on matches a path on a host already routed to a workload. No amount of authentication or source filtering would have made it expressible. The table is now host to an ordered list of rules, matched on path prefix. The order is total, not just by priority. Sorting on priority alone leaves rules that share one in whatever order the map produced, so the same declaration would serve differently between restarts — a fault that works, and works differently each time, which is the hardest kind to believe when reported. Within a priority the longer path wins, which is also the intuitive reading. auth names a secret and never holds one. A declaration carrying a credential is refused whole rather than served unprotected, so the option ADR 0108 rejected cannot return by accident. A secret that cannot be read makes the route refuse and say so, rather than serve the workload unprotected — a gate that cannot check is not a gate that opens, and the alternative turns a missing file into a silently public admin surface. Authentication costs one bcrypt comparison on every path including an unknown user, so an unknown user is not measurably faster than a known one with a wrong password. That difference is a way to enumerate a route's users from outside it. Redirects keep the request's own path and query, or canonicalising one name onto another would land every deep link on the front page and raise no error doing it. Eleven tests, four of them for the capabilities and two for the failure modes that rot quietly: the credential-in-a-declaration refusal, and the unreadable secret failing closed. Nothing else breaks if those stop working, so nothing else would report it. No new dependency: bcrypt comes from the x/crypto module already required. --- modules/route-proxy/cmd/route-proxy/main.go | 356 ++++++++++++++++-- .../cmd/route-proxy/policy_test.go | 233 ++++++++++++ .../cmd/route-proxy/routes_test.go | 43 ++- 3 files changed, 581 insertions(+), 51 deletions(-) create mode 100644 modules/route-proxy/cmd/route-proxy/policy_test.go diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 2962a85..10d7217 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -10,10 +10,31 @@ // 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 +// to say only where to send traffic, so this proxy applied nothing; the four things the ingress it +// replaces actually relies on are now part of the contribution. The set is closed at four, because +// 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: +// +// 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 +// deny refuse the request outright — the shape an incident mitigation needs +// redirect answer with a permanent redirect to this name, keeping the path and query +// auth the *path of a secret* holding `user:hash` lines, never the credential itself +// +// A host may appear more than once, which is what path scoping means: one rule refusing a path +// while another serves everything else on the same name. +// +// **`auth` names a secret and never holds one.** A declaration carrying a credential is refused +// 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 // other workload. @@ -23,6 +44,7 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/hex" @@ -42,6 +64,7 @@ import ( "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" + "golang.org/x/crypto/bcrypt" ) // Where public certificates come from when nothing says otherwise. @@ -74,7 +97,7 @@ func issuer() string { // to what it may serve. func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { return func(_ context.Context, host string) error { - if _, known := held.find(host); known { + if held.routed(host) { return nil } return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) @@ -95,48 +118,145 @@ type contribution struct { Values map[string]any `json:"values"` } +// policy is what a rule does with a request that matched it. +// +// **Decided by the mesh, not here** (novox/hq ADR 0108). A route grant used to hand back a name and +// say nothing about what the name admitted, so this proxy admitted everything. The set is closed at +// four — authentication, refusal, path scoping, redirect — because an open middleware surface +// recreates the thing being replaced and is far harder to narrow later than a closed one is to widen. +type policy struct { + // deny refuses the request outright, whatever it is. + deny bool + // redirectTo answers with a permanent redirect instead of proxying. The request's own path and + // query are carried across, which is what canonicalising one public name onto another means. + redirectTo string + // users is what a request must present, read at load time from the secret the declaration + // *named*. A declaration never carries the credential itself. + users map[string]string + // sealed is set when authentication was declared and the secret could not be read. The rule then + // refuses everything and says why. + // + // **Fail closed.** The alternative — serve the route unauthenticated because the gate is + // missing — turns an unreadable file into a silently public admin surface, which is the exact + // outcome ADR 0108 exists to prevent. A gate that cannot check is not a gate that opens. + sealed string +} + +// rule is one way a host may be routed. A host may have several, which is what path scoping means. +type rule struct { + path string // "" matches every path + priority int + policy policy + to *httputil.ReverseProxy + target string +} + // table is what the proxy is currently serving, replaced whole whenever the file changes. // // Replaced rather than merged: the file is 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. +// +// Keyed by host to an *ordered* list rather than to one target, because two of the four policies +// need a single host routed more than one way: a refusal on a path the ordinary route also matches, +// and a certificate-challenge path on a host that otherwise serves a workload. type table struct { - mu sync.RWMutex - to map[string]*httputil.ReverseProxy - targets map[string]string + mu sync.RWMutex + to map[string][]rule } -func (t *table) set(routes map[string]string) { - made := map[string]*httputil.ReverseProxy{} - for name, target := range routes { - where, err := url.Parse(target) - if err != nil { - log.Printf("route %s points at %q, which is not a URL: %v", name, target, err) +func (t *table) set(routes map[string][]rule) { + made := map[string][]rule{} + for host, rules := range routes { + kept := make([]rule, 0, len(rules)) + for _, r := range rules { + // A rule that only refuses or only redirects has nowhere to send anything, and needs + // nowhere: it answers by itself. + if r.policy.deny || r.policy.redirectTo != "" { + kept = append(kept, r) + continue + } + where, err := url.Parse(r.target) + if err != nil { + log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err) + continue + } + r.to = httputil.NewSingleHostReverseProxy(where) + kept = append(kept, r) + } + if len(kept) == 0 { continue } - made[name] = httputil.NewSingleHostReverseProxy(where) + inOrder(kept) + made[host] = kept } t.mu.Lock() - t.to, t.targets = made, routes + t.to = made t.mu.Unlock() } -func (t *table) find(host string) (*httputil.ReverseProxy, bool) { - // The port is not part of the name. A request to app.example:8080 is for app.example. +// inOrder puts the rules for one host into the order they are matched in, and does so totally. +// +// **Equal priorities must resolve identically every time** (ADR 0108). Sorting only by priority +// leaves rules that share one in whatever order the map produced, so the same declaration would +// serve differently between restarts — a proxy that is not reproducible. Longest path first within a +// priority is also the intuitive reading: the more specific rule wins. The last two keys exist only +// to make the order total. +func inOrder(rules []rule) { + sort.SliceStable(rules, func(i, j int) bool { + a, b := rules[i], rules[j] + if a.priority != b.priority { + return a.priority > b.priority + } + if len(a.path) != len(b.path) { + return len(a.path) > len(b.path) + } + if a.path != b.path { + return a.path < b.path + } + return a.target < b.target + }) +} + +// find is the rule that answers this request, or nothing if the host is not routed here at all. +func (t *table) find(host, path string) (rule, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + for _, r := range t.to[bareHost(host)] { + if r.path == "" || strings.HasPrefix(path, r.path) { + return r, true + } + } + return rule{}, false +} + +// routed says whether this proxy serves the name at all, whatever the path. +// +// Separate from find because certificate issuance is a question about the *name*: a host whose only +// rules are path-scoped is still a name this proxy answers to, and still needs a certificate. +func (t *table) routed(host string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.to[bareHost(host)]) > 0 +} + +// bareHost is the name without the port, lower-cased. +// +// The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased +// because a Host header is not case-sensitive, and a route that only answers the spelling in the +// manifest answers half the requests made to it. +func bareHost(host string) string { if h, _, err := net.SplitHostPort(host); err == nil { host = h } - t.mu.RLock() - defer t.mu.RUnlock() - p, ok := t.to[strings.ToLower(host)] - return p, ok + return strings.ToLower(host) } func (t *table) names() []string { t.mu.RLock() defer t.mu.RUnlock() - out := make([]string, 0, len(t.targets)) - for name := range t.targets { + out := make([]string, 0, len(t.to)) + for name := range t.to { out = append(out, name) } sort.Strings(out) @@ -285,13 +405,13 @@ func forThisAuthority(cache, directory string, root []byte) string { // newTable is an empty routing table. func newTable() *table { - return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}} + return &table{to: map[string][]rule{}} } // handler is the proxy itself, separated so it can be driven by a test without a listener. func handler(held *table) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - proxy, known := held.find(r.Host) + matched, known := held.find(r.Host, r.URL.Path) if !known { // **Named, not a bare 404.** A route that was withdrawn and a name that never existed // are different things, and a proxy that says only "not found" makes an operator go @@ -302,12 +422,89 @@ func handler(held *table) http.Handler { r.Host, strings.Join(held.names(), ", ")) return } - proxy.ServeHTTP(w, r) + + switch { + case matched.policy.sealed != "": + // Declared a gate, cannot check it. Refused, and says why — an operator reading this + // learns the secret is missing, rather than wondering why a protected name is 503. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "this route requires authentication and its credentials cannot be read: %s\n", + matched.policy.sealed) + return + + case matched.policy.deny: + http.Error(w, "this path is not served to you", http.StatusForbidden) + return + + case matched.policy.redirectTo != "": + http.Redirect(w, r, canonical(matched.policy.redirectTo, r.URL), http.StatusMovedPermanently) + return + + case len(matched.policy.users) > 0 && !allowed(matched.policy.users, r): + // The realm is the name asked for, so a browser's prompt says which route it is for. + w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", bareHost(r.Host))) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + matched.to.ServeHTTP(w, r) }) } -// routesFrom reads what the mesh wrote and turns it into name → target. -func routesFrom(path string) (map[string]string, error) { +// canonical is where a redirect sends this request. +// +// The declaration names the destination *name*; the request keeps its own path and query. That is +// what canonicalising one public name onto another means — a link to a page under the old name has +// to arrive at the same page under the new one, or the redirect silently loses every deep link. +func canonical(to string, from *url.URL) string { + where, err := url.Parse(to) + if err != nil { + return to + } + if where.Path == "" || where.Path == "/" { + where.Path = from.Path + } + if where.RawQuery == "" { + where.RawQuery = from.RawQuery + } + return where.String() +} + +// allowed says whether the request presented credentials this route accepts. +// +// **Every path costs one bcrypt comparison**, including an unknown user, which is why the miss +// compares against a fixed hash rather than returning early. Returning early would make an unknown +// user measurably faster than a known one with a wrong password, and that difference is a way to +// enumerate the users of a route from outside it. +func allowed(users map[string]string, r *http.Request) bool { + // A hash of nothing anybody knows. Its only job is to cost what a real comparison costs. + const absent = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" + user, password, ok := r.BasicAuth() + if !ok { + return false + } + want, known := users[user] + if !known { + want = absent + } + if err := bcrypt.CompareHashAndPassword([]byte(want), []byte(password)); err != nil { + return false + } + // `known` is checked after the comparison, not instead of it, so the timing is the same either + // way. subtle.ConstantTimeByteEq keeps the branch from being the thing that differs. + return subtle.ConstantTimeByteEq(boolByte(known), 1) == 1 +} + +func boolByte(b bool) byte { + if b { + return 1 + } + return 0 +} + +// routesFrom reads what the mesh wrote and turns it into host → the rules for that host. +func routesFrom(path string) (map[string][]rule, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err @@ -317,31 +514,114 @@ func routesFrom(path string) (map[string]string, error) { return nil, err } - out := map[string]string{} + out := map[string][]rule{} for _, c := range said.Given { name, _ := c.Values["name"].(string) if name == "" { log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node) continue } - 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, name) - continue + host := strings.ToLower(name) + + made := rule{path: asPath(c.Values["path"])} + if p, ok := asPort(c.Values["priority"]); ok { + made.priority = p } - // 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. - at := c.At - if at == "" { - at = "127.0.0.1" + 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, name) + continue + } + users, err := usersFrom(named) + if err != nil { + // Fail closed: the rule is kept so the name stays routed and answers, and it + // answers by refusing. Dropping it instead would make the name 404 and read as a + // withdrawn route rather than an unreadable secret. + made.policy.sealed = err.Error() + } + made.policy.users = users } - out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port) + + // 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, name) + continue + } + // 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. + at := c.At + if at == "" { + at = "127.0.0.1" + } + made.target = fmt.Sprintf("http://%s:%d", at, port) + } + + out[host] = append(out[host], made) } return out, nil } +// 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) + if err != nil { + return nil, fmt.Errorf("cannot read the secret named for this route: %w", err) + } + users := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + user, hash, ok := strings.Cut(line, ":") + if !ok || user == "" || hash == "" { + continue + } + users[user] = hash + } + if len(users) == 0 { + return nil, fmt.Errorf("the secret named for this route holds no usable credentials") + } + 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) { diff --git a/modules/route-proxy/cmd/route-proxy/policy_test.go b/modules/route-proxy/cmd/route-proxy/policy_test.go new file mode 100644 index 0000000..7f98ec4 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/policy_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +// What a route carries about the requests arriving at it — novox/hq ADR 0108. +// +// Each test here is one of the four capabilities that record closed the set at, plus the negative +// case it promised would be refused. The negative case is the one that rots quietly: nothing fails +// if it stops working, so nothing tells you it has. + +// served starts a workload and gives back the host and port the mesh would have recorded for it. +func served(t *testing.T, body string) (string, int) { + t.Helper() + workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(workload.Close) + host, port, _ := strings.Cut(strings.TrimPrefix(workload.URL, "http://"), ":") + n, err := strconv.Atoi(port) + if err != nil { + t.Fatal(err) + } + return host, n +} + +// ask makes one request through the proxy for a given name and path, without following redirects. +func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, proxy+path, nil) + if err != nil { + t.Fatal(err) + } + req.Host = name + if auth[0] != "" { + req.SetBasicAuth(auth[0], auth[1]) + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + answer, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = answer.Body.Close() }) + return answer +} + +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, err := routesFrom(path) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes) + server := httptest.NewServer(handler(held)) + t.Cleanup(server.Close) + return server.URL +} + +// A refusal on a path shadows the ordinary route for that path and leaves every other path alone. +// +// **This is why path scoping is a prerequisite and not a sibling capability.** The rule being +// reproduced matches a path on a host that is already routed to a workload, so a table mapping a +// host to one target cannot express it at all — no amount of authentication or source filtering +// would have helped. +func TestARefusedPathShadowsTheRouteAndLeavesTheRestServed(t *testing.T) { + at, port := served(t, "the workload") + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100,"deny":true}} + ]}`) + + if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { + t.Fatalf("the refused path answered %d, so the block that was put in front of it during an "+ + "incident is not in front of it any more", got) + } + if got := ask(t, proxy, "forge.example", "/", [2]string{}).StatusCode; got != http.StatusOK { + t.Fatalf("refusing one path took the whole route with it: %d", got) + } +} + +// A redirect answers with the redirect, and the request keeps its own path and query. +// +// Losing the path would turn canonicalising one name onto another into "every deep link now lands +// on the front page", which is the kind of breakage that produces no error anywhere. +func TestARedirectKeepsThePathAndQuery(t *testing.T) { + proxy := proxyFor(t, `{"given":[ + {"from":"site","node":"anchor","values":{"name":"www.example","redirect":"https://example/"}} + ]}`) + + answer := ask(t, proxy, "www.example", "/deep/page?ref=1", [2]string{}) + if answer.StatusCode != http.StatusMovedPermanently { + t.Fatalf("a declared redirect answered %d", answer.StatusCode) + } + where := answer.Header.Get("Location") + if !strings.Contains(where, "/deep/page") || !strings.Contains(where, "ref=1") { + t.Fatalf("the redirect dropped the path or the query: %q", where) + } +} + +// Authentication refuses a request with no credentials, admits one with the right ones, and refuses +// the wrong ones — with the credentials read from the secret the declaration *named*. +func TestAuthenticationAdmitsOnlyWhatTheSecretSays(t *testing.T) { + at, port := served(t, "the console") + hash, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + secret := filepath.Join(t.TempDir(), "console-auth") + if err := os.WriteFile(secret, []byte("# a comment\nadmin:"+string(hash)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + proxy := proxyFor(t, `{"given":[ + {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+secret+`"}} + ]}`) + + if got := ask(t, proxy, "console.example", "/", [2]string{}).StatusCode; got != http.StatusUnauthorized { + t.Fatalf("an admin surface with no login of its own answered %d without credentials", got) + } + if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "wrong"}).StatusCode; got != http.StatusUnauthorized { + t.Fatalf("the wrong password answered %d", got) + } + if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "correct horse"}).StatusCode; got != http.StatusOK { + t.Fatalf("the right password answered %d", got) + } +} + +// The negative case ADR 0108 promised would be refused: a credential in the declaration. +// +// **Refused whole, not tolerated and not served unprotected.** A hash carried in a declaration was +// the rejected option; nothing in the running system should quietly accept it later, because the +// precedent is far easier to set than to withdraw. If this test is deleted the option returns and +// nothing else notices. +func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { + inline := []string{ + `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"admin:$2a$10$abcdefghijklmnopqrstuv"}}]}`, + `{"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) + if err != nil { + t.Fatal(err) + } + if len(routes) != 0 { + t.Fatalf("a declaration carrying a credential was served anyway: %v", routes) + } + } +} + +// Authentication declared, secret unreadable: the route refuses. It does not serve unprotected. +// +// **Fail closed.** The alternative turns a missing file into a silently public admin surface, which +// is the outcome the whole record exists to prevent. It answers rather than 404s, so an operator +// sees "cannot read the credentials" instead of concluding the route was withdrawn. +func TestAnUnreadableSecretFailsClosed(t *testing.T) { + at, port := served(t, "the console") + missing := filepath.Join(t.TempDir(), "not-mounted") + + proxy := proxyFor(t, `{"given":[ + {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+missing+`"}} + ]}`) + + answer := ask(t, proxy, "console.example", "/", [2]string{}) + if answer.StatusCode == http.StatusOK { + t.Fatal("a route whose credentials could not be read served the workload unprotected") + } + if answer.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected the route to say it cannot check, got %d", answer.StatusCode) + } +} + +// Equal priorities resolve the same way every time, so the same declaration serves the same way +// after a restart. +// +// Sorting only by priority leaves rules that share one in whatever order the map produced. The +// proxy would still work, and would work differently between restarts — which is the hardest kind +// of fault to believe when it is reported. +func TestRulesThatShareAPriorityAreStillTotallyOrdered(t *testing.T) { + first := []rule{ + {path: "/a", priority: 10, target: "http://x:1"}, + {path: "/bb", priority: 10, target: "http://y:2"}, + {path: "", priority: 10, target: "http://z:3"}, + } + second := []rule{ + {path: "", priority: 10, target: "http://z:3"}, + {path: "/bb", priority: 10, target: "http://y:2"}, + {path: "/a", priority: 10, target: "http://x:1"}, + } + inOrder(first) + inOrder(second) + for i := range first { + if first[i].path != second[i].path || first[i].target != second[i].target { + t.Fatalf("two orderings of the same rules disagree at %d: %q vs %q", + i, first[i].path, second[i].path) + } + } + // And the more specific rule is matched first, which is the intuitive reading. + if first[0].path != "/bb" { + t.Fatalf("the longest path is not matched first: %q", first[0].path) + } +} + +// Priority decides before path length does, so a rule can be made to win regardless of specificity. +func TestPriorityOutranksPathLength(t *testing.T) { + rules := []rule{ + {path: "/very/long/path", priority: 1, target: "http://x:1"}, + {path: "", priority: 100, target: "http://y:2"}, + } + inOrder(rules) + if rules[0].priority != 100 { + t.Fatalf("a higher priority did not win: %+v", rules[0]) + } +} diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index 6740569..c4d6ef2 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -19,6 +19,23 @@ func write(t *testing.T, body string) string { return path } +// plain is the table an ordinary set of routes makes: one host, one target, no policy. +func plain(routes map[string]string) map[string][]rule { + out := map[string][]rule{} + for host, target := range routes { + out[host] = []rule{{target: target}} + } + return out +} + +// targetOf is where a host's first matching rule sends a request. +func targetOf(routes map[string][]rule, host string) string { + if rules := routes[host]; len(rules) > 0 { + return rules[0].target + } + return "" +} + // 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) { @@ -30,7 +47,7 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { } // Lower-cased, because a Host header is not case-sensitive and a route that only answers the // spelling in the manifest answers half the requests made to it. - if routes["app.example"] != "http://laptop.internal:8080" { + if targetOf(routes, "app.example") != "http://laptop.internal:8080" { t.Fatalf("the route does not point at the consumer: %v", routes) } } @@ -44,7 +61,7 @@ func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { if err != nil { t.Fatal(err) } - if routes["app.example"] != "http://127.0.0.1:9000" { + if targetOf(routes, "app.example") != "http://127.0.0.1:9000" { t.Fatalf("a workload on this machine was not reachable: %v", routes) } } @@ -59,7 +76,7 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { if err != nil { t.Fatal(err) } - if len(routes) != 1 || routes["fine.example"] == "" { + if len(routes) != 1 || targetOf(routes, "fine.example") == "" { t.Fatalf("an unusable contribution was served: %v", routes) } } @@ -75,7 +92,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { host, port, _ := strings.Cut(target, ":") held := newTable() - held.set(map[string]string{"app.example": "http://" + host + ":" + port}) + held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port})) proxy := httptest.NewServer(handler(held)) defer proxy.Close() @@ -120,16 +137,16 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { // nothing fails more visibly than a stale grant, which is exactly why it must not survive. func TestWithdrawingARouteStopsServingIt(t *testing.T) { held := newTable() - held.set(map[string]string{ + held.set(plain(map[string]string{ "going.example": "http://a.internal:80", "staying.example": "http://b.internal:80", - }) - held.set(map[string]string{"staying.example": "http://b.internal:80"}) + })) + held.set(plain(map[string]string{"staying.example": "http://b.internal:80"})) - if _, still := held.find("going.example"); still { + if _, still := held.find("going.example", "/"); still { t.Fatal("a route whose module was unassigned is still served") } - if _, kept := held.find("staying.example"); !kept { + if _, kept := held.find("staying.example", "/"); !kept { t.Fatal("withdrawing one route took another with it") } } @@ -137,8 +154,8 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) { // A Host header carries a port and the name does not. func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { held := newTable() - held.set(map[string]string{"app.example": "http://a.internal:8080"}) - if _, found := held.find("app.example:8080"); !found { + held.set(plain(map[string]string{"app.example": "http://a.internal:8080"})) + if _, found := held.find("app.example:8080", "/"); !found { t.Fatal("a request to app.example:8080 did not find the route for app.example") } } @@ -168,7 +185,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) { // rate limit — and the proxy would look healthy throughout. func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { held := newTable() - held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { @@ -184,7 +201,7 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { // A route withdrawn stops being certifiable, without the proxy restarting. func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { held := newTable() - held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { t.Fatal(err) -- 2.54.0 From 3397cb3f473aafedf8c0ed709d5e53feae87f3f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 14:20:06 +0200 Subject: [PATCH 07/51] route-proxy: a priority is an ordering, not a port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reviewing my own change before merging it, and it was load-bearing rather than cosmetic. Priority was read with asPort, which caps at 65535. A rule declared 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 path scoping and refusal would both have shipped looking complete, passing their tests, and doing nothing on the only case that motivated them. A priority is an ordering and has no range. asWhole takes any whole number the mesh wrote and rejects a non-integral one, which was not meant as a priority. Also: a host may now be routed on some paths and not others, which made the 404 dishonest — it said "no route for this name" while listing that very name as served, a contradiction an operator has to disbelieve the proxy to get past. An uncovered path now says so, and a name that is genuinely not served still lists what is. Two regression tests, both through the proxy rather than against the parser, because the parser was where the bug looked fine. --- modules/route-proxy/cmd/route-proxy/main.go | 31 +++++++++++++- .../cmd/route-proxy/policy_test.go | 40 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 10d7217..9b59643 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -416,8 +416,17 @@ func handler(held *table) http.Handler { // **Named, not a bare 404.** A route that was withdrawn and a name that never existed // are different things, and a proxy that says only "not found" makes an operator go // and read the mesh to tell them apart. What it is serving is the answer to both. + // + // And since a host may now be routed only on some paths, those are a third thing: + // saying "no route for this name" while listing that very name as served is a + // contradiction an operator would have to disbelieve the proxy to get past. w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusNotFound) + if held.routed(r.Host) { + fmt.Fprintf(w, "%s is served here, but no route covers %q.\n", + bareHost(r.Host), r.URL.Path) + return + } fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n", r.Host, strings.Join(held.names(), ", ")) return @@ -524,7 +533,7 @@ func routesFrom(path string) (map[string][]rule, error) { host := strings.ToLower(name) made := rule{path: asPath(c.Values["path"])} - if p, ok := asPort(c.Values["priority"]); ok { + if p, ok := asWhole(c.Values["priority"]); ok { made.priority = p } made.policy.deny, _ = c.Values["deny"].(bool) @@ -574,6 +583,26 @@ func routesFrom(path string) (map[string][]rule, error) { return out, nil } +// 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) diff --git a/modules/route-proxy/cmd/route-proxy/policy_test.go b/modules/route-proxy/cmd/route-proxy/policy_test.go index 7f98ec4..db1dc0b 100644 --- a/modules/route-proxy/cmd/route-proxy/policy_test.go +++ b/modules/route-proxy/cmd/route-proxy/policy_test.go @@ -231,3 +231,43 @@ func TestPriorityOutranksPathLength(t *testing.T) { t.Fatalf("a higher priority did not win: %+v", rules[0]) } } + +// A priority above a port number survives, because a priority is an ordering and not a port. +// +// **Found by review, and it was load-bearing.** Priority was first read with the port reader, which +// caps at 65535 — so a rule declared 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 +// capability would have shipped looking complete and doing nothing. +func TestAPriorityAboveAPortNumberSurvives(t *testing.T) { + at, port := served(t, "the workload") + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100000,"deny":true}} + ]}`) + + if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { + t.Fatalf("a rule declared at priority 100000 answered %d instead of refusing", got) + } +} + +// A host routed only on some paths says so, rather than claiming the name is not served here. +// +// Saying "no route for this name" while listing that very name as served is a contradiction an +// operator has to disbelieve the proxy to get past — and path scoping makes it reachable, because a +// host can now have rules that none of this request's paths match. +func TestAHostRoutedOnlyOnSomePathsSaysSo(t *testing.T) { + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","deny":true}} + ]}`) + + answer := ask(t, proxy, "forge.example", "/elsewhere", [2]string{}) + if answer.StatusCode != http.StatusNotFound { + t.Fatalf("an uncovered path answered %d", answer.StatusCode) + } + body := make([]byte, 256) + n, _ := answer.Body.Read(body) + said := string(body[:n]) + if !strings.Contains(said, "is served here") || !strings.Contains(said, "/elsewhere") { + t.Fatalf("the refusal does not distinguish an uncovered path from an unserved name: %q", said) + } +} -- 2.54.0 From edb85c04163c46c1e9d0354c669cfc2288953396 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:16:37 +0200 Subject: [PATCH 08/51] route-proxy serves a route's internal-name alias, never certifies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route now consumed with two hosts when the mesh composed both — the same host under internal-name reaches the same rule as its public name, restoring the convenience a predecessor proxy gave for reaching a service over the VPN without a public TLS round trip (the field composeName now writes, feat/route-carries-internal-alias — this branch depends on that one landing for internal-name to ever be populated; builds and tests clean without it, just serves nothing extra). Never certified: onlyWhatTheMeshSaid used routed(), which answered yes for any host in the table regardless of how it got there. A new eligibleForACME() checks a parallel 'public' set instead — every host reached through a route's own name, never one reached only through its internal-name — so an internal alias is proxied but never given its own failing ACME order. routed() is unchanged and still used for the 404 message, which legitimately wants 'is this host served at all.' --- modules/route-proxy/cmd/route-proxy/main.go | 50 ++++++++-- .../cmd/route-proxy/policy_test.go | 6 +- .../cmd/route-proxy/routes_test.go | 95 ++++++++++++++++--- 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 9b59643..78f52f7 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -97,10 +97,10 @@ func issuer() string { // to what it may serve. func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { return func(_ context.Context, host string) error { - if held.routed(host) { + if held.eligibleForACME(host) { return nil } - return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) + return fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", host) } } @@ -163,9 +163,14 @@ type rule struct { type table struct { mu sync.RWMutex to map[string][]rule + // public is which routed hosts are eligible for a real certificate — every host reached as a + // route's own `name`, never one reached only as its `internal-name`. A private alias can never + // pass ACME's own validation (it has no public DNS to prove it against), so asking for it is + // not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent. + public map[string]bool } -func (t *table) set(routes map[string][]rule) { +func (t *table) set(routes map[string][]rule, public map[string]bool) { made := map[string][]rule{} for host, rules := range routes { kept := make([]rule, 0, len(rules)) @@ -192,6 +197,7 @@ func (t *table) set(routes map[string][]rule) { } t.mu.Lock() t.to = made + t.public = public t.mu.Unlock() } @@ -234,6 +240,16 @@ func (t *table) find(host, path string) (rule, bool) { // // Separate from find because certificate issuance is a question about the *name*: a host whose only // rules are path-scoped is still a name this proxy answers to, and still needs a certificate. +// eligibleForACME says whether this proxy may ask a certificate authority for this name — every +// host reached as a route's own public `name`, never one reached only as its `internal-name` +// alias, which no public CA can ever validate. +func (t *table) eligibleForACME(host string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + bare := bareHost(host) + return len(t.to[bare]) > 0 && t.public[bare] +} + func (t *table) routed(host string) bool { t.mu.RLock() defer t.mu.RUnlock() @@ -282,7 +298,7 @@ func run() error { held := newTable() read := func() { - routes, err := routesFrom(path) + 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 @@ -290,7 +306,7 @@ func run() error { log.Printf("cannot read %s, keeping what is already served: %v", path, err) return } - held.set(routes) + held.set(routes, public) log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) } read() @@ -512,18 +528,21 @@ func boolByte(b bool) byte { return 0 } -// routesFrom reads what the mesh wrote and turns it into host → the rules for that host. -func routesFrom(path string) (map[string][]rule, error) { +// 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. +func routesFrom(path string) (map[string][]rule, map[string]bool, error) { raw, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, nil, err } var said given if err := json.Unmarshal(raw, &said); err != nil { - return nil, err + return nil, nil, err } out := map[string][]rule{} + public := map[string]bool{} for _, c := range said.Given { name, _ := c.Values["name"].(string) if name == "" { @@ -531,6 +550,7 @@ func routesFrom(path string) (map[string][]rule, error) { continue } host := strings.ToLower(name) + public[host] = true made := rule{path: asPath(c.Values["path"])} if p, ok := asWhole(c.Values["priority"]); ok { @@ -579,8 +599,18 @@ func routesFrom(path string) (map[string][]rule, error) { } out[host] = append(out[host], made) + + // The internal-network alias, the same rule under a second host — a predecessor proxy + // 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. + if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" { + out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made) + } } - return out, nil + return out, public, nil } // asWhole is any whole number the mesh wrote, whatever its magnitude. diff --git a/modules/route-proxy/cmd/route-proxy/policy_test.go b/modules/route-proxy/cmd/route-proxy/policy_test.go index db1dc0b..192af8d 100644 --- a/modules/route-proxy/cmd/route-proxy/policy_test.go +++ b/modules/route-proxy/cmd/route-proxy/policy_test.go @@ -61,12 +61,12 @@ func proxyFor(t *testing.T, routesJSON string) string { if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil { t.Fatal(err) } - routes, err := routesFrom(path) + routes, public, err := routesFrom(path) if err != nil { t.Fatal(err) } held := newTable() - held.set(routes) + held.set(routes, public) server := httptest.NewServer(handler(held)) t.Cleanup(server.Close) return server.URL @@ -157,7 +157,7 @@ func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } - routes, err := routesFrom(path) + routes, _, err := routesFrom(path) if err != nil { t.Fatal(err) } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index c4d6ef2..b4d4bfa 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -28,6 +28,16 @@ func plain(routes map[string]string) map[string][]rule { return out } +// allPublic is every host in a routes map, ACME-eligible — the ordinary case for a test with no +// internal-name alias of its own to distinguish. +func allPublic(routes map[string][]rule) map[string]bool { + out := map[string]bool{} + for host := range routes { + out[host] = true + } + return out +} + // targetOf is where a host's first matching rule sends a request. func targetOf(routes map[string][]rule, host string) string { if rules := routes[host]; len(rules) > 0 { @@ -39,7 +49,7 @@ 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(write(t, `{"contributions":1,"requirement":"route","given":[ {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} ]}`)) if err != nil { @@ -52,10 +62,49 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { } } +// A route with an internal-name alias is reachable under both hostnames, pointed at the same +// 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":[ + {"from":"app","node":"anchor","at":"anchor.internal", + "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if targetOf(routes, "app.example") != "http://anchor.internal:8080" { + t.Fatalf("the public name does not point at the consumer: %v", routes) + } + if targetOf(routes, "app.anchor.internal") != "http://anchor.internal:8080" { + t.Fatalf("the internal alias does not point at the same consumer: %v", routes) + } + if !public["app.example"] { + t.Errorf("the public name is not eligible for a certificate: %v", public) + } + if public["app.anchor.internal"] { + t.Errorf("the internal alias is eligible for a certificate no public CA could ever issue: %v", + public) + } +} + +// 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":[ + {"from":"app","node":"anchor","values":{"name":"app.example","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 1 { + t.Fatalf("a route with no internal-name grew a second host: %v", routes) + } +} + // 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(write(t, `{"given":[ {"from":"app","node":"anchor","values":{"name":"app.example","port":9000}} ]}`)) if err != nil { @@ -68,7 +117,7 @@ func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { // Skipped rather than served wrongly. A route with no port would proxy to :0. func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { - routes, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(write(t, `{"given":[ {"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}}, {"from":"b","node":"n","at":"n.internal","values":{"port":8080}}, {"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}} @@ -92,7 +141,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { host, port, _ := strings.Cut(target, ":") held := newTable() - held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port})) + held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}), allPublic(plain(map[string]string{"app.example": "http://" + host + ":" + port}))) proxy := httptest.NewServer(handler(held)) defer proxy.Close() @@ -137,11 +186,12 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { // nothing fails more visibly than a stale grant, which is exactly why it must not survive. func TestWithdrawingARouteStopsServingIt(t *testing.T) { held := newTable() - held.set(plain(map[string]string{ + initial := plain(map[string]string{ "going.example": "http://a.internal:80", "staying.example": "http://b.internal:80", - })) - held.set(plain(map[string]string{"staying.example": "http://b.internal:80"})) + }) + held.set(initial, allPublic(initial)) + held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}), allPublic(plain(map[string]string{"staying.example": "http://b.internal:80"}))) if _, still := held.find("going.example", "/"); still { t.Fatal("a route whose module was unassigned is still served") @@ -154,7 +204,7 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) { // A Host header carries a port and the name does not. func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { held := newTable() - held.set(plain(map[string]string{"app.example": "http://a.internal:8080"})) + held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}), allPublic(plain(map[string]string{"app.example": "http://a.internal:8080"}))) if _, found := held.find("app.example:8080", "/"); !found { t.Fatal("a request to app.example:8080 did not find the route for app.example") } @@ -185,7 +235,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) { // rate limit — and the proxy would look healthy throughout. func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { held := newTable() - held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { @@ -198,16 +248,39 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { } } +// A certificate is asked for on a route's public name, never on its internal-network alias — no +// 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":[ + {"from":"app","node":"anchor","at":"anchor.internal", + "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes, public) + policy := onlyWhatTheMeshSaid(held) + + if err := policy(context.Background(), "app.example"); err != nil { + t.Errorf("the route's public name was refused a certificate: %v", err) + } + if err := policy(context.Background(), "app.anchor.internal"); err == nil { + t.Error("a certificate was ordered for the internal alias, which no public CA can validate") + } +} + // A route withdrawn stops being certifiable, without the proxy restarting. func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { held := newTable() - held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"})) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { t.Fatal(err) } - held.set(nil) + held.set(nil, nil) if err := policy(context.Background(), "photos.example"); err == nil { t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " + "once rather than what is served now") -- 2.54.0 From d46b5327b146495aaa9715aa873d6cc28b4ea90d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:37:03 +0200 Subject: [PATCH 09/51] route-proxy: a second authority for internal names, and a target a route names the scheme of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal aliases were served over plain HTTP only — correctly refused a public certificate (no public CA can validate a private name), and then left with nothing. The mesh has two authorities for its two name spaces (08-connectivity §2), so the proxy now takes an optional internal ACME directory and dispatches at the handshake by the same question HostPolicy already answers: which authority may certify this name at all. A route may also say its target speaks https, with insecure for a backend whose own certificate nothing would trust — the shape Mailu's webmail front needs, and the exception: everything else the mesh hands this proxy stays plain http on the private network. --- modules/route-proxy/cmd/route-proxy/main.go | 163 ++++++++++++++---- .../cmd/route-proxy/routes_test.go | 87 ++++++++++ 2 files changed, 219 insertions(+), 31 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 78f52f7..c8f16a3 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -104,6 +104,19 @@ func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { } } +// onlyInternalNamesTheMeshSaid is onlyWhatTheMeshSaid's mirror for the internal authority — the +// same quota-spending concern applies even to an authority with no rate limit of its own, because +// an order for a name this proxy does not actually route is a bug worth refusing rather than +// serving. +func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy { + return func(_ context.Context, host string) error { + if held.eligibleForInternalACME(host) { + return nil + } + return fmt.Errorf("no internal-only route for %q in this mesh, so no certificate is asked for", host) + } +} + // what the mesh writes: the contributions file, one entry per consumer. type given struct { Given []contribution `json:"given"` @@ -149,6 +162,11 @@ type rule struct { policy policy to *httputil.ReverseProxy target string + // insecure skips certificate verification when target is reached over https. For a backend + // that terminates TLS with its own certificate this proxy has no reason to trust — Mailu's + // webmail front is the first of these — never for anything reached over plain http, where + // there is nothing to verify in the first place. + insecure bool } // table is what the proxy is currently serving, replaced whole whenever the file changes. @@ -187,6 +205,9 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) { continue } r.to = httputil.NewSingleHostReverseProxy(where) + if r.insecure { + r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } kept = append(kept, r) } if len(kept) == 0 { @@ -256,6 +277,21 @@ func (t *table) routed(host string) bool { return len(t.to[bareHost(host)]) > 0 } +// eligibleForInternalACME says whether this proxy may ask its *internal* authority for a +// certificate for this name — every host it routes that is not also a route's public `name`. +// +// **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 +// 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 { + t.mu.RLock() + defer t.mu.RUnlock() + bare := bareHost(host) + return len(t.to[bare]) > 0 && !t.public[bare] +} + // bareHost is the name without the port, lower-cased. // // The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased @@ -333,16 +369,81 @@ func run() error { return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " + "to persist, or every restart orders them again") } - client := &acme.Client{DirectoryURL: issuer()} - // An issuer that is not one of the public ones serves its own API over TLS with a certificate - // nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and - // names a file, rather than the client being told to skip verification: *skip* would also - // apply on the day this points at a public issuer, and nothing would say so. + publicManager, err := newManager(cache, issuer(), strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")), + onlyWhatTheMeshSaid(held)) + if err != nil { + return err + } + log.Printf("issuing public certificates from %s, for whatever the mesh routes here", issuer()) + + // The internal authority is optional: unset means this proxy serves internal-only aliases over + // plain HTTP exactly as it always has, which is the standalone-binary default and a safe one — + // it asks nothing of an authority it was not told about. + var internalManager *autocert.Manager + if directory := strings.TrimSpace(os.Getenv("INTERNAL_ACME_DIRECTORY")); directory != "" { + internalManager, err = newManager(cache, directory, strings.TrimSpace(os.Getenv("INTERNAL_ACME_CA_BUNDLE")), + onlyInternalNamesTheMeshSaid(held)) + if err != nil { + return fmt.Errorf("internal certificate authority: %w", err) + } + log.Printf("issuing internal certificates from %s, for every internal-only alias this routes", + directory) + } + + // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge + // must be answered *at the name being certified*, which is why issuance happens on the node + // that is publicly reachable rather than wherever the workload runs. Each manager's own + // HTTPHandler answers only the tokens it is itself expecting and falls through otherwise — for + // a token neither authority recognises, plain routing takes over, which is what lets a + // consumer's own ACME client — Mailu's, certifying its own name for a protocol this proxy never + // proxies — go on answering its own challenge through an ordinary path-scoped route. + port80 := publicManager.HTTPHandler(handler(held)) + if internalManager != nil { + port80 = publicManager.HTTPHandler(internalManager.HTTPHandler(handler(held))) + } + go func() { + if err := http.ListenAndServe(listen, port80); err != nil { + log.Printf("plain HTTP stopped: %v", err) + } + }() + + tlsConfig := publicManager.TLSConfig() + if internalManager != nil { + // Dispatched by which authority may certify this name at all — the same question + // eligibleForInternalACME already answers, asked once more at handshake time rather than + // only when an order is placed, since a cached certificate is served here on every request + // and never goes through HostPolicy again. + fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate + tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if held.eligibleForInternalACME(hello.ServerName) { + return fromInternal(hello) + } + return fromPublic(hello) + } + } + + server := &http.Server{ + Addr: secure, + Handler: handler(held), + TLSConfig: tlsConfig, + } + return server.ListenAndServeTLS("", "") +} + +// newManager is one ACME authority's autocert manager: where to ask, what to trust it with, and +// which names it may be asked to certify. +// +// **Trusting an authority names a file rather than skipping verification.** An issuer that is not +// one of the public ones — the lab's, or the mesh's own step-ca — serves its own ACME API over TLS +// with a certificate nothing trusts yet. *Skip* would also apply the day this points at a public +// issuer, and nothing would say so; naming a bundle is a deliberate, visible act instead. +func newManager(cache, directory, bundle string, policy autocert.HostPolicy) (*autocert.Manager, error) { + client := &acme.Client{DirectoryURL: directory} var root []byte - if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" { + if bundle != "" { read, err := os.ReadFile(bundle) if err != nil { - return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) + return nil, fmt.Errorf("the CA bundle names %s and it cannot be read: %w", bundle, err) } root = read // An empty bundle means the issuer's root is already in the system trust store — a public @@ -354,7 +455,7 @@ func run() error { if strings.TrimSpace(string(root)) != "" { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(root) { - return fmt.Errorf("%s holds no certificate this can trust", bundle) + return nil, fmt.Errorf("%s holds no certificate this can trust", bundle) } client.HTTPClient = &http.Client{ Timeout: 30 * time.Second, @@ -363,31 +464,16 @@ func run() error { } } // Where this authority's account and certificates are kept. Per authority, not per proxy — see - // forThisAuthority, which is what makes a re-initialised CA heal itself. - mine := forThisAuthority(cache, issuer(), root) - manager := &autocert.Manager{ + // forThisAuthority, which is what makes a re-initialised CA heal itself, and what lets the + // public and internal authorities share one ACME_CACHE without colliding: they hash to + // different names because their directories differ. + mine := forThisAuthority(cache, directory, root) + return &autocert.Manager{ Cache: autocert.DirCache(mine), Prompt: autocert.AcceptTOS, - HostPolicy: onlyWhatTheMeshSaid(held), + HostPolicy: policy, Client: client, - } - log.Printf("issuing from %s into %s, for whatever the mesh routes here", issuer(), mine) - - // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge - // must be answered *at the name being certified*, which is why issuance happens on the node - // that is publicly reachable rather than wherever the workload runs. - go func() { - if err := http.ListenAndServe(listen, manager.HTTPHandler(handler(held))); err != nil { - log.Printf("plain HTTP stopped: %v", err) - } - }() - - server := &http.Server{ - Addr: secure, - Handler: handler(held), - TLSConfig: manager.TLSConfig(), - } - return server.ListenAndServeTLS("", "") + }, nil } // forThisAuthority is where one ACME authority's account and certificates are kept. @@ -595,7 +681,22 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { if at == "" { at = "127.0.0.1" } - made.target = fmt.Sprintf("http://%s:%d", at, port) + // 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" + } + 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, name, scheme) + continue + } + made.insecure, _ = c.Values["insecure"].(bool) + made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port) } out[host] = append(out[host], made) diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index b4d4bfa..36d49a5 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -130,6 +130,68 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(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":[ + {"from":"mail","node":"anchor","at":"anchor.internal", + "values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if targetOf(routes, "mail.example") != "https://anchor.internal:7443" { + t.Fatalf("an https target was not built as one: %v", routes) + } + if !routes["mail.example"][0].insecure { + t.Fatal("insecure was declared and not carried onto the rule") + } +} + +// A scheme that is neither http nor https is refused rather than guessed at. +func TestARouteWithAnUnknownSchemeIsSkipped(t *testing.T) { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"a","node":"n","at":"n.internal","values":{"name":"bad.example","port":80,"scheme":"ftp"}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 0 { + t.Fatalf("a route with an unusable scheme was served: %v", routes) + } +} + +// End to end: a backend terminating TLS with a certificate nothing would ordinarily trust is still +// reached when the route declared `insecure`, and the response comes back through unmodified. +func TestTheProxyReachesAnInsecureHttpsBackend(t *testing.T) { + workload := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("the workload, over its own TLS")) + })) + defer workload.Close() + target := strings.TrimPrefix(workload.URL, "https://") + + held := newTable() + routes := map[string][]rule{"mail.example": {{target: "https://" + target, insecure: true}}} + held.set(routes, allPublic(routes)) + + proxy := httptest.NewServer(handler(held)) + defer proxy.Close() + + asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil) + if err != nil { + t.Fatal(err) + } + asked.Host = "mail.example" + answer, err := http.DefaultClient.Do(asked) + if err != nil { + t.Fatal(err) + } + defer answer.Body.Close() + if answer.StatusCode != http.StatusOK { + t.Fatalf("an insecure https backend was not reached: %d", answer.StatusCode) + } +} + // End to end through the proxy itself: a request for the name reaches the workload, and a name // nobody asked for is refused in a way that says what IS served. func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { @@ -271,6 +333,31 @@ 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":[ + {"from":"app","node":"anchor","at":"anchor.internal", + "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes, public) + policy := onlyInternalNamesTheMeshSaid(held) + + if err := policy(context.Background(), "app.anchor.internal"); err != nil { + t.Errorf("the internal alias was refused by its own authority: %v", err) + } + if err := policy(context.Background(), "app.example"); err == nil { + t.Error("the internal authority certified a route's public name, which the public authority already covers") + } + if err := policy(context.Background(), "unrouted.internal"); err == nil { + t.Error("the internal authority certified a name nobody routed here") + } +} + // A route withdrawn stops being certifiable, without the proxy restarting. func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { held := newTable() -- 2.54.0 From 4bb4970569f53cdabc1d1d4bb3d069761b492094 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:21:52 +0200 Subject: [PATCH 10/51] route-proxy: the challenge path falls through for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autocert's HTTPHandler answers 404 itself for a token it does not hold and never consults its fallback on the challenge path — the predecessor's exact fault, rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute probes each authority against a buffered writer and hands a token none of them holds to plain routing, so a consumer's own ACME client answers its own challenge through an ordinary path-scoped route. Four tests pin it, including the cache-key shape a restart-surviving token actually has. --- .../cmd/route-proxy/challenge_test.go | 86 ++++++++++++++++++ modules/route-proxy/cmd/route-proxy/main.go | 88 +++++++++++++++++-- 2 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 modules/route-proxy/cmd/route-proxy/challenge_test.go diff --git a/modules/route-proxy/cmd/route-proxy/challenge_test.go b/modules/route-proxy/cmd/route-proxy/challenge_test.go new file mode 100644 index 0000000..6919f24 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/challenge_test.go @@ -0,0 +1,86 @@ +package main + +// The challenge path falls through for real. autocert's own HTTPHandler answers 404 itself for a +// token it does not hold and never consults its fallback on the challenge path — the +// predecessor's fault, the edge owning /.well-known/acme-challenge outright, rediscovered live +// when Mailu's renewal died behind this proxy on cutover day (2026-09-26). These tests pin the +// three behaviours tokenOrRoute exists for. + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/acme/autocert" +) + +func routedTo(t *testing.T, marker string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(marker)); err != nil { + t.Fatal(err) + } + }) +} + +func TestATokenNoAuthorityHoldsIsRoutedNot404d(t *testing.T) { + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + h := tokenOrRoute(routedTo(t, "the workload answered"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/somebody-elses-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { + t.Fatalf("a token no authority holds must reach plain routing; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestATokenAManagerHoldsIsAnsweredByIt(t *testing.T) { + // autocert reads a token it does not have in memory from its cache, under "+http-01" — + // which is also how a token would survive the manager restarting mid-issuance. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "held-token+http-01"), []byte("the-key-authorization"), 0o600); err != nil { + t.Fatal(err) + } + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} + h := tokenOrRoute(routedTo(t, "must not be reached"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/held-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the-key-authorization" { + t.Fatalf("the manager holding a token answers it; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) { + first := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "internal-token+http-01"), []byte("internal-key"), 0o600); err != nil { + t.Fatal(err) + } + second := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} + h := tokenOrRoute(routedTo(t, "must not be reached"), first, second) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://git.internal/.well-known/acme-challenge/internal-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "internal-key" { + t.Fatalf("the second authority's token is found by probing past the first; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestAnOrdinaryPathNeverTouchesTheChallengeMachinery(t *testing.T) { + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + h := tokenOrRoute(routedTo(t, "routed"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://site.example/index.html", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "routed" { + t.Fatalf("an ordinary path goes straight to routing; got %d %q", rec.Code, rec.Body.String()) + } +} diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index c8f16a3..1e775f0 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -392,14 +392,18 @@ func run() error { // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge // must be answered *at the name being certified*, which is why issuance happens on the node - // that is publicly reachable rather than wherever the workload runs. Each manager's own - // HTTPHandler answers only the tokens it is itself expecting and falls through otherwise — for - // a token neither authority recognises, plain routing takes over, which is what lets a - // consumer's own ACME client — Mailu's, certifying its own name for a protocol this proxy never - // proxies — go on answering its own challenge through an ordinary path-scoped route. - port80 := publicManager.HTTPHandler(handler(held)) + // that is publicly reachable rather than wherever the workload runs. + // + // **autocert's own HTTPHandler does not fall through on the challenge path.** For a token it + // does not hold it answers 404 itself; its fallback only ever sees non-challenge paths — which + // is exactly the predecessor's fault, the edge owning `/.well-known/acme-challenge` outright, + // rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute + // probes each manager and hands a token neither authority recognises to plain routing, which + // is what lets a consumer's own ACME client — Mailu's, certifying its own name for a protocol + // this proxy never proxies — answer its own challenge through an ordinary path-scoped route. + port80 := tokenOrRoute(handler(held), publicManager) if internalManager != nil { - port80 = publicManager.HTTPHandler(internalManager.HTTPHandler(handler(held))) + port80 = tokenOrRoute(handler(held), publicManager, internalManager) } go func() { if err := http.ListenAndServe(listen, port80); err != nil { @@ -430,6 +434,76 @@ func run() error { return server.ListenAndServeTLS("", "") } +// tokenOrRoute serves port 80: each manager answers the challenge tokens it is itself holding, +// and a token none of them holds is routed like any other request instead of being 404'd at the +// edge. +// +// autocert gives no way to ask "is this your token?" — its HTTPHandler both answers and refuses — +// so each manager is probed against a buffered writer and its refusal (404 on the challenge path) +// is discarded in favour of the next candidate. The probe is cheap: the handler answers from +// memory, and the path only carries traffic while an issuance is actually running. +func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handler { + const challengePrefix = "/.well-known/acme-challenge/" + // Non-challenge paths never reach a manager at all; autocert's tryHTTP01 switch still has to + // be armed, which HTTPHandler is the only exported way to do. + probes := make([]http.Handler, len(managers)) + for i, m := range managers { + probes[i] = m.HTTPHandler(routes) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, challengePrefix) { + routes.ServeHTTP(w, r) + return + } + for _, probe := range probes { + buffered := &probedResponse{header: make(http.Header)} + probe.ServeHTTP(buffered, r) + if buffered.status == http.StatusNotFound { + continue // not this manager's token + } + buffered.replayTo(w) + return + } + routes.ServeHTTP(w, r) // no authority holds it: the workload behind a routed path may + }) +} + +// probedResponse buffers one handler's answer so a refusal can be discarded unseen. +type probedResponse struct { + header http.Header + status int + body bytes.Buffer +} + +func (p *probedResponse) Header() http.Header { return p.header } + +func (p *probedResponse) WriteHeader(status int) { + if p.status == 0 { + p.status = status + } +} + +func (p *probedResponse) Write(b []byte) (int, error) { + if p.status == 0 { + p.status = http.StatusOK + } + return p.body.Write(b) +} + +func (p *probedResponse) replayTo(w http.ResponseWriter) { + for k, vs := range p.header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + status := p.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = w.Write(p.body.Bytes()) +} + // newManager is one ACME authority's autocert manager: where to ask, what to trust it with, and // which names it may be asked to certify. // -- 2.54.0 From 1edee852a64cceb05b60fadf9b7b795241783966 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:26:01 +0200 Subject: [PATCH 11/51] route-proxy: a policy refusal is also not-my-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autocert checks the host policy before the token and answers 403 — the internal authority does this for every public name, so mail.novox.be's challenge died on the internal manager's probe one commit after it stopped dying on the public one's 404. Both shapes of refusal now fall through to routing; a fifth test pins the 403 case with a refusing policy. --- .../cmd/route-proxy/challenge_test.go | 23 +++++++++++++++++++ modules/route-proxy/cmd/route-proxy/main.go | 7 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/challenge_test.go b/modules/route-proxy/cmd/route-proxy/challenge_test.go index 6919f24..3afd141 100644 --- a/modules/route-proxy/cmd/route-proxy/challenge_test.go +++ b/modules/route-proxy/cmd/route-proxy/challenge_test.go @@ -7,6 +7,8 @@ package main // three behaviours tokenOrRoute exists for. import ( + "context" + "fmt" "net/http" "net/http/httptest" "os" @@ -73,6 +75,27 @@ func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) { } } +func TestAnAuthorityWhosePolicyRefusesTheNameIsProbedPast(t *testing.T) { + // autocert checks the host policy before the token and answers 403 — the internal authority + // does this for every public name. A policy refusal is as much "not mine" as a missing token: + // the request must still reach plain routing, where the workload's own ACME client answers. + refusing := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(t.TempDir()), + HostPolicy: func(ctx context.Context, host string) error { + return fmt.Errorf("no internal-only route for %q in this mesh", host) + }, + } + h := tokenOrRoute(routedTo(t, "the workload answered"), refusing) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/mailus-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { + t.Fatalf("a policy refusal must fall through to routing; got %d %q", rec.Code, rec.Body.String()) + } +} + func TestAnOrdinaryPathNeverTouchesTheChallengeMachinery(t *testing.T) { m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} h := tokenOrRoute(routedTo(t, "routed"), m) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 1e775f0..38d9308 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -458,8 +458,11 @@ func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handl for _, probe := range probes { buffered := &probedResponse{header: make(http.Header)} probe.ServeHTTP(buffered, r) - if buffered.status == http.StatusNotFound { - continue // not this manager's token + // Two shapes of "not mine": 404, a token this manager is not holding — and 403, a + // name its host policy would never certify at all (autocert checks the policy before + // the token, so the internal authority answers 403 for every public name). + if buffered.status == http.StatusNotFound || buffered.status == http.StatusForbidden { + continue } buffered.replayTo(w) return -- 2.54.0 From 91635ab5acfa1dd9bfd8d752afa1ed321717b33c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 16:01:21 +0200 Subject: [PATCH 12/51] A route may say the largest body it carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy configuration beside insecure, not a fifth policy — ADR 0108 closed that set at four, and both of these tune how a request is carried rather than deciding what a name admits. A registry is the case that needs it: image layers arrive as single requests of gigabytes and a proxy's own default refuses them long before the workload is reached. Absent is no limit, which is what every route already got. A limit that is not a whole positive number of bytes takes the route with it, named in the log like a port that is not one — serving it without the limit would carry exactly what the module said not to carry. Enforced on the declared length where there is one, and while reading for a chunked body, which declares none: without the second, a limit is advice. --- modules/route-proxy/cmd/route-proxy/main.go | 34 ++++++ .../cmd/route-proxy/routes_test.go | 115 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 38d9308..5b9a39a 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -167,6 +167,16 @@ type rule struct { // webmail front is the first of these — never for anything reached over plain http, where // there is nothing to verify in the first place. insecure bool + // maxRequestBody is the largest body, in bytes, this route carries. Zero is no limit, which is + // what every route gets by saying nothing: this proxy has never limited a body, and a default + // arriving with the field would change every route that never asked for one. + // + // **Configuration, not a policy** (novox/hq ADR 0108 closed that set at four). It belongs beside + // `insecure` for the same reason `insecure` is not a policy: both tune how this proxy carries a + // request to a backend, rather than deciding what the name admits or who may reach it. A + // registry is the case that needs it — image layers arrive as single requests of gigabytes, and + // a proxy's own default refuses them long before the workload is reached. + maxRequestBody int64 } // table is what the proxy is currently serving, replaced whole whenever the file changes. @@ -636,6 +646,18 @@ func handler(held *table) http.Handler { return } + if matched.maxRequestBody > 0 { + // Refused on the declared length where there is one, so an upload that cannot succeed + // is answered before it is carried; and capped while reading for a chunked body, which + // declares no length at all. Without the second, a limit is advice. + if r.ContentLength > matched.maxRequestBody { + http.Error(w, fmt.Sprintf("request body too large for this route: %d bytes is the most it carries", + matched.maxRequestBody), http.StatusRequestEntityTooLarge) + return + } + r.Body = http.MaxBytesReader(w, r.Body, matched.maxRequestBody) + } + matched.to.ServeHTTP(w, r) }) } @@ -773,6 +795,18 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { 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, name, asked) + continue + } + made.maxRequestBody = int64(bytes) + } made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port) } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index 36d49a5..3bb71a3 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -373,3 +375,116 @@ func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { "once rather than what is served now") } } + +// A route may say the largest body it carries, and the proxy holds requests to it. +// +// The registry is why: image layers arrive as single requests of gigabytes, and a proxy's own +// 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":[ + {"from":"registry","node":"anchor","at":"anchor.internal", + "values":{"name":"images.example","port":5000,"max-request-body":21474836480}} + ]}`)) + if err != nil { + t.Fatal(err) + } + rules := routes["images.example"] + if len(rules) != 1 { + t.Fatalf("the route is not served once: %v", routes) + } + if rules[0].maxRequestBody != 21474836480 { + t.Fatalf("the limit did not survive the contribution: %d", rules[0].maxRequestBody) + } +} + +// Saying nothing leaves the route unlimited, which is what every route already got. +func TestARouteThatSaysNothingCarriesAnySize(t *testing.T) { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"app","node":"anchor","at":"anchor.internal","values":{"name":"app.example","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if got := routes["app.example"][0].maxRequestBody; got != 0 { + t.Fatalf("a route that asked for no limit got one: %d", got) + } +} + +// A limit that is not a whole positive number of bytes takes the route with it. Serving it without +// the limit would carry exactly what the module said not to carry, and report success doing it. +func TestARouteWithAnUnusableLimitIsSkipped(t *testing.T) { + for _, asked := range []string{`"lots"`, `-1`, `0`, `1.5`} { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"registry","node":"anchor","at":"anchor.internal", + "values":{"name":"images.example","port":5000,"max-request-body":`+asked+`}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes["images.example"]) != 0 { + t.Errorf("a route asking for a max-request-body of %s was served anyway: %v", asked, routes) + } + } +} + +// A request larger than the route carries is refused by the proxy, with the limit named, and the +// workload never sees it. A request within it is proxied normally. +func TestABodyOverTheLimitIsRefusedAndOneUnderItIsCarried(t *testing.T) { + var reached int + workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + reached++ + fmt.Fprintf(w, "carried %d bytes", len(body)) + })) + defer workload.Close() + + 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}} + ]}`)) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes, map[string]bool{}) + proxy := httptest.NewServer(handler(held)) + defer proxy.Close() + + over, err := post(proxy.URL, "images.example", "123456789") + if err != nil { + t.Fatal(err) + } + defer over.Body.Close() + if over.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("a body over the limit answered %d, not 413", over.StatusCode) + } + if reached != 0 { + t.Fatalf("the workload was reached by a request the route said it would not carry") + } + + under, err := post(proxy.URL, "images.example", "1234") + if err != nil { + t.Fatal(err) + } + defer under.Body.Close() + if under.StatusCode != http.StatusOK { + t.Fatalf("a body within the limit answered %d, not 200", under.StatusCode) + } + if reached != 1 { + t.Fatalf("the workload was not reached by a request within the limit") + } +} + +// post sends a body to the proxy as the named route, since a route is found by the Host header. +func post(url, host, body string) (*http.Response, error) { + asked, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return nil, err + } + asked.Host = host + asked.Header.Set("Content-Type", "application/octet-stream") + return http.DefaultClient.Do(asked) +} -- 2.54.0 From 531431a42f1d5e4592f09c41479e23423f31a5e9 Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 1 Oct 2026 23:31:57 +0200 Subject: [PATCH 13/51] route-proxy: serve a route that names only its internal host Since ADR 0138 an endpoint that reaches only the private network gets an internal-name and no name, and the proxy skipped it as naming nothing, so every internal-only module was unreachable by name (novox/hq issue 191). --- modules/route-proxy/cmd/route-proxy/main.go | 39 +++++++++++----- .../cmd/route-proxy/routes_test.go | 44 +++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 5b9a39a..484ce2a 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -716,6 +716,12 @@ func boolByte(b bool) byte { // 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. +// +// **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 { @@ -730,12 +736,18 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { public := map[string]bool{} for _, c := range said.Given { name, _ := c.Values["name"].(string) - if name == "" { + 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 } - host := strings.ToLower(name) - public[host] = true + // 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 { @@ -752,7 +764,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { 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, name) + c.From, c.Node, called) continue } users, err := usersFrom(named) @@ -770,7 +782,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { 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, name) + c.From, c.Node, called) continue } // Where the mesh says that machine is. Empty means it is this one — a workload beside @@ -791,7 +803,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { } 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, name, scheme) + "nor https; skipped", c.From, c.Node, called, scheme) continue } made.insecure, _ = c.Values["insecure"].(bool) @@ -802,7 +814,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { 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, name, asked) + "not a whole positive number of bytes; skipped", c.From, c.Node, called, asked) continue } made.maxRequestBody = int64(bytes) @@ -810,15 +822,20 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port) } - out[host] = append(out[host], made) + if name != "" { + host := strings.ToLower(name) + out[host] = append(out[host], made) + public[host] = true + } - // The internal-network alias, the same rule under a second host — a predecessor proxy + // The internal-network name, the same rule under a second host — a predecessor proxy // 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. - if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" { + // already has. 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) } } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index 3bb71a3..1ad9a26 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -90,6 +90,50 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) { } } +// A route whose endpoint reaches only the private network carries an internal name and no public +// 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":[ + {"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) + } + if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" { + t.Fatalf("the internal-only route is not served: %v", routes) + } + if len(routes) != 1 { + t.Errorf("an internal-only route made hosts it never named: %v", routes) + } + if len(public) != 0 { + t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public) + } + + held := newTable() + held.set(routes, public) + if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil { + t.Errorf("the internal authority refused the internal-only route's name: %v", err) + } + if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil { + t.Error("a public certificate was ordered for an internal-only name") + } +} + +// A route with neither name has nothing to be served under, and is still skipped. +func TestARouteWithNeitherNameIsSkipped(t *testing.T) { + routes, public, err := routesFrom(write(t, `{"given":[ + {"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 0 || len(public) != 0 { + t.Errorf("a route that named nothing was served: %v %v", routes, public) + } +} + // 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":[ -- 2.54.0 From 70b3bef9eeef111e9452fc68726991f2a4a31f4f Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 2 Oct 2026 01:46:26 +0200 Subject: [PATCH 14/51] The proxy is told its routes and the mesh on the bus, and serves internal names to the mesh only The proxy answered every routed name to any request carrying it, so an internal-only route would have been public under its internal name. Each membership now carries what its module receives, from the same composition as its received file, and every machine's private-network address, the list the packet filter's "from the mesh" is. The proxy follows its membership, serves internal names only to those machines and itself, and keeps the file until the bus has spoken (novox/hq ADR 0167, issue 191). --- modules/route-proxy/cmd/route-proxy/bus.go | 132 +++++++++++ modules/route-proxy/cmd/route-proxy/main.go | 165 ++++++++++++-- .../route-proxy/cmd/route-proxy/reach_test.go | 206 ++++++++++++++++++ 3 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 modules/route-proxy/cmd/route-proxy/bus.go create mode 100644 modules/route-proxy/cmd/route-proxy/reach_test.go diff --git a/modules/route-proxy/cmd/route-proxy/bus.go b/modules/route-proxy/cmd/route-proxy/bus.go new file mode 100644 index 0000000..51ed510 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/bus.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" +) + +// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167). +// +// **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. + +// credential is the bus account the mesh delivered as this module's own secret named broker. +type credential struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` +} + +// 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) { + for { + err := followOnce(path, held, fromBus) + if err == nil { + return + } + log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err) + time.Sleep(30 * time.Second) + } +} + +func followOnce(path string, held *table, fromBus *atomic.Bool) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var cred credential + if err := json.Unmarshal(raw, &cred); err != nil { + return fmt.Errorf("the broker credential is not one: %w", err) + } + if cred.Node == "" || cred.Module == "" { + return fmt.Errorf("the broker credential names no node or module, so it has no membership") + } + + opts := []nats.Option{ + nats.Name(cred.Node + "." + cred.Module), + nats.UserInfo(cred.User, cred.Password), + // Its own inbox, and nothing wider: every principal is granted `_INBOX..>` alone. + nats.CustomInboxPrefix("_INBOX." + cred.User), + // The bus being restarted is an upgrade, not a reason to stop following. + nats.MaxReconnects(-1), + } + if strings.TrimSpace(cred.Fingerprint) != "" { + opts = append(opts, nats.Secure(broker.PinnedToFingerprint(cred.Fingerprint))) + } + conn, err := nats.Connect(cred.URL, opts...) + if err != nil { + return fmt.Errorf("connecting to the bus at %s: %w", broker.BareAddress(cred.URL), err) + } + + subject := broker.MembershipSubject(cred.Node, cred.Module) + apply := func(body []byte) { + var issued broker.Membership + if err := json.Unmarshal(body, &issued); err != nil { + log.Printf("a membership arrived that is not one: %v", err) + return + } + if took := applyMembership(issued, held); took && !fromBus.Swap(true) { + log.Printf("routes now come from this proxy's membership on %s", subject) + } + } + + // Followed first, read second: an issue landing between the two is applied, not missed. + if _, err := conn.Subscribe(subject, func(m *nats.Msg) { apply(m.Data) }); err != nil { + conn.Close() + return fmt.Errorf("cannot follow %s: %w", subject, err) + } + // The subject-addressed direct get: the one request this account may make of the stream. + got, err := conn.Request("$JS.API.DIRECT.GET."+broker.AssignmentsStream+"."+subject, nil, 5*time.Second) + switch { + case err != nil: + log.Printf("cannot read the membership issued on %s yet (%v); following it", subject, err) + case got.Header.Get("Status") != "" || len(got.Data) == 0: + log.Printf("no membership issued on %s yet; serving the file until one is", subject) + default: + apply(got.Data) + } + 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. +func applyMembership(issued broker.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) + 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(), ", ")) + return true +} diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 484ce2a..47d4f93 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -54,12 +54,14 @@ import ( "net" "net/http" "net/http/httputil" + "net/netip" "net/url" "os" "path/filepath" "sort" "strings" "sync" + "sync/atomic" "time" "golang.org/x/crypto/acme" @@ -196,6 +198,63 @@ type table struct { // pass ACME's own validation (it has no public DNS to prove it against), so asking for it is // not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent. public map[string]bool + // inside is where a request must come from to be served a name that is only internal: every + // machine's address on the private network, as the mesh issued it in this proxy's membership + // (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside. + inside sources +} + +// sources is who may be served an internal name: the private network's addresses as the mesh +// issued them. The machine itself is always inside — anything on a machine may call anything on it +// (novox/hq ADR 0144) — so loopback needs no entry. +type sources []netip.Prefix + +// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does +// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to +// fewer machines than the mesh said, and say nothing. +func sourcesOf(mesh []string) (sources, error) { + var out sources + for _, entry := range mesh { + entry = strings.TrimSpace(entry) + if prefix, err := netip.ParsePrefix(entry); err == nil { + out = append(out, prefix.Masked()) + continue + } + addr, err := netip.ParseAddr(entry) + if err != nil { + return nil, fmt.Errorf("%q is not an address on the private network", entry) + } + addr = addr.Unmap() + out = append(out, netip.PrefixFrom(addr, addr.BitLen())) + } + return out, nil +} + +// holds says whether a request from this remote address came from the mesh or the machine itself. +// +// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source +// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request +// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a +// mesh address leave by the tunnel, never back to the claimant. +func (s sources) holds(remote string) bool { + host := remote + if h, _, err := net.SplitHostPort(remote); err == nil { + host = h + } + addr, err := netip.ParseAddr(host) + if err != nil { + return false + } + addr = addr.Unmap() + if addr.IsLoopback() { + return true + } + for _, prefix := range s { + if prefix.Contains(addr) { + return true + } + } + return false } func (t *table) set(routes map[string][]rule, public map[string]bool) { @@ -314,6 +373,41 @@ func bareHost(host string) string { return strings.ToLower(host) } +// hiddenFrom says whether this host must look unrouted to a request from this address: it is +// only an internal name, and the request did not come from the private network. +// +// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true** +// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from +// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does. +// Answered exactly as a name that was never routed, so an outsider learns nothing from asking. +func (t *table) hiddenFrom(host, remote string) bool { + if !t.eligibleForInternalACME(host) { + return false + } + t.mu.RLock() + defer t.mu.RUnlock() + return !t.inside.holds(remote) +} + +// setInside replaces who the mesh is, as the membership said. +func (t *table) setInside(inside sources) { + t.mu.Lock() + t.inside = inside + t.mu.Unlock() +} + +// namesSeenFrom is what this proxy says it serves to a request from this address — every routed +// name, less the internal-only ones when the request came from outside. +func (t *table) namesSeenFrom(remote string) []string { + out := []string{} + for _, name := range t.names() { + if !t.hiddenFrom(name, remote) { + out = append(out, name) + } + } + return out +} + func (t *table) names() []string { t.mu.RLock() defer t.mu.RUnlock() @@ -343,7 +437,20 @@ func run() error { } held := newTable() + // **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 @@ -422,19 +529,7 @@ func run() error { }() tlsConfig := publicManager.TLSConfig() - if internalManager != nil { - // Dispatched by which authority may certify this name at all — the same question - // eligibleForInternalACME already answers, asked once more at handshake time rather than - // only when an order is placed, since a cached certificate is served here on every request - // and never goes through HostPolicy again. - fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate - tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - if held.eligibleForInternalACME(hello.ServerName) { - return fromInternal(hello) - } - return fromPublic(hello) - } - } + tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager) server := &http.Server{ Addr: secure, @@ -592,6 +687,33 @@ func forThisAuthority(cache, directory string, root []byte) string { return filepath.Join(cache, hex.EncodeToString(sum[:])[:16]) } +// certificateFor picks the certificate a handshake is answered with. +// +// Dispatched by which authority may certify this name at all — the same question +// eligibleForInternalACME already answers, asked once more at handshake time rather than only when +// an order is placed, since a cached certificate is served here on every request and never goes +// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the +// private network asking for a name that is only internal: the certificate would name it. +func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error), + internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error) + if internalManager != nil { + fromInternal = internalManager.TLSConfig().GetCertificate + } + return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if held.eligibleForInternalACME(hello.ServerName) { + if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) { + return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", + hello.ServerName) + } + if fromInternal != nil { + return fromInternal(hello) + } + } + return fromPublic(hello) + } +} + // newTable is an empty routing table. func newTable() *table { return &table{to: map[string][]rule{}} @@ -600,8 +722,9 @@ func newTable() *table { // handler is the proxy itself, separated so it can be driven by a test without a listener. func handler(held *table) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hidden := held.hiddenFrom(r.Host, r.RemoteAddr) matched, known := held.find(r.Host, r.URL.Path) - if !known { + if hidden || !known { // **Named, not a bare 404.** A route that was withdrawn and a name that never existed // are different things, and a proxy that says only "not found" makes an operator go // and read the mesh to tell them apart. What it is serving is the answer to both. @@ -611,13 +734,13 @@ func handler(held *table) http.Handler { // contradiction an operator would have to disbelieve the proxy to get past. w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusNotFound) - if held.routed(r.Host) { + if !hidden && held.routed(r.Host) { fmt.Fprintf(w, "%s is served here, but no route covers %q.\n", bareHost(r.Host), r.URL.Path) return } fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n", - r.Host, strings.Join(held.names(), ", ")) + r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", ")) return } @@ -731,10 +854,16 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { 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. +func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) { out := map[string][]rule{} public := map[string]bool{} - for _, c := range said.Given { + for _, c := range contributions { name, _ := c.Values["name"].(string) name = strings.TrimSpace(name) internal, _ := c.Values["internal-name"].(string) @@ -839,7 +968,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made) } } - return out, public, nil + return out, public } // asWhole is any whole number the mesh wrote, whatever its magnitude. diff --git a/modules/route-proxy/cmd/route-proxy/reach_test.go b/modules/route-proxy/cmd/route-proxy/reach_test.go new file mode 100644 index 0000000..151a41a --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/reach_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" +) + +// behind is a workload the proxy can send to, and a table routing one public name and one +// internal-only name to it, with the mesh's machines as the membership would issue them. +func behind(t *testing.T, mesh ...string) *table { + t.Helper() + workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "the workload") + })) + t.Cleanup(workload.Close) + at, _ := url.Parse(workload.URL) + host, port, _ := net.SplitHostPort(at.Host) + + routes, public, err := routesFrom(write(t, 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))) + if err != nil { + t.Fatal(err) + } + held := newTable() + inside, err := sourcesOf(mesh) + if err != nil { + t.Fatal(err) + } + held.setInside(inside) + held.set(routes, public) + return held +} + +// askFrom is what the proxy answers a request for host coming from remote. +func askFrom(held *table, host, remote string) (int, string) { + r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) + r.RemoteAddr = remote + w := httptest.NewRecorder() + handler(held).ServeHTTP(w, r) + return w.Code, w.Body.String() +} + +// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR +// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name +// being internal kept nobody out: a request from the internet only had to carry it. +func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) { + held := behind(t, "10.10.0.1", "10.10.0.7") + + if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK || + body != "the workload" { + t.Errorf("a request from the private network was not served: %d %q", code, body) + } + if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK { + t.Errorf("a request from the machine itself was not served: %d %q", code, body) + } + + code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000") + if code != http.StatusNotFound { + t.Fatalf("a request from outside the private network reached an internal-only name: %d %q", + code, body) + } + // Answered as a name never routed, and the list of what is served does not name it either — + // otherwise the refusal would tell an outsider exactly what to ask for from inside. + if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") { + t.Errorf("the refusal names the internal-only route to an outsider: %q", body) + } + if !strings.Contains(body, "app.example") { + t.Errorf("the refusal stopped listing the public names: %q", body) + } +} + +// The internal name of a route that also has a public one is internal too: served inside, and to +// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a +// name stays one thing whichever route it came from. +func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) { + held := behind(t, "10.10.0.1", "10.10.0.7") + if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK { + t.Errorf("the internal alias stopped answering the private network: %d %q", code, body) + } + if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound { + t.Errorf("the internal alias was served to an outsider: %d", code) + } + if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK { + t.Errorf("the public name was refused to an outsider: %d", code) + } +} + +// Before a membership has said who the mesh is, only the machine itself is inside — refused to +// everyone else, never served to everyone. +func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) { + held := behind(t) + if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound { + t.Errorf("an internal-only name was served with no private network said: %d", code) + } + if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK { + t.Errorf("an internal-only name was refused to the machine itself: %d", code) + } +} + +type from struct { + net.Conn + remote net.Addr +} + +func (c from) RemoteAddr() net.Addr { return c.remote } + +// The handshake refuses an internal-only name to an outsider too: the certificate would name it, +// and serving it would answer the question the routing refuses to. +func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) { + held := behind(t, "10.10.0.1", "10.10.0.7") + served := &tls.Certificate{} + pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil) + hello := func(name, remote string) *tls.ClientHelloInfo { + addr, _ := net.ResolveTCPAddr("tcp", remote) + return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}} + } + + if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil { + t.Error("an outsider was handed a certificate for an internal-only name") + } + if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served { + t.Errorf("a client on the private network was refused: %v", err) + } + if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served { + t.Errorf("a public name was refused to an outsider: %v", err) + } +} + +// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is +// refused rather than skipped, so a typo never quietly narrows or widens who is inside. +func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) { + if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil { + t.Error("an entry that is not an address was accepted") + } + inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"}) + if err != nil { + t.Fatal(err) + } + for remote, want := range map[string]bool{ + "10.10.0.1:1": true, + "[::ffff:10.10.0.1]:1": true, + "[fd00::1]:1": true, + "10.20.0.200:1": true, + "10.10.0.2:1": false, + "192.168.1.10:1": false, + "not-an-address": false, + } { + if inside.holds(remote) != want { + t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want) + } + } +} + +// 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) { + held := newTable() + took := applyMembership(broker.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 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(broker.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(broker.Membership{ + Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)}, + Mesh: []string{"not-an-address"}, + }, held) { + t.Error("a membership whose mesh cannot be read was applied") + } +} -- 2.54.0 From b0d831cd085b0af4130c718704ea029a42c08b1c Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 2 Oct 2026 15:38:29 +0200 Subject: [PATCH 15/51] The example images build from the Go the manifest pins Four example Dockerfiles named golang:1.25 while go.mod requires 1.26; the control plane's image takes GO_BASE from the manifest and these did not, so a build that could not fetch a newer toolchain failed at go mod download (found running the lab through the mesh). --- modules/route-proxy/cmd/route-proxy/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/route-proxy/cmd/route-proxy/Dockerfile b/modules/route-proxy/cmd/route-proxy/Dockerfile index c807c3d..1c03627 100644 --- a/modules/route-proxy/cmd/route-proxy/Dockerfile +++ b/modules/route-proxy/cmd/route-proxy/Dockerfile @@ -2,7 +2,10 @@ # # Static and FROM scratch like the control plane's image, and for the same reason: it is fetched # by digest and run on a machine, so everything in it is something a person would have to audit. -FROM golang:1.25-alpine AS build +# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the +# build machine alike; the default only serves a hand build, and matches go.mod. +ARG GO_BASE=golang:1.26-alpine +FROM ${GO_BASE} AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -- 2.54.0 From 961bf804c9dc56249f73981e0b8177e338337a8e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 17:02:49 +0200 Subject: [PATCH 16/51] The intrusion-prevention seat serves status, banned, ban and unban; the proxy logs a refused name (hq ADR 0179) Every holder of node-intrusion-prevention owes the four verbs, as the packet filter's holder owes its three (ADR 0170). The proxy says in its log when a name this mesh does not serve is asked for, with the asking address last, so its own jail can read it. --- modules/route-proxy/cmd/route-proxy/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 47d4f93..4a12f3a 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -732,6 +732,11 @@ func handler(held *table) http.Handler { // And since a host may now be routed only on some paths, those are a third thing: // saying "no route for this name" while listing that very name as served is a // contradiction an operator would have to disbelieve the proxy to get past. + // **Said in the log as well as to the client.** A name this mesh does not serve, asked + // for from outside, is what a scanner does, and the machine's intrusion prevention reads + // this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the + // jail's filter expects it. + log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusNotFound) if !hidden && held.routed(r.Host) { -- 2.54.0 From 6d0a6a5116669b395035133e564e5aebf3319673 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 22:22:51 +0200 Subject: [PATCH 17/51] The route proxy tells a backend the request was HTTPS, and for which name NewSingleHostReverseProxy sets only X-Forwarded-For, so a backend that writes its own addresses saw the plain hop from the proxy: Gitea's Go import tag named an http clone URL and go get refused the SDK's module path. The proxy now sets X-Forwarded-Proto, -Host and -For from the request it received, and keeps the Host header as it was. --- .../cmd/route-proxy/forwarded_test.go | 29 +++++++++++++++++++ modules/route-proxy/cmd/route-proxy/main.go | 16 +++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 modules/route-proxy/cmd/route-proxy/forwarded_test.go diff --git a/modules/route-proxy/cmd/route-proxy/forwarded_test.go b/modules/route-proxy/cmd/route-proxy/forwarded_test.go new file mode 100644 index 0000000..0c31068 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/forwarded_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// A backend behind the proxy learns the client used TLS and which name it asked for, so the addresses +// it writes into its own pages are the ones a client can use (2026-10-03: a forge's Go import tag +// named an http clone URL, and Go refused the module path). +func TestABackendIsToldTheRequestWasHTTPSAndForWhichName(t *testing.T) { + var proto, host, fwdHost, fwdFor string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proto, host, fwdHost, fwdFor = r.Header.Get("X-Forwarded-Proto"), r.Host, r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Forwarded-For") + })) + defer backend.Close() + where, _ := url.Parse(backend.URL) + req := httptest.NewRequest(http.MethodGet, "https://git.example.org/novox/mesh-sdk/go?go-get=1", nil) + req.TLS = &tls.ConnectionState{} + req.Host = "git.example.org" + req.RemoteAddr = "192.0.2.7:51000" + towards(where).ServeHTTP(httptest.NewRecorder(), req) + if proto != "https" || fwdHost != "git.example.org" || host != "git.example.org" || fwdFor != "192.0.2.7" { + t.Errorf("the backend was told proto=%q host=%q forwarded-host=%q for=%q", proto, host, fwdHost, fwdFor) + } +} diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go index 4a12f3a..ebadf63 100644 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ b/modules/route-proxy/cmd/route-proxy/main.go @@ -273,7 +273,7 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) { log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err) continue } - r.to = httputil.NewSingleHostReverseProxy(where) + r.to = towards(where) if r.insecure { r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } @@ -1060,3 +1060,17 @@ func asPort(v any) (int, bool) { } 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 +// proxy and writes `http://`, though every client reached it over TLS: Go refused the forge's module +// path for exactly that on 2026-10-03, its import tag naming an http clone URL. +// The standard library's NewSingleHostReverseProxy sets only X-Forwarded-For. +func towards(where *url.URL) *httputil.ReverseProxy { + return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(where) + pr.Out.Host = pr.In.Host + pr.SetXForwarded() + }} +} -- 2.54.0 From 99b2737b79c0d9c4e88cf4621f18a9f79cc4a801 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 06:47:18 +0200 Subject: [PATCH 18/51] Stamp no commit in this repository's own image recipes either (hq issue 363, review of #292) The genesis image copies the whole clone with .git, so its controller carried the commit it was built at, as the bundles did. --- modules/route-proxy/cmd/route-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/route-proxy/cmd/route-proxy/Dockerfile b/modules/route-proxy/cmd/route-proxy/Dockerfile index 1c03627..0395803 100644 --- a/modules/route-proxy/cmd/route-proxy/Dockerfile +++ b/modules/route-proxy/cmd/route-proxy/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy FROM scratch COPY --from=build /mesh-route-proxy /mesh-route-proxy -- 2.54.0 From d82f24b51a4b0662746afbd0130f14169a9d55d5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 18:15:33 +0200 Subject: [PATCH 19/51] Bring build-agent and route-proxy in whole (issue 1019) --- modules/route-proxy/Dockerfile | 29 ++ modules/route-proxy/README.md | 92 +++++++ .../route-proxy/cmd/route-provider/main.go | 37 +++ .../cmd/route-provider/provider.go | 36 +++ .../cmd/route-provider/provider_test.go | 125 +++++++++ modules/route-proxy/files/acme.env | 3 + modules/route-proxy/files/internal-acme.env | 3 + modules/route-proxy/go.mod | 5 + modules/route-proxy/go.sum | 2 + modules/route-proxy/module.json | 253 ++++++++++++++++++ 10 files changed, 585 insertions(+) create mode 100644 modules/route-proxy/Dockerfile create mode 100644 modules/route-proxy/README.md create mode 100644 modules/route-proxy/cmd/route-provider/main.go create mode 100644 modules/route-proxy/cmd/route-provider/provider.go create mode 100644 modules/route-proxy/cmd/route-provider/provider_test.go create mode 100644 modules/route-proxy/files/acme.env create mode 100644 modules/route-proxy/files/internal-acme.env create mode 100644 modules/route-proxy/go.mod create mode 100644 modules/route-proxy/go.sum create mode 100644 modules/route-proxy/module.json diff --git a/modules/route-proxy/Dockerfile b/modules/route-proxy/Dockerfile new file mode 100644 index 0000000..eb7fdaf --- /dev/null +++ b/modules/route-proxy/Dockerfile @@ -0,0 +1,29 @@ +ARG ALPINE_BASE=alpine:3.20 +ARG GO_BASE=golang:1.25 +# The route-proxy module's runtime image: the reference reverse proxy compiled into a container. +# +# **The proxy source sits beside this module.** The canonical proxy, the contract written as something +# that runs, lives in this same repository, mesh-controller, at examples/route-proxy (novox/hq 08-connectivity +# §3), and the repository is the build context (a module lives whole in one repository, novox/hq ADR 0420), +# so this Dockerfile compiles ./examples/route-proxy from the repository root. The build seat hands it only +# that program's build source, its import closure, go.mod, go.sum and the vendored packages it uses +# (novox/hq ADR 0267, the artifact's `compiles`), so a merge elsewhere in the repository does not +# move this image, and a COPY of anything outside it fails by name. +# +# docker build -f modules/route-proxy/Dockerfile -t mesh-route-proxy:development . +# +# The mesh pins the digest of what this produces; the committed module.json carries the placeholder +# digest every mesh-built image does, replaced at publish. +FROM ${GO_BASE} AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-route-proxy ./examples/route-proxy + +# A small runtime with the public CA roots the ACME client needs to reach a real authority, and run +# as root so it can bind :80 and :443 — the two privileged ports a public front door listens on. +FROM ${ALPINE_BASE} +RUN apk add --no-cache ca-certificates +COPY --from=build /mesh-route-proxy /usr/local/bin/mesh-route-proxy +ENTRYPOINT ["/usr/local/bin/mesh-route-proxy"] diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md new file mode 100644 index 0000000..19fdea1 --- /dev/null +++ b/modules/route-proxy/README.md @@ -0,0 +1,92 @@ +# route-proxy — the mesh's public ingress + +The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08-connectivity §3 +"Exposure"). It is the `traefik` replacement: the module that **provides `route`**. + +## 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). + +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. + +| field | value | why | +|---|---|---| +| `provides` | `route` (scope `mesh`) | 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 | +| `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. + +## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) + +**The public issuer is this module's own fact, not a provision.** Until 2026-10-06 it came from a +second module, `public-acme`, that ran nothing and offered `acme-ca` pointing at Let's Encrypt; every +node running a proxy was assigned it too, and nothing else ever consumed it. It is retired: the proxy +names the production directory in its own `acme.env`, and the only issuer it is still bound to is the +mesh's own (`internal-acme-ca`, from `step-ca`). + +**`acme.env` is byte for byte what the binding rendered.** The proxy keeps each authority's account +and certificates in a directory under `/var/lib/route-proxy/acme` named after a digest of the +directory URL — `https://acme-v02.api.letsencrypt.org:443/directory`, port included, as the binding +spelled it — and of the root bundle the `trust` container copies in. Any change to either is a new +authority to the proxy: a new account, and every routed name ordered again against Let's Encrypt's +rate limits. So the URL keeps the `:443`, `ACME_ROOTS_PATH` stays empty, and the `trust` artifact's +pinned image is not moved without a decision; mesh-controller's +`TestRouteProxyKeepsItsPublicAccountDirectory` holds all three. + +## How it ships the Go proxy + +The proxy is a Go program, unlike the TypeScript tool-runtime modules. Its source sits in this same +repository, mesh-controller, at `examples/route-proxy`, the contract written as something that runs. This +module ships the packaging: a multi-stage [`Dockerfile`](Dockerfile) whose build context is the repository +root and which compiles `./examples/route-proxy` into `mesh-route-proxy`. The committed `module.json` carries the +placeholder digest every mesh-built image does (`@sha256:0000…`); the mesh pins the real digest at +publish. + +In the lab, `mesh-lab/scripts/build-route-proxy-image.sh` builds this into the local daemon as +`mesh-route-proxy:development`, and a scenario stocks it and serves it by digest. + +## Certificates, and the endpoint (resolves 04-ISSUES/004) + +TLS is opt-in and terminates here for public names, via ACME (autocert). A proxy with no +`TLS_LISTEN` serves plain HTTP; with it, it obtains a publicly-trusted certificate for exactly the +names the mesh routes here (`HostPolicy` = only routed names, so a scan cannot spend the account's +issuance quota) and answers the HTTP-01 challenge on `:80` at the name being certified. + +**`ACME_DIRECTORY` defaults to Let's Encrypt *staging*, not production.** This closes +novox/hq 04-ISSUES/004 (certificate issuance targets production): +the issuer is a per-node choice, and the safe endpoint is the default. Production issuance is +rate-limited per domain and per account and does not replenish quickly; defaulting to production +would leave the safe path depending on somebody remembering to opt out of it, on exactly the work +most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces +itself on the first request rather than a fortnight later at the rate limit. + +**The module states production.** The binary's staging default stands for anything that runs it +without the module — the lab, a developer's machine — and the module's `acme.env` names production +for every node it is assigned to, because a node assigned the public proxy is one that serves public +traffic. + +| env | default | meaning | +|---|---|---| +| `ROUTES` | `/routes/mesh.json` | the contributions file the mesh writes (`receives.route`) | +| `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 | +| `ACME_DIRECTORY` | Let's Encrypt **staging** in the binary; production in the module's `acme.env` | the issuer | +| `ACME_CA_BUNDLE` | *(unset)* | a file of roots to trust for the issuer's own API — set only for a private/lab authority whose API certificate the world does not yet trust | + +## Answered by the provider helper (issue 792) + +A small Go program beside the proxy (`cmd/route-provider`) runs the SDK's provider helper for `route`, with no credential, +and the offer says `"answered-by": "helper"`: a handed consumer (gitea's origins file) is filled by its machine's client +from this module's entry, which carries the public names the controller composed for it. The module is **not** marked +moved: its own files, `acme.env` and the broker secret, are composed as before, and the proxy itself is unchanged. diff --git a/modules/route-proxy/cmd/route-provider/main.go b/modules/route-proxy/cmd/route-provider/main.go new file mode 100644 index 0000000..e70a689 --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/main.go @@ -0,0 +1,37 @@ +// route-provider: route-proxy's Go half, one binary the node's runtime launches and speaks MCP to over stdio +// through the Go SDK. It serves no tools; it runs the SDK's provider helper that answers the consumers of `route` +// (issue 792). The TypeScript adapter beside it keeps writing the proxy's files. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/provider" +) + +func say(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[route-proxy] "+format+"\n", args...) +} + +func main() { + node := os.Getenv("MESH_NODE") + if node == "" { + say("MESH_NODE is not set; answering no consumers") + } else { + p := newProvider(node, provider.StateBus(ModuleName), say) + go func() { + if err := p.Run(context.Background()); err != nil { + say("the provider helper stopped: %v", err) + } + }() + } + if err := stdio.Serve("", nil); err != nil { + say("%v", err) + os.Exit(1) + } +} diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go new file mode 100644 index 0000000..7eee671 --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/provider.go @@ -0,0 +1,36 @@ +package main + +// route-proxy handed to the provisioner (novox/hq ADR 0349 clause 12, issue 792): the module runs the SDK's provider +// helper for `route`, so a consumer of a route — a handed gitea reading `${bound:route:name-web}` — is told its public +// name from the entry in this module's bucket, the one its machine's client fills its files from. +// +// **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. + +import ( + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// ProvisionName is what route-proxy provides, as its manifest says. +const ProvisionName = "route" + +// ModuleName is this module. +const ModuleName = "route-proxy" + +// 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. +func newProvider(node string, bus provider.Bus, logf func(string, ...any)) *provider.Provider { + return provider.New(provider.Options{ + Node: node, + Module: ModuleName, + Offers: map[string]provider.Offer{ProvisionName: { + Credential: provider.NoCredential, + Serves: map[string]any{}, + }}, + Bus: bus, + Logf: logf, + }) +} diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go new file mode 100644 index 0000000..d7ccaff --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "sync" + "testing" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// A consumer of a route is answered with the names its plan's entry carries, and nothing of a credential (issue 792): +// the entry's values are the controller's composition, copied unchanged, and no vault is asked. +func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + plan, _ := json.Marshal(map[string]any{"node": "g14", "module": "gitea", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": ModuleName, "at": "10.7.0.1", "from": "novox", "as": "mesh_g14_gitea", + "names": map[string]any{"name": "git.example.org", "internal-name": "git.novox.internal", + "name-web": "git.example.org"}}}}}) + if err := bus.Put(context.Background(), provider.AssignmentsBucket, "g14.gitea", plan); err != nil { + t.Fatal(err) + } + p := newProvider("novox", bus, t.Logf) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("the pass did %+v", out) + } + var e provider.Entry + if err := json.Unmarshal(bus.buckets[provider.BucketOf(ModuleName)]["g14.gitea.route"], &e); err != nil { + t.Fatal(err) + } + if e.Values["name-web"] != "git.example.org" || e.Values["name"] != "git.example.org" || + e.Values["internal-name"] != "git.novox.internal" || e.VaultName != "" || e.User != "" || e.Refused != "" { + t.Fatalf("the entry is %+v", e) + } +} + +// route-proxy's own files and resources stay as they were (issue 792): the module answers its route through the +// helper but is not marked moved, so its acme.env, its broker secret and its containers are composed as today and +// 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. +func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, moved := m["moved"]; moved { + t.Fatal("route-proxy is marked moved: its own files would be handed") + } + provides, _ := m["provides"].([]any) + if len(provides) != 1 || provides[0].(map[string]any)["answered-by"] != "helper" { + t.Fatalf("route is not said to be answered by the helper: %v", provides) + } + for _, k := range []string{"build", "provides", "state", "reads"} { + delete(m, k) + } + pinned, err := json.Marshal(m) // sorted keys, compact + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(pinned) + if got := hex.EncodeToString(sum[:]); got != "57a39de0943d8b33b9db6cb998ce97f8793602cca6cdfa5849c1c392390454a3" { + 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 + // with, so a change to either is a change to the public ingress. + for file, want := range map[string]string{ + "acme.env": "d66eba145c893080e30e013329fc00e51395f0c31fc8621a20c7b26abc11b52f", + "internal-acme.env": "baf4da75379183758c8ae505be4dd8a79d5b089ae1e7c8ded4d232df40251df5", + } { + text, err := os.ReadFile("../../files/" + file) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(text) + if got := hex.EncodeToString(sum[:]); got != want { + t.Fatalf("route-proxy's files/%s changed: digest %s, pinned %s; if the change is meant, check that the "+ + "public and internal ingress still ask for the certificates they should, then pin the new digest here", + file, got, want) + } + } +} diff --git a/modules/route-proxy/files/acme.env b/modules/route-proxy/files/acme.env new file mode 100644 index 0000000..428aae2 --- /dev/null +++ b/modules/route-proxy/files/acme.env @@ -0,0 +1,3 @@ +ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory +ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443 +ACME_ROOTS_PATH= diff --git a/modules/route-proxy/files/internal-acme.env b/modules/route-proxy/files/internal-acme.env new file mode 100644 index 0000000..72b0f7b --- /dev/null +++ b/modules/route-proxy/files/internal-acme.env @@ -0,0 +1,3 @@ +INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path} +INTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots} +INTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots} diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod new file mode 100644 index 0000000..54bca71 --- /dev/null +++ b/modules/route-proxy/go.mod @@ -0,0 +1,5 @@ +module route-proxy + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum new file mode 100644 index 0000000..63b4da4 --- /dev/null +++ b/modules/route-proxy/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json new file mode 100644 index 0000000..a2734f1 --- /dev/null +++ b/modules/route-proxy/module.json @@ -0,0 +1,253 @@ +{ + "module": "route-proxy", + "version": "1", + "slug": "rproxy", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh", + "identity": false, + "answered-by": "helper" + } + ], + "serves": { + "route": {} + }, + "receives": { + "route": "${dir:routes-dir}/mesh.json" + }, + "requires": [ + "internal-acme-ca" + ], + "binds": { + "internal-acme-ca": "${dir:state}/internal-acme-ca.json" + }, + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "listens": [ + { + "name": "http", + "port": 80, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" + }, + { + "name": "https", + "port": 443, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTPS for every name the mesh routes here" + } + ], + "data": { + "own": [ + { + "id": "acme", + "path": "${dir:acme-cache}", + "class": "rebuildable", + "why": "issued certificates, issued again" + }, + { + "id": "ca", + "path": "${dir:ca-dir}", + "class": "cache", + "why": "the authority's roots, fetched again at every start" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh" + }, + { + "id": "routes-dir", + "type": "directory", + "path": "/var/lib/route-proxy/routes", + "mode": "0700" + }, + { + "id": "acme-cache", + "type": "directory", + "path": "/var/lib/route-proxy/acme", + "mode": "0700" + }, + { + "id": "ca-dir", + "type": "directory", + "path": "/var/lib/route-proxy/ca", + "mode": "0755" + }, + { + "id": "acme-env", + "type": "file", + "path": "${dir:state}/acme.env", + "mode": "0600", + "template": "files/acme.env" + }, + { + "id": "internal-acme-env", + "type": "file", + "path": "${dir:state}/internal-acme.env", + "mode": "0600", + "template": "files/internal-acme.env" + }, + { + "id": "trust", + "type": "container", + "name": "route-proxy-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "${dir:state}/acme.env" + ], + "volumes": [ + "${dir:ca-dir}:/ca" + ], + "args": [ + "sh", + "-c", + "if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + ], + "restart-on": [ + "acme-env" + ] + }, + { + "id": "internal-trust", + "type": "container", + "name": "route-proxy-internal-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "${dir:state}/internal-acme.env" + ], + "volumes": [ + "${dir:ca-dir}:/ca" + ], + "args": [ + "sh", + "-c", + "if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + ], + "restart-on": [ + "internal-acme-env" + ] + }, + { + "id": "server", + "type": "container", + "name": "route-proxy", + "artifact": "server", + "network": "host", + "env-file": [ + "${dir:state}/acme.env", + "${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" + ], + "env": { + "ROUTES": "/routes/mesh.json", + "LISTEN": ":80", + "TLS_LISTEN": ":443", + "ACME_CACHE": "/acme", + "ACME_CA_BUNDLE": "/ca/root.crt", + "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt", + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "restart-on": [ + "trust", + "acme-env", + "internal-trust", + "internal-acme-env" + ], + "logging": "journald", + "health": { + "kind": "tcp", + "endpoint": "http", + "interval": "10s", + "timeout": "5s", + "looks": 3, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile", + "compiles": "examples/route-proxy", + "context": { + "seat": "git", + "repository": "novox/mesh-controller", + "ref": "main" + } + }, + { + "name": "trust", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ], + "on": [ + { + "arg": "GO_BASE", + "image": "golang@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9" + }, + { + "arg": "ALPINE_BASE", + "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" + } + ] + }, + "jails": [ + { + "name": "route-proxy", + "failregex": "^.*TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from :\\d+$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" + } + ], + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} -- 2.54.0 From f0c6ce265c8bb50dab488d8ef4607eb70243c077 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 18:15:43 +0200 Subject: [PATCH 20/51] Vendor route-proxy's SDK so its provider builds offline (issue 1019) --- .../git.novox.be/novox/mesh-sdk/go/README.md | 19 + .../git.novox.be/novox/mesh-sdk/go/caller.go | 59 ++ .../git.novox.be/novox/mesh-sdk/go/log.go | 123 +++ .../novox/mesh-sdk/go/merge-check.sh | 20 + .../novox/mesh-sdk/go/provider/consumers.go | 72 ++ .../novox/mesh-sdk/go/provider/provider.go | 746 ++++++++++++++++++ .../novox/mesh-sdk/go/provision.go | 90 +++ .../git.novox.be/novox/mesh-sdk/go/seat.go | 153 ++++ .../git.novox.be/novox/mesh-sdk/go/state.go | 178 +++++ .../git.novox.be/novox/mesh-sdk/go/stdio.go | 522 ++++++++++++ modules/route-proxy/vendor/modules.txt | 4 + 11 files changed, 1986 insertions(+) create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/caller.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/log.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/consumers.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provision.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/seat.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/state.go create mode 100644 modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/stdio.go create mode 100644 modules/route-proxy/vendor/modules.txt diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md new file mode 100644 index 0000000..6a591b2 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md @@ -0,0 +1,19 @@ +# mesh-sdk for Go + +`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime +names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a +function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `.` is the seat's implementation. +`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). `stdio.Config(key)` and `stdio.Bound(provision, key)` read, and `stdio.WatchConfig` and `stdio.WatchBound` watch, the module's own config values and the non-secret values of what it is bound to, for what its manifest declares: the runtime asks its machine's provisioner client as the module it launched, never a module the code names. `stdio.Secret(name)` is refused while the module's code runs in the tool runner; its secrets reach it through its files (novox/hq ADR 0349 §14, ADR 0370). `provider` is a providing module's half of a binding (novox/hq ADR 0349 §12, issue 678): it sees each consumer that binds the provider in the plans of the assignments mirror, writes one entry per consumer to `_bound` (`..[.]`: served values composed with the consumer's identity, the login, and the name of the vault's credential, never a secret), hands the provider's adapter the vault's value (`provision//` shared, `pair////[/]` per consumer), refuses a derived value to a consumer bound under a local name, naming both ends, and removes the entry of one that unbinds; the bus and the vault are interfaces the provider wires, the vault as `provider.ConsumersFile`, the file its machine's provisioner client fills from `${consumers:}` with each bound consumer's credential (novox/hq ADR 0373), since a provider in the tool runner never asks the vault itself. Standard library only. + +## Installing + +``` +GOPRIVATE=git.novox.be go get git.novox.be/novox/mesh-sdk/go@latest +``` + +Releases are tagged `go/vX.Y.Z` in this repository, the subdirectory form Go's module system expects, +at the same version as the TypeScript package. + +What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol +`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one +answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/caller.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/caller.go new file mode 100644 index 0000000..f9a108a --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/caller.go @@ -0,0 +1,59 @@ +package stdio + +import ( + "reflect" + "regexp" + "sync" + "unsafe" +) + +// Unproven is the caller of a call the runtime named no caller for: one that arrived on the subject that names +// none, or from a runtime that names none (novox/hq issue 365). Said as a word, never left empty, so a record +// shows it was not proven. +const Unproven = "unproven" + +// MetaCaller is where in a `tools/call`'s `_meta` the runtime names the caller: the bus user that published the +// call, which the runtime takes from the subject the call arrived on — that subject's last token, which the +// bus lets each user publish with its own name alone (novox/hq issue 365, by ADR 0259 §3's precedent). The +// runtime writes `_meta`; a caller writes only `arguments`, so no caller can name another. +const MetaCaller = "mesh/caller" + +// busUser is the shape of a bus user's name: `.`, `person.`, `controller` — names whose +// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven. +var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) + +// calls holds the caller of every call while its tool runs, by the arguments map that call was handed. +var calls sync.Map // unsafe.Pointer → string + +func argsKey(args map[string]any) unsafe.Pointer { + return reflect.ValueOf(args).UnsafePointer() +} + +// CallerOf is who called the tool that was handed args, as the runtime proved it: the bus user that +// published the call, or Unproven. Read from the runtime, never from the arguments' contents — a `caller` +// argument names nobody. Valid while the tool runs; arguments no call was handed have no caller. +func CallerOf(args map[string]any) string { + if args == nil { + return Unproven + } + if c, ok := calls.Load(argsKey(args)); ok { + return c.(string) + } + return Unproven +} + +// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven. +func callerIn(meta map[string]any) string { + if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) { + return s + } + return Unproven +} + +// runAs runs a tool with the caller held for its arguments for as long as it runs. +func runAs(t Tool, caller string, args map[string]any) (any, error) { + key := argsKey(args) + calls.Store(key, caller) + defer calls.Delete(key) + return t.Run(args) +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/log.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/log.go new file mode 100644 index 0000000..7b1030d --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/log.go @@ -0,0 +1,123 @@ +package stdio + +import ( + "encoding/json" + "fmt" + "time" +) + +// Log — a module's record of operations on the bus, kept as long as the log is (novox/hq ADR 0297). Not the +// current value: that is state, and a log's state is its fold. Each entry is one operation on one key, its +// sequence in the log is its identity, and the order of the log is the order of the operations. A module +// declares each log it owns in its manifest (`logs`) and names it here as it named it there; a log is its +// owner's alone, and the runtime refuses, with the reason, one it did not issue. + +// LogEntry is one operation on one key, as the log holds it. +type LogEntry struct { + Sequence uint64 `json:"sequence"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + At time.Time `json:"at"` +} + +// LogPage is one page of a read: its entries, in order, the sequence to read on from (0 when nothing +// follows) and the log's last sequence when it was read. +type LogPage struct { + Entries []LogEntry `json:"entries"` + Next uint64 `json:"next"` + Last uint64 `json:"last"` +} + +// LogInfo is how much a log holds: its entries and bytes, its cap in bytes, and its first and last sequence. +type LogInfo struct { + Entries uint64 `json:"entries"` + Bytes uint64 `json:"bytes"` + MaxBytes uint64 `json:"max_bytes"` + First uint64 `json:"first"` + Last uint64 `json:"last"` +} + +// KeptLog is one log this module owns. +type KeptLog struct{ name string } + +// Log is a log this module owns, by its local name. +func Log(name string) KeptLog { return KeptLog{name: name} } + +const logTimeout = 30 * time.Second + +func (l KeptLog) ask(verb string, params map[string]any) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + params["log"] = l.name + return c.ask("mesh/log."+verb, params, logTimeout) +} + +func (l KeptLog) append(params map[string]any) (uint64, error) { + raw, err := l.ask("append", params) + if err != nil { + return 0, err + } + var appended struct { + Sequence uint64 `json:"sequence"` + } + return appended.Sequence, json.Unmarshal(raw, &appended) +} + +// Append writes an entry for key, whatever came before it, and returns its sequence. A key is one or more +// names of letters, digits, `-` and `_`, joined by `.`; a value is JSON of at most 256 KiB. A full log +// refuses the entry: it never drops an older one to make room. +func (l KeptLog) Append(key string, value any) (uint64, error) { + return l.append(map[string]any{"key": key, "value": value}) +} + +// AppendAt writes an entry for key only if the key's last entry is still at expect — the sequence the caller +// read, 0 for a key with no entry yet — and returns its sequence. When another entry for the key came in +// between, nothing is written and the error is ErrChanged, as for a lost State update: read again and +// decide again. +func (l KeptLog) AppendAt(key string, value any, expect uint64) (uint64, error) { + return l.append(map[string]any{"key": key, "value": value, "expect": expect}) +} + +// Read is the entries from the sequence from on, oldest first, at most limit of them (0 is the runtime's +// page, 100; it gives at most 1000). keys narrows it: a key, with `*` for one name and `>` for the rest; +// empty is every key. Read on from the page's Next until it is 0. +func (l KeptLog) Read(from uint64, keys string, limit int) (*LogPage, error) { + if limit < 0 { + return nil, fmt.Errorf("a read of %s asks for at least no entries: limit %d", l.name, limit) + } + params := map[string]any{"from": from} + if keys != "" { + params["keys"] = keys + } + if limit > 0 { + params["limit"] = limit + } + raw, err := l.ask("read", params) + if err != nil { + return nil, err + } + var p LogPage + return &p, json.Unmarshal(raw, &p) +} + +// Last is a key's last entry, or nil when the key has none. +func (l KeptLog) Last(key string) (*LogEntry, error) { + raw, err := l.ask("last", map[string]any{"key": key}) + if err != nil || len(raw) == 0 || string(raw) == "null" { + return nil, err + } + var e LogEntry + return &e, json.Unmarshal(raw, &e) +} + +// Info is how much the log holds and how much it may. +func (l KeptLog) Info() (*LogInfo, error) { + raw, err := l.ask("info", map[string]any{}) + if err != nil { + return nil, err + } + var i LogInfo + return &i, json.Unmarshal(raw, &i) +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh new file mode 100644 index 0000000..19a7ec0 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# mesh-check-toolchain: go +# +# The Go part of the SDK's own check (novox/hq ADR 0237 as amended, ADR 0272): the Go binding, run by the build +# seat in the mesh's Go toolchain, its own container, because the SDK's merge-check.sh declares it +# (`# mesh-check-also: go go/merge-check.sh`) — and by hand, from anywhere: `sh go/merge-check.sh`. +# +# Formatted, vetted, and its whole suite run: the stdio loop and the local protocol mesh-cli speaks to the +# node-engine. Standard library only, so nothing is fetched. +set -eu +cd "$(dirname "$0")" + +unformatted=$(gofmt -l .) +if [ -n "$unformatted" ]; then + echo "not gofmt'd:" + echo "$unformatted" + exit 1 +fi +go vet ./... +go test -count=1 ./... diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/consumers.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/consumers.go new file mode 100644 index 0000000..326d0a9 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/consumers.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" +) + +// The provider's consumers' credentials, from the file its machine's provisioner client fills (novox/hq ADR 0373, +// issue 679). +// +// **The provider never asks the vault.** A provider in the tool runner has no account of its own, and the vault +// hands a secret only to a node's provisioner client or to a module under an account of its own (ADR 0349 clause +// 11, ADR 0370 clause 2). So the provider's template names `${consumers:}`, and its machine's client +// fills that file with one entry per consumer bound to it for that provision, each credential asked of the vault in +// the client's own name; the file is the provider's alone (mode 0600, owned). ConsumersFile is that file as the +// helper's Vault: it reads the file on each ask, so a consumer that binds or a credential the vault remakes reaches +// the provider at its next pass. + +// ConsumerCredential is one entry of the file: the consumer, the vault's name of its credential, and its value. +type ConsumerCredential struct { + // Consumer and Module are the consumer's machine and module, Local its local name for the provision, if any. + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // Credential is the vault's name of the consumer's credential, PairName's. + Credential string `json:"credential"` + // Value is the credential itself. + Value string `json:"value"` +} + +// ConsumersFile answers a credential from the files the provider machine's client fills: one per provision whose +// template names `${consumers:}`, each a JSON list of ConsumerCredential. +type ConsumersFile struct { + Paths []string +} + +// Value is the credential named name, from the first file carrying it. A file not there yet is passed over; one not +// in the client's form is an error, never read as no consumers. +func (f ConsumersFile) Value(_ context.Context, name string) ([]byte, error) { + var missing []string + for _, at := range f.Paths { + raw, err := os.ReadFile(at) + if errors.Is(err, fs.ErrNotExist) { + missing = append(missing, at) + continue + } + if err != nil { + return nil, fmt.Errorf("the consumers' file %s: %w", at, err) + } + var entries []ConsumerCredential + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("the consumers' file %s is not the provisioner client's list of consumers", at) + } + for _, e := range entries { + if e.Credential == name { + if e.Value == "" { + return nil, fmt.Errorf("the consumers' file %s carries %s with no value", at, name) + } + return []byte(e.Value), nil + } + } + } + if len(missing) == len(f.Paths) { + return nil, fmt.Errorf("%s: no consumers' file is filled yet (%v)", name, f.Paths) + } + return nil, fmt.Errorf("%s is in no consumers' file: the vault holds none for it yet, or its machine's client "+ + "has not filled it", name) +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go new file mode 100644 index 0000000..33bf61f --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go @@ -0,0 +1,746 @@ +// Package provider is a providing module's half of a binding: it answers the provider's consumers itself, over the +// bus (novox/hq ADR 0349 clause 12, issue 678): the provider module, not the controller, makes each bound +// consumer's entry. +// +// **Who binds the provider.** Each node's provisioner client records the needs of the modules handed to it from +// their plans, and the controller names in each plan, per required provision and local name, the provider that +// resolution chose: `provisioning.provisions` in `.` of the assignments mirror (issue 760). A recorded +// need names no provider (issue 677's note 7), so the plan's entry is where a bind is seen, as the vault sees it +// (mesh-vault pair.go). A consumer binds this provider when an entry names this machine and this module, and +// unbinds when the entry, or the assignment, goes. A consumer still filled the old way has no plan and is not seen +// here; the controller answers it as before (ADR 0349 clause 17). +// +// **One entry per consumer, in the provider's own bucket.** For each bind the helper composes the consumer's +// served values from the provider's offer (its config already composed into it, ADR 0202 §7) and the consumer's +// identity (`${consumer:as}`, `${consumer:as:dns}`, ADR 0202 §1), and writes one entry, `.. +// [.]` of `_bound`: the one statement both ends read. It never carries a +// secret. It names the credential the vault keeps for the pair, which the consumer's client asks the vault for. +// +// **The credential is the vault's** (ADR 0349 clause 8). The helper mints nothing and reads nothing of the +// provider's own secrets: it reads the value from the vault and hands it to the provider's adapter, which makes the +// login its software keeps. An offer whose software holds one credential (ADR 0158) is the vault's one value per +// provider and provision, `provision//`, with the provider's one user name; an offer with +// a login per consumer (ADR 0048, ADR 0158 §11, the default) is each consumer's own value, +// `pair////[/]`, with the consumer's identity as its login (issue +// 677's notes 12 and 13). A credential the vault does not hold yet is waited for: nothing is made and no entry is +// written, so a consumer is never told of a login its provider has not made. A provider in the tool runner never asks +// the vault itself: its Vault is ConsumersFile, the file its machine's provisioner client fills (ADR 0373). +// +// **A multi-holder consumer gets no derived value** (ADR 0202 §10 as ADR 0349 clause 12 changes it): a consumer +// bound under a local name keeps several holders of the provision, each with its own login, while what the +// provider derives names one; its entry refuses it, naming both ends, and its client reports it. +// +// The helper speaks to the bus and the vault through the two interfaces below, so a provider wires it to its +// runtime and a test to a stand-in. Standard library only. +package provider + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// AssignmentsBucket is the controller's assignments mirror, `.`, whose value carries the plan of a +// module handed to the provisioner. +const AssignmentsBucket = "mesh-controller_assignments" + +// BucketOf is a providing module's own bucket of entries, one per consumer: `_bound`, the module's state +// `bound`. +func BucketOf(module string) string { return module + "_bound" } + +// Bus is what the helper needs of the bus: a key-value watch and the provider's writes to its own bucket. +type Bus interface { + // Watch hands handle every current key and value of bucket, then each change as it comes, a removed key with a + // nil value; it returns once the current values are handed, and the watch lasts until ctx ends. + Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error + Put(ctx context.Context, bucket, key string, value []byte) error + Delete(ctx context.Context, bucket, key string) error +} + +// ConditionalBus is a Bus that can also delete a key only while it still holds what the caller read. The helper uses +// it when the Bus offers it, so a pass that lets a consumer go never removes an entry another instance of the +// provider wrote after the pass read it; a Bus without it deletes unconditionally. DeleteIf returns an error that +// is stdio.ErrChanged when the key holds something else now. +type ConditionalBus interface { + Bus + DeleteIf(ctx context.Context, bucket, key string, held []byte) error +} + +// Vault is how the provider reads a credential the vault keeps, by its name, opened. +type Vault interface { + Value(ctx context.Context, name string) ([]byte, error) +} + +// CredentialForm is which credential an offer's consumers present. +type CredentialForm int + +const ( + // PerConsumer is a login of each consumer's own (ADR 0048, ADR 0158 §11): the default. + PerConsumer CredentialForm = iota + // Shared is the one credential the provider's software holds, shared with every consumer (ADR 0158). + Shared + // NoCredential is an offer whose consumers present none. + NoCredential +) + +// Offer is what the provider serves one provision with. +type Offer struct { + // Serves are the values served to each consumer, the provider's config already composed into them; a string + // may name the consumer's identity, `${consumer:as}` or `${consumer:as:dns}`. + Serves map[string]any + // Credential is the form of the credential its consumers present. A consumer's plan entry that names a + // credential form of its own wins over it (the plan's `credential-form`). + Credential CredentialForm + // User is the provider's one user name, for a Shared credential. + User string +} + +// Entry is one consumer's entry in the provider's bucket: the one statement both ends read. Never a secret. +type Entry struct { + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // As is the consumer's identity, the mesh's (ADR 0202 §1). + As string `json:"as,omitempty"` + // User is the login the consumer presents: its identity, or the provider's one user name for a shared credential. + User string `json:"user,omitempty"` + // Values are the served values, composed for this consumer: what `${bound::}` reads. + Values map[string]any `json:"values,omitempty"` + // VaultName is the name under which the vault keeps this consumer's credential, which the consumer's client asks + // the vault for: a name, never the credential. Its key is not "credential" because the runtime's state guard + // refuses any key ending in that word, whatever the value (issue 945). + VaultName string `json:"vault-name,omitempty"` + // Refused, when set, is why the consumer is not served, naming both ends. + Refused string `json:"refused,omitempty"` + // Contributes is what the consumer's module tells its provider about this provision (a database's name, its + // extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket. + Contributes map[string]any `json:"-"` + // At and From are where the consumer's plan says the provider is and which machine it is (issue 788): the values + // today's binding file gives that consumer. Written into Values as `at` and `from`; never alone to the bucket. + At, From string `json:"-"` + // Names are the names the controller composed for this consumer on this provision (issue 792): its own name, its + // internal name, and one of each per local name it contributes under, keyed as its binding file names them. + // Written into Values over anything the provider serves under the key; never alone to the bucket. + Names map[string]string `json:"-"` + // CredentialForm and Name are from the consumer's plan entry (issue 825): the form of the credential the + // controller chose for this bind (`shared`, `pair`, `issued` or `given`) and the holder's name. When set, they + // win over the offer's form. Never written to the bucket. + CredentialForm, Name string `json:"-"` +} + +// Key is the entry's key in the provider's bucket: `..`, and `.` for a bind under a +// local name. +func (e Entry) Key() string { + k := e.Consumer + "." + e.Module + "." + e.Provision + if e.Local != "" { + k += "." + e.Local + } + return k +} + +// Consumer is what the provider's adapter makes: the consumer's entry and the credential the vault keeps for it. +type Consumer struct { + Entry Entry + Credential []byte +} + +// Options are the provider's. +type Options struct { + // Node and Module are this provider: the machine it runs on and the module serving. + Node, Module string + // Offers are the provisions it serves, by name. + Offers map[string]Offer + Bus Bus + Vault Vault + // Make brings a consumer into being in the provider's software under the vault's credential. Asked on every + // pass for every consumer answered, so it must leave one that is already as asked alone; a rotated credential + // reaches the software this way. + Make func(ctx context.Context, c Consumer) error + // Gone, when set, hears that a consumer no longer binds the provider, before its entry is removed; an error + // keeps the entry, and it is heard again on the next pass. Retiring what was kept for it is the provider's + // (ADR 0230). + Gone func(ctx context.Context, e Entry) error + // Identity is the consumer's identity for an entry whose plan names none; ConsumerIdentity of the node and the + // module when not set. + Identity func(node, module string) string + // Every is how often a pass runs without a change watched; a minute when not set. + Every time.Duration + // Logf says what a pass did that differs from the last; the standard log when not set. + Logf func(format string, args ...any) +} + +// Provider answers its consumers. +type Provider struct { + opts Options + + mu sync.Mutex + assignments map[string][]byte + entries map[string][]byte + said map[string]string + poke chan struct{} +} + +// New is a provider answering its consumers as opts say. +func New(opts Options) *Provider { + if opts.Identity == nil { + opts.Identity = ConsumerIdentity + } + if opts.Every <= 0 { + opts.Every = time.Minute + } + if opts.Logf == nil { + opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) } + } + return &Provider{opts: opts, assignments: map[string][]byte{}, entries: map[string][]byte{}, + said: map[string]string{}, poke: make(chan struct{}, 1)} +} + +// What a pass did for one bind. +const ( + Answered = "answered" + Refused = "refused" + Waiting = "waiting" + Failed = "failed" + Removed = "removed" + // Superseded is an entry no bind asks for any more that another instance of the provider wrote after this pass + // read it: it is left standing. + Superseded = "superseded" +) + +// Outcome is what a pass did for one consumer's entry. +type Outcome struct { + Key string `json:"key"` + Status string `json:"status"` + Why string `json:"why,omitempty"` +} + +// Run watches the binds and the provider's own entries, and passes once at start, on every change and every +// Every, until ctx ends. +func (p *Provider) Run(ctx context.Context) error { + if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil { + return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err) + } + if err := p.watch(ctx, AssignmentsBucket, p.assignments); err != nil { + return fmt.Errorf("watching %s: %w", AssignmentsBucket, err) + } + tick := time.NewTicker(p.opts.Every) + defer tick.Stop() + for { + p.mu.Lock() + assignments, entries := clone(p.assignments), clone(p.entries) + p.mu.Unlock() + if _, err := p.pass(ctx, assignments, entries); err != nil { + p.say("pass", err.Error()) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.poke: + case <-tick.C: + } + } +} + +func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]byte) error { + return p.opts.Bus.Watch(ctx, bucket, func(key string, value []byte) { + p.mu.Lock() + if value == nil { + delete(into, key) + } else { + into[key] = append([]byte(nil), value...) + } + p.mu.Unlock() + if bucket == AssignmentsBucket { + select { + case p.poke <- struct{}{}: + default: + } + } + }) +} + +// Pass reads the binds and the provider's entries from the bus and answers once: each bind of this provider gets +// its entry, and each entry of this provider no bind asks for is removed. An assignment that cannot be read, or that +// is not the one its key names, is skipped and said (an outcome of status Failed under the assignment's key, and a +// line of the log), and the entries of its consumer are left as they are, since a bind missed would read as an +// unbind; the pass goes on for the rest. Run passes by itself; Pass is for a provider that passes when it chooses. +// +// Over StateBus each call to Pass leaves two watches open for the life of the process, silenced once the call has +// ended (see StateBus): a provider that passes often should use Run, which watches once. +func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) { + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + var mu sync.Mutex + read := func(bucket string) (map[string][]byte, error) { + got := map[string][]byte{} + err := p.opts.Bus.Watch(watchCtx, bucket, func(key string, value []byte) { + mu.Lock() + defer mu.Unlock() + if value == nil { + delete(got, key) + } else { + got[key] = append([]byte(nil), value...) + } + }) + mu.Lock() + defer mu.Unlock() + return clone(got), err + } + entries, err := read(BucketOf(p.opts.Module)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err) + } + assignments, err := read(AssignmentsBucket) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", AssignmentsBucket, err) + } + return p.pass(ctx, assignments, entries) +} + +// planned is as much of an assignment as the helper reads: lenient, since the plan is the client's to act on. +type planned struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Provision string `json:"provision"` + Local string `json:"local"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Contributes map[string]any `json:"contributes"` + At string `json:"at"` + From string `json:"from"` + As string `json:"as"` + Names map[string]string `json:"names"` + CredentialForm string `json:"credential-form"` + Name string `json:"name"` + } `json:"provisions"` + } `json:"provisioning"` +} + +func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]byte) ([]Outcome, error) { + var binds []Entry + var out []Outcome + unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged + for _, key := range sortedKeys(assignments) { + var a planned + if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key { + o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+ + "who it binds is not known; it is skipped, and its entries are left as they are", key)} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + unknown[key] = true + continue + } + if a.Provisioning == nil { + continue + } + for _, b := range a.Provisioning.Provisions { + if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module { + continue + } + binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, + Provision: b.Provision, Consumer: a.Node, Module: a.Module, Local: b.Local, + Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, Names: b.Names, + CredentialForm: b.CredentialForm, Name: b.Name}) + } + } + + want := map[string]bool{} + for _, b := range binds { + key := b.Key() + want[key] = true + o := p.answer(ctx, b, entries[key]) + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + } + for _, key := range sortedKeys(entries) { + if want[key] || unknown[consumerOf(key)] { + continue + } + var e Entry + if err := json.Unmarshal(entries[key], &e); err != nil || e.Provider != p.opts.Node || + e.ProviderModule != p.opts.Module { + continue // another machine's instance of this provider wrote it, or nothing of this helper's + } + if p.opts.Gone != nil { + if err := p.opts.Gone(ctx, e); err != nil { + o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + + err.Error()} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + } + if err := p.remove(ctx, key, entries[key]); err != nil { + if errors.Is(err, stdio.ErrChanged) { + o := Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " + + "since this pass read it, so it stands"} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + + err.Error()} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + out = append(out, Outcome{Key: key, Status: Removed}) + p.say(key, Removed) + p.mu.Lock() + delete(p.said, key) + p.mu.Unlock() + } + return out, nil +} + +// consumerOf is the `.` an entry's key starts with: its first two parts. +func consumerOf(key string) string { + parts := strings.SplitN(key, ".", 3) + if len(parts) < 3 { + return key + } + return parts[0] + "." + parts[1] +} + +// remove deletes an entry this instance read as held, and only while it is still as read when the Bus can say so. +func (p *Provider) remove(ctx context.Context, key string, held []byte) error { + if c, ok := p.opts.Bus.(ConditionalBus); ok { + return c.DeleteIf(ctx, BucketOf(p.opts.Module), key, held) + } + return p.opts.Bus.Delete(ctx, BucketOf(p.opts.Module), key) +} + +// answer makes one bind's entry and writes it when it differs from what the bucket holds. +func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome { + key := e.Key() + offer, offered := p.opts.Offers[e.Provision] + switch { + case !offered: + e.Refused = fmt.Sprintf("%s on %s is bound to %s on %s for %s, which %s does not serve", e.Module, + e.Consumer, e.ProviderModule, e.Provider, e.Provision, e.ProviderModule) + case e.Local != "" && derives(offer.Serves): + e.Refused = fmt.Sprintf("%s on %s keeps several holders of %s (this one is %q), and %s on %s derives %s for "+ + "each consumer from its login: each holder has a login of its own and a consumer is told one value per "+ + "requirement, so the two ends would name different things (novox/hq ADR 0202 §10, ADR 0349 clause 12)", + e.Module, e.Consumer, e.Provision, e.Local, e.ProviderModule, e.Provider, + strings.Join(derivedKeys(offer.Serves), ", ")) + } + if e.Refused != "" { + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + + // The plan's identity is the controller's, slug first; Identity only answers for an entry without one. + if e.As == "" { + e.As = p.opts.Identity(e.Consumer, e.Module) + } + values, err := ServedTo(offer.Serves, e.As) + if err != nil { + e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision, + e.Module, e.Consumer, err) + e.As = "" + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + if e.At != "" || e.From != "" { + if values == nil { + values = map[string]any{} + } + if e.At != "" { + values["at"] = e.At + } + if e.From != "" { + values["from"] = e.From + } + } + if len(e.Names) > 0 { + if values == nil { + values = map[string]any{} + } + for key, name := range e.Names { + values[key] = name + } + } + e.Values = values + var credential []byte + // The plan's form wins; the offer's is the default. + // An offer that declares no credential has none whatever the plan says: the controller writes "pair" for it. + form := e.CredentialForm + if offer.Credential == NoCredential { + form = "" + } else if form == "" { + form = map[CredentialForm]string{Shared: "shared", PerConsumer: "pair"}[offer.Credential] + } + switch form { + case "shared": + e.User = offer.User + e.VaultName = SharedName(e.Provider, e.Provision) + case "pair": + e.User = e.As + e.VaultName = PairName(e.Provider, e.Provision, e.Consumer, e.Module, e.Local) + case "issued", "given": + // A per-holder value, as mesh-vault names it (perholder.go). + e.User = e.As + e.VaultName = HolderName(e.Provider, e.Provision, e.Consumer, e.Module) + } + if e.VaultName != "" { + credential, err = p.opts.Vault.Value(ctx, e.VaultName) + if err != nil { + return Outcome{Key: key, Status: Waiting, Why: fmt.Sprintf("the vault's %s: %v", e.VaultName, err)} + } + } + if p.opts.Make != nil { + if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); err != nil { + why := err.Error() + why = redact(why, credential) + return Outcome{Key: key, Status: Failed, Why: "the provider could not make it: " + why} + } + } + return p.write(ctx, e, have, Outcome{Key: key, Status: Answered}) +} + +// redact takes a credential out of text in the forms it can come back in: as it is, in base64 (standard and URL, +// padded and not) and as a JSON string says it (escapes included). +func redact(text string, credential []byte) string { + if len(credential) == 0 { + return text + } + quoted, _ := json.Marshal(string(credential)) + forms := []string{string(credential), base64.StdEncoding.EncodeToString(credential), + base64.RawStdEncoding.EncodeToString(credential), base64.URLEncoding.EncodeToString(credential), + base64.RawURLEncoding.EncodeToString(credential), string(quoted[1 : len(quoted)-1])} + sort.SliceStable(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) + for _, f := range forms { + text = strings.ReplaceAll(text, f, "") + } + return text +} + +func (p *Provider) write(ctx context.Context, e Entry, have []byte, o Outcome) Outcome { + body, err := json.Marshal(e) + if err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: err.Error()} + } + if sameJSON(have, body) { + return o + } + if err := p.opts.Bus.Put(ctx, BucketOf(p.opts.Module), o.Key, body); err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: "its entry could not be written: " + err.Error()} + } + return o +} + +// say logs what a key's pass said when it differs from the last. +func (p *Provider) say(key, what string) { + p.mu.Lock() + same := p.said[key] == what + p.said[key] = what + p.mu.Unlock() + if !same { + p.opts.Logf("%s: %s", key, strings.TrimSpace(what)) + } +} + +// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677). +func SharedName(provider, provision string) string { return "provision/" + provider + "/" + provision } + +// HolderName is the vault's value of one holder's own credential, issued or given for it (mesh-vault perholder.go). +func HolderName(provider, provision, node, module string) string { + return "provision/" + provider + "/" + provision + "/" + node + "/" + module +} + +// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12). +func PairName(provider, provision, node, module, local string) string { + n := "pair/" + provider + "/" + provision + "/" + node + "/" + module + if local != "" { + n += "/" + local + } + return n +} + +// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue +// identity.go, the one derivation). +var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) + +// ConsumerIdentity is what one module on one machine is called wherever it authenticates: `mesh__`, +// lower case, anything but letters, digits and `_` written `_` (mesh-controller ConsumerIdentity). module is the +// identity source: the module's slug when it declares one, otherwise its name (ADR 0049). +func ConsumerIdentity(node, module string) string { + clean := func(s string) string { + return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") + } + return "mesh_" + clean(node) + "_" + clean(module) +} + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// ServedTo fills an offer's served values for one consumer whose identity is as (ADR 0202 §1): `${consumer:as}` as +// it is, `${consumer:as:dns}` with `-` for `_`. Only strings carry placeholders; any other fact or alphabet is +// refused, never left standing. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if serves == nil { + return nil, nil + } + out := make(map[string]any, len(serves)) + for _, key := range sortedKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + out[key] = serves[key] + continue + } + var failed error + out[key] = consumerFact.ReplaceAllStringFunc(text, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + switch { + case parts[1] == "as" && parts[2] == "": + return as + case parts[1] == "as" && parts[2] == "dns": + return strings.ReplaceAll(as, "_", "-") + } + if failed == nil { + failed = fmt.Errorf("the value served as %q says %s, and the mesh states only ${consumer:as} and "+ + "${consumer:as:dns}", key, match) + } + return match + }) + if failed != nil { + return nil, failed + } + } + return out, nil +} + +func derives(serves map[string]any) bool { return len(derivedKeys(serves)) > 0 } + +func derivedKeys(serves map[string]any) []string { + var out []string + for _, k := range sortedKeys(serves) { + if text, ok := serves[k].(string); ok && strings.Contains(text, "${consumer:") { + out = append(out, k) + } + } + return out +} + +func clone(m map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func sameJSON(a, b []byte) bool { + if a == nil || b == nil { + return false + } + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return string(ja) == string(jb) +} + +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket, +// BucketOf(module), is the module's state `bound`, and AssignmentsBucket is the state it reads, +// `mesh-controller.assignments` (both declared in the module's manifest). Any other bucket is refused. +// +// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the +// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps +// sending changes that are dropped. A Pass, which watches twice per call, is therefore correct, only the dropped +// changes of its two spent watches remain. +func StateBus(module string) Bus { return stateBus{module: module} } + +type stateBus struct{ module string } + +func (b stateBus) state(bucket string) (stdio.KeptState, error) { + switch bucket { + case BucketOf(b.module): + return stdio.State("bound"), nil + case AssignmentsBucket: + return stdio.State("mesh-controller.assignments"), nil + } + return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket) +} + +func (b stateBus) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Watch("", func(ch stdio.StateChange) error { + if ctx.Err() != nil { + return nil + } + if ch.Op == "delete" { + handle(ch.Key, nil) + } else { + handle(ch.Key, ch.Value) + } + return nil + }) +} + +func (b stateBus) Put(_ context.Context, bucket, key string, value []byte) error { + s, err := b.state(bucket) + if err != nil { + return err + } + _, err = s.Put(key, json.RawMessage(value)) + return err +} + +// DeleteIf removes the key only while it still holds what the caller read; it reads the key first, so another +// writer's change in that short while can still be lost. +func (b stateBus) DeleteIf(_ context.Context, bucket, key string, held []byte) error { + s, err := b.state(bucket) + if err != nil { + return err + } + cur, err := s.Get(key) + if err != nil { + return err + } + if cur == nil { + return nil + } + if !sameJSON(cur.Value, held) { + return fmt.Errorf("%s is not as it was read: %w", key, stdio.ErrChanged) + } + return s.Delete(key) +} + +func (b stateBus) Delete(_ context.Context, bucket, key string) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Delete(key) +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provision.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provision.go new file mode 100644 index 0000000..e4d65c7 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provision.go @@ -0,0 +1,90 @@ +package stdio + +import ( + "encoding/json" + "errors" + "time" +) + +// A module's code reads its values at runtime (novox/hq ADR 0349 clause 14, ADR 0370, and the part of the feature 57 design that lets a running module read, rather than be handed in a file, its provisioned values): its config +// values and its bindings' values that are not secret, for what its manifest declares, and watches them. The +// runtime that launched this bundle asks its machine's provisioner client **as this bundle's module** — the module it +// launched, never a name this code passes — and the client answers only what that module declares, only while the +// mesh places it on that machine. A secret is refused while the module's code runs in the tool runner: the vault +// hands a secret only to a caller the bus proves, and the bus proves only the tool runner there, so a module's +// secrets reach it through its files until it runs under a bus account of its own (novox/hq issue 569). + +// ProvisionChange is what a watch hands over: the value now, or — once it is no longer answered, the module taken +// off the machine or the key no longer declared — Refused, why. +type ProvisionChange struct { + Kind string `json:"kind"` + Key string `json:"key"` + Value json.RawMessage `json:"value,omitempty"` + Refused string `json:"refused,omitempty"` +} + +const provisionTimeout = 30 * time.Second + +func provisionGet(kind, key string) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + return c.ask("mesh/provision.get", map[string]any{"kind": kind, "key": key}, provisionTimeout) +} + +func provisionWatch(kind, key string, handle func(ProvisionChange) error) error { + if handle == nil { + return errors.New("a watch needs a handler") + } + c, err := live() + if err != nil { + return err + } + id := c.newID() + c.mu.Lock() + c.provisioned[id] = handle + c.mu.Unlock() + if _, err := c.askAs(id, "mesh/provision.watch", map[string]any{"kind": kind, "key": key}, provisionTimeout); err != nil { + c.mu.Lock() + delete(c.provisioned, id) + c.mu.Unlock() + return err + } + return nil +} + +// Config is one of this module's config values as mesh-config resolved it for this machine, as JSON: the key as +// its manifest declares it under `config`. A key it does not declare is refused, with the reason. +func Config(key string) (json.RawMessage, error) { return provisionGet("config", key) } + +// WatchConfig hands handle the config value now and then every change, in order, and returns once the value now is +// handled. The watch lasts as long as the process. +func WatchConfig(key string, handle func(ProvisionChange) error) error { + return provisionWatch("config", key, handle) +} + +// Bound is one value, not a secret, of what this module is bound to: the provision as its manifest requires it, +// and the key its provider answers (`${bound::}`). +func Bound(provision, key string) (json.RawMessage, error) { + return provisionGet("bound", provision+":"+key) +} + +// WatchBound is WatchConfig for a bound value. +func WatchBound(provision, key string, handle func(ProvisionChange) error) error { + return provisionWatch("bound", provision+":"+key, handle) +} + +// Secret is one of this module's secrets. Refused, with the reason, while the module's code runs in the tool +// runner (novox/hq ADR 0349 §11, ADR 0370 §2): its secrets reach it through its files. +func Secret(name string) ([]byte, error) { + raw, err := provisionGet("secret", name) + if err != nil { + return nil, err + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New("the runtime answered a secret that is not a string") + } + return []byte(s), nil +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/seat.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/seat.go new file mode 100644 index 0000000..aa2f144 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/seat.go @@ -0,0 +1,153 @@ +package stdio + +import ( + "encoding/json" + "strings" + "time" +) + +// Seats that name their caller or their kind (novox/hq ADR 0259 §3): a module submits an accept or says an +// event under its own name or kind, takes a held seat's work, asks a proof and answers one, and reads a +// record a seat keeps for it. The runtime does each as the module, only as far as its membership lists, +// and refuses the rest with the reason. + +// Work is one piece of a seat's work this bundle takes: the subject it was submitted on, its body, and the +// headers the submitter's runtime set (x-source is the submitting module, x-event-id its id). +type Work struct { + Worker string `json:"worker,omitempty"` + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + Headers map[string]string `json:"headers,omitempty"` +} + +const seatTimeout = 30 * time.Second + +// SeatPublish submits an accept, or says an event, on a seat, de-duplicated by id: the same id twice is +// one message. An empty id is the runtime's to make. +func SeatPublish(subject string, body any, id string) error { + c, err := live() + if err != nil { + return err + } + _, err = c.ask("mesh/seat.publish", map[string]any{"subject": subject, "body": body, "id": id}, seatTimeout) + return err +} + +// SeatProve asks a proof — core request and reply, never kept — and returns its answer. +func SeatProve(subject string, body any) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + return c.ask("mesh/seat.prove", map[string]any{"subject": subject, "body": body}, seatTimeout) +} + +// SeatAnswer answers every proof on a subject (a pattern, `*` one token) with what handle returns. +func SeatAnswer(subject string, handle func(subject string, body json.RawMessage) (any, error)) error { + c, err := live() + if err != nil { + return err + } + c.mu.Lock() + c.provers[subject] = handle + c.mu.Unlock() + _, err = c.ask("mesh/seat.answer", map[string]any{"subject": subject}, seatTimeout) + return err +} + +// SeatTake takes a held seat's work from a worker, handing each piece to handle: taken when it returns +// nil, offered again after an error. Make the work safe to do twice, or skip an x-event-id already done. +func SeatTake(worker string, handle func(Work) error) error { + c, err := live() + if err != nil { + return err + } + c.mu.Lock() + c.takers[worker] = handle + c.mu.Unlock() + _, err = c.ask("mesh/seat.take", map[string]any{"worker": worker}, seatTimeout) + return err +} + +// SeatRecord reads one key of a record a seat keeps for this module, under its own name, or nil. +func SeatRecord(bucket, key string) (*StateEntry, error) { + c, err := live() + if err != nil { + return nil, err + } + raw, err := c.ask("mesh/seat.record", map[string]any{"bucket": bucket, "key": key}, seatTimeout) + if err != nil || len(raw) == 0 || string(raw) == "null" { + return nil, err + } + var e StateEntry + return &e, json.Unmarshal(raw, &e) +} + +// KindHeld is one kind of a kinded bench and who holds it, with what it promises (novox/hq ADR 0259 §5). +type KindHeld struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Module string `json:"module"` + Node string `json:"node"` + Capabilities []string `json:"capabilities,omitempty"` +} + +// Promises says whether this kind's claim declares a capability. +func (k KindHeld) Promises(capability string) bool { + for _, c := range k.Capabilities { + if c == capability { + return true + } + } + return false +} + +// SeatKinds is every kind of the kinded benches this module uses or watches, as the controller issued +// them in its membership: the one account of which channel is which, never a channel's own word. +func SeatKinds() ([]KindHeld, error) { + c, err := live() + if err != nil { + return nil, err + } + raw, err := c.ask("mesh/seat.kinds", map[string]any{}, seatTimeout) + if err != nil { + return nil, err + } + var out []KindHeld + return out, json.Unmarshal(raw, &out) +} + +// Create writes a key only when it has none, and returns the revision; a key that has one is refused. +func (s KeptState) Create(key string, value any) (uint64, error) { + return s.revisionOf(s.ask("create", map[string]any{"key": key, "value": value})) +} + +// Update writes a key only when it is still at revision, and returns the new one; a key changed +// meanwhile is refused, so of two writers that read one value only the first writes. +func (s KeptState) Update(key string, value any, revision uint64) (uint64, error) { + return s.revisionOf(s.ask("update", map[string]any{"key": key, "value": value, "revision": revision})) +} + +func (s KeptState) revisionOf(raw json.RawMessage, err error) (uint64, error) { + if err != nil { + return 0, err + } + var put struct { + Revision uint64 `json:"revision"` + } + return put.Revision, json.Unmarshal(raw, &put) +} + +// subjectMatches is a subject against a pattern in the bus's wildcards: `*` one token, `>` the rest. +func subjectMatches(pattern, subject string) bool { + p, s := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, part := range p { + if part == ">" { + return len(s) > i + } + if i >= len(s) || (part != "*" && part != s[i]) { + return false + } + } + return len(p) == len(s) +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/state.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/state.go new file mode 100644 index 0000000..70e1b35 --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/state.go @@ -0,0 +1,178 @@ +package stdio + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// State — the current value of something a module keeps on the bus, which every machine sees, +// including one that joins later (novox/hq ADR 0201). Not history: that is an event. Never a secret, +// sealed or not. A module declares what it keeps (`state`) and what it reads (`reads`, as +// `.`) and names each here as it named it there; the runtime refuses, with the reason, +// what it did not declare. + +// StateEntry is one key's current value. +type StateEntry struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` +} + +// StateChange is one change a watch hands over: a key put or deleted. +type StateChange struct { + State string `json:"state"` + Key string `json:"key"` + Op string `json:"op"` + Value json.RawMessage `json:"value,omitempty"` + Revision uint64 `json:"revision"` + // Current is true for a value that was there when the watch began, false for a change since. + Current bool `json:"current"` +} + +// StateRevision is one value a bucket still keeps for a key: what was written, at which revision, by which +// operation ("put" or "delete", whose value is null) and when (novox/hq ADR 0307). +type StateRevision struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` + Op string `json:"op"` + At time.Time `json:"at"` +} + +// KeptState is one state this module keeps, or reads. +type KeptState struct{ name string } + +// State is a state this module keeps (by its local name) or reads (`.`). +func State(name string) KeptState { return KeptState{name: name} } + +const stateTimeout = 30 * time.Second + +func (s KeptState) ask(verb string, params map[string]any) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + params["state"] = s.name + return c.ask("mesh/state."+verb, params, stateTimeout) +} + +// Get is a key's current value, or nil when it has none. +func (s KeptState) Get(key string) (*StateEntry, error) { + raw, err := s.ask("get", map[string]any{"key": key}) + if err != nil || len(raw) == 0 || string(raw) == "null" { + return nil, err + } + var e StateEntry + return &e, json.Unmarshal(raw, &e) +} + +// History is every value the bucket still keeps for key, oldest first — as many as the state declared it +// keeps, deletes included — and empty for a key it holds nothing for (novox/hq ADR 0307). A read, like Get: +// the owner's instances and its readers may ask it, and it writes nothing. +func (s KeptState) History(key string) ([]StateRevision, error) { + raw, err := s.ask("history", map[string]any{"key": key}) + if err != nil { + return nil, err + } + revisions := []StateRevision{} + if len(raw) == 0 || string(raw) == "null" { + return revisions, nil + } + return revisions, json.Unmarshal(raw, &revisions) +} + +// Put writes a key — the owner's instances only — and returns the revision written. +func (s KeptState) Put(key string, value any) (uint64, error) { + raw, err := s.ask("put", map[string]any{"key": key, "value": value}) + if err != nil { + return 0, err + } + var put struct { + Revision uint64 `json:"revision"` + } + return put.Revision, json.Unmarshal(raw, &put) +} + +// Delete removes a key — the owner's instances only. A key that was not there is not an error. +func (s KeptState) Delete(key string) error { + _, err := s.ask("delete", map[string]any{"key": key}) + return err +} + +// Increment adds one to a counter kept at key and returns the number it now holds: one for a key with no +// value. Read, then written only if the key is still at the revision read (Create when it had none, Update +// when it had one), so of any number of writers each number is handed out once and no increment is lost. +// A write another writer won is read again and tried again, at most attempts times in all; past that the +// error is ErrChanged and nothing was counted. Any other error is returned at once, as is a value that is +// not a whole number (null included): nothing is ever reported counted that was not written. The reverse +// does not hold: an error that is not ErrChanged — a timeout above all — may come after the write landed, so +// that number may be spent and never handed out. A caller tolerates gaps, never a number handed out twice. +// Deleting the key starts the count again at one; a key whose numbers must stay unique is never deleted. +func (s KeptState) Increment(key string, attempts int) (uint64, error) { + if attempts < 1 { + return 0, fmt.Errorf("an increment of %s is tried at least once: %d attempts", key, attempts) + } + for i := 0; i < attempts; i++ { + e, err := s.Get(key) + if err != nil { + return 0, err + } + var next uint64 = 1 + if e == nil { + _, err = s.Create(key, next) + } else { + var n uint64 + if string(e.Value) == "null" { + return 0, fmt.Errorf("%s holds null, not a counter", key) + } + if err := json.Unmarshal(e.Value, &n); err != nil { + return 0, fmt.Errorf("%s holds %s, not a counter: %w", key, e.Value, err) + } + if n == ^uint64(0) { + return 0, fmt.Errorf("%s is at the largest number a counter holds", key) + } + next = n + 1 + _, err = s.Update(key, next, e.Revision) + } + if err == nil { + return next, nil + } + if !errors.Is(err, ErrChanged) { + return 0, err + } + } + return 0, fmt.Errorf("%s was written by another writer first %d times: %w", key, attempts, ErrChanged) +} + +// Keys is every key with a current value, sorted. +func (s KeptState) Keys() ([]string, error) { + raw, err := s.ask("keys", map[string]any{}) + if err != nil { + return nil, err + } + var keys []string + return keys, json.Unmarshal(raw, &keys) +} + +// Watch hands handle every current value — none that is deleted — and then every change, in order, +// and returns once the current values have all been handled. key narrows it: a key, with `*` for one +// name and `**` for the rest; empty is every key. The watch lasts as long as the process. +func (s KeptState) Watch(key string, handle func(StateChange) error) error { + c, err := live() + if err != nil { + return err + } + id := c.newID() + c.mu.Lock() + c.watches[id] = handle + c.mu.Unlock() + if _, err := c.askAs(id, "mesh/state.watch", map[string]any{"state": s.name, "key": key}, stateTimeout); err != nil { + c.mu.Lock() + delete(c.watches, id) + c.mu.Unlock() + return err + } + return nil +} diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/stdio.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/stdio.go new file mode 100644 index 0000000..03db9bd --- /dev/null +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/stdio.go @@ -0,0 +1,522 @@ +// Package stdio is the Novox Mesh SDK for Go (novox/hq ADR 0188, ADR 0193). +// +// A tools bundle is a process the node's runtime launches and speaks MCP over stdio to: +// `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and +// stdout. This package is the loop, so a bundle writes its tools and nothing else. A tool named +// `.` is the module's implementation of that seat's verb; any other name is the +// module's own. +// +// No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175). +// What a bundle asks of the bus — emitting an event — it asks of the runtime over the same channel, +// and the runtime does it as the module (ADR 0193). stdout is the protocol; log to stderr. +package stdio + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// Protocol is the MCP version this speaks; the runtime says the same. +const Protocol = "2025-03-26" + +// ServedAs is the word the runtime sets for a bundle it launches: the module it serves it as +// (ADR 0193). A bundle served under that name lists its own tools by their names. +const ServedAs = "MESH_SERVED_MODULE" + +// Tool is one tool a bundle serves. +type Tool struct { + Name string + Description string + // Input is the JSON schema of the arguments; nil is a tool that takes nothing. + Input map[string]any + // Run answers one call. Who called is CallerOf(args): the bus user the runtime proved, or Unproven. + Run func(args map[string]any) (any, error) +} + +type message struct { + ID *json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// channel is the one conversation with the runtime: what this bundle writes, serialised, and what +// it asked and waits for. +type channel struct { + mu sync.Mutex + enc *json.Encoder + next int + waiting map[string]chan message + listeners []listener + // subscribed numbers the listeners, so one whose subscribe failed is the one removed. + subscribed int + // watches are this bundle's state watches by the id each was asked with (ADR 0201). + watches map[string]func(StateChange) error + // provisioned are this bundle's watches of its values, by the id each was asked with (provision.go). + provisioned map[string]func(ProvisionChange) error + // takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259). + takers map[string]func(Work) error + provers map[string]func(subject string, body json.RawMessage) (any, error) +} + +type listener struct { + n int + pattern string + handle func(Envelope) error +} + +// Envelope is one event as the mesh carries it: its type, the machine it came from, its body as JSON, +// and the ADR 0042 headers (x-event-id, x-source, x-node, x-time …). +type Envelope struct { + Key string `json:"key"` + Node string `json:"node"` + Body json.RawMessage `json:"body"` + Headers map[string]string `json:"headers,omitempty"` +} + +func (c *channel) say(m map[string]any) { + m["jsonrpc"] = "2.0" + c.mu.Lock() + defer c.mu.Unlock() + _ = c.enc.Encode(m) +} + +// ask sends the runtime a request and waits for its answer. +func (c *channel) ask(method string, params any, timeout time.Duration) (json.RawMessage, error) { + return c.askAs(c.newID(), method, params, timeout) +} + +func (c *channel) newID() string { + c.mu.Lock() + defer c.mu.Unlock() + c.next++ + return "bundle-" + strconv.Itoa(c.next) +} + +// askAs is ask under an id the caller chose, for a request whose id something is registered under +// before it is sent. +func (c *channel) askAs(id, method string, params any, timeout time.Duration) (json.RawMessage, error) { + c.mu.Lock() + answer := make(chan message, 1) + c.waiting[id] = answer + c.mu.Unlock() + c.say(map[string]any{"id": id, "method": method, "params": params}) + select { + case m := <-answer: + if m.Error != nil { + return nil, &refused{message: m.Error.Message, code: m.Error.Code} + } + return m.Result, nil + case <-time.After(timeout): + c.mu.Lock() + delete(c.waiting, id) + c.mu.Unlock() + return nil, fmt.Errorf("the runtime did not answer %s in %s", method, timeout) + } +} + +// refused is the runtime's error answer, as opposed to no answer: what it refused, it did not do. +type refused struct { + message string + code int +} + +func (r *refused) Error() string { return r.message } + +// Is makes a compare-and-set the runtime refused because another writer was first ErrChanged. +func (r *refused) Is(target error) bool { return target == ErrChanged && r.code == CodeChanged } + +// ErrChanged is a compare-and-set that lost: the key was made or changed by another writer first (novox/hq +// ADR 0259). Told apart by the runtime's error code, never by its words. +var ErrChanged = errors.New("the key was written by another writer first") + +// CodeChanged is the error code the runtime answers a lost compare-and-set with. +const CodeChanged = -32010 + +var ( + current *channel + currentMu sync.Mutex +) + +// Serve answers the runtime on stdin and stdout until stdin closes. An empty name serves as the +// module the runtime named in MESH_SERVED_MODULE. +func Serve(name string, tools []Tool) error { + return ServeOn(name, tools, os.Stdin, os.Stdout) +} + +// ServeOn is Serve over any pair of streams, for a test. +func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { + if name == "" { + name = os.Getenv(ServedAs) + } + if name == "" { + name = "bundle" + } + byName := map[string]Tool{} + for _, t := range tools { + byName[t.Name] = t + } + c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{}, + provisioned: map[string]func(ProvisionChange) error{}, + takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}} + currentMu.Lock() + current = c + currentMu.Unlock() + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var m message + if err := json.Unmarshal(scanner.Bytes(), &m); err != nil { + continue + } + // The runtime's answer to something this bundle asked. + if m.Method == "" && m.ID != nil { + var key string + if json.Unmarshal(*m.ID, &key) == nil { + c.mu.Lock() + answer, ok := c.waiting[key] + delete(c.waiting, key) + c.mu.Unlock() + if ok { + answer <- m + } + } + continue + } + id := any(nil) + if m.ID != nil { + id = *m.ID + } + switch m.Method { + case "initialize": + c.say(map[string]any{"id": id, "result": map[string]any{"protocolVersion": Protocol, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": name, "version": "1"}}}) + case "notifications/initialized": + case "ping": + if id != nil { + c.say(map[string]any{"id": id, "result": map[string]any{}}) + } + case "tools/list": + listed := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + schema := t.Input + if schema == nil { + schema = map[string]any{"type": "object", "properties": map[string]any{}} + } else if _, isSchema := schema["type"]; !isSchema { + schema = map[string]any{"type": "object", "properties": schema} + } + listed = append(listed, map[string]any{"name": t.Name, "description": t.Description, "inputSchema": schema}) + } + c.say(map[string]any{"id": id, "result": map[string]any{"tools": listed}}) + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + // Meta is the runtime's, never the caller's: who called, taken from the subject (CallerOf). + Meta map[string]any `json:"_meta"` + } + _ = json.Unmarshal(m.Params, &p) + t, ok := byName[p.Name] + if !ok { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": fmt.Sprintf("%s has no tool %s", name, p.Name)}}) + continue + } + // Beside the loop, never in it: a tool that emits waits for the runtime's answer, which + // arrives on the line this loop reads next — and calls do not queue behind a slow one. + go func(id any, t Tool, args map[string]any, caller string) { + if args == nil { + args = map[string]any{} + } + result, err := runAs(t, caller, args) + if err != nil { + c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}}) + return + } + text, _ := json.Marshal(result) + c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}}) + }(id, t, p.Arguments, callerIn(p.Meta)) + case "mesh/event": + // One event the runtime read from this module's consumer (ADR 0198), answered once every + // handler it is for has taken it; an error is answered as one, so the runtime does not + // acknowledge it and the bus delivers it again. Beside the loop, like a tool call. + var p struct { + Envelope *Envelope `json:"envelope"` + } + if json.Unmarshal(m.Params, &p) != nil || p.Envelope == nil || p.Envelope.Key == "" { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/event carries no envelope"}}) + continue + } + c.mu.Lock() + matching := []listener{} + for _, l := range c.listeners { + if TopicMatches(l.pattern, p.Envelope.Key) { + matching = append(matching, l) + } + } + c.mu.Unlock() + go func(id any, env Envelope, ls []listener) { + for _, l := range ls { + if err := l.handle(env); err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + } + c.say(map[string]any{"id": id, "result": map[string]any{}}) + }(id, *p.Envelope, matching) + case "mesh/state": + // One change to a state this bundle watches (ADR 0201), named by the watch's id, answered + // once its handler has taken it. Beside the loop, like a tool call. + var p struct { + Watch string `json:"watch"` + Change *StateChange `json:"change"` + } + _ = json.Unmarshal(m.Params, &p) + c.mu.Lock() + handle := c.watches[p.Watch] + c.mu.Unlock() + if handle == nil || p.Change == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/state names no watch of this bundle"}}) + continue + } + go func(id any, change StateChange) { + if err := handle(change); err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + c.say(map[string]any{"id": id, "result": map[string]any{}}) + }(id, *p.Change) + case "mesh/provision": + // One change to a value of this module's that this bundle watches (provision.go), named by the + // watch's id, answered once its handler has taken it. Beside the loop, like a tool call. + var p struct { + Watch string `json:"watch"` + Change *ProvisionChange `json:"change"` + } + _ = json.Unmarshal(m.Params, &p) + c.mu.Lock() + handle := c.provisioned[p.Watch] + c.mu.Unlock() + if handle == nil || p.Change == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/provision names no watch of this bundle"}}) + continue + } + go func(id any, change ProvisionChange) { + if err := handle(change); err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + c.say(map[string]any{"id": id, "result": map[string]any{}}) + }(id, *p.Change) + case "mesh/work": + // One piece of a seat's work this bundle takes (novox/hq ADR 0259): taken by an answer that is + // not an error, offered again after one. Beside the loop, like a tool call. + var p struct { + Work *Work `json:"work"` + } + if json.Unmarshal(m.Params, &p) != nil || p.Work == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/work carries no work"}}) + continue + } + c.mu.Lock() + handle := c.takers[p.Work.Worker] + if handle == nil && len(c.takers) == 1 { + for _, h := range c.takers { + handle = h + } + } + c.mu.Unlock() + go func(id any, w Work) { + if handle == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": "nothing here takes " + w.Subject}}) + return + } + if err := handle(w); err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + c.say(map[string]any{"id": id, "result": map[string]any{}}) + }(id, *p.Work) + case "mesh/proof": + // One proof this bundle answers (novox/hq ADR 0259): what the handler returns is the answer; an + // error is answered in words, never as silence. + var p struct { + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + } + _ = json.Unmarshal(m.Params, &p) + c.mu.Lock() + var handle func(string, json.RawMessage) (any, error) + for pattern, h := range c.provers { + if subjectMatches(pattern, p.Subject) { + handle = h + } + } + c.mu.Unlock() + go func(id any, subject string, body json.RawMessage) { + if handle == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": "nothing here answers " + subject}}) + return + } + out, err := handle(subject, body) + if err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + c.say(map[string]any{"id": id, "result": out}) + }(id, p.Subject, p.Body) + default: + if id != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32601, "message": "no " + m.Method}}) + } + } + } + return scanner.Err() +} + +func live() (*channel, error) { + currentMu.Lock() + defer currentMu.Unlock() + if current == nil { + return nil, errors.New("before Serve: a bundle reaches the bus through the runtime that launched it") + } + return current, nil +} + +// Subscribe asks the runtime for this module's events whose type matches the pattern (`*` one +// segment, `#` any), and hands each to handle (ADR 0198). +// +// **Taking an event** (see the repository's README): handle returning nil takes the event — the +// runtime acknowledges it once every matching handler did; an error is the one way to have it offered +// again, after a few seconds and a bounded number of times, after which the bus gives up and the +// controller raises a condition. So return an error only when the work was not done, never for an +// answer that went right but read oddly; and since delivery is at-least-once, make the work safe to +// do twice, or skip an event whose x-event-id was already done. +// +// A subscribe the runtime refused leaves no handler behind, so asking again — as a module does until +// the runtime takes it — does not hand one event to the same handler twice. (One the runtime did not +// answer in time is kept, as the runtime may have bound it.) Call it from a goroutine +// started before Serve, or from a tool, once Serve runs. +func Subscribe(pattern string, handle func(Envelope) error) error { + c, err := live() + if err != nil { + return err + } + c.mu.Lock() + c.subscribed++ + n := c.subscribed + c.listeners = append(c.listeners, listener{n: n, pattern: pattern, handle: handle}) + c.mu.Unlock() + _, err = c.ask("mesh/subscribe", map[string]any{"pattern": pattern}, 30*time.Second) + // Removed only when the runtime answered that it refused: one that did not answer in time may + // have bound the consumer, and a handler removed then would have its events taken unhandled. + var no *refused + if errors.As(err, &no) { + c.mu.Lock() + for i, l := range c.listeners { + if l.n == n { + c.listeners = append(c.listeners[:i:i], c.listeners[i+1:]...) + break + } + } + c.mu.Unlock() + } + return err +} + +// Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns +// its result as JSON. The key is `.` as the runtime calls it. +func Ask(key string, body any) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + return c.ask("mesh/ask", map[string]any{"key": key, "body": body}, 60*time.Second) +} + +// TopicMatches says whether an event's type matches a pattern: `*` is one dot-separated segment, `#` +// (or `**`) any number, as the TypeScript SDK matches. +func TopicMatches(pattern, key string) bool { + return matchFrom(strings.Split(pattern, "."), 0, strings.Split(key, "."), 0) +} + +func matchFrom(p []string, pi int, k []string, ki int) bool { + if pi == len(p) { + return ki == len(k) + } + if p[pi] == "#" || p[pi] == "**" { + for skip := ki; skip <= len(k); skip++ { + if matchFrom(p, pi+1, k, skip) { + return true + } + } + return false + } + if ki == len(k) || (p[pi] != "*" && p[pi] != k[ki]) { + return false + } + return matchFrom(p, pi+1, k, ki+1) +} + +// EmitOptions is the provenance an emitter may attach (ADR 0042). +type EmitOptions struct { + CausationID string + Schema string +} + +// Emit asks the runtime to publish an event as this bundle's module, and returns once the bus has +// accepted it (ADR 0193). The envelope and headers are the TypeScript SDK's exactly: the source and +// node come from MESH_MODULE and MESH_NODE, which the runtime sets. +func Emit(eventType string, body any, opts ...EmitOptions) error { + currentMu.Lock() + c := current + currentMu.Unlock() + if c == nil { + return errors.New("emit before Serve: a bundle emits through the runtime that launched it") + } + source, node := os.Getenv("MESH_MODULE"), os.Getenv("MESH_NODE") + if source == "" { + source = "unknown" + } + if node == "" { + node = "unknown" + } + headers := map[string]string{ + "x-event-id": eventID(), "x-source": source, "x-node": node, + "x-time": time.Now().UTC().Format(time.RFC3339Nano), "content-type": "application/json", + } + for _, o := range opts { + if o.CausationID != "" { + headers["x-causation-id"] = o.CausationID + } + if o.Schema != "" { + headers["x-schema"] = o.Schema + } + } + _, err := c.ask("mesh/publish", map[string]any{"key": eventType, "node": node, "body": body, "headers": headers}, 30*time.Second) + return err +} + +// eventID is a random UUID, version 4, as the TypeScript SDK's randomUUID. +func eventID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + h := hex.EncodeToString(b[:]) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] +} diff --git a/modules/route-proxy/vendor/modules.txt b/modules/route-proxy/vendor/modules.txt new file mode 100644 index 0000000..abae1a0 --- /dev/null +++ b/modules/route-proxy/vendor/modules.txt @@ -0,0 +1,4 @@ +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 +## explicit; go 1.22 +git.novox.be/novox/mesh-sdk/go +git.novox.be/novox/mesh-sdk/go/provider -- 2.54.0 From 5d20d96c579f3d12bdfa0f25cf0d0135dfb58ced Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 18:15:52 +0200 Subject: [PATCH 21/51] One route-proxy Dockerfile: the module's, which the Makefile builds (issue 1019) --- modules/route-proxy/cmd/route-proxy/Dockerfile | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 modules/route-proxy/cmd/route-proxy/Dockerfile diff --git a/modules/route-proxy/cmd/route-proxy/Dockerfile b/modules/route-proxy/cmd/route-proxy/Dockerfile deleted file mode 100644 index 0395803..0000000 --- a/modules/route-proxy/cmd/route-proxy/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# The route proxy, as a module ships one. -# -# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched -# by digest and run on a machine, so everything in it is something a person would have to audit. -# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the -# build machine alike; the default only serves a hand build, and matches go.mod. -ARG GO_BASE=golang:1.26-alpine -FROM ${GO_BASE} AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy - -FROM scratch -COPY --from=build /mesh-route-proxy /mesh-route-proxy -ENTRYPOINT ["/mesh-route-proxy"] -- 2.54.0 From bef006075e1c0dd0a399d408c51b08ade1b650b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 18 Oct 2026 03:36:05 +0200 Subject: [PATCH 22/51] Review round 1 of milestone 5: the move settles changed rows under the lock; reports counted by kind; route-proxy at the new SDK; the plan pass never revives an ended assignment; the store's mirror is refused once moved (issues 1090, 1092, 1093) 1090/1092: the move tests pick each report by kind and expect three, all already, on the second run. 1090-1093: under the share lock the switch reads the rows again and writes every key whose value differs from its row (and ends an assignment the store dropped) before recording the kind, so an update the old controller made after the first pass is not lost. 1093: route-proxy is pinned at mesh-sdk f185aedb and vendored; the controller uses only the SDK's asks package. KeepAssignmentPlans decides each key by keepStep, which never makes or revives an assignment. AssignmentsMirrorToWrite refuses once the assignments are moved. --- modules/route-proxy/go.mod | 2 +- modules/route-proxy/go.sum | 2 ++ .../novox/mesh-sdk/go/provider/provider.go | 23 +++++++++++++++++++ modules/route-proxy/vendor/modules.txt | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod index 54bca71..a9826b9 100644 --- a/modules/route-proxy/go.mod +++ b/modules/route-proxy/go.mod @@ -2,4 +2,4 @@ module route-proxy go 1.25.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum index 63b4da4..1a76953 100644 --- a/modules/route-proxy/go.sum +++ b/modules/route-proxy/go.sum @@ -1,2 +1,4 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go index 33bf61f..4adee9e 100644 --- a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/provider/provider.go @@ -335,6 +335,9 @@ func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]b var out []Outcome unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged for _, key := range sortedKeys(assignments) { + if !Standing(assignments[key]) { + continue // ended or pending: read as a removed key (issue 1098) + } var a planned if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key { o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+ @@ -744,3 +747,23 @@ func (b stateBus) Delete(_ context.Context, bucket, key string) error { } return s.Delete(key) } + +// Standing is whether an assignment's value is a standing assignment: false when `ended` is set (the module was +// taken off its node, novox/hq ADR 0408 clause 4) or `state` is not empty (a pending one). Such a value is read +// exactly as a removed key. Every other field, known or not, changes nothing (issue 1098). +func Standing(value []byte) bool { + var v struct { + Ended json.RawMessage `json:"ended"` + State json.RawMessage `json:"state"` + } + if json.Unmarshal(value, &v) != nil { + return true // not decided here: the parse that follows refuses it + } + return !fieldSet(v.Ended) && !fieldSet(v.State) +} + +// fieldSet is whether a field carries something: not absent, null or empty. +func fieldSet(f json.RawMessage) bool { + s := strings.TrimSpace(string(f)) + return s != "" && s != "null" && s != `""` +} diff --git a/modules/route-proxy/vendor/modules.txt b/modules/route-proxy/vendor/modules.txt index abae1a0..a4b722a 100644 --- a/modules/route-proxy/vendor/modules.txt +++ b/modules/route-proxy/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go git.novox.be/novox/mesh-sdk/go/provider -- 2.54.0 From 50751428f2f0e973f105e3620231f97fa65b150d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 14:25:34 +0200 Subject: [PATCH 23/51] Pin route-proxy to a commit of mesh-sdk main, since the pinned one is on a feature branch only (issue 1669) --- modules/route-proxy/go.mod | 4 +- modules/route-proxy/go.sum | 6 +- .../git.novox.be/novox/mesh-sdk/go/README.md | 2 + .../novox/mesh-sdk/go/merge-check.sh | 119 +++++++++++++++++- .../git.novox.be/novox/mesh-sdk/go/stdio.go | 28 ++++- modules/route-proxy/vendor/modules.txt | 4 +- 6 files changed, 146 insertions(+), 17 deletions(-) diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod index a9826b9..68fa991 100644 --- a/modules/route-proxy/go.mod +++ b/modules/route-proxy/go.mod @@ -1,5 +1,5 @@ module route-proxy -go 1.25.0 +go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum index 1a76953..6162079 100644 --- a/modules/route-proxy/go.sum +++ b/modules/route-proxy/go.sum @@ -1,4 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md index 6a591b2..58dcfa4 100644 --- a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/README.md @@ -17,3 +17,5 @@ at the same version as the TypeScript package. What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol `mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. + +From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows. diff --git a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh index 19a7ec0..92e9d9c 100644 --- a/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh +++ b/modules/route-proxy/vendor/git.novox.be/novox/mesh-sdk/go/merge-check.sh @@ -5,16 +5,127 @@ # seat in the mesh's Go toolchain, its own container, because the SDK's merge-check.sh declares it # (`# mesh-check-also: go go/merge-check.sh`) — and by hand, from anywhere: `sh go/merge-check.sh`. # -# Formatted, vetted, and its whole suite run: the stdio loop and the local protocol mesh-cli speaks to the -# node-engine. Standard library only, so nothing is fetched. +# Formatted, vetted (with and without the build tag `integration`), and run at two levels (novox/hq ADR 0391): +# +# 1. the unit level: `go test ./...` untagged, no bus or other process, timed and held to a bound +# (unit_bound below); over it the check fails and names the slowest tests (issue 895); +# 2. the integration level: the whole suite with `-tags integration`, each test against a bus of its +# own (internal/testbus, which issue 1171 brings with the first test that needs a bus) so nothing is +# serialised. With no tagged test yet, the level runs the unit tests again and says their count. +# +# **A test that starts a bus or another process carries `//go:build integration`** as the first line of +# its file; a file that held both kinds is two files (`_integration_test.go` beside `_test.go`). +# Each level says its test count and its time. +# +# No dependency is committed (no vendor directory: .gitignore; the operator, 2026-10-19; issue 1304). Every dependency +# is resolved by the version and checksum go.sum names (novox/hq ADR 0405 §5), from its public source: this binding +# requires nothing of the mesh's own forge. `go mod verify` fails by module name when a downloaded dependency does not +# match go.sum, and -mod=readonly fails a build whose go.sum lacks an entry instead of writing it. gofmt reads only +# the binding's own files. +# +# Exit 0 is a pass; anything else fails `mesh/repo-check` with the last line printed. set -eu cd "$(dirname "$0")" +# The toolchain the check runs in sets -mod=vendor; there is no vendor directory, so it is replaced. +GOFLAGS="$(printf '%s' "${GOFLAGS:-}" | sed 's/-mod=[a-z]*//g') -mod=readonly" +export GOFLAGS +export GOPRIVATE=git.novox.be -unformatted=$(gofmt -l .) +# **The unit level's bound, in seconds** (issue 895): the slowest package's own test time of the untagged run, +# the compile not counted. A run over it fails the check and names the slowest tests. The SDK's unit suite is +# small (its slowest package measured well under a second on a quiet machine), so 2 s leaves room for a busier +# build node without hiding a test that should carry `integration` instead. +unit_bound=2 + +# judge_unit_run says the unit level as `go test -v` reported it and fails when it was over the bound: $1 the +# file holding the output of the untagged `go test -v` of every package (packages one after another, each +# ending in its `ok