From 36640f68e65a8c86de9d7d1e501dfc7fe41bf15d Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 6 Sep 2026 15:07:16 +0200 Subject: [PATCH 1/2] Add route-proxy provider and hello-web consumer modules route-proxy is the shipping form of the reference reverse proxy (novox/hq ADR 0007, 08-connectivity section 3): it provides route, is given every consumer as the file at receives.route, and forwards by the Host header. It ships the Go proxy from mesh-control/examples/route-proxy via a multi-stage Dockerfile; no broker, own-secret or provisioner, since it only reads the file the mesh writes. ACME_DIRECTORY defaults to Let's Encrypt staging and is overridable per node to production, so there is no hardcoded production default -- resolving novox/hq 04-ISSUES/004. hello-web is a minimal consumer that requires route and contributes name+port, to exercise the grant. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/hello-web/module.json | 65 ++++++++++++++++++++++++++++++ modules/route-proxy/Dockerfile | 25 ++++++++++++ modules/route-proxy/README.md | 69 ++++++++++++++++++++++++++++++++ modules/route-proxy/module.json | 71 +++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 modules/hello-web/module.json create mode 100644 modules/route-proxy/Dockerfile create mode 100644 modules/route-proxy/README.md create mode 100644 modules/route-proxy/module.json diff --git a/modules/hello-web/module.json b/modules/hello-web/module.json new file mode 100644 index 0000000..c5a5b85 --- /dev/null +++ b/modules/hello-web/module.json @@ -0,0 +1,65 @@ +{ + "module": "hello-web", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "name": "hello.example", + "port": 8080 + } + }, + "binds": { + "route": "/var/lib/hello-web/route.json" + }, + "listens": [ + { + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the demo web page; only the route-proxy reaches it, and the public name is a route grant" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/hello-web", + "mode": "0700" + }, + { + "id": "page", + "type": "file", + "path": "/var/lib/hello-web/index.html", + "mode": "0644", + "content": "hello from hello-web, routed by the mesh\n" + }, + { + "id": "net", + "type": "network", + "name": "hello-web" + }, + { + "id": "server", + "type": "container", + "name": "hello-web", + "image": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", + "network": "hello-web", + "ports": [ + "8080:8080" + ], + "volumes": [ + "/var/lib/hello-web/index.html:/www/index.html:ro" + ], + "args": [ + "sh", + "-c", + "while true; do { printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\n'; cat /www/index.html; } | nc -l -p 8080; done" + ] + } + ] +} diff --git a/modules/route-proxy/Dockerfile b/modules/route-proxy/Dockerfile new file mode 100644 index 0000000..f49d1c9 --- /dev/null +++ b/modules/route-proxy/Dockerfile @@ -0,0 +1,25 @@ +# The route-proxy module's runtime image: the reference reverse proxy compiled into a container. +# +# **The proxy source is not vendored here.** The canonical proxy — the contract written as something +# that runs — lives in the mesh-control repository at examples/route-proxy (novox/hq 08-connectivity +# §3). This module ships the *packaging*, not a second copy of the contract, so the build context is +# the mesh-control repository root, and this Dockerfile compiles ./examples/route-proxy from it. +# +# docker build -f mesh-catalog/modules/route-proxy/Dockerfile \ +# -t mesh-route-proxy:development /mesh-control +# +# 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 golang:1.25 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -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:3.20 +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..2075b86 --- /dev/null +++ b/modules/route-proxy/README.md @@ -0,0 +1,69 @@ +# 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 broker account, no own-secrets, no provisioner: the proxy neither mints a credential nor emits +an event. It only reads the file the mesh writes. (Contrast `redis`, which mints passwords, and +`cloudflare-dns`, which emits record events.) + +## How it ships the Go proxy + +The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is +**not vendored here** — it lives in the mesh-control repository at `examples/route-proxy`, the +contract written as something that runs. This module ships only the packaging: a multi-stage +[`Dockerfile`](Dockerfile) whose build context is the mesh-control 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. + +**A node that serves real public traffic states so**, by overriding `ACME_DIRECTORY` to the +production directory `https://acme-v02.api.letsencrypt.org/directory` for this module on that node. +It is a node property, and the node facing the public internet is the one that opts in. + +| 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** | the issuer; a public-serving node overrides it to production | +| `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 | diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json new file mode 100644 index 0000000..1fef8c9 --- /dev/null +++ b/modules/route-proxy/module.json @@ -0,0 +1,71 @@ +{ + "module": "route-proxy", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh" + } + ], + "serves": { + "route": {} + }, + "receives": { + "route": "/var/lib/route-proxy/routes/mesh.json" + }, + "listens": [ + { + "port": 80, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" + }, + { + "port": 443, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTPS for every name the mesh routes here" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/route-proxy", + "mode": "0700" + }, + { + "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": "server", + "type": "container", + "name": "route-proxy", + "image": "mesh-route-proxy@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/route-proxy/routes:/routes:ro", + "/var/lib/route-proxy/acme:/acme" + ], + "env": { + "ROUTES": "/routes/mesh.json", + "LISTEN": ":80", + "TLS_LISTEN": ":443", + "ACME_CACHE": "/acme", + "ACME_DIRECTORY": "https://acme-staging-v02.api.letsencrypt.org/directory" + } + } + ] +} -- 2.54.0 From 89254ef2c8e5f6c0bb387b07d4d6761853545fb7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 6 Sep 2026 15:16:56 +0200 Subject: [PATCH 2/2] hello-web: add slug so its identity fits the 20-char backend bound (ADR 0049) mesh_anchor_hello_web is 21 chars, one over the S3 access-key bound; slug 'hello' brings it to 17. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/hello-web/module.json | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/hello-web/module.json b/modules/hello-web/module.json index c5a5b85..cf69223 100644 --- a/modules/hello-web/module.json +++ b/modules/hello-web/module.json @@ -1,5 +1,6 @@ { "module": "hello-web", + "slug": "hello", "version": "1", "capabilities": [ "container-runtime" -- 2.54.0