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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 <account>@<domain> 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.
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.
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.
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.
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.