From 61eb201f8af38f45f60a9bac2134b6c4f666045c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 16:39:11 +0200 Subject: [PATCH 01/48] postgres: declare the data directory's real owner; keycloak: use the port template postgres: mesh-store's data directory has always had split ownership -- everything inside pgdata/ is owned by UID 999 (the pgvector image's real runtime user), while only the top-level mount point happened to be 70:70. Invisible while the directory's mode was 1777 (world-accessible, from the named volume this replaced); broke the moment mode: 0700 was enforced, locking out the actual owning process. mesh-store crash-looped on Permission denied twice before this was found -- once at container creation, once mid-session on a checkpoint, after ownership looked correct by every check that didn't look inside pgdata/ specifically. keycloak: MESH_KEYCLOAK_URL was hardcoded to :8080, but the module's own port override (settings set keycloak {ports:{8080:28080}} on novox) means the real published port is 28080. Same bug class as the postgres connection-string fix earlier tonight -- now using the mesh's own template instead, which is exactly the mechanism internal/catalogue/port_into.go describes for a sidecar dialling its own server over the machine's loopback. --- modules/keycloak/module.json | 2 +- modules/postgres/module.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 919be3c..04e6341 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -118,7 +118,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_KEYCLOAK_URL": "http://127.0.0.1:8080", + "MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}", "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" }, "restart-on": [ diff --git a/modules/postgres/module.json b/modules/postgres/module.json index 3855f20..b543e5f 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -73,7 +73,8 @@ "id": "store-data", "type": "directory", "path": "/var/lib/mesh-store", - "mode": "0700" + "mode": "0700", + "owner": "999:70" }, { "id": "server", From 13d036164001a5ed148a4a5ca34ac169f2aa6a85 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 17:25:16 +0200 Subject: [PATCH 02/48] keycloak: carry over HAL's hostname/proxy settings, dropped during conversion Reported: files.novox.be's login button redirects to http://keycloak.novox.be, not https. HAL's original config (/services/keycloak/docker-compose.yml) set three settings the mesh's manifest never carried over: KC_HOSTNAME: keycloak.novox.be KC_HOSTNAME_STRICT_HTTPS: true KC_PROXY: edge Without KC_PROXY: edge, Keycloak has no way to know it sits behind a TLS-terminating reverse proxy (traefik) -- it generates URLs from what it directly sees, which is plain HTTP from traefik's backend connection. Same pattern as the named-volume conversion: the shape was rebuilt from general knowledge of what a keycloak container needs, not from what this installation's own working config actually had. --- modules/keycloak/module.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 04e6341..d65681a 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -88,7 +88,10 @@ "env": { "KC_DB": "postgres", "KC_HTTP_ENABLED": "true", - "KC_HEALTH_ENABLED": "true" + "KC_HEALTH_ENABLED": "true", + "KC_HOSTNAME": "keycloak.novox.be", + "KC_HOSTNAME_STRICT_HTTPS": "true", + "KC_PROXY": "edge" }, "env-file": [ "/var/lib/keycloak/admin.env", From ed0f4602a68f1378b0cbe06239e14ae6d491f6e7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 17:27:04 +0200 Subject: [PATCH 03/48] keycloak: use Hostname v2's actual config shape, not v1's deprecated flags The previous commit on this branch used KC_PROXY=edge and KC_HOSTNAME_STRICT_HTTPS=true, carried over from HAL's config -- but HAL ran an older Keycloak using the v1 hostname provider. This image (26.0.8) defaults to Hostname v2, which warned 'options [proxy, hostname-strict-https] are still in use, please review your configuration' and kept generating http:// URLs regardless -- verified against /realms/Novox/.well-known/openid-configuration directly, not just the login button, after the first fix deployed. v2's actual shape (keycloak.org/server/hostname): KC_HOSTNAME is a full URL, not a bare hostname -- the scheme in the URL is what tells Keycloak to generate https, not a separate strict-https flag. KC_PROXY_HEADERS replaces KC_PROXY: xforwarded to trust traefik's X-Forwarded-* headers, which it sends by default. --- modules/keycloak/module.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index d65681a..44e0d44 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -89,9 +89,8 @@ "KC_DB": "postgres", "KC_HTTP_ENABLED": "true", "KC_HEALTH_ENABLED": "true", - "KC_HOSTNAME": "keycloak.novox.be", - "KC_HOSTNAME_STRICT_HTTPS": "true", - "KC_PROXY": "edge" + "KC_HOSTNAME": "https://keycloak.novox.be", + "KC_PROXY_HEADERS": "xforwarded" }, "env-file": [ "/var/lib/keycloak/admin.env", From 945390e59a013a116eab9debfa15429603aa4711 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 18:31:38 +0200 Subject: [PATCH 04/48] minio: run the real 4-node/8-drive erasure-coded cluster, not a single container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single standalone instance from the first pass didn't match HAL's actual topology: HAL runs minio1-4, two drives each, behind an nginx load balancer on 9000 (S3) and 9001 (console). This rewrite mirrors that exactly — same node count, same erasure-coding command, same LB config — so the migration is a real like-for-like move, not a simplification. Only the two images that had to change did: the minio server (dead upstream, already fixed in the prior commit) and nginx (1.19.2-alpine is long EOL; repinned to current stable-alpine by digest). Data still lands on a fresh, empty, mesh-owned path, never HAL's live drives. The OIDC-wait entrypoint wrapper HAL used is dropped: it's a no-op when MINIO_IDENTITY_OPENID_CONFIG_URL is unset (it always is here — no OIDC integration was ever wired to minio itself), and this catalogue has no container resource field for overriding a container's entrypoint anyway — every converted module relies on the image's own entrypoint plus args, which is exactly what the original single-node version already did. --- modules/minio/module.json | 108 ++++++++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 17 deletions(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index bbca5d2..26c4692 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -19,7 +19,13 @@ "port": 9000, "protocol": "tcp", "from": "mesh", - "why": "the S3 endpoint" + "why": "the S3 endpoint, load-balanced across the 4-node erasure-coded cluster" + }, + { + "port": 9001, + "protocol": "tcp", + "from": "mesh", + "why": "the admin console, load-balanced across the 4-node erasure-coded cluster" } ], "serves": { @@ -71,37 +77,105 @@ "path": "/var/lib/minio-store", "mode": "0700" }, + { + "id": "nginx-conf", + "type": "file", + "path": "/var/lib/minio/nginx.conf", + "mode": "0644", + "content": "user nginx;\nworker_processes auto;\n\nerror_log /var/log/nginx/error.log warn;\npid /var/run/nginx.pid;\n\nevents {\n worker_connections 4096;\n}\n\nhttp {\n include /etc/nginx/mime.types;\n default_type application/octet-stream;\n\n log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n '$status $body_bytes_sent \"$http_referer\" '\n '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n access_log /var/log/nginx/access.log main;\n sendfile on;\n keepalive_timeout 65;\n\n upstream minio {\n server minio1:9000;\n server minio2:9000;\n server minio3:9000;\n server minio4:9000;\n }\n\n upstream console {\n ip_hash;\n server minio1:9001;\n server minio2:9001;\n server minio3:9001;\n server minio4:9001;\n }\n\n server {\n listen 9000;\n listen [::]:9000;\n server_name localhost;\n\n ignore_invalid_headers off;\n client_max_body_size 0;\n proxy_buffering off;\n\n location / {\n proxy_set_header Host $http_host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n\n proxy_connect_timeout 300;\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n chunked_transfer_encoding off;\n\n proxy_pass http://minio;\n }\n }\n\n server {\n listen 9001;\n listen [::]:9001;\n server_name localhost;\n\n ignore_invalid_headers off;\n client_max_body_size 0;\n proxy_buffering off;\n\n location / {\n proxy_set_header Host $http_host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-NginX-Proxy true;\n\n real_ip_header X-Real-IP;\n\n proxy_connect_timeout 300;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n\n chunked_transfer_encoding off;\n\n proxy_pass http://console;\n }\n }\n}\n" + }, { "id": "net", "type": "network", "name": "minio" }, { - "id": "server", + "id": "minio1", "type": "container", - "name": "minio", + "name": "minio1", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", "network": "minio", - "args": [ - "server", - "/data", - "--console-address", - ":9001" - ], - "env-file": [ - "/var/lib/minio/root.env" - ], - "ports": [ - "9000" - ], + "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], + "env-file": ["/var/lib/minio/root.env"], "volumes": [ - "/var/lib/minio-store:/data", + "/var/lib/minio-store/data1-1:/data1", + "/var/lib/minio-store/data1-2:/data2", + "/var/lib/minio-store/unused-volume-stub/minio1:/data", "/var/lib/minio/root.secret:/run/secrets/root:ro" ], "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" } }, + { + "id": "minio2", + "type": "container", + "name": "minio2", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", + "network": "minio", + "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], + "env-file": ["/var/lib/minio/root.env"], + "volumes": [ + "/var/lib/minio-store/data2-1:/data1", + "/var/lib/minio-store/data2-2:/data2", + "/var/lib/minio-store/unused-volume-stub/minio2:/data", + "/var/lib/minio/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" + } + }, + { + "id": "minio3", + "type": "container", + "name": "minio3", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", + "network": "minio", + "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], + "env-file": ["/var/lib/minio/root.env"], + "volumes": [ + "/var/lib/minio-store/data3-1:/data1", + "/var/lib/minio-store/data3-2:/data2", + "/var/lib/minio-store/unused-volume-stub/minio3:/data", + "/var/lib/minio/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" + } + }, + { + "id": "minio4", + "type": "container", + "name": "minio4", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", + "network": "minio", + "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], + "env-file": ["/var/lib/minio/root.env"], + "volumes": [ + "/var/lib/minio-store/data4-1:/data1", + "/var/lib/minio-store/data4-2:/data2", + "/var/lib/minio-store/unused-volume-stub/minio4:/data", + "/var/lib/minio/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" + } + }, + { + "id": "lb", + "type": "container", + "name": "minio", + "image": "docker.io/library/nginx@sha256:985220252f3863977e468f611ef118ebd01421289dd86ee1ae99cb068c3bce2b", + "network": "minio", + "ports": ["9000", "9001"], + "volumes": [ + "/var/lib/minio/nginx.conf:/etc/nginx/nginx.conf:ro" + ] + }, { "id": "runtime", "type": "container", From 973d80aaa2e50066eda548c82d6ad4ce96f1a5af Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 18:45:52 +0200 Subject: [PATCH 05/48] minio: publish both public routes now that a module can answer route twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit files-api.novox.be (port 9000, the S3 data API) and files.novox.be (port 9001, the console) — same two names HAL routes today, via nginx's own upstream split. Needed mesh-controller#55 (a module answering one requirement several times) to exist first; it's merged and deployed. --- modules/minio/module.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/modules/minio/module.json b/modules/minio/module.json index 26c4692..8e3f724 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -7,6 +7,21 @@ "scope": "mesh" } ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "api": { + "label": "files-api", + "port": 9000 + }, + "console": { + "label": "files", + "port": 9001 + } + } + }, "capabilities": [ "container-runtime" ], From 6a6dd4a7dcd65dd19b1b96252fca8c692b110b2a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 20:31:20 +0200 Subject: [PATCH 06/48] minio: name the network minio-net, not minio Collided with the LB container's own name. docker inspect minio resolved to the network instead of the (not-yet-created) container, and mesh-host's existence check crashed on the mismatched shape rather than reporting absence -- a real mesh-host bug (fixed separately, mesh-host#25), but this sidesteps it here without waiting on a host-level binary update. --- modules/minio/module.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index 8e3f724..4d2db13 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -102,14 +102,14 @@ { "id": "net", "type": "network", - "name": "minio" + "name": "minio-net" }, { "id": "minio1", "type": "container", "name": "minio1", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio", + "network": "minio-net", "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], "env-file": ["/var/lib/minio/root.env"], "volumes": [ @@ -128,7 +128,7 @@ "type": "container", "name": "minio2", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio", + "network": "minio-net", "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], "env-file": ["/var/lib/minio/root.env"], "volumes": [ @@ -147,7 +147,7 @@ "type": "container", "name": "minio3", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio", + "network": "minio-net", "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], "env-file": ["/var/lib/minio/root.env"], "volumes": [ @@ -166,7 +166,7 @@ "type": "container", "name": "minio4", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio", + "network": "minio-net", "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], "env-file": ["/var/lib/minio/root.env"], "volumes": [ @@ -185,7 +185,7 @@ "type": "container", "name": "minio", "image": "docker.io/library/nginx@sha256:985220252f3863977e468f611ef118ebd01421289dd86ee1ae99cb068c3bce2b", - "network": "minio", + "network": "minio-net", "ports": ["9000", "9001"], "volumes": [ "/var/lib/minio/nginx.conf:/etc/nginx/nginx.conf:ro" @@ -195,7 +195,7 @@ "id": "runtime", "type": "container", "name": "mesh-minio", - "network": "minio", + "network": "minio-net", "volumes": [ "/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "/var/lib/minio/grants:/var/lib/minio/grants:ro", From 20df40c949e6c9f9fcfcffa8d7b166bc5ef3da57 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 23:07:02 +0200 Subject: [PATCH 07/48] minio: revert to single-node after measuring the real cost of sharding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-node/8-drive erasure-coded cluster matched HAL's topology faithfully, but real throughput testing against both showed why that costs more than it's worth here: every write on the sharded cluster fans out across 4 processes over the internal network with erasure-coding overhead, capping safe throughput around 1.3-2 MiB/s and breaking outright above ~256 concurrent transfers (IncompleteBody errors, confirmed via a controlled 512x test). The identical copy against a single-node instance sustained 23+ MiB/s at the same concurrency with zero errors — over 10x faster, verified side-by-side, not assumed. Trades away erasure-coded redundancy (no single-drive fault tolerance) for that throughput. Deliberate, and reversible if it turns out to matter later -- the data itself is migrated over the S3 API either way, so the storage topology underneath isn't locked in by anything upstream of it. --- modules/minio/module.json | 102 +++++--------------------------------- 1 file changed, 13 insertions(+), 89 deletions(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index 4d2db13..73b5403 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -34,13 +34,13 @@ "port": 9000, "protocol": "tcp", "from": "mesh", - "why": "the S3 endpoint, load-balanced across the 4-node erasure-coded cluster" + "why": "the S3 endpoint" }, { "port": 9001, "protocol": "tcp", "from": "mesh", - "why": "the admin console, load-balanced across the 4-node erasure-coded cluster" + "why": "the admin console" } ], "serves": { @@ -92,104 +92,28 @@ "path": "/var/lib/minio-store", "mode": "0700" }, - { - "id": "nginx-conf", - "type": "file", - "path": "/var/lib/minio/nginx.conf", - "mode": "0644", - "content": "user nginx;\nworker_processes auto;\n\nerror_log /var/log/nginx/error.log warn;\npid /var/run/nginx.pid;\n\nevents {\n worker_connections 4096;\n}\n\nhttp {\n include /etc/nginx/mime.types;\n default_type application/octet-stream;\n\n log_format main '$remote_addr - $remote_user [$time_local] \"$request\" '\n '$status $body_bytes_sent \"$http_referer\" '\n '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n access_log /var/log/nginx/access.log main;\n sendfile on;\n keepalive_timeout 65;\n\n upstream minio {\n server minio1:9000;\n server minio2:9000;\n server minio3:9000;\n server minio4:9000;\n }\n\n upstream console {\n ip_hash;\n server minio1:9001;\n server minio2:9001;\n server minio3:9001;\n server minio4:9001;\n }\n\n server {\n listen 9000;\n listen [::]:9000;\n server_name localhost;\n\n ignore_invalid_headers off;\n client_max_body_size 0;\n proxy_buffering off;\n\n location / {\n proxy_set_header Host $http_host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n\n proxy_connect_timeout 300;\n proxy_http_version 1.1;\n proxy_set_header Connection \"\";\n chunked_transfer_encoding off;\n\n proxy_pass http://minio;\n }\n }\n\n server {\n listen 9001;\n listen [::]:9001;\n server_name localhost;\n\n ignore_invalid_headers off;\n client_max_body_size 0;\n proxy_buffering off;\n\n location / {\n proxy_set_header Host $http_host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-NginX-Proxy true;\n\n real_ip_header X-Real-IP;\n\n proxy_connect_timeout 300;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n\n chunked_transfer_encoding off;\n\n proxy_pass http://console;\n }\n }\n}\n" - }, { "id": "net", "type": "network", "name": "minio-net" }, { - "id": "minio1", - "type": "container", - "name": "minio1", - "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio-net", - "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], - "env-file": ["/var/lib/minio/root.env"], - "volumes": [ - "/var/lib/minio-store/data1-1:/data1", - "/var/lib/minio-store/data1-2:/data2", - "/var/lib/minio-store/unused-volume-stub/minio1:/data", - "/var/lib/minio/root.secret:/run/secrets/root:ro" - ], - "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", - "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" - } - }, - { - "id": "minio2", - "type": "container", - "name": "minio2", - "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio-net", - "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], - "env-file": ["/var/lib/minio/root.env"], - "volumes": [ - "/var/lib/minio-store/data2-1:/data1", - "/var/lib/minio-store/data2-2:/data2", - "/var/lib/minio-store/unused-volume-stub/minio2:/data", - "/var/lib/minio/root.secret:/run/secrets/root:ro" - ], - "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", - "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" - } - }, - { - "id": "minio3", - "type": "container", - "name": "minio3", - "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio-net", - "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], - "env-file": ["/var/lib/minio/root.env"], - "volumes": [ - "/var/lib/minio-store/data3-1:/data1", - "/var/lib/minio-store/data3-2:/data2", - "/var/lib/minio-store/unused-volume-stub/minio3:/data", - "/var/lib/minio/root.secret:/run/secrets/root:ro" - ], - "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", - "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" - } - }, - { - "id": "minio4", - "type": "container", - "name": "minio4", - "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", - "network": "minio-net", - "args": ["server", "--console-address", ":9001", "http://minio{1...4}/data{1...2}"], - "env-file": ["/var/lib/minio/root.env"], - "volumes": [ - "/var/lib/minio-store/data4-1:/data1", - "/var/lib/minio-store/data4-2:/data2", - "/var/lib/minio-store/unused-volume-stub/minio4:/data", - "/var/lib/minio/root.secret:/run/secrets/root:ro" - ], - "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", - "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" - } - }, - { - "id": "lb", + "id": "server", "type": "container", "name": "minio", - "image": "docker.io/library/nginx@sha256:985220252f3863977e468f611ef118ebd01421289dd86ee1ae99cb068c3bce2b", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", "network": "minio-net", + "args": ["server", "/data", "--console-address", ":9001"], + "env-file": ["/var/lib/minio/root.env"], "ports": ["9000", "9001"], "volumes": [ - "/var/lib/minio/nginx.conf:/etc/nginx/nginx.conf:ro" - ] + "/var/lib/minio-store:/data", + "/var/lib/minio/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" + } }, { "id": "runtime", From 440e3e446e8b8a15600b34409a76c8a5731ed96f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 10:45:01 +0200 Subject: [PATCH 08/48] minio: set the region to eu-west, matching where this mesh actually runs Left at MinIO's us-east-1 default. Novox is hosted in Germany, the team is in Belgium -- eu-west is correct, and matters beyond labeling: it's part of the SigV4 signature, so a client using the wrong region fails auth even with valid credentials. Set on the server (MINIO_REGION), the served provision value, and the runtime sidecar's own client. --- modules/minio/module.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index 73b5403..dff4b32 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -46,7 +46,7 @@ "serves": { "s3-bucket": { "scheme": "http", - "region": "us-east-1", + "region": "eu-west", "port": 9000 } }, @@ -112,7 +112,8 @@ ], "env": { "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", - "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be" + "MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be", + "MINIO_REGION": "eu-west" } }, { @@ -129,6 +130,7 @@ "MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MESH_MINIO_REGION": "eu-west", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" }, From 5d13a5078c22e8ceba7636ddc1c01cda3047d211 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 14:37:23 +0200 Subject: [PATCH 09/48] =?UTF-8?q?minio:=20install=20mc=20in=20the=20runtim?= =?UTF-8?q?e=20image=20=E2=80=94=20the=20provisioner=20needs=20it=20to=20r?= =?UTF-8?q?un?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-minio's s3-bucket provisioner shells out to mc to create buckets and service accounts on the live server, but mc was never in this module's own runtime image, only in minio's own. It's been silently retrying 'spawn mc ENOENT' forever, so every s3-bucket grant reached the control-plane layer (store.json, sealed secret) without the credential ever actually existing on minio — nextcloud's live instance just hit this as InvalidAccessKeyId on a real user session. Copies mc from minio's own image (docker.io/pgsty/minio, already pinned and pulled as this module's server container) rather than introducing a new base — mc there is a working, already-verified binary. /usr/bin/mc is a symlink to mcli; both are copied so it resolves. --- modules/minio/Dockerfile | 12 ++++++++++++ modules/minio/module.json | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/modules/minio/Dockerfile b/modules/minio/Dockerfile index e5588e6..fc0e121 100644 --- a/modules/minio/Dockerfile +++ b/modules/minio/Dockerfile @@ -9,6 +9,11 @@ # image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. ARG BUILD_BASE ARG RUNTIME_BASE +ARG MC_CLI + +# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder +# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM. +FROM ${MC_CLI} AS mccli FROM ${BUILD_BASE} AS build # Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own @@ -22,6 +27,13 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provision FROM ${RUNTIME_BASE} COPY --from=build /app/modules/minio/dist /app/modules/minio/dist +# The provisioner shells out to mc to actually create buckets and service accounts on the running +# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried +# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever +# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli — +# both copied so the symlink resolves. +COPY --from=mccli /usr/bin/mcli /usr/bin/mcli +COPY --from=mccli /usr/bin/mc /usr/bin/mc # Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a # provider's provisioner runs its reconcile loop in the same process, with the broker connected — # the convention novox/hq issues 060/061 settled. diff --git a/modules/minio/module.json b/modules/minio/module.json index bbca5d2..ef31847 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -133,6 +133,10 @@ "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" + }, + { + "arg": "MC_CLI", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372" } ], "artifacts": [ From b3de7e7944b24b806e0af2f33994e7e70540031c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 15:10:30 +0200 Subject: [PATCH 10/48] minio: declare region eu-west, don't leave it as live-only drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serves.s3-bucket.region still said us-east-1 (PR #58 already fixed this, unmerged) while the live mesh-minio sidecar had MESH_MINIO_REGION=eu-west set out-of-band, not in the manifest at all — and the actual minio server had no region configured whatsoever (mc admin config get region: empty), apparently lost across a container recreation since nothing declared it. Every s3-bucket consumer binding ${bound:s3-bucket:region} was reading the stale us-east-1 declaration regardless of what was actually live. Declares MINIO_REGION on the server container and MESH_MINIO_REGION on the sidecar, matching the static serves declaration, so this is mesh- managed and durable rather than a manual mc admin config or docker env override that the next recreation silently drops. --- modules/minio/module.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index ef31847..8b1a05a 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -25,7 +25,7 @@ "serves": { "s3-bucket": { "scheme": "http", - "region": "us-east-1", + "region": "eu-west", "port": 9000 } }, @@ -99,7 +99,8 @@ "/var/lib/minio/root.secret:/run/secrets/root:ro" ], "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_REGION": "eu-west" } }, { @@ -116,6 +117,7 @@ "MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MESH_MINIO_REGION": "eu-west", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" }, From ae6dfea2c9686be1345f5df971f593651308a883 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:07:58 +0200 Subject: [PATCH 11/48] gitea: listens its real internal ssh port (22), not 2222 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2222 never meant anything — no container of gitea's publishes it, the software never listens on it, and nobody could say where it came from. The container's real internal sshd is 22 (gitea's own default, unmodified — every other module's listens.port already means the container's real internal port, this one didn't). ports now declares 222:22 directly: 222 is the real, fixed, always-known public git-ssh port, so it needs no per-node setting to reach — unlike an arbitrary auto-assigned port, this one has external consumers who already know the number. --- modules/gitea/module.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a5bb2ce..a6ef32f 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -42,10 +42,10 @@ "why": "the forge, over http" }, { - "port": 2222, + "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it" + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" } ], "serves": { @@ -118,7 +118,7 @@ ], "ports": [ "3000", - "22" + "222:22" ], "volumes": [ "/services/gitea/gitea:/data" From 755b0a559964526aabe8a911cf70c7dea9b3b88f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:08:12 +0200 Subject: [PATCH 12/48] builder: consume package-registry as a real mesh grant, not a hand-faked one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'package-binding' resource was a hardcoded JSON fragment standing in for a real grant — {"provision": "package-registry", "from": "gitea", "at": "127.0.0.1", ...} written as if it were mesh-resolved, when nothing resolved it. Declares requires: package-registry properly instead, with binds/secrets pointing at the same file paths the resource used to manually author, so the mesh mints the grant and writes it there. npm-password renamed to package-registry.secret: it's gitea's generic user+password, not npm-specific — the same credential works for basic auth against cargo/PyPI/Go package endpoints too, once gitea's manifest grows them (novox/hq ADR 0109). Known gap, not fixed here (novox/hq issue 117): this makes builder correct for the steady state but breaks a genesis bootstrap — gitea's own image is built by builder, so builder cannot yet hold this grant the first time either has to exist. Filed rather than silently accepted. --- modules/builder/module.json | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 6d02dfd..8e32ccc 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -11,14 +11,20 @@ } ], "requires": [ - "artifact-store" + "artifact-store", + "package-registry" ], + "binds": { + "package-registry": "/var/lib/mesh/builder/package-registry.json" + }, + "secrets": { + "package-registry": "/var/lib/mesh/builder/package-registry.secret" + }, "emits": [ "module.builder.built" ], "own-secrets": { - "broker": "/var/lib/mesh/builder/broker", - "npm-password": "/var/lib/mesh/builder/npm-password" + "broker": "/var/lib/mesh/builder/broker" }, "resources": [ { @@ -38,27 +44,13 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/var/lib/builder/workspace\n" - }, - { - "id": "package-binding", - "type": "file", - "path": "/var/lib/mesh/builder/package-registry.json", - "mode": "0600", - "merge": "json", - "protected": [ - "provision", - "from", - "at", - "as" - ], - "content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n" }, { "id": "server", "type": "container", "name": "mesh-builder", - "image": "mesh-builder@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "image": "mesh-builder@sha256:42f5203a6838776447790d9e7d27f22a56e9462bdd25401645f22d188da56704", "env-file": [ "/var/lib/mesh/builder/builder.env" ], @@ -68,9 +60,7 @@ "/var/run/docker.sock:/var/run/docker.sock" ], "restart-on": [ - "builder-env", - "package-binding", - "needs-npm-password" + "builder-env" ], "network": "host" } From 1b02dc1f66cc06f5b797d0e69c4818c361adbbe9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:10:56 +0200 Subject: [PATCH 13/48] builder: qualify its own image with the registry host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare mesh-builder@sha256:... is only resolvable for a module with a build section — the mesh's own build step rewrites the reference to a real registry path as part of resolving build.artifacts. builder is handed over, not built, so nothing ever rewrites it: pushed as written, docker read it literally and tried Docker Hub. Took the live node's mesh-builder down for the length of one push-and-fix (docker: pull access denied for mesh-builder, repository does not exist). novox.internal:5100, not the literal external IP docker inspect showed live, for the same reason addresses generally don't get hardcoded in this catalogue. --- modules/builder/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 8e32ccc..229ca97 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -50,7 +50,7 @@ "id": "server", "type": "container", "name": "mesh-builder", - "image": "mesh-builder@sha256:42f5203a6838776447790d9e7d27f22a56e9462bdd25401645f22d188da56704", + "image": "novox.internal:5100/mesh-builder@sha256:42f5203a6838776447790d9e7d27f22a56e9462bdd25401645f22d188da56704", "env-file": [ "/var/lib/mesh/builder/builder.env" ], From 8369fe22b8733a155ef128b2d906d3382aec657d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:54:46 +0200 Subject: [PATCH 14/48] builder is a real built module now, not handed over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its own image ('mesh-builder@sha256:0000...0000', later manually pinned to a real digest tonight when the placeholder blocked a push) was never produced by anything the mesh tracks — cmd/mesh-builder lives in mesh-controller's own repository, and nothing declared how to build an image from it. Uses the same context mechanism route-proxy does (mesh- controller#62): the Dockerfile compiles ./cmd/mesh-builder from a clone of mesh-controller's repository, not a vendored copy. Unlike mesh-controller's own FROM scratch (ADR 0006 — nothing to audit but one binary), the build machine's whole job is shelling out to git and docker, so its runtime is Alpine with both installed from the base's own packages, not fetched on their own. Bootstrapped live tonight: a manual build got the new image running long enough to build itself properly through the pipeline it had just gained, and mesh-controller itself needed the same upgrade first (it parses manifests too, and rejected the new context field with the old binary) — genesis's own kind of ordering problem, solved by hand exactly once. --- modules/builder/Dockerfile | 25 +++++++++++++++++++++++++ modules/builder/module.json | 27 +++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 modules/builder/Dockerfile diff --git a/modules/builder/Dockerfile b/modules/builder/Dockerfile new file mode 100644 index 0000000..5b2f18f --- /dev/null +++ b/modules/builder/Dockerfile @@ -0,0 +1,25 @@ +ARG GO_BASE +ARG ALPINE_BASE +# builder's own image: the build machine itself, compiled into a container. +# +# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder, +# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one +# half of. This module ships the packaging, not a second copy of the source, so the build context +# is the mesh-controller repository root (declared under build.artifacts[].context), and this +# Dockerfile compiles ./cmd/mesh-builder from it — the same shape route-proxy already uses for the +# same reason. +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 -o /mesh-builder ./cmd/mesh-builder + +# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build +# machine's whole job is shelling out to git and docker — it needs a real userland to do that in, +# not a second copy of either tool vendored into this image. apk installs both from the base's own +# packages, not fetched on its own at build time. +FROM ${ALPINE_BASE} +RUN apk add --no-cache docker-cli git +COPY --from=build /mesh-builder /usr/local/bin/mesh-builder +ENTRYPOINT ["/usr/local/bin/mesh-builder"] diff --git a/modules/builder/module.json b/modules/builder/module.json index 229ca97..325dbe6 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -50,7 +50,7 @@ "id": "server", "type": "container", "name": "mesh-builder", - "image": "novox.internal:5100/mesh-builder@sha256:42f5203a6838776447790d9e7d27f22a56e9462bdd25401645f22d188da56704", + "artifact": "server", "env-file": [ "/var/lib/mesh/builder/builder.env" ], @@ -64,5 +64,28 @@ ], "network": "host" } - ] + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile", + "context": { + "repository": "https://git.novox.be/novox/mesh-controller.git", + "ref": "main" + } + } + ], + "on": [ + { + "arg": "GO_BASE", + "image": "golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59" + }, + { + "arg": "ALPINE_BASE", + "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" + } + ] + } } From dd5b973e66e549cf22891c1d8d158522b98ddba2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 17:58:10 +0200 Subject: [PATCH 15/48] route-proxy: declare the build context its own Dockerfile has always needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile's own comment already said it — 'the build context is the mesh-controller repository root' — but nothing in the manifest actually said so to the mesh, so every build attempt used mesh-catalog's own directory instead and failed with 'stat go.mod: file does not exist'. Never caught before because route-proxy has never been assigned anywhere. Uses the context mechanism just added (mesh-controller#62), proven working tonight on builder's own self-build. --- modules/route-proxy/module.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index aecaca4..f5d42c0 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -123,7 +123,11 @@ { "name": "server", "kind": "image", - "from": "Dockerfile" + "from": "Dockerfile", + "context": { + "repository": "https://git.novox.be/novox/mesh-controller.git", + "ref": "main" + } }, { "name": "trust", From 3147fac08b9bda5523f667bdd456cd5c199915bc Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 18:08:28 +0200 Subject: [PATCH 16/48] public-acme: the roots field is named roots, not root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step-ca (the other acme-ca provider) already spells it correctly; route- proxy's own template reads ${bound:acme-ca:roots}. Found live, assigning public-acme for the first time tonight: the mesh refused the push outright rather than composing a broken binding — 'route-proxy asks its acme-ca binding for roots, and what answers it says ... root'. Empty stays empty: a public CA's root is the system trust store already, per route-proxy's own design (an empty ACME_CA_BUNDLE means exactly that). --- modules/public-acme/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/public-acme/module.json b/modules/public-acme/module.json index df25ce7..39e0e07 100644 --- a/modules/public-acme/module.json +++ b/modules/public-acme/module.json @@ -13,7 +13,7 @@ "at": "acme-v02.api.letsencrypt.org", "port": 443, "path": "/directory", - "root": "" + "roots": "" } } } From 4c5e69903f3bebecac4215876ca8cc3d917755a4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 18:09:09 +0200 Subject: [PATCH 17/48] route-proxy: the server container resolves its own built artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was still pinned to the scaffold's placeholder digest (mesh-route- proxy@sha256:0000...0000) even after the build+context work landed — never caught because nothing had assigned route-proxy before tonight. artifact: server, matching trust's own reference a few lines up and every other built module in the catalogue. --- modules/route-proxy/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index f5d42c0..534970a 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -95,7 +95,7 @@ "id": "server", "type": "container", "name": "route-proxy", - "image": "mesh-route-proxy@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifact": "server", "network": "host", "env-file": [ "/var/lib/route-proxy/acme.env" From 95a0a5672c84cc900aaf22c3ea828592f5bb6147 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 18:14:58 +0200 Subject: [PATCH 18/48] route-proxy: the trust step skips the fetch when the CA names no roots to get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composed ACME_ROOTS unconditionally from ${bound:acme-ca:roots} even when that field is empty — public-acme's own case, where an empty roots means 'the system trust store', not 'fetch from the bare authority host'. The run-once step wget'd https://acme-v02.api.letsencrypt.org:443 (host, no path) for two minutes every apply and failed, blocking every resource after it — found live tonight, assigning route-proxy for the first time. Carries the raw, uncomposed roots value alongside the composed URL (ACME_ROOTS_PATH) so the step can tell 'nothing to fetch' apart from 'the authority didn't answer' — a distinction the composed URL alone cannot make. Empty copies the image's own system CA bundle to /ca/root.crt instead of fetching one, so ACME_CA_BUNDLE stays the one path it has always been rather than needing to become conditional itself. --- modules/route-proxy/module.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 534970a..3e24339 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -67,7 +67,7 @@ "type": "file", "path": "/var/lib/route-proxy/acme.env", "mode": "0600", - "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\n" + "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n" }, { "id": "trust", @@ -85,7 +85,7 @@ "args": [ "sh", "-c", - "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" + "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" From f0aa9e5fed2cb4f69f518ec401396c2d81e94e67 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 18:19:27 +0200 Subject: [PATCH 19/48] minio: declare the route contribution it has always needed, scoped from PR #58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit files-api.novox.be and files.novox.be worked earlier tonight from route- adapter-generated files, but minio's module.json on main never actually carried a route requirement — that capability has been sitting in PR #58 the whole time, bundled with an unrelated network rename and console redirect URL that need their own calmer review. This is just the two routes: requires: route, contributes.route.api/.console (ContributesMany, proven working via mesh-controller #55/#57), and the console port (9001) actually published and declared in listens. Found assigning route-proxy for the first time tonight: its own routes file, generated the identical way route-adapter's always was, had four hostnames in it instead of six — nothing served files-api/files at all, which would have been a real, silent outage the moment Traefik stopped. --- modules/minio/module.json | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/minio/module.json b/modules/minio/module.json index 8b1a05a..6a07e40 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -7,6 +7,21 @@ "scope": "mesh" } ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "api": { + "label": "files-api", + "port": 9000 + }, + "console": { + "label": "files", + "port": 9001 + } + } + }, "capabilities": [ "container-runtime" ], @@ -20,6 +35,12 @@ "protocol": "tcp", "from": "mesh", "why": "the S3 endpoint" + }, + { + "port": 9001, + "protocol": "tcp", + "from": "mesh", + "why": "the admin console" } ], "serves": { @@ -92,7 +113,8 @@ "/var/lib/minio/root.env" ], "ports": [ - "9000" + "9000", + "9001" ], "volumes": [ "/var/lib/minio-store:/data", From 962cba7c04cd548e39b109f9454caee32ce10a4a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:36:41 +0200 Subject: [PATCH 20/48] route-proxy: internal names are certified by the mesh's own authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two name spaces, two authorities (08-connectivity §2): a public name is certified by a public CA, an internal one by the mesh's own. step-ca now offers that second seat as internal-acme-ca beside its existing acme-ca, and route-proxy requires both — the server dispatches by which authority may certify the name at all, so an .internal alias stops being plain-HTTP only without ever asking a public CA for a name it cannot validate. --- modules/route-proxy/module.json | 45 +++++++++++++++++++++++++++++---- modules/step-ca/module.json | 8 ++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 3e24339..94c0d2e 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -18,10 +18,12 @@ "route": "/var/lib/route-proxy/routes/mesh.json" }, "requires": [ - "acme-ca" + "acme-ca", + "internal-acme-ca" ], "binds": { - "acme-ca": "/var/lib/route-proxy/acme-ca.json" + "acme-ca": "/var/lib/route-proxy/acme-ca.json", + "internal-acme-ca": "/var/lib/route-proxy/internal-acme-ca.json" }, "listens": [ { @@ -69,6 +71,13 @@ "mode": "0600", "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n" }, + { + "id": "internal-acme-env", + "type": "file", + "path": "/var/lib/route-proxy/internal-acme.env", + "mode": "0600", + "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" + }, { "id": "trust", "type": "container", @@ -91,6 +100,28 @@ "acme-env" ] }, + { + "id": "internal-trust", + "type": "container", + "name": "route-proxy-internal-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "/var/lib/route-proxy/internal-acme.env" + ], + "volumes": [ + "/var/lib/route-proxy/ca:/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", @@ -98,7 +129,8 @@ "artifact": "server", "network": "host", "env-file": [ - "/var/lib/route-proxy/acme.env" + "/var/lib/route-proxy/acme.env", + "/var/lib/route-proxy/internal-acme.env" ], "volumes": [ "/var/lib/route-proxy/routes:/routes:ro", @@ -110,11 +142,14 @@ "LISTEN": ":80", "TLS_LISTEN": ":443", "ACME_CACHE": "/acme", - "ACME_CA_BUNDLE": "/ca/root.crt" + "ACME_CA_BUNDLE": "/ca/root.crt", + "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt" }, "restart-on": [ "trust", - "acme-env" + "acme-env", + "internal-trust", + "internal-acme-env" ] } ], diff --git a/modules/step-ca/module.json b/modules/step-ca/module.json index 31cc53a..d73f612 100644 --- a/modules/step-ca/module.json +++ b/modules/step-ca/module.json @@ -8,12 +8,20 @@ { "name": "acme-ca", "scope": "mesh" + }, + { + "name": "internal-acme-ca", + "scope": "mesh" } ], "serves": { "acme-ca": { "path": "/acme/acme/directory", "roots": "/roots.pem" + }, + "internal-acme-ca": { + "path": "/acme/acme/directory", + "roots": "/roots.pem" } }, "listens": [ From 45dd0366237b6614d888175577f7b05ee585c1f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:48:10 +0200 Subject: [PATCH 21/48] The npm registry is a seat gitea holds, and gitea holds the git seat a build's source can live on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements novox/hq ADR 0109, 0110 and 0111 in the catalogue. package-registry becomes npm-package-registry throughout (ADR 0109): gitea provides and serves it, verdaccio provides it, the builder requires, binds and receives its secret under it. gitea's contributions file is grants/npm.json, so a second ecosystem's file has an obvious name beside it. gitea claims two mesh seats (ADR 0110): npm-package-registry, which it delivers, and git, which it now provides with what a clone URL is composed from — http on the forge's web port (ADR 0111). verdaccio provides npm-package-registry and claims nothing: it is the second provider the seat exists to make harmless, since a consumer now resolves to the seat's holder without a pin. No cargo or PyPI provision is added; ADR 0109 defers that. git mints no credential, so gitea's provisioner registers nothing for it — the mesh's own repositories are public, and a clone credential is undecided (ADR 0111). The provisioner still reads where its contributions land from $MESH_RECEIVES, and names no path itself. One variable carries one path, so a second registration in this module would need the mesh to say where each provision's file is; that is not possible yet and is not faked here. Verified: the controller's tests read this catalogue — every claim is a seat in the set, the forge holds both seats and serves what a clone URL needs, the builder requires what the npm seat delivers — and pass. Not verified here: a TypeScript build of gitea, whose dependencies resolve from the private registry. --- modules/builder/module.json | 6 +++--- modules/gitea/module.json | 28 +++++++++++++++++++++++----- modules/gitea/provisioner/index.ts | 22 ++++++++++++++++------ modules/verdaccio/module.json | 2 +- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 325dbe6..9d38701 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -12,13 +12,13 @@ ], "requires": [ "artifact-store", - "package-registry" + "npm-package-registry" ], "binds": { - "package-registry": "/var/lib/mesh/builder/package-registry.json" + "npm-package-registry": "/var/lib/mesh/builder/package-registry.json" }, "secrets": { - "package-registry": "/var/lib/mesh/builder/package-registry.secret" + "npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" }, "emits": [ "module.builder.built" diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a6ef32f..2802d1d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -49,18 +49,32 @@ } ], "serves": { - "package-registry": { + "npm-package-registry": { "scheme": "http", "port": 3000, "npm-path": "/api/packages/novox/npm/" + }, + "git": { + "scheme": "http", + "port": 3000 } }, "receives": { - "package-registry": "/var/lib/gitea/grants/mesh.json" + "npm-package-registry": "/var/lib/gitea/grants/npm.json" }, "grants": { - "package-registry": "/var/lib/gitea/grants" + "npm-package-registry": "/var/lib/gitea/grants" }, + "claims": [ + { + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", + "scope": "mesh" + } + ], "own-secrets": { "broker": "/var/lib/mesh/gitea/broker" }, @@ -177,7 +191,7 @@ "MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", "MESH_GITEA_STATE_DIR": "/run/state", - "MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json" + "MESH_RECEIVES": "/var/lib/gitea/grants/npm.json" }, "artifact": "runtime", "restart-on": [ @@ -187,7 +201,11 @@ ], "provides": [ { - "name": "package-registry", + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", "scope": "mesh" } ], diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 36d66e2..ef2a99f 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -1,9 +1,15 @@ -// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry` -// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are -// the sdk harness's; this writes only the per-service half: how gitea creates and removes a -// consumer's npm credential (novox/hq ADR 0048/0076). +// gitea's provisioner — the adapter that makes gitea a provider of the mesh +// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the +// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea +// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076). // -// The `package-registry` interface: a consumer authenticates to the npm registry at +// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat +// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another +// `receives` path and another registration below — not widening this one. `git`, which gitea also +// provides, mints nothing and so registers nothing here: the mesh's own repositories are public, +// and a clone credential is not yet decided (ADR 0111). +// +// The `npm-package-registry` interface: a consumer authenticates to the npm registry at // `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can // read and write packages under the `@novox` scope. The registry's npm owner is the gitea org // `novox`; a consumer is a gitea *user* placed on that org's package team. @@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages"; const gitea = GiteaAdmin.fromEnv(); -runProvisioner("package-registry", { +// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written +// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that +// path in code would drift from it. One variable carries one path, so a second registration in this +// module needs the mesh to say where each provision's file is — not yet possible, and not faked. +runProvisioner("npm-package-registry", { async create(p: Provision): Promise { // The org and its package team are the same for every consumer; ensuring them per-create is // idempotent and needs no separate bootstrap step. diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index ad291cd..5a161d4 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -102,7 +102,7 @@ }, "provides": [ { - "name": "package-registry", + "name": "npm-package-registry", "scope": "mesh" } ], From c2353fc0a635235d1ae1aeba9cea195715ce9d69 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:51:44 +0200 Subject: [PATCH 22/48] gitea: the internal-API refusal is part of the route, not a file beside the proxy The 2026-09-12 incident response blocked /api/internal by hand in the predecessor's dynamic directory, with a note that its durable home is the mesh's routing. A route carries the policy applied to a request (ADR 0108), so the refusal now travels with the grant: route-proxy enforces it on both the public name and the internal alias the moment it serves this route, and the adapter skips it aloud (no port, nothing to write) while the predecessor's own file still stands. The hand-authored file retires with the proxy it configures. --- modules/gitea/module.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a6ef32f..3b1b705 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -11,8 +11,16 @@ "name": "gitea" }, "route": { - "label": "git", - "port": 3000 + "web": { + "label": "git", + "port": 3000 + }, + "internal-api-refused": { + "label": "git", + "path": "/api/internal", + "deny": true, + "priority": 100000 + } } }, "binds": { From 02ccf31a7117ecf6ca2d51c99730448c9320018f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 21:42:38 +0200 Subject: [PATCH 23/48] gitea: a consumer's user is actually created, and a failed create says why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder's package-registry grant — the first this provider ever received — retried for a day saying only that an edit 404'd. Two faults under it: the API refuses an email without a dotted domain, so `@localhost` failed validation at create (the CLI that made mesh-admin accepts it, which is why the admin exists and no consumer did); and ensureUser read that 422 as 'already exists' and went on to edit a user that was never made, burying the create's own message. The address is now gitea's own hidden-address shape, and the edit path is taken only for a user that is actually there. --- modules/gitea/client.ts | 23 +++++++++++++++-------- modules/gitea/provisioner/index.ts | 7 ++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 983186e..62ddb74 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -387,7 +387,11 @@ export class GiteaAdmin { } /** Ensure a user exists with exactly this password. Created if absent; if already there, its - * password is patched — so the mesh minting a new secret takes on the next reconcile. */ + * password is patched — so the mesh minting a new secret takes on the next reconcile. + * + * The edit path is taken only when the user actually exists. A 422 from the create is also what + * a plain validation failure returns, and reading it as "already there" made the follow-up edit + * 404 — burying the create's own message, which is the one that says what is actually wrong. */ async ensureUser(username: string, password: string, email: string): Promise { const res = await this.request("/admin/users", { method: "POST", @@ -395,13 +399,16 @@ export class GiteaAdmin { }); if (res.status === 201) return; if (res.status === 422 || res.status === 409) { - const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { - method: "PATCH", - // login_name is required by the admin edit endpoint; for a local user it is the username. - body: JSON.stringify({ login_name: username, password, must_change_password: false }), - }); - if (patch.status === 200) return; - GiteaAdmin.fail(`/admin/users/${username}`, patch); + const seen = await this.request(`/users/${encodeURIComponent(username)}`); + if (seen.status === 200) { + const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { + method: "PATCH", + // login_name is required by the admin edit endpoint; for a local user it is the username. + body: JSON.stringify({ login_name: username, password, must_change_password: false }), + }); + if (patch.status === 200) return; + GiteaAdmin.fail(`/admin/users/${username}`, patch); + } } GiteaAdmin.fail("/admin/users", res); } diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 36d66e2..577487b 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -34,7 +34,12 @@ runProvisioner("package-registry", { const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true); // The user carries the consumer's login and the mesh's minted password, set every run so a // rotation takes. Membership of the package team is what grants read+write on packages. - await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`); + // + // The address is gitea's own convention for one that is not real: its email validation + // requires a dotted domain, so `@localhost` was refused at create — the fault that had this + // grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives + // hidden addresses. + await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`); await gitea.addUserToTeam(teamId, p.as); }, From 311f7f1fdb61020feb484decdd2c58464b30b149 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 21:59:09 +0200 Subject: [PATCH 24/48] gitea: the package team may read code, and its units are reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder's first credentialed clone of a private repository answered 'not found': the packages team named only repo.packages in its units_map, which is exhaustive — so members had no code unit at all, and gitea hides what a user cannot read. One credential answering npm and git alike was the whole design of the builder's grant; the team now says so. And found teams are patched, not just returned: a team is configuration the reconcile loop owns, the same as a user's password, so a unit this code gains reaches the team that already exists rather than only the next mesh raised from scratch. --- modules/gitea/client.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 62ddb74..0fcc47e 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -352,24 +352,34 @@ export class GiteaAdmin { GiteaAdmin.fail("/orgs", res); } - /** Ensure the org's package team exists, granting read+write on packages, and return its id. The - * team is found by name if it is already there, created otherwise; a lost create race is resolved - * by re-listing. */ + /** Ensure the org's package team exists with exactly these units, and return its id. Found or + * created, the units are applied either way — a team is configuration the reconcile loop owns, + * the same as a user's password, so a unit this code gains reaches a team that already exists + * rather than only the next mesh raised from scratch. A lost create race is resolved by + * re-listing. */ async ensureTeam(org: string, team: string, packageWrite: boolean): Promise { + // The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a + // unit the team does not have — so code read must be said here: without it gitea answers a + // member's clone of a private repository with "not found", which is how the builder's first + // credentialed clone failed against a team that named only packages. + const units = { + permission: "read", + units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, + includes_all_repositories: true, + can_create_org_repo: false, + }; const found = await this.findTeam(org, team); - if (found !== null) return found; + if (found !== null) { + const patch = await this.request(`/teams/${found}`, { + method: "PATCH", + body: JSON.stringify({ name: team, ...units }), + }); + if (patch.status === 200) return found; + GiteaAdmin.fail(`/teams/${found}`, patch); + } const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { method: "POST", - body: JSON.stringify({ - name: team, - permission: "read", - // Package access is a per-unit grant; the team needs write on the packages unit and nothing - // else. includes_all_repositories keeps the team's repo view whole without widening its - // repo permission beyond read. - units_map: { "repo.packages": packageWrite ? "write" : "read" }, - includes_all_repositories: true, - can_create_org_repo: false, - }), + body: JSON.stringify({ name: team, ...units }), }); if (res.status === 201) return Number(res.body?.id); if (res.status === 422 || res.status === 409) { From ed5d1386cea4d25d77100a7c63aa1b8b69afe809 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 22:39:29 +0200 Subject: [PATCH 25/48] mailu: the manifest matches the machine, provides smtp, and carries automx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five gaps between the draft and what actually runs, each verified live before being written down: - front published bare 80 — the machine port Traefik holds; now the predecessor's own mappings (7080:80, 7443:443) plus the 110/143/995 parity ports the draft dropped. Pruning legacy protocols is its own deliberate change, not a cutover side effect. - TLS_FLAVOR said cert, which nothing supplies; live is letsencrypt — mailu runs its own certbot, state already on disk, HTTP-01 answered through a path-scoped route contribution (priority above the web one). - the web route said http:7080, the redirect-loop shape; it now says what the hand-authored file always knew: https 7443, insecure. - automx was absent entirely: the autoconfig responder is now a second artifact (its Containerfile moved in from the predecessor's images dir, base declared per ADR 0097), a container on a real data dir — the anonymous-volume loss of 2026-08-10 stays fixed — and the three public names are route contributions. - and the reason this moved ahead of de-spiegel: mailu now provides smtp. A consumer contributes the account it sends as; the provisioner creates @ via the admin API and applies the minted password every reconcile (ADR 0048). The domain is served on the binding so a consumer composes its own login from mesh facts. route-adapter learns to say no: a contribution over https, scoped to a path, or carrying a policy is skipped aloud rather than written into a file shape that cannot say it — plain http into a TLS listener was the concrete wrong file this prevents. The hand-authored files keep covering those routes until the mesh's own proxy takes over, exactly as today. --- modules/mailu/Dockerfile | 4 +- modules/mailu/automx/Dockerfile | 32 +++++ modules/mailu/automx/files/add-domains | 49 ++++++++ modules/mailu/automx/files/automx2.conf | 21 ++++ modules/mailu/automx/files/setup | 12 ++ modules/mailu/automx/files/setup-db | 83 +++++++++++++ modules/mailu/automx/files/setupvenv.sh | 38 ++++++ modules/mailu/automx/files/start | 8 ++ modules/mailu/module.json | 131 +++++++++++++++++++-- modules/mailu/provisioner/index.ts | 65 ++++++++++ modules/route-adapter/adapter.ts | 18 +++ modules/route-adapter/test/adapter.test.ts | 21 ++++ 12 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 modules/mailu/automx/Dockerfile create mode 100644 modules/mailu/automx/files/add-domains create mode 100644 modules/mailu/automx/files/automx2.conf create mode 100644 modules/mailu/automx/files/setup create mode 100644 modules/mailu/automx/files/setup-db create mode 100644 modules/mailu/automx/files/setupvenv.sh create mode 100644 modules/mailu/automx/files/start create mode 100644 modules/mailu/provisioner/index.ts diff --git a/modules/mailu/Dockerfile b/modules/mailu/Dockerfile index b4c8a17..2462f57 100644 --- a/modules/mailu/Dockerfile +++ b/modules/mailu/Dockerfile @@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build # resolved away. WORKDIR /app/modules/mailu COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} @@ -27,4 +27,4 @@ COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist # the convention novox/hq issues 060/061 settled. A container that instead ran only its # provisioner (`run`) served no tools and emitted no events; a container that named no command # ran no provisioner at all. -ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js +ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js diff --git a/modules/mailu/automx/Dockerfile b/modules/mailu/automx/Dockerfile new file mode 100644 index 0000000..7fd7933 --- /dev/null +++ b/modules/mailu/automx/Dockerfile @@ -0,0 +1,32 @@ +# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own +# artifact: it is a config-baked sidecar of this mail server, not a standalone application +# (novox/hq ADR 0015 draws that line at applications). +# +# The base is named rather than pinned (novox/hq issue 044): declared in module.json's +# `build.on`. The build context is the module's own directory; every ADD says so. +ARG PYTHON_BASE + +FROM ${PYTHON_BASE} +RUN apk add --no-cache bash sqlite +WORKDIR /automx2 + +ADD automx/files/setupvenv.sh /automx2/setupvenv.sh +ADD automx/files/start /automx2/start +ADD automx/files/setup /automx2/setup +ADD automx/files/setup-db /automx2/setup-db +ADD automx/files/add-domains /automx2/add-domains +RUN chmod u+x setupvenv.sh start add-domains setup setup-db + +RUN ./setupvenv.sh \ + && . .venv/bin/activate \ + && pip install automx2 + +ENV AUTOMX2_CONF=/etc/automx2.conf +ADD automx/files/automx2.conf /etc/automx2.conf + +# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on +# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead. +ENTRYPOINT ["/bin/sh"] +CMD ["./start"] + +EXPOSE 4243 diff --git a/modules/mailu/automx/files/add-domains b/modules/mailu/automx/files/add-domains new file mode 100644 index 0000000..e413623 --- /dev/null +++ b/modules/mailu/automx/files/add-domains @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -e + +echo "${MAIL_DOMAINS}" + +# Split domains into array +IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}" + +# User configurable section -- START +PROVIDER_ID=001 +SQL_CMD=""; + +# Iterate domains resulting from split on second arg +for element in "${array[@]}" +do + # Set vars + DOMAIN=$element + PROVIDER_NAME=$DOMAIN + PROVIDER_SHORTNAME=$DOMAIN + + # Optional LDAP server + #LDAP_SERVER="ldap.${DOMAIN}" + # User configurable section -- END + s1_id=$((PROVIDER_ID + 1)) + s2_id=$((PROVIDER_ID + 2)) + s3_id=$((PROVIDER_ID + 3)) + dom_id=$((PROVIDER_ID + 4)) + + s3_id='NULL' + + SQL_CMD=$(cat <&2 "Directory '${dir}' already exists, exiting." + exit 1 +fi +python3 -m venv "${dir}" +source "${dir}/bin/activate" + +set +e +pip install -U pip setuptools wheel || true + +#set -e +## vim:tabstop=4:noexpandtab +## +## Creates a Python 3 virtual environment. The target directory can be passed +## as a parameter. The default path is 'venv' in the current directory. +# +#dir="${1:-venv}" +# +#set -e +#if [ -d "${dir}" ]; then +# echo "Directory '${dir}' already exists, exiting." >&2 +# exit 1 +#fi +#python3 -m venv "${dir}" +#. "${dir}/bin/activate" +# +#set +e +#pip install -U pip setuptools || true diff --git a/modules/mailu/automx/files/start b/modules/mailu/automx/files/start new file mode 100644 index 0000000..d23943b --- /dev/null +++ b/modules/mailu/automx/files/start @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +# Setup +./setup + +# Start +./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243 diff --git a/modules/mailu/module.json b/modules/mailu/module.json index b04ce66..7aa4793 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -14,8 +14,30 @@ "name": "mailu" }, "route": { - "label": "mail", - "port": 7080 + "web": { + "label": "mail", + "port": 7443, + "scheme": "https", + "insecure": true + }, + "acme": { + "label": "mail", + "path": "/.well-known/acme-challenge", + "port": 7080, + "priority": 100 + }, + "autoconfig": { + "label": "autoconfig", + "port": 4243 + }, + "autodiscover": { + "label": "autodiscover", + "port": 4243 + }, + "automx": { + "label": "automx", + "port": 4243 + } } }, "binds": { @@ -44,6 +66,20 @@ "why": "mail from other mail servers", "fixed": true }, + { + "port": 110, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change", + "fixed": true + }, + { + "port": 143, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP with STARTTLS, kept at parity", + "fixed": true + }, { "port": 465, "protocol": "tcp", @@ -55,7 +91,7 @@ "port": 587, "protocol": "tcp", "from": "anywhere", - "why": "submission", + "why": "submission; also what the smtp provision serves consumers", "fixed": true }, { @@ -65,11 +101,30 @@ "why": "IMAP over TLS", "fixed": true }, + { + "port": 995, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3 over TLS, kept at parity", + "fixed": true + }, { "port": 7080, "protocol": "tcp", "from": "mesh", - "why": "the web interface (admin, webmail, admin API), behind the route proxy" + "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy" + }, + { + "port": 7443, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here" + }, + { + "port": 4243, + "protocol": "tcp", + "from": "mesh", + "why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here" } ], "own-secrets": { @@ -88,12 +143,24 @@ "path": "/var/lib/mailu", "mode": "0700" }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/mailu/grants", + "mode": "0700" + }, + { + "id": "data-automx", + "type": "directory", + "path": "/services/mailu/data/automx", + "mode": "0700" + }, { "id": "config-env", "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n" + "content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n" }, { "id": "secret-env", @@ -365,10 +432,14 @@ ], "ports": [ "25", + "110", + "143", "465", "587", "993", - "80" + "995", + "7080:80", + "7443:443" ], "volumes": [ "/services/mailu/data/certs:/certs", @@ -391,6 +462,7 @@ "volumes": [ "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", "/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro", + "/var/lib/mailu/grants:/var/lib/mailu/grants:ro", "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", "/var/run/docker.sock:/var/run/docker.sock" ], @@ -399,12 +471,30 @@ "MESH_MAILU_URL": "http://mailu-admin/api/v1", "MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", - "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" + "MESH_MAILU_CONFIG_FILE": "/run/config/config.json", + "MESH_MAILU_DOMAIN": "novox.be", + "MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json" }, "restart-on": [ "runtime-config" ], "artifact": "runtime" + }, + { + "id": "automx", + "type": "container", + "name": "mailu-automx", + "artifact": "automx", + "network": "mailu", + "env-file": [ + "/var/lib/mailu/mailu.env" + ], + "ports": [ + "4243" + ], + "volumes": [ + "/services/mailu/data/automx:/data" + ] } ], "build": { @@ -418,6 +508,10 @@ "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" + }, + { + "arg": "PYTHON_BASE", + "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" } ], "artifacts": [ @@ -425,7 +519,30 @@ "name": "runtime", "kind": "image", "from": "Dockerfile" + }, + { + "name": "automx", + "kind": "image", + "from": "automx/Dockerfile" } ] + }, + "provides": [ + { + "name": "smtp", + "scope": "mesh" + } + ], + "serves": { + "smtp": { + "port": 587, + "domain": "novox.be" + } + }, + "receives": { + "smtp": "/var/lib/mailu/grants/mesh.json" + }, + "grants": { + "smtp": "/var/lib/mailu/grants" } } diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts new file mode 100644 index 0000000..42ef44d --- /dev/null +++ b/modules/mailu/provisioner/index.ts @@ -0,0 +1,65 @@ +// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` interface. +// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk +// harness's; this writes only the per-service half: how mailu creates and removes a consumer's +// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling). +// +// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real +// mailbox this provisioner creates. The address is `@`: the local part is the +// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's +// own login for a consumer that named none; the domain is the mail server's, which is this +// module's fact, not the consumer's. +// +// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands +// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals +// nothing: the consumer already has its copy through the mesh's own channel. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { MailuClient } from "../client.js"; + +const mailu = MailuClient.fromEnv(); + +// The mail server's own domain. From the environment the manifest composes, because the client's +// config file carries the admin API's coordinates, not the mail domain. +function domain(): string { + const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim(); + if (named === "") { + throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed"); + } + return named; +} + +// The address one consumer sends as. The local part is refused rather than sanitised when it is +// not a plain mailbox name — a rewritten name is an address nobody asked for. +function addressOf(p: { as: string; values?: Readonly> }): string { + const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : ""; + const local = contributed !== "" ? contributed : p.as; + if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) { + throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`); + } + return `${local}@${domain()}`; +} + +runProvisioner("smtp", { + async create(p: Provision): Promise { + const email = addressOf(p); + // Create if absent, and set exactly the minted password either way so a rotation takes. + // Mailu's create refuses a duplicate address, which is the signal to fall through to the + // password set — the same found-then-apply shape gitea's ensureUser settled on. + try { + await mailu.createUser(email, p.password); + } catch { + await mailu.changePassword(email, p.password); + } + }, + + async remove(p: { as: string }): Promise { + // The withdrawal only knows the mesh login, never the contributed local part — so accounts + // that contributed one are removed when the address matching the login is absent? No: the + // harness hands remove only `as`, and an address composed from a contribution cannot be + // recomputed from it. The account is therefore removed by its login-shaped address when one + // exists, and left otherwise — a mailbox holding mail is the one thing a background loop + // must not guess about (this module's own events file says the same). Withdrawal of a + // named-account consumer is an operator action until the harness carries values here. + await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {}); + }, +}); diff --git a/modules/route-adapter/adapter.ts b/modules/route-adapter/adapter.ts index 9b0324a..84c503d 100644 --- a/modules/route-adapter/adapter.ts +++ b/modules/route-adapter/adapter.ts @@ -151,6 +151,24 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[ skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`); continue; } + // What this adapter's one file shape cannot say, it skips aloud rather than approximating: + // a backend over its own TLS (the file would send plain http into a TLS listener), a + // path-scoped or refusing or redirecting rule (the file routes whole hosts). The mesh's own + // proxy serves all of these the day it takes over; until then the predecessor's hand-authored + // files keep covering them, exactly as they do today. + const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : ""; + if (scheme !== "" && scheme !== "http") { + skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`); + continue; + } + if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") { + skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`); + continue; + } + if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") { + skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`); + continue; + } const port = asPort(entry.values?.["port"]); if (port === undefined) { skipped.push(`${from} asked for route ${name} and gave no usable port`); diff --git a/modules/route-adapter/test/adapter.test.ts b/modules/route-adapter/test/adapter.test.ts index b8515f6..6a7aaca 100644 --- a/modules/route-adapter/test/adapter.test.ts +++ b/modules/route-adapter/test/adapter.test.ts @@ -205,6 +205,27 @@ test("a contribution it cannot act on is skipped and named", async () => { assert.deepEqual(routesFrom(undefined, machine).routes, []); }); +// What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS +// listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect. +// The mesh's own proxy serves all of these the day it takes over; until then the predecessor's +// hand-authored files keep covering them. +test("a contribution the file shape cannot say is skipped and says which part", async () => { + const machine = defaults.machine; + const { routes, skipped } = routesFrom({ given: [ + { from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } }, + { from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } }, + { from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } }, + { from: "site", values: { name: "www.example", redirect: "https://example" } }, + { from: "mailu", values: { name: "autoconfig.example", port: 4243 } }, + ] }, machine); + assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]); + assert.equal(skipped.length, 4); + assert.match(skipped[0]!, /over https/); + assert.match(skipped[1]!, /scoped to a path/); + assert.match(skipped[2]!, /scoped to a path/); + assert.match(skipped[3]!, /policy/); +}); + // The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write // into — and writing anyway would put route files somewhere nothing reads, reporting success. test("it refuses when the predecessor's directory is not there, and says why", async () => { From e3d8bd0726a13fdc5a1ffd336148020487b93927 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 22:56:27 +0200 Subject: [PATCH 26/48] mailu: 2024.06 closes 110/143/587 by default; parity says open them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PORTS defaults to 25,80,443,465,993,995,4190 in 2024.06 — submission on 587 among the closed, which is what every client of this server uses. The same parity decision the listens already state, now stated where the software reads it. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 7aa4793..e781d26 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n" + "content": "PORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n" }, { "id": "secret-env", From 75f993e92bd96263990aef9ae223af31327d0eb9 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:34:13 +0200 Subject: [PATCH 27/48] mailu: pin exactly what phase 1 verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (MAILU-CUTOVER.md) upgraded the live stack 1.9→2024.06 and its lessons land here as pins: every image is the digest running and verified tonight — webmail under the name 2024.06 actually uses (the draft's roundcube pin misled a whole hop), antivirus on the upstream clamav image with the signature DB in its own directory (the mailu-built image ended at 2.0), and the front trusting the proxy address the live config actually names. The welcome-mail texts ride along for parity. TLS_FLAVOR stays letsencrypt deliberately where the live .env says cert: the cert files are copies whose HAL-era renewal hook died with HAL (expiry Nov 27); mailu managing its own issuance through the existing ACME passthrough is the fix, and the files remain on disk as the fallback flavor if first issuance misbehaves during the window. --- modules/mailu/module.json | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index e781d26..45fb0d2 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "PORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n" + "content": "PORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", @@ -219,6 +219,12 @@ "path": "/services/mailu/data/filter", "mode": "0700" }, + { + "id": "data-clamav", + "type": "directory", + "path": "/services/mailu/data/clamav", + "mode": "0700" + }, { "id": "data-redis", "type": "directory", @@ -282,7 +288,7 @@ "id": "resolver", "type": "container", "name": "mailu-resolver", - "image": "ghcr.io/mailu/unbound@sha256:142aaad82ad1b0d5b59a5f1303778dba61a3e0a540f5d969c48862bcc99f6f5d", + "image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -294,7 +300,7 @@ "id": "redis", "type": "container", "name": "mailu-redis", - "image": "redis@sha256:1db42ccef14898aa29bae778452d567534b59c107129cbc1163fb552de184d3c", + "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", "network": "mailu", "volumes": [ "/services/mailu/data/redis:/data" @@ -304,7 +310,7 @@ "id": "admin", "type": "container", "name": "mailu-admin", - "image": "ghcr.io/mailu/admin@sha256:dcac20e9cbdad560faef9653b1b5ac0d9266f4098dc00f0e7f0d35f4e70ed8f1", + "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -322,7 +328,7 @@ "id": "imap", "type": "container", "name": "mailu-imap", - "image": "ghcr.io/mailu/dovecot@sha256:46d18ba51032be8ebd6841aa49c1ef8762c729038c5fd86a081b5b884d478af9", + "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -336,7 +342,7 @@ "id": "smtp", "type": "container", "name": "mailu-smtp", - "image": "ghcr.io/mailu/postfix@sha256:bbf882880f68849511710b35237a933f3fe80c4b28bf48ff20205dbd1f1433d7", + "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -350,7 +356,7 @@ "id": "antispam", "type": "container", "name": "mailu-antispam", - "image": "ghcr.io/mailu/rspamd@sha256:e87ab93dd252cc69499caa5317dd10d445fd4291a7ecf6bca09793c7d475a0c8", + "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -364,22 +370,17 @@ "id": "antivirus", "type": "container", "name": "mailu-antivirus", - "image": "ghcr.io/mailu/clamav@sha256:01d30483e4a8a20a54566addb1f9b00ebb51e8a103f9226602379c412cf5fb62", + "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", "network": "mailu", - "env-file": [ - "/var/lib/mailu/mailu.env", - "/var/lib/mailu/secret.env" - ], "volumes": [ - "/services/mailu/data/filter:/data" - ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "/services/mailu/data/clamav:/var/lib/clamav" + ] }, { "id": "webmail", "type": "container", "name": "mailu-webmail", - "image": "ghcr.io/mailu/roundcube@sha256:19ccc9c21b2420dabb893ffa707ef90785c785e53dcb6bb9f98da01598412c43", + "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -395,7 +396,7 @@ "id": "webdav", "type": "container", "name": "mailu-webdav", - "image": "ghcr.io/mailu/radicale@sha256:e13cbad3791c0a6841b5d387e57e49a117808dcef87b8c9969f671ae9c3b67c0", + "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -410,7 +411,7 @@ "id": "fetchmail", "type": "container", "name": "mailu-fetchmail", - "image": "ghcr.io/mailu/fetchmail@sha256:7dcd1392882925d612ab2d0230d437f0c660989d572283c48b0d0f2d491adce7", + "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -425,7 +426,7 @@ "id": "front", "type": "container", "name": "mailu-front", - "image": "ghcr.io/mailu/nginx@sha256:09f28ab6d36367fcacc7994f7021f132ac845bdc05f04bf80906102d11aaa057", + "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" From aa8c4253f15f069248b1bd756808cf0ccf284cbe Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:44:34 +0200 Subject: [PATCH 28/48] automx: the launcher's wrapper is written by the build that ships it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predecessor's image carried .venv/scripts/flask.sh, created by a build step that never made it into the files this module holds — the image worked and its recipe could not reproduce it, caught the moment the mesh built it from source (exit 127 crash loop at cutover). The wrapper is now written explicitly, verbatim from the proven image, so the recipe is the whole truth about the image. --- modules/mailu/automx/Dockerfile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/mailu/automx/Dockerfile b/modules/mailu/automx/Dockerfile index 7fd7933..80ae1b9 100644 --- a/modules/mailu/automx/Dockerfile +++ b/modules/mailu/automx/Dockerfile @@ -21,6 +21,18 @@ RUN ./setupvenv.sh \ && . .venv/bin/activate \ && pip install automx2 +# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build +# step that never made it into the files this module carries — the image worked and the recipe +# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build +# is the whole truth about the image again. +RUN mkdir -p .venv/scripts && printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + '. .venv/bin/activate' \ + "export FLASK_ENV='production'" \ + "export FLASK_APP='automx2.server:app'" \ + 'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh + ENV AUTOMX2_CONF=/etc/automx2.conf ADD automx/files/automx2.conf /etc/automx2.conf From 546231718323f56966d60f2e59a89b2df39f8317 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:45:00 +0200 Subject: [PATCH 29/48] mailu: the admin API answers on 8080 since 2024.06 1.9's admin served on 80; 2024.06's gunicorn listens on 8080, and the runtime's fetch failed against the old port the moment the broker credential let it try. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 45fb0d2..71a4ddf 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -469,7 +469,7 @@ ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_MAILU_URL": "http://mailu-admin/api/v1", + "MESH_MAILU_URL": "http://mailu-admin:8080/api/v1", "MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", "MESH_MAILU_CONFIG_FILE": "/run/config/config.json", From 047f228fe362c488618a9f2818663a0d4589ab14 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:49:17 +0200 Subject: [PATCH 30/48] mailu: every container asks the module's own resolver, pinned where they can find it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2024.06's admin refuses to serve behind a resolver that does not validate DNSSEC — found live as an unhealthy admin, 454s on submission and a 500 webmail, with the runtime's forwarder validating nothing. The unbound this module always shipped becomes reachable: pinned at the predecessor's own address on the module network (the subnet the mesh adopted), and named as dns by the nine containers that resolve anything. Stands on mesh-host #26, which gave the vocabulary these two fields. --- modules/mailu/module.json | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 71a4ddf..8012d3b 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -294,7 +294,8 @@ "/var/lib/mailu/mailu.env", "/var/lib/mailu/secret.env" ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "ip": "192.168.203.254" }, { "id": "redis", @@ -322,7 +323,10 @@ "/services/mailu/data/data:/data", "/services/mailu/data/dkim:/dkim" ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] }, { "id": "imap", @@ -336,6 +340,9 @@ "volumes": [ "/services/mailu/data/mail:/mail", "/services/mailu/data/overrides/dovecot:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { @@ -350,6 +357,9 @@ "volumes": [ "/services/mailu/data/mailqueue:/queue", "/services/mailu/data/overrides/postfix:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { @@ -364,6 +374,9 @@ "volumes": [ "/services/mailu/data/filter:/var/lib/rspamd", "/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro" + ], + "dns": [ + "192.168.203.254" ] }, { @@ -374,6 +387,9 @@ "network": "mailu", "volumes": [ "/services/mailu/data/clamav:/var/lib/clamav" + ], + "dns": [ + "192.168.203.254" ] }, { @@ -390,7 +406,10 @@ "/services/mailu/data/webmail:/data", "/services/mailu/data/overrides/roundcube:/overrides:ro" ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] }, { "id": "webdav", @@ -405,7 +424,10 @@ "volumes": [ "/services/mailu/data/dav:/data" ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] }, { "id": "fetchmail", @@ -420,7 +442,10 @@ "volumes": [ "/services/mailu/data/data/fetchmail:/data" ], - "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] }, { "id": "front", @@ -445,6 +470,9 @@ "volumes": [ "/services/mailu/data/certs:/certs", "/services/mailu/data/overrides/nginx:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { From 4e37b3e84dd4c4ca2f74a28fcaaa1888c7bb84f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:56:28 +0200 Subject: [PATCH 31/48] mailu: the containers are named by the vocabulary 2024.06 reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The front resolves its upstreams from *_ADDRESS, defaulting to the bare compose service names — admin, antispam — and reads the 1.9-era HOST_* not at all. Phase 1 never noticed because the predecessor's service names WERE the defaults; the mesh's containers are mailu-*, and the front answered 502 asking docker for a name nothing carries. The dead vocabulary goes; every upstream is named as the container actually is. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 8012d3b..90ce5f5 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "PORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", From 30b7ce429cddae5094ec86ac90dbe47ceba90a24 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 23:59:42 +0200 Subject: [PATCH 32/48] automx: the schema its seed writes belongs to one automx2, so that one is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unpinned pip install took the latest automx2, whose schema grew a column (server.prio) the module's own seeding SQL predates — a 500 on every autoconfig request against a table the seed had just written. 2021.6 is what the proven image runs; the seed and the software agree again. Regenerating the seed for a newer automx2 is its own change, made deliberately, not by whatever pip resolved this week. --- modules/mailu/automx/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/automx/Dockerfile b/modules/mailu/automx/Dockerfile index 80ae1b9..c48cab2 100644 --- a/modules/mailu/automx/Dockerfile +++ b/modules/mailu/automx/Dockerfile @@ -19,7 +19,7 @@ RUN chmod u+x setupvenv.sh start add-domains setup setup-db RUN ./setupvenv.sh \ && . .venv/bin/activate \ - && pip install automx2 + && pip install automx2==2021.6 # The launcher `start` expects. In the predecessor's image this wrapper appeared during a build # step that never made it into the files this module carries — the image worked and the recipe From 480627fdd99c5fdb154ab579b95b9fdc6f547e69 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:06:24 +0200 Subject: [PATCH 33/48] automx: the seed writes the schema 2021.6 reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written seed predates automx2's prio column, so every config-v1.1.xml request 500'd against a table the seed had just made — and the predecessor's own database had the same gap: client autoconfiguration has been silently broken on the old stack for a long time, behind a root page that answered 200. The live database gained the column by ALTER; a fresh mesh now seeds it right. --- modules/mailu/automx/files/setup-db | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/mailu/automx/files/setup-db b/modules/mailu/automx/files/setup-db index b4832cb..c9272d3 100644 --- a/modules/mailu/automx/files/setup-db +++ b/modules/mailu/automx/files/setup-db @@ -32,6 +32,7 @@ EOT SERVER=$(cat < Date: Sat, 26 Sep 2026 00:11:11 +0200 Subject: [PATCH 34/48] mailu: cert flavor while the predecessor's proxy owns the challenge path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit letsencrypt was the aspiration and cannot work yet, proven live: the predecessor's own ACME machinery owns /.well-known/acme-challenge on port 80 outright (unknown tokens get its 404) and its entrypoint redirect owns every other path — the hand-authored passthrough never matched anything, which is why mailu's certbot state had quietly expired in April while the copied files carried the name. cert flavor serves those files (valid to Nov 27). Mailu certifying itself becomes possible the day route-proxy takes port 80, whose handler falls through unknown tokens by design — that flip is one line here, made then. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 90ce5f5..23028f7 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", From a055334c9b160a2b355566b273cc641d56b3db52 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:17:41 +0200 Subject: [PATCH 35/48] mailu: the queue is root's and traversable, which is postfix's own convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared 0700 was applied at take and broke mail quietly: postfix's master runs as root but pickup and smtpd drop to uid postfix, and a spool root they cannot traverse is a maildrop they cannot scan and a rewrite socket they cannot open — auth succeeded and MAIL FROM hung. 0755 root is exactly what postfix's own set-permissions makes of /var/spool/postfix. Fixed live by chmod first; declared here so the next push stops undoing it. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 23028f7..6f3fada 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -211,7 +211,7 @@ "id": "data-mailqueue", "type": "directory", "path": "/services/mailu/data/mailqueue", - "mode": "0700" + "mode": "0755" }, { "id": "data-filter", From 3d271f72ea5ee114c5771802b1fda12cb8b00ebf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:53:13 +0200 Subject: [PATCH 36/48] redis: say whether it still holds a consumer's ACL user The server keeps ACL users in memory only, so a restart forgets every consumer while the provisioner keeps running (hq issue 120). holds() checks ACL GETUSER for the user, enabled, with the mesh's password, so the harness makes a forgotten user again. Needs mesh-sdk 0.1.1. --- modules/redis/client.ts | 23 ++++++++++++++++++++++- modules/redis/package.json | 2 +- modules/redis/provisioner/index.ts | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/modules/redis/client.ts b/modules/redis/client.ts index 93a355d..3993842 100644 --- a/modules/redis/client.ts +++ b/modules/redis/client.ts @@ -8,7 +8,7 @@ // order requests were sent, which is what the queue below relies on. import { createConnection, type Socket } from "node:net"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; /** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */ @@ -113,6 +113,27 @@ export class RedisClient { await this.command("ACL", "DELUSER", username); } + /** + * Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks + * `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among + * them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its + * users live in memory and a restart forgets them. This is how the provisioner notices + * (novox/hq issue 120). + */ + async holdsAclUser(username: string, password: string): Promise { + const reply = await this.command("ACL", "GETUSER", username); + if (!Array.isArray(reply)) return false; + const field = (name: string): RespValue | undefined => { + const i = reply.indexOf(name); + return i >= 0 ? reply[i + 1] : undefined; + }; + const flags = field("flags"); + const passwords = field("passwords"); + if (!Array.isArray(flags) || !flags.includes("on")) return false; + if (!Array.isArray(passwords)) return false; + return passwords.includes(createHash("sha256").update(password).digest("hex")); + } + close(): void { if (this.socket) { this.socket.destroy(); diff --git a/modules/redis/package.json b/modules/redis/package.json index 7d32bdb..5e76d02 100644 --- a/modules/redis/package.json +++ b/modules/redis/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index c3ea7f7..84aea66 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -43,4 +43,11 @@ runProvisioner("redis-cache", { await redis.deleteAclUser(p.as); await announce("module.redis.cache.deprovisioned", { username: p.as }); }, + + // This server keeps its ACL users in memory only, so a restart of it forgets every consumer while + // this provisioner keeps running. Asked every minute, so a forgotten user is made again instead + // of every consumer failing to authenticate in silence (novox/hq issue 120). + async holds(p: Provision): Promise { + return redis.holdsAclUser(p.as, p.password); + }, }); From 0cb0f814b485c533b348559dc762fc5d16bccaea Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:09:52 +0200 Subject: [PATCH 37/48] Every credential provider says whether it still holds a consumer holds() for postgres, mssql, mongodb, minio, lavinmq, mosquitto, mailu and gitea, so the harness makes again a login the backend lost (hq issue 120). Each checks the mesh's password as the consumer presents it, or compares it read-only, and returns false only when the backend says the credential is absent or wrong; an unreachable backend throws. --- modules/gitea/client.ts | 23 +++++++++ modules/gitea/package.json | 2 +- modules/gitea/provisioner/index.ts | 5 ++ modules/lavinmq/client.ts | 33 +++++++++++++ modules/lavinmq/package.json | 2 +- modules/lavinmq/provisioner/index.ts | 5 ++ modules/mailu/client.ts | 29 +++++++++++ modules/mailu/package.json | 2 +- modules/mailu/provisioner/index.ts | 5 ++ modules/minio/client.ts | 21 ++++++-- modules/minio/package.json | 2 +- modules/minio/provisioner/index.ts | 6 +++ modules/mongodb/client.ts | 26 ++++++++++ modules/mongodb/package.json | 2 +- modules/mongodb/provisioner/index.ts | 5 ++ modules/mosquitto/client.ts | 66 ++++++++++++++++++++++++++ modules/mosquitto/package.json | 2 +- modules/mosquitto/provisioner/index.ts | 5 ++ modules/mssql/client.ts | 19 ++++++++ modules/mssql/package.json | 2 +- modules/mssql/provisioner/index.ts | 5 ++ modules/postgres/client.ts | 24 ++++++++++ modules/postgres/package.json | 2 +- modules/postgres/provisioner/index.ts | 5 ++ 24 files changed, 286 insertions(+), 12 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 0fcc47e..d510b10 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -433,6 +433,29 @@ export class GiteaAdmin { GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); } + /** + * Whether a consumer's user logs in with exactly this password and is still a member of the + * package team. Read-only: the password is checked as the consumer presents it, basic auth on the + * API, and membership through the admin API. `false` for a refused login or a missing member; any + * other answer rejects (novox/hq issue 120). + */ + async holdsTeamMember(org: string, team: string, username: string, password: string): Promise { + const me = await fetch(`${this.baseUrl}/api/v1/user`, { + headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") }, + }); + if (me.status === 401 || me.status === 403) return false; + if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); + const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + if (teams.status === 404) return false; + if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams); + const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team); + if (!found) return false; + const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`); + if (member.status === 200 || member.status === 204) return true; + if (member.status === 404) return false; + GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member); + } + /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not * an error, so a re-run of remove is safe. */ async deleteUser(username: string): Promise { diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 04e8df1..ec33440 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -9,7 +9,7 @@ "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 577487b..f211baa 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -46,4 +46,9 @@ runProvisioner("package-registry", { async remove(p: { as: string }): Promise { await gitea.deleteUser(p.as); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password); + }, }); diff --git a/modules/lavinmq/client.ts b/modules/lavinmq/client.ts index 04716b2..04a30c4 100644 --- a/modules/lavinmq/client.ts +++ b/modules/lavinmq/client.ts @@ -94,6 +94,39 @@ export class LavinmqClient { await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" }); } + /** + * Whether a consumer's user exists with exactly this password and full permissions on its own + * vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the + * password is checked by hashing it with the stored salt rather than by logging in. `false` when + * the user or its permission is gone or the password differs; an unreachable API rejects + * (novox/hq issue 120). + */ + async holdsConsumer(login: string, password: string): Promise { + const v = encodeURIComponent(login); + const u = encodeURIComponent(login); + const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`); + if (!user?.password_hash) return false; + if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) { + throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`); + } + const stored = Buffer.from(user.password_hash, "base64"); + if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false; + const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`); + return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*"; + } + + /** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */ + private async getOrNull(path: string): Promise { + const resp = await fetch(`${this.conn.base}/api${path}`, { + headers: { + Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"), + }, + }); + if (resp.status === 404) return null; + if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`); + return (await resp.json()) as T; + } + /** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */ async removeConsumer(login: string): Promise { const v = encodeURIComponent(login); diff --git a/modules/lavinmq/package.json b/modules/lavinmq/package.json index 1a4b297..9e21bb1 100644 --- a/modules/lavinmq/package.json +++ b/modules/lavinmq/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/lavinmq/provisioner/index.ts b/modules/lavinmq/provisioner/index.ts index f5514bf..7cea27f 100644 --- a/modules/lavinmq/provisioner/index.ts +++ b/modules/lavinmq/provisioner/index.ts @@ -48,4 +48,9 @@ runProvisioner("amqp", { await lavinmq.removeConsumer(p.as); await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as }); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return lavinmq.holdsConsumer(p.as, p.password); + }, }); diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 340c70f..e1d1291 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -134,6 +134,35 @@ export class MailuClient { await this.api("DELETE", `/user/${encodeURIComponent(email)}`); } + /** + * Whether a mailbox exists, is enabled, and accepts exactly this password. Read-only. Existence + * from the admin API; the password from `doveadm auth test` in the imap container, which is how + * the mail server itself authenticates, and which exits 77 for a refused login. The password + * reaches doveadm through the exec's environment, never the host's argv. An unreachable API or + * container rejects (novox/hq issue 120). + */ + async holdsUser(email: string, password: string): Promise { + const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, { + headers: { Authorization: this.apiKey, Accept: "application/json" }, + }); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`); + const user = (await res.json()) as { enabled?: boolean }; + if (user.enabled === false) return false; + try { + await run( + "docker", + ["exec", "-e", "MESH_USER", "-e", "MESH_PW", this.imapContainer, + "sh", "-c", 'doveadm auth test "$MESH_USER" "$MESH_PW"'], + { env: { ...process.env, MESH_USER: email, MESH_PW: password }, timeout: 30_000 }, + ); + return true; + } catch (err) { + if ((err as { code?: number }).code === 77) return false; + throw err; + } + } + async listAliases(): Promise { const aliases = await this.api("GET", "/alias"); return (aliases ?? []).map((a) => ({ diff --git a/modules/mailu/package.json b/modules/mailu/package.json index 00d231a..14156bd 100644 --- a/modules/mailu/package.json +++ b/modules/mailu/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 42ef44d..27ecb37 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -62,4 +62,9 @@ runProvisioner("smtp", { // named-account consumer is an operator action until the harness carries values here. await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {}); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return mailu.holdsUser(addressOf(p), p.password); + }, }); diff --git a/modules/minio/client.ts b/modules/minio/client.ts index a3f2ebe..beb751f 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -125,6 +125,18 @@ export class MinioClient { throw new Error(`minio bucketExists ${bucket}: ${status}`); } + /** + * Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the + * bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the + * secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120). + */ + async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise { + const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey }); + if (status === 200) return true; + if (status === 403 || status === 404) return false; + throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`); + } + async createBucket(bucket: string): Promise { const { status, text } = await this.request("PUT", `/${bucket}`); // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. @@ -251,6 +263,7 @@ export class MinioClient { method: string, path: string, query: Record = {}, + as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword }, ): Promise<{ status: number; headers: Headers; text: string }> { const { amzDate, dateStamp } = this.stamp(); const host = new URL(this.baseUrl).host; @@ -262,8 +275,8 @@ export class MinioClient { const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); - const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); - const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex"); + const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; const res = await fetch(url, { @@ -275,8 +288,8 @@ export class MinioClient { return { status: res.status, headers: res.headers, text }; } - private signingKey(dateStamp: string): Buffer { - const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); + private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer { + const kDate = hmac(`AWS4${secretKey}`, dateStamp); const kRegion = hmac(kDate, this.region); const kService = hmac(kRegion, "s3"); return hmac(kService, "aws4_request"); diff --git a/modules/minio/package.json b/modules/minio/package.json index 9e74934..7441fc6 100644 --- a/modules/minio/package.json +++ b/modules/minio/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index cc07052..5e15c01 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -53,6 +53,12 @@ runProvisioner("s3-bucket", { await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); }, + + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return minio.canReachAs(bucketFor(p.as), p.as, p.password); + }, }); /** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ce4f2e3..f0fb4de 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -109,6 +109,32 @@ print(EJSON.stringify({ ok: 1 })); await this.evalJs<{ ok: number }>(js); } + /** + * Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner` + * there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an + * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). + */ + async canAuthenticateAs(database: string, user: string, password: string): Promise { + const uri = + `mongodb://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${this.conn.host}:${this.conn.port}` + + `/${encodeURIComponent(database)}?authSource=${encodeURIComponent(database)}&serverSelectionTimeoutMS=10000`; + let stdout: string; + try { + ({ stdout } = await run( + "mongosh", + [uri, "--quiet", "--eval", + "print(EJSON.stringify(db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"], + { timeout: 30_000 }, + )); + } catch (err) { + const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; + if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; + throw err; + } + const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; + return roles.some((r) => r.role === "dbOwner" && r.db === database); + } + /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the * user is removed first so a re-grant of the same login starts clean. */ async dropDatabaseAndUser(database: string, user: string): Promise { diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 4195793..479e7ea 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index b42b2fc..e532b62 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -45,4 +45,9 @@ runProvisioner("mongodb-database", { await mongo.dropDatabaseAndUser(p.as, p.as); await announce("module.mongodb.database.deprovisioned", { database: p.as }); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return mongo.canAuthenticateAs(p.as, p.as, p.password); + }, }); diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index f1a7877..08044ba 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -15,6 +15,7 @@ // The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README. import { randomBytes } from "node:crypto"; +import { connect as tcpConnect } from "node:net"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -163,6 +164,19 @@ export class MosquittoClient { } } + /** + * Whether a consumer's client accepts exactly this password and still carries its own role. + * Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it, + * and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised. + * Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120). + */ + async holdsClient(username: string, password: string): Promise { + const code = await mqttConnack(this.conn.host, this.conn.port, username, password); + if (code === 4 || code === 5) return false; + if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); + return this.clientHasRole(username, username); + } + /** Remove a client and the per-client role created for it, idempotently. */ async deleteScopedClient(username: string): Promise { await ignoreMissing(this.ctl("deleteClient", username)); @@ -249,3 +263,55 @@ function readSecretFile(path: string | undefined): string | undefined { return undefined; } } + +/** + * Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code, + * and disconnect. A clean session under a throwaway client id, so no consumer session is taken over. + */ +function mqttConnack(host: string, port: number, username: string, password: string): Promise { + const str = (v: string): Buffer => { + const b = Buffer.from(v, "utf8"); + const len = Buffer.alloc(2); + len.writeUInt16BE(b.length); + return Buffer.concat([len, b]); + }; + const variable = Buffer.concat([str("MQTT"), Buffer.from([4, 0xc2, 0, 10])]); // level 4; user+pass+clean; keepalive 10s + const payload = Buffer.concat([str(`mesh-holds-${randomBytes(6).toString("hex")}`), str(username), str(password)]); + let remaining = variable.length + payload.length; + const lenBytes: number[] = []; + do { + let byte = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) byte |= 0x80; + lenBytes.push(byte); + } while (remaining > 0); + const packet = Buffer.concat([Buffer.from([0x10, ...lenBytes]), variable, payload]); + + return new Promise((resolve, reject) => { + const socket = tcpConnect({ host, port }); + let buf = Buffer.alloc(0); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`no CONNACK from ${host}:${port} within 10s`)); + }, 10_000); + socket.on("connect", () => socket.write(packet)); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + if (buf.length < 4) return; + clearTimeout(timer); + if (buf[0] !== 0x20) { + socket.destroy(); + reject(new Error(`unexpected MQTT packet 0x${buf[0].toString(16)} instead of CONNACK`)); + return; + } + const code = buf[3]; + if (code === 0) socket.end(Buffer.from([0xe0, 0])); // DISCONNECT + else socket.destroy(); + resolve(code); + }); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/modules/mosquitto/package.json b/modules/mosquitto/package.json index 6f33f27..156253d 100644 --- a/modules/mosquitto/package.json +++ b/modules/mosquitto/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mosquitto/provisioner/index.ts b/modules/mosquitto/provisioner/index.ts index 39dc471..60f9ed6 100644 --- a/modules/mosquitto/provisioner/index.ts +++ b/modules/mosquitto/provisioner/index.ts @@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", { await mosquitto.deleteScopedClient(p.as); await announce("module.mosquitto.topic.deprovisioned", { username: p.as }); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return mosquitto.holdsClient(p.as, p.password); + }, }); diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 4b1a89a..6a0ac07 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -142,6 +142,25 @@ export class MssqlClient { await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } + /** + * Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of + * `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so + * nothing logs in and no failed-login is recorded (novox/hq issue 120). + */ + async holdsLogin(database: string, login: string, password: string): Promise { + const server = await this.query( + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + + `AND is_disabled = 0 AND PWDCOMPARE(${literal(password)}, password_hash) = 1) ` + + `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, + ); + if (Number(server[0]?.ok) !== 1) return false; + const owner = await this.query( + `SELECT CAST(IS_ROLEMEMBER('db_owner', ${literal(login)}) AS int) AS ok`, + database, + ); + return Number(owner[0]?.ok) === 1; + } + /** Drop a database and its login, idempotently, after evicting live connections. */ async dropDatabaseAndLogin(database: string, login: string): Promise { const dbs = await this.query( diff --git a/modules/mssql/package.json b/modules/mssql/package.json index b0b97f6..31eeb79 100644 --- a/modules/mssql/package.json +++ b/modules/mssql/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mssql/provisioner/index.ts b/modules/mssql/provisioner/index.ts index e12e8b0..16d0907 100644 --- a/modules/mssql/provisioner/index.ts +++ b/modules/mssql/provisioner/index.ts @@ -44,4 +44,9 @@ runProvisioner("mssql-database", { await mssql.dropDatabaseAndLogin(p.as, p.as); await announce("module.mssql.database.deprovisioned", { database: p.as }); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return mssql.holdsLogin(p.as, p.as, p.password); + }, }); diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index fa60b1b..f16af8b 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -99,6 +99,30 @@ export class PostgresClient { await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`); } + /** + * Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its + * credential, checked by connecting as it. Read-only. `false` only when the server says so (the + * role, the password or the database is wrong or gone); an unreachable server rejects instead, + * because being unable to ask is not evidence of loss (novox/hq issue 120). + */ + async canConnectAs(database: string, role: string, password: string): Promise { + try { + await run( + "psql", + ["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database, + "-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"], + { env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 }, + ); + return true; + } catch (err) { + const text = `${(err as { stderr?: string }).stderr ?? ""}`; + if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) { + return false; + } + throw err; + } + } + /** Drop a database and its owning role, idempotently, after evicting live connections. */ async dropDatabaseAndRole(database: string, role: string): Promise { await this.query( diff --git a/modules/postgres/package.json b/modules/postgres/package.json index a348964..1256cb6 100644 --- a/modules/postgres/package.json +++ b/modules/postgres/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 16bd09e..825cd98 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -45,4 +45,9 @@ runProvisioner("postgres-database", { await postgres.dropDatabaseAndRole(p.as, p.as); await announce("module.postgres.database.deprovisioned", { database: p.as }); }, + // Asked every minute by the harness: whether the backend still holds this consumer exactly as + // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). + async holds(p: Provision): Promise { + return postgres.canConnectAs(p.as, p.as, p.password); + }, }); From 6fd93afc6c9f21d38277f1fcedab9d7d95cec181 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:24:32 +0200 Subject: [PATCH 38/48] Review fixes: holds and create agree, and no password leaves a check create re-enables what holds refuses (mssql login, mosquitto client, mailu mailbox, gitea user) and clears an expired postgres password, so no disabled account loops. mssql and mongodb checks take the password from the environment, never argv; mosquitto_ctrl failures no longer repeat -P. mosquitto reads 'could not ask' as an error, not absence. mailu checks existence and enabled only: its imap passdb cannot verify a password. mssql checks the user's SID; gitea pages teams at 50. --- modules/gitea/client.ts | 8 ++++--- modules/mailu/client.ts | 32 ++++++++++----------------- modules/mailu/provisioner/index.ts | 2 +- modules/mongodb/client.ts | 22 ++++++++++--------- modules/mosquitto/client.ts | 33 ++++++++++++++++++++++++---- modules/mssql/client.ts | 35 +++++++++++++++++++++++++----- modules/postgres/client.ts | 6 +++-- 7 files changed, 92 insertions(+), 46 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index d510b10..06cbb7d 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -390,7 +390,7 @@ export class GiteaAdmin { } private async findTeam(org: string, team: string): Promise { - const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (res.status !== 200) return null; const match = (res.body as any[] | null)?.find((t) => t?.name === team); return match ? Number(match.id) : null; @@ -414,7 +414,9 @@ export class GiteaAdmin { const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { method: "PATCH", // login_name is required by the admin edit endpoint; for a local user it is the username. - body: JSON.stringify({ login_name: username, password, must_change_password: false }), + // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong + // password, so the provisioner's check reports it lost; applying again must undo both. + body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }), }); if (patch.status === 200) return; GiteaAdmin.fail(`/admin/users/${username}`, patch); @@ -445,7 +447,7 @@ export class GiteaAdmin { }); if (me.status === 401 || me.status === 403) return false; if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); - const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (teams.status === 404) return false; if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams); const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team); diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index e1d1291..08c54b4 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -127,7 +127,9 @@ export class MailuClient { } async changePassword(email: string, password: string): Promise { - await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + // enabled: a disabled mailbox is what the provisioner's check reports as lost, so applying the + // mesh's password again also enables it; otherwise the two would disagree for ever. + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); } async deleteUser(email: string): Promise { @@ -135,34 +137,24 @@ export class MailuClient { } /** - * Whether a mailbox exists, is enabled, and accepts exactly this password. Read-only. Existence - * from the admin API; the password from `doveadm auth test` in the imap container, which is how - * the mail server itself authenticates, and which exits 77 for a refused login. The password - * reaches doveadm through the exec's environment, never the host's argv. An unreachable API or - * container rejects (novox/hq issue 120). + * Whether a mailbox exists and is enabled. Read-only, through the admin API. + * + * **The password is not checked.** Mailu authenticates in its admin service, behind the front; + * the imap server's own password database accepts any password from Mailu's subnet, so asking it + * (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught; + * a password changed by hand is not (novox/hq issue 120). */ - async holdsUser(email: string, password: string): Promise { + async holdsUser(email: string): Promise { const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, { headers: { Authorization: this.apiKey, Accept: "application/json" }, }); if (res.status === 404) return false; if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`); const user = (await res.json()) as { enabled?: boolean }; - if (user.enabled === false) return false; - try { - await run( - "docker", - ["exec", "-e", "MESH_USER", "-e", "MESH_PW", this.imapContainer, - "sh", "-c", 'doveadm auth test "$MESH_USER" "$MESH_PW"'], - { env: { ...process.env, MESH_USER: email, MESH_PW: password }, timeout: 30_000 }, - ); - return true; - } catch (err) { - if ((err as { code?: number }).code === 77) return false; - throw err; - } + return user.enabled !== false; } + async listAliases(): Promise { const aliases = await this.api("GET", "/alias"); return (aliases ?? []).map((a) => ({ diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 27ecb37..41232bb 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -65,6 +65,6 @@ runProvisioner("smtp", { // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). async holds(p: Provision): Promise { - return mailu.holdsUser(addressOf(p), p.password); + return mailu.holdsUser(addressOf(p)); }, }); diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index f0fb4de..ae8c666 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -115,21 +115,23 @@ print(EJSON.stringify({ ok: 1 })); * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). */ async canAuthenticateAs(database: string, user: string, password: string): Promise { - const uri = - `mongodb://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${this.conn.host}:${this.conn.port}` + - `/${encodeURIComponent(database)}?authSource=${encodeURIComponent(database)}&serverSelectionTimeoutMS=10000`; + // Connected without credentials, then authenticated inside the eval from the environment, so + // the consumer's password is neither on argv nor in the message of a failed command. + const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; + const js = + "const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + + "t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + + "print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; let stdout: string; try { - ({ stdout } = await run( - "mongosh", - [uri, "--quiet", "--eval", - "print(EJSON.stringify(db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"], - { timeout: 30_000 }, - )); + ({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { + env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, + timeout: 30_000, + })); } catch (err) { const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; - throw err; + throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`); } const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; return roles.some((r) => r.role === "dbOwner" && r.db === database); diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index 08044ba..d6fb10e 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -88,9 +88,20 @@ export class MosquittoClient { "-u", this.conn.adminUser, "-P", this.conn.adminPassword, ]; - const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { - maxBuffer: 16 << 20, - }); + let stdout: string; + let stderr: string; + try { + ({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { + maxBuffer: 16 << 20, + timeout: 30_000, + })); + } catch (err) { + // A failed run's message repeats its argv, the admin password (-P) included; say what failed + // without it. + const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string }; + const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500); + throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`); + } const failure = ctlError(`${stdout}\n${stderr}`); if (failure) { throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`); @@ -141,6 +152,11 @@ export class MosquittoClient { if (await this.clientExists(username)) { await this.ctl("setClientPassword", username, password); + // A disabled client is refused like a wrong password, so the check the provisioner runs + // reports it lost; applying again must enable it, or the two would disagree for ever. + if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) { + await this.ctl("enableClient", username); + } } else { await this.ctl("createClient", username, "-p", password); } @@ -174,7 +190,16 @@ export class MosquittoClient { const code = await mqttConnack(this.conn.host, this.conn.port, username, password); if (code === 4 || code === 5) return false; if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); - return this.clientHasRole(username, username); + // The role, asked directly: only "not found" means absent. Any other failure to ask rejects, + // unlike clientHasRole, which reads every failure as "no role". + let out: string; + try { + out = await this.ctl("getClient", username); + } catch (err) { + if (/not\s*found|does not exist|no such/i.test(String(err))) return false; + throw err; + } + return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out); } /** Remove a client and the per-client role created for it, idempotently. */ diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 6a0ac07..e1802a4 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -75,14 +75,18 @@ export class MssqlClient { * prints (split across output lines for a large result, and reassembled here) is parsed. An * empty result yields no output at all — an empty array. */ - async query(select: string, database = "master"): Promise[]> { + async query( + select: string, + database = "master", + variables: Record = {}, + ): Promise[]> { const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; - const stdout = await this.sqlcmd(wrapped, database); + const stdout = await this.sqlcmd(wrapped, database, variables); return parseJsonRows(stdout); } /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ - private async sqlcmd(sql: string, database: string): Promise { + private async sqlcmd(sql: string, database: string, variables: Record = {}): Promise { // `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long // JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin // cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships @@ -101,7 +105,9 @@ export class MssqlClient { "-W", "-Q", sql, ], - { env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, + // `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting + // variables: a value that must not appear on argv, or in the message of a failed command. + { env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, ); return stdout; } @@ -121,6 +127,9 @@ export class MssqlClient { ); } else { await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`); + // A disabled login is refused like a wrong password; the check the provisioner runs reports it + // lost, so applying again must enable it or the two would disagree for ever. + await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`); } const dbs = await this.query( @@ -138,6 +147,10 @@ export class MssqlClient { ); if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); + } else { + // Re-point an existing user at the login. A database restored from elsewhere keeps its user + // under the old login's SID, orphaned; this maps it back, and is a no-op when it already is. + await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } @@ -148,14 +161,24 @@ export class MssqlClient { * nothing logs in and no failed-login is recorded (novox/hq issue 120). */ async holdsLogin(database: string, login: string, password: string): Promise { + // The password reaches sqlcmd as a scripting variable from the environment, never inside the + // query text, so it is neither on argv nor in the message of a failed command. It is the mesh's + // minted value, which carries no quote. const server = await this.query( `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + - `AND is_disabled = 0 AND PWDCOMPARE(${literal(password)}, password_hash) = 1) ` + + `AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` + `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, + "master", + { MESHHOLDSPW: password }, ); if (Number(server[0]?.ok) !== 1) return false; + // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the + // right name and the wrong SID, and cannot be reached through the login. const owner = await this.query( - `SELECT CAST(IS_ROLEMEMBER('db_owner', ${literal(login)}) AS int) AS ok`, + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` + + `JOIN sys.server_principals sp ON dp.sid = sp.sid ` + + `WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` + + `AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`, database, ); return Number(owner[0]?.ok) === 1; diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index f16af8b..ee4d6a9 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -88,9 +88,11 @@ export class PostgresClient { async createDatabaseAndRole(database: string, role: string, password: string): Promise { const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role)); if (roles.rows.length === 0) { - await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`); } else { - await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + // VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the + // provisioner runs would report it lost, and only clearing the expiry makes applying it again work. + await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`); } const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database)); if (dbs.rows.length === 0) { From 620b47d309af5b692e410f05937d0ae3a73df50f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:30:15 +0200 Subject: [PATCH 39/48] mssql remaps a user only when orphaned; mailu's operator tool no longer re-enables ALTER USER ... WITH LOGIN runs only when the user's SID is not the login's, so an already-mapped user is left alone. The provisioner enables a mailbox through its own method; the password tool an operator uses keeps changing the password only. --- modules/mailu/client.ts | 12 ++++++++++-- modules/mailu/provisioner/index.ts | 2 +- modules/mssql/client.ts | 14 +++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 08c54b4..4ec4cdb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -127,8 +127,16 @@ export class MailuClient { } async changePassword(email: string, password: string): Promise { - // enabled: a disabled mailbox is what the provisioner's check reports as lost, so applying the - // mesh's password again also enables it; otherwise the two would disagree for ever. + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + } + + /** + * Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is + * what the provisioner's check reports as lost, so applying again must enable it, or the two would + * disagree for ever. Separate from changePassword, which an operator's tool uses and which must + * not re-enable a mailbox someone disabled. + */ + async applyProvisioned(email: string, password: string): Promise { await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); } diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 41232bb..2b73274 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -48,7 +48,7 @@ runProvisioner("smtp", { try { await mailu.createUser(email, p.password); } catch { - await mailu.changePassword(email, p.password); + await mailu.applyProvisioned(email, p.password); } }, diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index e1802a4..e3304ac 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -148,9 +148,17 @@ export class MssqlClient { if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); } else { - // Re-point an existing user at the login. A database restored from elsewhere keeps its user - // under the old login's SID, orphaned; this maps it back, and is a no-op when it already is. - await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); + // Re-point an existing user at the login when its SID is not the login's: a database restored + // from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that + // is already mapped is left alone. + const orphaned = await this.query( + `SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` + + `AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`, + database, + ); + if (orphaned.length > 0) { + await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); + } } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } From ccb6e7500e55916ee63e3f64693393299ef4513b Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:56:02 +0200 Subject: [PATCH 40/48] mongodb: the server container is mongodb-server, not the predecessor's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adopted node still runs the predecessor's mongo container, and it must keep running: invoicing points at novox.be:27017 and is not migrating in this window. A module container named 'mongo' would be held at assign and would replace the predecessor at take, cutting invoicing off its database. The mesh's server coexists instead — fresh data directory, its own name, auto-allocated machine port — and the predecessor retires with its last consumer. --- modules/mongodb/module.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index 73b7263..d228b95 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -75,7 +75,7 @@ { "id": "server", "type": "container", - "name": "mongo", + "name": "mongodb-server", "image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3", "network": "mongodb", "env": { @@ -101,7 +101,7 @@ "/var/lib/mongodb/root.secret:/run/secrets/root:ro" ], "env": { - "MESH_PROVISION_MONGODB": "mongodb://root@mongo:27017/admin?authSource=admin", + "MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin", "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json" From b704bf5ad8401b9caba21ce7156aa426db06a694 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 03:15:26 +0200 Subject: [PATCH 41/48] invoicing: the photos lessons, applied before its window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mongo credential authenticates against its own database and the database is the granted one (mesh_novox_invoice), not the contributed name the provisioner ignores. Same for the store: the key is sealed to the derived bucket (mesh-novox-invoice) — the data mirrors in during the window, the ncloud/photos pattern. And the api gets the route contribution it always needed: invoicing-api.novox.be is today a traefik container label, invisible to every file survey, and it must be a grant before the edge can ever flip. --- modules/invoicing/module.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json index 9acd9cf..64bb3c0 100644 --- a/modules/invoicing/module.json +++ b/modules/invoicing/module.json @@ -18,8 +18,14 @@ "bucket": "invoicing" }, "route": { - "label": "invoicing", - "port": 80 + "site": { + "label": "invoicing", + "port": 80 + }, + "api": { + "label": "invoicing-api", + "port": 9000 + } } }, "binds": { @@ -63,7 +69,7 @@ "type": "file", "path": "/var/lib/invoicing/api.env", "mode": "0600", - "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" + "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" }, { "id": "net", From 2409afda607bcd02dc5641a5fc618f56020284bf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 03:34:00 +0200 Subject: [PATCH 42/48] invoicing: MONGO_DB says the granted database's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app reads MONGO_DB (default 'invoicing') for every operation and uses the URL only to connect — listCollections ran against a database the granted user cannot see. Same fault and same fix as photos' MONGO_DB, found by the API's own logs at take. --- modules/invoicing/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json index 64bb3c0..d7386ad 100644 --- a/modules/invoicing/module.json +++ b/modules/invoicing/module.json @@ -69,7 +69,7 @@ "type": "file", "path": "/var/lib/invoicing/api.env", "mode": "0600", - "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" + "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" }, { "id": "net", From a2da2e4910cbb0c961298635df55b7d63248157c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 03:44:47 +0200 Subject: [PATCH 43/48] only-office: pin the machine side of its port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare '80' tried to bind the node's port 80 — the edge's — instead of auto-allocating. 9070 is the predecessor's number and the one the route contribution already names. --- modules/only-office/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/only-office/module.json b/modules/only-office/module.json index 12fc97a..5afcf62 100644 --- a/modules/only-office/module.json +++ b/modules/only-office/module.json @@ -99,7 +99,7 @@ "/var/lib/only-office/server.env" ], "ports": [ - "80" + "9070:80" ], "volumes": [ "/services/only-office/logs:/var/log/onlyoffice", From 00ada1e9f7f0da739dd9008f02f95806cf178b24 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 03:46:11 +0200 Subject: [PATCH 44/48] portainer: serve its public name, hold its real data, run the image the machine runs The manifest predated the working deployment on three axes: it declared a data directory the running portainer never used (taking it would have started empty), pinned an image digest the machine has moved past (issue 099), and contributed no route while portainer.novox.be rides a traefik container label today. Now: the predecessor's portainer_data path, the running image's digest, 9090:9000 kept as the predecessor's machine port with the route contribution naming it, and 9443 kept for the runtime sidecar's own TLS conversation. --- modules/portainer/module.json | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/modules/portainer/module.json b/modules/portainer/module.json index cb36c93..2bc5c97 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -6,11 +6,17 @@ "container-runtime" ], "listens": [ + { + "port": 9090, + "protocol": "tcp", + "from": "mesh", + "why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number" + }, { "port": 9443, "protocol": "tcp", "from": "mesh", - "why": "the container dashboard, over its own tls" + "why": "the same dashboard over its own tls; the runtime sidecar talks to it here" } ], "resources": [ @@ -23,19 +29,20 @@ { "id": "data", "type": "directory", - "path": "/services/portainer/data", + "path": "/services/portainer/portainer_data", "mode": "0700" }, { "id": "server", "type": "container", "name": "portainer", - "image": "portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa", + "image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e", "ports": [ - "9443" + "9090:9000", + "9443:9443" ], "volumes": [ - "/services/portainer/data:/data", + "/services/portainer/portainer_data:/data", "/var/run/docker.sock:/var/run/docker.sock" ] }, @@ -90,5 +97,17 @@ "from": "Dockerfile" } ] + }, + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "portainer", + "port": 9090 + } + }, + "binds": { + "route": "/var/lib/mesh/portainer/route.json" } } From a978b53d1c45a1dcc42be5ca67855cdc423e8ee8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:15:12 +0200 Subject: [PATCH 45/48] mailu certifies itself: the edge passes unknown ACME tokens through now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #82 set TLS_FLAVOR=cert as the honest interim while the predecessor's proxy owned /.well-known/acme-challenge outright. route-proxy took port 80 today and its handler passes unknown tokens through to routed paths by design — the one line #82 promised, made now. The copied cert (valid to Nov 27) stays on disk untouched; mailu's own certbot takes over from here. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 6f3fada..0ada60c 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", From 61775657413861dc46cba55cc951213fc818481e Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:19:05 +0200 Subject: [PATCH 46/48] =?UTF-8?q?mailu:=20back=20to=20the=20copied=20cert?= =?UTF-8?q?=20=E2=80=94=20the=20edge's=20fall-through=20is=20a=20belief,?= =?UTF-8?q?=20not=20a=20behaviour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The letsencrypt flavor served certbot's April-expired state to live IMAPS users within minutes: autocert's HTTPHandler answers 404 itself for tokens it does not hold and never consults the fallback for challenge paths, so mailu's own client cannot answer through the path-scoped route. cert flavor (valid to Nov 27) until route-proxy's handler actually falls through. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 0ada60c..6f3fada 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", From 6769e66c82d1173379b1e06142526126f9fe4521 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:27:45 +0200 Subject: [PATCH 47/48] =?UTF-8?q?mailu=20certifies=20itself,=20take=20two?= =?UTF-8?q?=20=E2=80=94=20the=20fall-through=20is=20now=20a=20behaviour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take one (#90) died on two real edge bugs, both fixed and pinned by tests in mesh-controller (#66: autocert 404s unknown tokens itself; #67: the internal authority 403s every public name before the token lookup). The challenge path verified end to end reaching mailu's own nginx before this flip. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 6f3fada..0ada60c 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env", From 4489e569359a37ec464625dafa3299beb886d95f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 15:04:32 +0200 Subject: [PATCH 48/48] mailu: one WEBMAIL_ADDRESS, the mesh's container name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env block carried the key twice — mailu-webmail from #79's address sweep, and a stray =webmail further down that survived it. Last write wins in an env file, so the front resolved a name that answers nowhere on the mesh's network and 502'd every logged-in webmail request. Latent since the cutover: the SSO redirect the checks watched never touches the upstream; the operator's first real login did. --- modules/mailu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 0ada60c..a329701 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -160,7 +160,7 @@ "type": "file", "path": "/var/lib/mailu/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" }, { "id": "secret-env",