Compare commits

..
Author SHA1 Message Date
jschoubben 0010b6bf21 ombi: adopt the Plex entry that plex itself answers for
ace's ombi holds one Plex entry, loaded from an older server and later
retyped to plex's public name: its stored machineIdentifier is not plex's,
while its address answers as plex. Matching by identifier alone would leave
it and add a second entry for the same server.

When no entry carries the server's identifier, each entry's own address is
asked for /identity, and an entry plex answers for is adopted: the bound
connection laid over, and the identifier corrected (ombi builds its "view in
Plex" links from it). Its name, libraries and every other choice stay. An
entry that cannot be asked, or answers as another server, is left alone;
nothing is guessed. Every call of the step is now bounded, since an entry may
name a host that no longer answers.
2026-09-30 13:14:12 +02:00
jschoubben b068a9d399 ombi: reach plex through the mesh, in the same step as the Servarr apps
ombi reached plex at its public name, typed into its settings screen, so it
depended on plex's public route and on nobody moving plex. ombi now requires
plex-api, and the run-once step that writes its Servarr connections writes
its Plex one too - renamed from `servarr` to `connections`, since it is no
longer only that.

ombi keeps several Plex servers. The entry this provision names is found by
the server's own machineIdentifier (plex answers it at /identity, and ombi
stored it when the server was loaded), and only its host, port, TLS, base
path and token are written, only when they differ. Another server's entry,
the selected libraries, whether Plex is enabled and every other choice are
left alone. An ombi with no entry for the server gets one.

The token is tried against plex first. Until the operator accepts the
server's X-Plex-Token for this pair the mesh delivers a value it minted,
which plex refuses (401, or 400 on a network it trusts); refused, nothing is
written and the step fails naming the secret accept, so a working token in
ombi is never replaced by a dead one.

Tests import the compiled step, as keycloak's do: the step imports its
sibling with the .js specifier the build needs, which type stripping does
not resolve. `npm test` builds first.
2026-09-30 12:59:12 +02:00
jschoubben f14c763463 ombi: reach sonarr, radarr and lidarr through the mesh
ombi keeps its Servarr connections in its own database, so the mesh has no
file to write them into. A run-once step reads the three bindings and pair
credentials and writes host, port, TLS, base path and key into ombi through
ombi's own API - only when they differ, and nothing else ombi keeps.

Until the operator accepts an app's key for this pair the mesh delivers a
value it minted, which no Servarr app accepts. The step tries the key against
the app first and, refused, writes nothing and fails naming the secret accept
that fixes it, so the old working key in ombi is never replaced by a dead one.

Declared last so its failing gates nothing else of ombi (ADR 0136), and
restart-on its six inputs so it runs again when a provider moves (ADR 0099).
2026-09-30 00:39:19 +02:00
jschoubben ab44ff02e1 sonarr, radarr, lidarr: provide their API to the mesh
A consumer on another machine (ombi first; jackett, bazarr and home-assistant
later) reached these by container name on HAL's shared network, which the mesh
does not have. Each app now provides <app>-api at mesh scope and serves the
software port, so the mesh tells a consumer where it is and redirects the
port to where the machine published it.

Named per app, not one servarr-api: a requirement is matched by name and
answered by exactly one provider per node, so a consumer cannot require one
name from three providers - and ombi's code is written against each app's
own API version (ADR 0027).

No grants and no provisioner: a Servarr instance has one API key, which the
mesh cannot mint. The operator accepts it as the pair credential for each
consumer (ADR 0092).
2026-09-30 00:39:19 +02:00
jschoubben c4c44efb1b Merge remote-tracking branch 'origin/fix/sidecars-dial-the-port-they-were-given' into feat/servarr-api-provision 2026-09-30 00:25:55 +02:00
jschoubben 5cc6258326 Sidecars dial the port they were given, not the software's
A host-network sidecar reaches its service over the machine's loopback, and
the mesh publishes that service on a machine port it assigns (ADR 0038) —
so dialling the software's port reaches whatever else holds it. On ace,
searxng's sidecar dialled 127.0.0.1:8080 and got unifi's inform port. The
same shape in bazarr, bookshelf, lidarr, nzbget, qbittorrent, radarr and
sonarr; each now asks with ${port:N} (hq 088). Found in review of ace's
module preparation.
2026-09-29 23:50:19 +02:00
jschoubben 21f5301268 ombi: its config is placed, its image is the one ace runs
ombi's definition named /services/ombi/config (a HAL machine path) in three
places and pinned an image older than the one ace runs. Ombi migrates its
own SQLite schema, so a take onto the older pin (v4.53.10-ls267) would start
it on a database the newer build (ls269) already touched.

- config is a pathless placed directory, mounted as ${dir:config}
- a state directory placed at the assignment root carries route.json
- image pinned to the digest ace runs today (v4.53.10-ls269)
- the sidecar reaches ombi on the machine port the mesh assigns
  (${port:3579}) rather than assuming 3579 is free
- the sidecar no longer mounts ombi's data directory: MESH_OMBI_CONFIG_DIR
  is read by no code, and the mount exposed the databases for nothing

Verified: catalogue tests (MESH_CATALOGUE set, 6 pass, none skipped); the
pinned image starts as PUID 1000 in a 0700 dir and answers /api/v1/Status
200; data owned 1001:2000 (ace's media ids) under a 1000:1000 dir is
re-owned by the image's init and serves 200; a minted ApiKey is refused
(401) - the api-key secret must be accepted from ombi's own settings.
2026-09-29 23:38:56 +02:00
mesh-admin 8064e5da8f Merge pull request 'searxng: its settings are a file the mesh writes, not the image's defaults' (#143) from feat/searxng-settings-as-a-file into main 2026-09-29 20:45:13 +00:00
jschoubben 63a255c5cb searxng: bind its route where its state now lives
The route binding still named /var/lib/searxng-module, the directory the
previous commit placed elsewhere — the host would have written it into a
directory nothing declares. Same shape as gitea and nextcloud.
2026-09-29 22:41:56 +02:00
jschoubben 7ad1fbd5c6 searxng: its settings are a file the mesh writes, not the image's defaults
The module ran searxng on the image's built-in settings, which serve html
only — so the module's own search tool (format=json) was refused by the
software it fronts. And there was no way to configure it per machine: the
only file settings reach was the sidecar's.

settings.yml is now the module's one mergeable file (JSON is YAML): generic
defaults in the manifest (json format on, limiter and image proxy off,
valkey wired), and whatever differs per machine — base_url, method,
autocomplete, suspended times — set as the assignment's settings. The
secret key is filled on the machine through ${secret:secret}, so the
secrets-in-environment exception and the env file go. Directories are
placed. Image pinned to 2026.9.20, what ace runs today (the old pin was
older, 2026.9.1).

The sidecar's config.json is no longer mergeable: settings merge into every
mergeable file of a module, and the sidecar would have received searxng's
keys. It only ever read an optional url, which its env already carries.

Verified on ace: the pinned image serves html and json from a read-only,
root-owned 0600 JSON settings.yml.
2026-09-29 22:33:38 +02:00
jschoubben 67f5f4cffd Merge pull request 'ca-trust: a machine trusts the mesh's authority because a module put its root there' (#142) from feat/ca-trust into main 2026-09-29 14:06:36 +00:00
jschoubben 8797335fbc ca-trust: a machine trusts the mesh's authority because a module put its root there
novox/hq ADR 0147, issue 129. Every internal HTTPS name fails verification
on every machine: the certificates are genuine and nothing on a machine has
ever been told what issued them. The proxy's fetch answers for the proxy and
for nothing else — a browser, git over HTTPS and every module calling another
by an internal name read the machine's own trust store.

The module requires internal-acme-ca, fetches the root over the mesh's own
network (no prior trust to have; that is what this establishes), installs it
among the machine's anchors and refreshes the extracted bundles. Being
unassigned stops the unit, and stopping it takes the anchor away and
refreshes them again.

Arch's layout is named out loud: a machine that keeps anchors elsewhere fails
visibly rather than writing a file nothing reads.
2026-09-29 15:07:40 +02:00
mesh-admin 53dc108603 Merge pull request 'Remove the network-checker module: it does not do what was decided' (#141) from chore/remove-the-network-checker-module into main 2026-09-29 12:43:34 +00:00
jschoubben ebf5ba2d4c Remove the network-checker module: it does not do what was decided
What was in the catalogue was the first thing I built, not the thing ADR 0146
describes. It dialled raw ports on machine addresses from one hosting form and
emitted nothing, so findings would have sat in a file on the machine — the exact
thing issue 145 is about. It was never registered, never assigned, and never ran.

0146 says names per hosting form, fetched over TLS with the certificate verified,
and machines discovered over the bus. That shares nothing with this but the word
checker, so it goes rather than being bent into shape. Recorded as work to be
analysed and built deliberately.

Connectivity is checked by hand in the meantime, against the services the mesh
already runs.
2026-09-29 14:43:25 +02:00
mesh-admin bbac08a7d2 Merge pull request 'A network-checker module: dial what the mesh claims, from where the callers are' (#140) from feat/a-network-checker-module into main 2026-09-29 11:44:37 +00:00
jschoubben 784a5a6514 A network-checker module: dial what the mesh claims, from where the callers are
The mesh asserts three things are callable (ADR 0144) — what runs on the same
machine, another machine's service exposed to the private network, and another
machine's service exposed publicly — and has never checked any of them. The first
was broken for eleven hours while the mesh reported every machine healthy.

This runs on every machine, on the cadence the mesh already has, in its own
container: the same position every other module calls from. Not the host and not
the control plane, both of which reach these addresses by paths no ordinary caller
uses and would have passed throughout that outage.

**Its probe is its own endpoint, and that is the point.** Declared reachable over
the private network like any other service, so it is admitted by exactly the rule
that governs every internally-exposed service and fails when that rule is wrong.
The tempting target is a service every machine has, and those are the ones never
closed — ssh above all — which would have passed while the thing that actually
broke was a service exposed to the private network.

It resolves before it dials and says which failed, because a name that does not
resolve and a port that does not answer have different owners. One failure is not
a fault: a machine rebooting is ordinary, so a path is broken after consecutive
runs and the count travels with the result. It reports and repairs nothing.

novox/hq ADR 0145. Eight tests; the consecutive-failure logic proved by reverting
it once. Not yet registered or assigned.
2026-09-29 13:44:16 +02:00
mesh-admin 0c31499fb0 Merge pull request 'Every module names its endpoints, and every route names the one it serves' (#139) from feat/modules-name-their-endpoints into main 2026-09-29 09:51:56 +00:00
jschoubben f118344246 Every module names its endpoints, and every route names the one it serves
75 endpoints across 50 modules, named from what each one is for rather than by a
rule: mail's seven protocol ports are smtp, imaps, submission and the rest; unifi's
nine are inform, stun, discovery, the two portal ports and syslog; minio's two are
s3 and console; the resolver's two are dns-udp and dns-tcp.

And 35 route contributions name the endpoint they serve instead of repeating its
port. A route and a listen both carried a port and nothing said they were the same
thing; now one of them does. gitea's path-level deny rule names neither, because it
is a rule about a name rather than an endpoint.

novox/hq ADR 0138. The words shipped a release ahead in mesh-controller #138 and
#139, and the control plane running today is built from that merge — checked before
this was written, because an unknown manifest key is refused and a catalogue using
one against an older control plane would stop resolving.
2026-09-29 11:51:39 +02:00
mesh-admin 822df220ab Merge pull request 'A routed module listens from the mesh, not from anywhere' (#138) from fix/a-routed-module-listens-from-the-mesh into main 2026-09-29 00:58:39 +00:00
jschoubben 9eb1265bc8 A routed module listens from the mesh, not from anywhere
umami declared its port reachable from anywhere, reasoning that the collection
endpoint tracked browsers POST to must be public. That is true of the name and
not of the port: both its surfaces are served through the proxy by name, so the
port is how the proxy reaches it and nothing else (ADR 0045).

Measured, which is how this was found: with the port open to the internet, the
dashboard's login page was served over plain HTTP directly on the machine's port,
bypassing every rule the proxy applies by path. The route stays exactly as it was,
so the collection endpoint keeps working.
2026-09-29 02:54:10 +02:00
mesh-admin 41cfc70b53 Merge pull request 'The resolver declares both protocols it answers on' (#137) from fix/the-resolver-declares-both-protocols into main 2026-09-28 22:04:19 +00:00
jschoubben acedc5d9d9 The resolver declares both protocols it answers on
It declared udp/53 only. The daemon listens on tcp/53 as well, and a resolver is
asked over tcp whenever an answer will not fit in a datagram — so on every
converged machine that port is closed while the service reports itself healthy
and the manifest reads as though the resolver were fully declared.

The same fault as issue 136 in miniature: the declaration covers part of what the
service does, and the gap is silent because nothing compares the two.
2026-09-28 23:53:03 +02:00
mesh-admin 521a8dd1e2 Merge pull request 'sshd: the daemon it owns starts at boot' (#136) from fix/sshd-declares-the-daemon-it-owns into main 2026-09-28 19:43:29 +00:00
jschoubben e145e2236c sshd: the daemon it owns starts at boot
The module said the service must be running and nothing about boot, so the
machine's own way back in was enabled only because something before the mesh
had enabled it. All four machines happen to be enabled today; none of them is
enabled because the mesh says so, and a machine adopted tomorrow would run ssh
until its first reboot.

Not `state: running` alone for the same reason the module exists: this is the
one daemon whose absence cannot be fixed remotely.
2026-09-28 21:43:27 +02:00
mesh-admin 4d7e37e319 Merge pull request 'fail2ban bans through an action every machine has' (#135) from fix/fail2ban-bans-through-what-every-machine-has into main 2026-09-28 18:48:26 +00:00
jschoubben 026421fd6e fail2ban: ban through an action every machine has
jail.local named ufw as the ban action. Two machines on this mesh have no ufw,
and fail2ban does not check: it starts, the jail reads the log, counts the
attempts, runs the ban command, gets 127 -- 'ufw: command not found' -- and
logs an error nobody reads. The service is active, the mesh reports the module
applied, and the machine is not protected. Proven by banning a documentation
address on such a machine today.

The replacement is this module's own dualchain action, already used by the
recidive jail on all four machines, so it is not a new dependency. It bans in
DOCKER-USER as well as INPUT, which ufw's action did not, and it bans all
ports, which ufw's action did.
2026-09-28 20:48:24 +02:00
mesh-admin af89bb11ff Merge pull request 'fail2ban declares the log its own recidive jail reads' (#134) from fix/fail2ban-declares-the-log-its-own-jail-reads into main 2026-09-28 18:45:34 +00:00
jschoubben 7c18cdbd39 fail2ban: declare the log its own recidive jail reads
The recidive jail bans whoever keeps coming back by reading fail2ban's own
log, and fail2ban checks every jail's log file while it configures itself --
before it has created that log. On a machine where the file is not there
already, no jail is found for recidive, configuration fails, and the whole
service refuses to start, taking the sshd jail with it. Two machines assigned
this module today came up failed for exactly that reason; the two where it
worked had a log from years of the service running.

Declared create-once: the mesh puts an empty file there when it is absent and
never touches it again, because what grows in it is fail2ban's, and the
logrotate file this module already ships is what keeps it small.

This also reverts the previous two commits' fail2ban.local. It declared a
logtarget that the package already sets to the same path on every machine
here -- pacman reports the config pristine -- so it fixed nothing and said
something untrue about why.
2026-09-28 20:45:32 +02:00
mesh-admin 4fb16b2e6b Merge pull request 'fail2ban restarts when the log declaration changes' (#133) from fix/fail2ban-restarts-on-its-log-target into main 2026-09-28 18:42:44 +00:00
jschoubben c5af8635c8 fail2ban: restart when the log declaration changes
The file that says where fail2ban logs was not in restart-on, so a change to
it would sit on disk with the running service unaware of it -- the same shape
as any other jail file this module already restarts for.
2026-09-28 20:42:42 +02:00
mesh-admin 87366c5f36 Merge pull request 'fail2ban declares where it logs, so the recidive jail has a file to read' (#132) from fix/fail2ban-declares-where-it-logs into main 2026-09-28 18:41:16 +00:00
jschoubben f8ca36aacf fail2ban: declare where it logs, so the recidive jail has a file to read
The recidive jail reads /var/log/fail2ban.log and this module ships the
logrotate file for it, but nothing ever told fail2ban to write there. Where
the package default stands, fail2ban logs to the journal, the recidive jail
finds no log file, and the whole service refuses to start -- taking the sshd
jail with it. Two machines assigned this module today came up failed; the two
where it worked had /etc/fail2ban/fail2ban.conf edited by hand, which a
package upgrade would have undone.

Declared in fail2ban.local, because fail2ban.conf belongs to the package.
2026-09-28 20:41:09 +02:00
mesh-admin 812355bf31 Merge pull request 'The catalogue hears what it missed' (#131) from feat/the-catalogue-hears-what-it-missed into main 2026-09-28 14:08:48 +00:00
jschoubben 016ddb2b3a The catalogue hears what it missed
It asks what it missed on every start and the answer never arrived: the control plane replayed each
build it held as a module's event from a module called "control-plane", which does not exist, so its
own account refused the publish and the graph kept the gap. The control plane now states those under
the seat it holds (novox/hq ADR 0134, mesh-controller #129), so this consumes that too — one handler,
because what a build means for the graph is the same whether the build machine says it as it happens
or the mesh says what it already held.
2026-09-28 16:08:46 +02:00
mesh-admin ea17bf46d2 Merge pull request 'The catalogue prepares its own schema instead of migrating at start' (#130) from feat/the-catalogue-prepares-its-own-schema into main 2026-09-28 13:40:30 +00:00
jschoubben 4258f01614 The catalogue prepares its own schema instead of migrating at start
It brought its schema up inside its runtime, on every start. That made a schema it could not reach a
crash loop rather than a stop, with the module graph keeping a gap and nothing saying so — which is
how a whole morning's builds went unrecorded. The mesh now prepares this module's state before it
starts this version and does not start it if that failed (novox/hq ADR 0135): the work moves to an
entrypoint the image names in MESH_PREPARE, beside the entrypoints it already names.

The reason it was at start — that a step blocking the apply would block the very apply bringing the
overlay up — stopped being true when a step's failure became its module's business rather than the
machine's (ADR 0136).
2026-09-28 15:40:28 +02:00
mesh-admin 4d9b4fdfa6 Merge pull request 'The catalogue declares the event it emits on starting' (#129) from fix/the-catalogue-declares-the-event-it-emits into main 2026-09-28 07:49:06 +00:00
jschoubben eff11b1d4d The catalogue declares the event it emits on starting
Its runtime announces that it has just started and may have missed builds — the event the control
plane follows to replay them — and its manifest did not declare it. A module's authority on the bus
is derived from what it declares, so the publish was refused and the runtime died on start, in a
loop, with the mesh's graph never catching up.
2026-09-28 09:49:03 +02:00
mesh-admin 4ead13d4d4 Merge pull request 'A merge says which files it changed' (#128) from feat/a-merge-rebuilds-what-it-changed into main 2026-09-28 07:20:05 +00:00
jschoubben 3b77dde666 A merge says which files it changed
Every module built from a repository was rebuilt for a change to any of them: one merge in this
repository meant twenty-six builds, which is what exhausted a public registry's pull limit. The
forge lists the files a merge changed and the event carries them, from the watcher and from the
merge tool alike; a merge that changed more files than were asked for says so, and the mesh then
treats the whole repository as changed rather than guessing.
2026-09-28 09:17:38 +02:00
mesh-admin 5ea4961980 Merge pull request 'A merge older than the watching is history, not news' (#127) from fix/a-merge-older-than-the-watching-is-history into main 2026-09-28 03:08:22 +00:00
jschoubben 2b8a668d06 A merge older than the watching is history, not news
An old merge past the first page of the forge's listing surfaced as newer pull requests were
updated, and was announced as if it had just happened; the mesh then rebuilt everything built from
that repository, once per old merge. The moment the watching began is kept with the record, and
only a merge made since is announced.
2026-09-28 05:08:20 +02:00
mesh-admin 719fb1e025 Merge pull request 'A merge the forge announces is said in its log' (#126) from fix/a-merge-announced-is-said into main 2026-09-28 02:44:19 +00:00
jschoubben ac5630bee2 A merge the forge announces is said in its log
A trigger that fires silently is indistinguishable from one that did not fire (novox/hq issue
131); the announcement is now one line an operator can read.
2026-09-28 04:44:17 +02:00
mesh-admin 1c995fa9fc Merge pull request 'The forge watches every repository, not the administrator's own' (#125) from fix/the-forge-watches-every-repository into main 2026-09-28 02:31:25 +00:00
jschoubben d70cb18ea0 The forge watches every repository, not the administrator's own
/user/repos lists what the token's user owns, which for the mesh's administrator is nothing — so
the forge module watched an empty list and never announced a merge. It reads the forge's whole
view through the search endpoint, every page.
2026-09-28 04:31:20 +02:00
mesh-admin 1d71787896 Merge pull request 'The forge announces every merge, whoever made it' (#124) from feat/the-forge-announces-every-merge into main 2026-09-28 01:03:48 +00:00
jschoubben eb62289f89 The forge announces every merge, whoever made it
The merge tool emitted at the instant it acted; a merge made in the forge's own
pages or over its API emitted nothing, and the mesh went on believing every module
current with its source (novox/hq 04-ISSUES/131). Merged pull requests are now
watched the way repositories are: what the forge holds, asked for on a tick,
announced once, with the merge commit and the clone URL a build needs. What has
been announced is kept beside the module's state, so a restart does not announce
the whole history again, and a first tick with no record announces nothing.
2026-09-28 02:54:48 +02:00
jschoubben f5969a2f9f Merge pull request 'nats declares the certificate directory it mounts' (#123) from feat/nats-serves-the-meshs-certificate into main 2026-09-27 22:31:20 +00:00
jschoubben 7f3d259cf5 nats: drop the access to a certificate directory it no longer mounts 2026-09-28 00:16:23 +02:00
jschoubben 721149eda1 nats declares the certificate directory it mounts
The mesh's broker certificate is the operator's, kept outside any module and
mounted read-only by whatever serves the bus; the controller declares that
access and now so does nats, the same way. A mount nothing declares is refused
at registration (ADR 0030), which is how this was found.
2026-09-28 00:15:59 +02:00
jschoubben 8e27bc1e36 Merge pull request 'nats serves the mesh's existing broker certificate' (#122) from feat/nats-serves-the-meshs-certificate into main 2026-09-27 22:07:29 +00:00
jschoubben f1212620e4 nats serves the mesh's existing broker certificate
The module mounted a TLS directory nothing fills, so the server could not start
on a mesh that was not raised by the genesis template. The mesh already has a
broker certificate every machine pins by fingerprint and the controller trusts;
serving the new bus with it means no pin changes when a machine moves and there
is no second certificate to be wrong about. No ca_file: the directory has none,
and a pinning client checks the leaf and nothing else.
2026-09-28 00:04:10 +02:00
jschoubben b1b18ae390 Merge pull request 'nats declares the upstream image it is built from' (#121) from fix/nats-declares-its-base into main 2026-09-27 21:40:50 +00:00
jschoubben 3f0a174392 nats declares the upstream image it is built from
The recipe started FROM the upstream server's digest directly, and the build machine
refuses that: every base is declared under build.on and copied into the mesh's own
store before a build, so a build never reaches out to a registry the mesh does not
run (novox/hq ADR 0097). Found the first time the module was built on a real mesh.

Same digest, now declared as NATS_BASE and arriving as a build argument; the
Dockerfile says where it comes from and why the digest is the index's.
2026-09-27 23:40:16 +02:00
jschoubben c0edebefb9 Merge pull request 'lavinmq, amqp-ping and amqp-email-forwarder leave the catalogue' (#120) from feat/amqp-leaves-the-catalogue into main 2026-09-27 21:30:19 +00:00
jschoubben b9605a6e3b lavinmq, amqp-ping and amqp-email-forwarder leave the catalogue
AMQP is not a provision (novox/hq ADR 0131, design 28 task 5.4). These were the
only three manifests that named it: the broker that provided it, a proof that a
grant worked end to end, and a forwarder reading mail off a queue. Removed, not
converted — a module that wants messaging wants the mesh's bus, reached through
the sdk and named by the mesh-broker seat, and either of the last two is re-done
against that if wanted, as a new module under the record.

The controller refuses a manifest naming amqp from its next release, so these
could not be re-registered anyway. Nothing else in the catalogue referenced them.
2026-09-27 23:27:24 +02:00
jschoubben dca84d3bb4 Merge pull request 'lavinmq holds mesh-broker again, now the check reads the store' (#119) from restore/broker-claim into main 2026-09-27 20:24:22 +00:00
jschoubben 9f9ce92d0f lavinmq holds mesh-broker again, now the check reads the store
The claim comes back for the third and last time. The store's row says the bus seat
answers for `amqp`, lavinmq provides `amqp`, and with mesh-controller#89 the check
that judges a claim reads that row instead of a copy compiled into the build
machine. So this is accepted for the reason it should have been all along.

Restores the holder the controller composes its own bus address through, which is
what ends tonight's crash loop. nats takes the seat over when the cutover is done
deliberately, not because the seat emptied itself.
2026-09-27 22:23:37 +02:00
jschoubben 6e5b2557ba Merge pull request 'Undo: claiming mesh-broker made lavinmq unbuildable' (#118) from revert/broker-seat-claim into main 2026-09-27 19:53:49 +00:00
jschoubben f97b7dd544 Undo: lavinmq cannot hold mesh-broker, and claiming it makes lavinmq unbuildable
Putting the claim back was wrong on its own terms. `mesh-broker` delivers
`mesh-bus`, and a seat that delivers a provision may only be held by a module that
provides it — so the claim is refused at registration:

  lavinmq claims mesh-broker, whose holder answers for "mesh-bus",
  and lavinmq does not provide "mesh-bus" at mesh scope

Which means the merged claim does not restore the holder, it stops lavinmq being
built at all. Removed again.

The seat being empty is still the live fault, and it has only one valid answer: the
holder must provide `mesh-bus`, and the module that does is nats. Recorded against
the rollout, because it moves a step that was optional into the critical path.
2026-09-27 21:27:55 +02:00
jschoubben 5f5798ec8a Merge pull request 'lavinmq keeps mesh-broker until something else can take it' (#117) from fix/broker-seat-must-stay-held into main 2026-09-27 19:17:45 +00:00
jschoubben 42550dbe43 lavinmq keeps mesh-broker until something else can take it
Taking the claim off made the seat unheld, and the controller dereferences that
seat to find its own bus (to-be 26, "the one exception is the controller itself").
Unheld, the composed address fell back to a default port nothing serves, and the
control plane crash-looped: "cannot reach the broker named in MESH_BROKER_AMQP:
dial tcp 127.0.0.1:5672". The broker itself never stopped — it is healthy on the
port the mesh actually assigned it.

lavinmq becoming an ordinary provider is right, and it is still a provider of amqp
here. What was wrong is the order: the seat has to pass from one holder to the next,
and it cannot be empty in between, because the thing that reads it is the thing that
would have to fix it.
2026-09-27 21:13:13 +02:00
jschoubben 51713dd631 Merge pull request 'The Go base has to be 1.26 for what compiles the controller's code' (#116) from fix/go-126-base into main 2026-09-27 19:00:38 +00:00
jschoubben 4cda964a43 The Go base has to be 1.26 for what compiles the controller's code
builder and route-proxy both build from the mesh-controller repository's context,
so its go.mod is theirs, and `nats.go v1.54.0` puts that at `go >= 1.26`. Pinned at
1.25.14 they cannot compile it: the build machine's own build failed with "go.mod
requires go >= 1.26.0 (running go 1.25.14)".

Each moves to the 1.26.8 digest of the flavour it already used — alpine for
builder, debian for route-proxy — so nothing changes but the compiler version.
2026-09-27 20:58:09 +02:00
jschoubben adb02da136 Merge pull request 'The nats module, and every manifest's event names made local' (#115) from feat/nats-genesis into main 2026-09-27 17:31:04 +00:00
jschoubben 06954b5a70 Merge main: the trunk's seat names, this branch's event names
Two lines of work renamed the same seats differently. The trunk named them for their
scope — node-scoped ones `node-*`, leaving `the-artifact-store`, `npm-package-registry`
and `git` as they were — and this branch had renamed ten of them to `mesh-*`. The trunk's
set is what the live controller loads and what the live seats were actually renamed to, so
a manifest claiming this branch's name is one the running mesh refuses. Three of them
needed reverting by hand: git had auto-merged this branch's names where the trunk had not
touched those lines, which is the quiet kind of merge result.

Event names are this branch's, because the trunk has not converted them and they are what
issue 127 was about.

Verdaccio goes with the trunk's removal of it. The template work on dnsmasq's roster fact
is the trunk's, sitting beside this branch's local event names in the same file — the one
hunk where both changes landed together.

75 manifests, all parsing, no claim outside the trunk's set and no event name left in the
old bus's form.
2026-09-27 18:25:57 +02:00
jschoubben 3d7d896014 Merge pull request 'fail2ban never bans a tunnel peer: ignoreip names the mesh range' (#113) from fix/fail2ban-ignores-the-mesh-range into main 2026-09-27 14:55:55 +00:00
jschoubben 278610c0c3 fail2ban never bans a tunnel peer: ignoreip names the mesh range
The jail.local [DEFAULT] gains ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}
— localhost plus the mesh's own private range, named through the placeholder
rather than hardcoded (data is the mesh's, ADR 0112). Without it fail2ban could
ban the mesh's own nodes on 10.10.0.0/24; on novox that rule survived only in
memory from a now-deleted HAL file and would be lost on the next restart.
2026-09-27 16:55:34 +02:00
jschoubben b58a3b487d A build's outcome belongs to the role, not to the module holding it
ADR 0121. The builder declared `built` as its own event, so every consumer depended
on which module happens to be the build machine today. It is the build-machine
role's event now: the builder declares none of its own, and the catalogue listens
for `mesh-build-machine.built` rather than `builder.built`.

Nothing changes about what reaches the catalogue. What changes is that it survives
the build machine being a different module, which is the whole reason the mesh has a
word for a role.
2026-09-27 15:37:21 +02:00
jschoubben 7b06a7a408 Event names are local now, in the manifests and in the code
Every module named its events the way the old bus spelled a routing key —
`module.<module>.<verb>`. Design 29 says a module names an event locally and the
mesh works out where it lands, so all 37 were stale against a rule already
decided. On the new bus that derives into a namespace belonging to a module
called "module", so no cross-module subscription in the mesh matched anything:
nothing failed, nothing reacted (novox/hq 04-ISSUES/127).

36 manifests converted, and 43 files of module code with them. The code mattered
as much as the manifests: the runtime builds the subject from what `emit()` is
handed, so a converted manifest with unconverted code would have had the
permission and the subject disagree.

Three things the new check found on the way:

- `photos` emitted an event its manifest never declared, which the new bus refuses
  outright. Declared.
- `showcase` waited for an event nothing emits, so its demo could never be
  triggered — only `showcase` may publish under its own name. It emits both halves
  now.
- `distribution` declared an event named after a different module. It emits
  `image.pushed` under its own name. An event about a *role* belongs on the seat,
  where the name outlives whoever holds it, but the sdk has no way to publish on a
  seat yet, so that stays recorded rather than declared.

The audit logger's "everything" pattern is `**` rather than the old bus's `#`.
2026-09-27 14:42:28 +02:00
jschoubben f0a6ce8d4a Merge pull request 'Rename seat claims to mesh-*/node-*; retire verdaccio (ADR 0121)' (#112) from feat/system-seats-named-by-scope into main 2026-09-27 12:32:29 +00:00
jschoubben 6bedcd3f21 Rename seat claims to the mesh-*/node-* convention; retire verdaccio (ADR 0121)
Claims renamed to match the controller's seat set: node-dns-resolver (dnsmasq),
node-intrusion-prevention (fail2ban), node-packet-filter (nftables),
node-resolver-config (resolv-conf, resolved-split-dns), node-uplink
(networkmanager, systemd-networkd, dhcpcd), mesh-build-machine (builder, +mesh
scope), mesh-catalog (mesh-catalog). showcase now declares its own seat and
claims it. verdaccio removed — the mesh keeps distribution as its registry and
gitea already serves npm, so a second npm registry is redundant.
2026-09-27 14:30:56 +02:00
jschoubben a093c88c32 nats declares its own server settings, and where the mesh's users go
The split the controller now makes, from this side. The module's own
configuration — ports, TLS, JetStream — is a declared file resource, because those
are properties of this container and change when its image does. `bus-users` names
where the mesh writes every account and permission, in the same directory, and the
module's configuration includes it.

**Both files in one directory because they have to be.** An absolute include path
is resolved relative to the including file's directory: nats-server given
`include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for
/etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the
server, and recorded in the configuration itself where somebody moving a file will
read it.

**`verify: true` is gone, and it was refusing every connection in the mesh.** It
makes the server demand a client certificate; a host pins this server's exact
certificate and authenticates with the password the mesh minted, and presents none.
Found by building this image and connecting to it as a host would.

The entrypoint now waits for both files and watches the mesh's half: the module's
own does not change without a new declaration, and that recreates the container
anyway. Verified end to end against this image — the mesh's user list rewritten,
the module noticing and reloading the server itself with no signal from outside,
and the connection the mesh already had still working afterwards.
2026-09-27 02:50:35 +02:00
jschoubben f67f0ca9bc Merge pull request 'dnsmasq owns its resolver format: node-zones is a template (ADR 0120)' (#111) from feat/roster-facts-are-templates into main 2026-09-26 23:51:29 +00:00
jschoubben 968473219a dnsmasq owns its resolver format: node-zones is a template, not a controller formatter (ADR 0120)
The node-zones fact was a path; the local=/address= syntax lived in the
control plane. It is dnsmasq's configuration language, so it moves into
dnsmasq's manifest as a template over the roster. The mesh renders it; it
reads none of it. Output is unchanged.

Lands with mesh-controller's ADR 0120 change — the two are one schema step.
2026-09-27 01:33:28 +02:00
jschoubben ad219beee2 Merge pull request 'The uplink's managers are modules: networkmanager, systemd-networkd, dhcpcd (hq ADR 0117)' (#110) from feat/the-uplink-modules into main 2026-09-26 23:00:30 +00:00
jochen fd09b1a50e review: the uplink managers are the machine's — no state on their services, none for dhcpcd; comments corrected 2026-09-27 00:07:27 +02:00
jochen 7aea08d6c3 dhcpcd's mesh block goes at the start: lines after an interface line are that interface's 2026-09-26 23:48:20 +02:00
jochen 23d735a0bf The uplink's managers are modules (hq ADR 0117)
networkmanager, systemd-networkd and dhcpcd each claim the-uplink and
declare only what keeps the machine's own network manager from
contradicting the mesh: the resolver file left to resolv-conf, mesh0
left alone. Never a link, profile or credential — the link is the
mesh's only channel to the machine, so NetworkManager and networkd are
reloaded on a change, never restarted, and dhcpcd (no reload; a restart
drops the address) takes its block at its next start.
2026-09-26 23:47:31 +02:00
jschoubben ae99204a8c Claim the renamed seats (novox/hq ADR 0118)
Ten manifests claim mesh-* names now. What they PROVIDE is unchanged: gitea
still provides git and npm-package-registry, and a consumer requires the
interface, not the seat.
2026-09-26 23:07:09 +02:00
jschoubben 226eab4c6f Merge pull request 'dnsmasq: the operator's own names have a home the mesh never rewrites' (#109) from feat/dnsmasq-has-a-home-for-operator-names into main 2026-09-26 20:43:53 +00:00
jschoubben bb8f2e76a9 dnsmasq: the operator's own names have a home the mesh never rewrites
A workstation's job includes names that are neither a mesh machine nor
a routed name (novox/hq 122): shanks carries 13 Mediahuis entries in
/etc/hosts, and mesh-wireguard replaces /etc/hosts whole when taken —
so without this they vanish, and the take gates the node. Two homes,
neither the mesh's to own: conf-dir=/etc/dnsmasq.d/,*.conf (drop-in
directives, HAL's dnsmasq-app used exactly this) and
addn-hosts=/etc/hosts.local (plain host lines). The mesh creates and
rewrites neither; a machine with none loses nothing. The migration
moves such names here BEFORE the /etc/hosts take, closing the window.
2026-09-26 22:43:29 +02:00
jschoubben 86882cacd4 nats provides mesh-bus (novox/hq ADR 0120) 2026-09-26 21:17:21 +02:00
jschoubben ea7f6796e8 lavinmq is a provider, not foundation
It claims no seat: mesh-broker is the NATS server's (novox/hq ADR 0119).
The amqp interface stays exactly as it is — a backing service a module may
require, like a database.
2026-09-26 21:08:40 +02:00
jschoubben 372450851f Merge pull request 'mssql: its data is placed — the last /services placement retires' (#108) from feat/mssql-data-is-placed into main 2026-09-26 18:36:16 +00:00
jschoubben f4e4e12c99 mssql: its data is placed — the last /services placement retires
The stated path was the adopted-data exception; with the take done and
the placement vocabulary live, the exception has no reason left. The
landing window renames the directory and recreates the container, since
a changed volume path does not do that by itself (hq 126).
2026-09-26 20:36:03 +02:00
jschoubben fd9be011c0 Merge pull request 'sshd: the operator's door is a module' (#107) from feat/sshd-module into main 2026-09-26 18:15:08 +00:00
jschoubben a85b0ee346 sshd: the operator's door is a module
The spec is the working system: HAL's 99-hal.conf, restated as
10-mesh.conf so lexical include order makes the mesh's answer the one
that wins while the predecessor's file is still on disk. Subsystem
stays the stock config's — first-set wins and it sits before the
Include. Port 22 from anywhere, said in listens with its reason: the
machines that need the door are exactly the ones not on the mesh yet,
and locking the operator out is the one failure a firewall must never
arrange.
2026-09-26 20:14:54 +02:00
jschoubben 34243c9e34 Merge pull request 'dnsmasq: the runtime's DNS is written into daemon.json, never over it' (#106) from fix/dnsmasq-writes-into-daemon-json into main 2026-09-26 18:12:20 +00:00
jschoubben 870a541072 dnsmasq: the runtime's DNS is written into daemon.json, never over it
The file is shared — the operator's insecure-registries for the mesh's
own store live there — and replacing it whole would break every pull
from that store the moment the module is taken (the 098 class, caught
in the pre-take diff this time). ADR 0102's verb is merge.
2026-09-26 20:12:06 +02:00
jschoubben 9b063a77b2 nats: the module, and an image that reloads in place
Step 1.1 and 1.2 of novox/hq ADR 0116. The server is a built artifact rather
than the upstream image directly, because it needs an entrypoint of its own:
the host can only recreate a container, and recreating the bus for every
permission change drops every connection and every in-flight ack. nats-server
reloads on SIGHUP by itself, so the config is mounted as a directory (not
digest-tracked, hq issue 103) and the entrypoint watches the one file.

Verified against the real server, not assumed: a user added to the config
connects, a revoked one is refused, both within one poll interval, with the
container's PID and restart count unchanged and "Reloaded: accounts" in its
log.

Two corrections found by checking rather than reading:
- the seat delivers nothing now (hq ADR 0117), and the controller's parser
  refused the manifest until it did — "nats claims mesh-broker, whose holder
  answers for amqp, and nats does not provide amqp"
- pinned to the multi-arch index digest; the first pin was the amd64
  manifest, which builds here and fails on any other architecture
2026-09-26 19:34:14 +02:00
jschoubben a59750fa28 Merge pull request 'mailu: the smtp provision serves the name its certificate answers to' (#105) from fix/smtp-serves-its-tls-name into main 2026-09-26 17:27:06 +00:00
jschoubben 81592a3b2c mailu: the smtp provision serves the name its certificate answers to
A consumer connecting by the binding's address meets a certificate for
mail.novox.be and refuses it — found live by the forwarder's cutover
proof, one send before production would have. The TLS name is mailu's
own fact (HOSTNAMES), so the binding carries it; consumers say
${bound:smtp:name} and verification holds.
2026-09-26 19:26:53 +02:00
jschoubben 705ceec1e7 Merge pull request 'Six modules name no /var/lib: the root is a place, the maps reference it' (#104) from feat/six-modules-name-no-var-lib into main 2026-09-26 16:21:00 +00:00
jschoubben afdd149ab7 Six modules name no /var/lib: the root is a place, the maps reference it
The state directories say place "." — the assignment's own root — and
every bind, secret, own-secret, receives and grants path references it
as ${dir:state}/…; grants directories that are their own resources are
placed by id. gitea's two coincidence strings from the first pass
(${dir:data}base.json — resolving correctly by pure concatenation) are
spelled honestly now. What still says /var/lib is inside containers —
the software's contract — or under /var/lib/mesh, the mesh's own
plumbing, which the requirements unification absorbs next. Every
resolved path is byte-identical to what runs; landing this is a no-op
on the node, and the converter checks its own boundaries this time.
2026-09-26 18:20:47 +02:00
jschoubben dde8b15483 Merge pull request 'mailu: seventeen data directories are placed, not stated' (#103) from feat/mailu-dirs-are-placed into main 2026-09-26 16:07:24 +00:00
jschoubben 8a046be198 mailu: seventeen data directories are placed, not stated
Each resolves to <root>/mailu/<id> — the maildir at
/var/lib/mailu/data-mail, certs at data-certs, and so on. Landing this
is a window, not an edit: seventeen renames on the node (the nested
data/ tree flattens to the ids), then the full stack recreated, because
a changed volume path does not recreate a container by itself (hq 126).
Ids are untouched on purpose — a renamed id orphans its held record,
and the mail spool is the wrong place to learn what a removal step does
with one.
2026-09-26 18:07:11 +02:00
jschoubben 668278bde4 Merge pull request 'nextcloud: it lives under its own name, and html is placed' (#102) from feat/nextcloud-lives-under-its-own-name into main 2026-09-26 16:05:41 +00:00
jschoubben 6761bb02a1 nextcloud: it lives under its own name, and html is placed
The module is nextcloud; its tree was /var/lib/nextcloud-module — a
historic spelling nothing depends on. The root moves to
/var/lib/nextcloud (a rename on the node, done in this change's
window), and html drops its path: the mesh resolves it to
<root>/nextcloud/html. Landing this requires the window: rename the
tree, push, recreate the container — a changed volume path does not
recreate one by itself (hq 126).
2026-09-26 18:05:28 +02:00
jschoubben f98c9859d2 Merge pull request 'gitea: its data and grants are placed, not stated' (#101) from feat/gitea-dirs-are-placed into main 2026-09-26 16:04:30 +00:00
jschoubben cac5eab7da gitea: its data and grants are placed, not stated
The mesh resolves both to <root>/gitea/<id> — where the 5.8G forge and
its grant files already sit, so the roll-out its upgrade policy makes
of this build changes no byte of the spec. The module root and the
mesh's plumbing stay stated.
2026-09-26 18:04:17 +02:00
jschoubben 770c9f6a78 Merge pull request 'mongodb: its data directory is placed, not stated' (#100) from feat/mongodb-dir-is-placed into main 2026-09-26 16:03:36 +00:00
jschoubben 5427118614 mongodb: its data directory is placed, not stated
The mesh resolves it to <root>/mongodb/data — where the granted
databases already sit. The provider's own state, grants and the mesh's
plumbing stay stated.
2026-09-26 18:03:25 +02:00
jschoubben 53765335cf Merge pull request 'portainer: its data directory is placed, not stated' (#99) from feat/portainer-dir-is-placed into main 2026-09-26 16:02:42 +00:00
jschoubben 5fd2ed9686 portainer: its data directory is placed, not stated
The mesh resolves it to <root>/portainer/data — where the 16M of
endpoints and users already sit. A textual no-op on this node.
2026-09-26 18:02:25 +02:00
jschoubben f7887d706d Merge pull request 'only-office: its directories are placed, not stated' (#98) from feat/only-office-dirs-are-placed into main 2026-09-26 16:01:43 +00:00
jschoubben d6dd21a091 only-office: its directories are placed, not stated
Seven data directories drop their paths; the mesh resolves each to
<root>/only-office/<id>, which is exactly where the data already sits —
a textual no-op on this node, and the first module speaking ADR 0112's
vocabulary. The module root and the mesh's own state stay stated.
2026-09-26 18:01:31 +02:00
jschoubben a844701577 Merge pull request 'Module data lives in /var/lib, now that nothing is mid-cutover' (#97) from feat/module-data-lives-in-var-lib into main 2026-09-26 14:47:37 +00:00
jschoubben 50a99f022c Module data lives in /var/lib, now that nothing is mid-cutover
The /services paths were the adopted-node pattern doing its job: take
replaced containers over the predecessor's data without moving a byte
(gitea set it — 'its data never moved'). With every cutover done the
exception has no reason left, and the operator called it: a nox
module's world is /var/lib/<module>, data included. Six modules
repathed; mssql keeps its /services path deliberately — it is still
held, HAL-run, and moves at its own take. Both trees are one
filesystem, so each move is a rename.
2026-09-26 16:47:24 +02:00
jschoubben 382a44621e Merge pull request 'A bucket is the one the mesh derives, and the photos module named another' (#96) from fix/a-bucket-is-the-one-the-mesh-derives into main 2026-09-26 14:46:30 +00:00
jochen ddb67fc095 A bucket is the one the mesh derives, and the photos module named another
The provisioner derives a consumer's bucket from the login the mesh minted — 'derived from the
login, so teardown recomputes it with nothing to persist' — and never reads the bucket a manifest
contributed. Three modules contributed one anyway, and the value was decorative in two and wrong in
the third: photos told its container MINIO_BUCKET=photos, the predecessor's bucket, while its minted
key is scoped to mesh-novox-photos. Deployed as it stood, it would have authenticated and then been
denied on every object.

photos now names the bucket the mesh actually provisions, and the contributed bucket is gone from
all three: a value nothing reads, that reads as though it decides.

Verified against the live store before changing anything: the derived names are the populated ones —
mesh-novox-ncloud (77,886 objects, 174.9 GiB), mesh-novox-photos and mesh-novox-invoice. Nothing has
to move.
2026-09-26 16:46:10 +02:00
jschoubben 78595e4db3 Merge pull request 'lavinmq: the broker TLS directory is the operator's, read by whoever needs it' (#95) from fix/the-broker-tls-directory-is-the-operators into main 2026-09-26 14:12:45 +00:00
jschoubben 37634de1e3 lavinmq: the broker TLS directory is the operator's, read by whoever needs it
The controller now accesses /var/lib/mesh-broker-tls (mesh-controller
#54) and the push refused whole: lavinmq declared the directory as an
owned resource, and shared data is the operator's, owned by no module
(ADR 0051). lavinmq only ever reads the certs — genesis laid them down
— so it declares a read access like the controller does, and the
directory belongs to nobody.
2026-09-26 16:12:29 +02:00
jschoubben 36c5f87130 Merge pull request 'route-adapter: write a body limit as the predecessor's buffering middleware' (#94) from feat/a-route-may-limit-the-body-it-carries into main 2026-09-26 14:02:03 +00:00
jochen b2e39eb2cd route-adapter: write a body limit as the predecessor's buffering middleware
The adapter skips what its one file shape cannot say. A body limit is the exception: the predecessor
has a buffering middleware and served its own registry name with exactly it, so this is written
rather than skipped, named after the router so the two halves cannot drift.

A limit that is not a whole positive number of bytes takes the route with it. Written without the
limit, the predecessor would carry what the module said not to carry and this module would report
success. Silence stays silence — no middleware, the predecessor's default.
2026-09-26 16:01:23 +02:00
jschoubben 3d73c9f54e Merge pull request 'nextcloud: real mesh module, MariaDB→PostgreSQL, S3 via _FILE secrets' (#59) from feat/nextcloud-module-postgres-migration into main 2026-09-26 13:06:32 +00:00
jschoubben fb95eb6e46 Merge main 2026-09-26 15:06:11 +02:00
jschoubben 4d715f8b73 Merge pull request 'minio: the real 4-node/8-drive erasure-coded cluster, both public routes, verified live on novox' (#58) from feat/minio-real-cluster-not-single-node into main 2026-09-26 13:05:57 +00:00
jochen afe8aae826 Merge main
# Conflicts:
#	modules/minio/module.json
2026-09-26 15:05:40 +02:00
jschoubben fa91be4941 Merge pull request 'postgres: declare the data directory's real owner; keycloak: use the port template' (#55) from fix/postgres-owner-and-keycloak-port-template into main 2026-09-26 13:05:10 +00:00
jschoubben 87f73dce6a Merge main 2026-09-26 15:04:47 +02:00
jschoubben fc5ccdfe2a Merge pull request 'mailu: one WEBMAIL_ADDRESS, the mesh's container name' (#93) from fix/one-webmail-address into main 2026-09-26 13:04:44 +00:00
jschoubben 4489e56935 mailu: one WEBMAIL_ADDRESS, the mesh's container name
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.
2026-09-26 15:04:32 +02:00
jschoubben 08e947e4c8 Merge pull request 'The npm registry is a seat gitea holds, and gitea holds the git seat a build's source can live on' (#69) from feat/seats-are-a-closed-set into main 2026-09-26 12:31:10 +00:00
jschoubben 7fb9dd0254 Merge main 2026-09-26 14:29:28 +02:00
jschoubben 420d05e8dd Merge pull request 'mailu certifies itself, take two — the fall-through is now a behaviour' (#92) from fix/mailu-certifies-itself-take-two into main 2026-09-26 12:27:56 +00:00
jschoubben 6769e66c82 mailu certifies itself, take two — the fall-through is now a behaviour
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.
2026-09-26 14:27:45 +02:00
jschoubben 15b35b53db Merge pull request 'mailu: back to the copied cert — the edge's fall-through is a belief, not a behaviour' (#91) from fix/mailu-back-to-cert-while-the-fallthrough-is-fixed into main 2026-09-26 12:19:17 +00:00
jschoubben 6177565741 mailu: back to the copied cert — the edge's fall-through is a belief, not a behaviour
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.
2026-09-26 14:19:05 +02:00
jschoubben 6b2ea0972a Merge pull request 'mailu certifies itself: the edge passes unknown ACME tokens through now' (#90) from fix/mailu-certifies-itself into main 2026-09-26 12:15:24 +00:00
jschoubben a978b53d1c mailu certifies itself: the edge passes unknown ACME tokens through now
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.
2026-09-26 14:15:12 +02:00
jschoubben 7501c1db9e Merge pull request 'portainer: serve its public name, hold its real data, run the image the machine runs' (#89) from fix/portainer-serves-its-name into main 2026-09-26 01:46:24 +00:00
jschoubben 00ada1e9f7 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.
2026-09-26 03:46:11 +02:00
jschoubben 67b443d5ad Merge pull request 'only-office: pin the machine side of its port' (#88) from fix/only-office-pins-its-machine-port into main 2026-09-26 01:44:59 +00:00
jschoubben a2da2e4910 only-office: pin the machine side of its port
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.
2026-09-26 03:44:47 +02:00
jschoubben bcb9ca8f93 Merge pull request 'invoicing: MONGO_DB says the granted database's name' (#87) from fix/invoicing-names-its-database into main 2026-09-26 01:34:09 +00:00
jschoubben 2409afda60 invoicing: MONGO_DB says the granted database's name
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.
2026-09-26 03:34:00 +02:00
jschoubben 511200ed9c Merge pull request 'invoicing: the photos lessons, applied before its window' (#86) from fix/invoicing-learns-the-photos-lessons into main 2026-09-26 01:15:38 +00:00
jschoubben b704bf5ad8 invoicing: the photos lessons, applied before its window
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.
2026-09-26 03:15:26 +02:00
jschoubben 142d65c52a Merge pull request 'mongodb: the server container is mongodb-server, not the predecessor's name' (#85) from fix/mongodb-coexists-with-the-predecessor into main 2026-09-26 00:37:51 +00:00
jschoubben ccb6e7500e mongodb: the server container is mongodb-server, not the predecessor's name
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.
2026-09-26 01:37:41 +02:00
jschoubben bbda88c13b Merge pull request 'Every credential provider says whether it still holds a consumer (hq issue 120)' (#84) from fix/120-redis-says-what-it-holds into main 2026-09-25 23:31:28 +00:00
jochen 620b47d309 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.
2026-09-26 01:30:15 +02:00
jochen 6fd93afc6c 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.
2026-09-26 01:24:32 +02:00
jochen 0cb0f814b4 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.
2026-09-26 01:09:52 +02:00
jochen 3d271f72ea 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.
2026-09-26 00:53:13 +02:00
jschoubben 76ca479f37 Merge pull request 'mailu: the queue is root's and traversable, which is postfix's own convention' (#83) from fix/the-queue-is-traversable into main 2026-09-25 22:17:54 +00:00
jschoubben a055334c9b mailu: the queue is root's and traversable, which is postfix's own convention
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.
2026-09-26 00:17:41 +02:00
jschoubben d5b169b6b6 Merge pull request 'mailu: cert flavor while the predecessor's proxy owns the challenge path' (#82) from fix/mailu-tls-cert-behind-the-predecessors-proxy into main 2026-09-25 22:11:26 +00:00
jschoubben 8ecc5a7249 mailu: cert flavor while the predecessor's proxy owns the challenge path
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.
2026-09-26 00:11:11 +02:00
jschoubben 29f00f33a5 Merge pull request 'automx: the seed writes the schema 2021.6 reads' (#81) from fix/automx-seed-knows-prio into main 2026-09-25 22:06:36 +00:00
jschoubben 480627fdd9 automx: the seed writes the schema 2021.6 reads
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.
2026-09-26 00:06:24 +02:00
jschoubben c8c939a595 Merge pull request 'automx: the schema its seed writes belongs to one automx2, so that one is named' (#80) from fix/automx-pins-the-schema-its-seed-writes into main 2026-09-25 21:59:54 +00:00
jschoubben 30b7ce429c automx: the schema its seed writes belongs to one automx2, so that one is named
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.
2026-09-25 23:59:42 +02:00
jschoubben b8a50ee7a0 Merge pull request 'mailu: the containers are named by the vocabulary 2024.06 reads' (#79) from fix/mailu-speaks-2024-06-addresses into main 2026-09-25 21:56:40 +00:00
jschoubben 4e37b3e84d mailu: the containers are named by the vocabulary 2024.06 reads
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.
2026-09-25 23:56:28 +02:00
jschoubben 7cb7659fbe Merge pull request 'mailu: every container asks the module's own resolver, pinned where they can find it' (#78) from feat/mailu-points-at-its-own-resolver into main 2026-09-25 21:49:30 +00:00
jschoubben 047f228fe3 mailu: every container asks the module's own resolver, pinned where they can find it
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.
2026-09-25 23:49:17 +02:00
jschoubben 40aa93dc2b Merge pull request 'mailu: the admin API answers on 8080 since 2024.06' (#77) from fix/mailu-admin-answers-on-8080 into main 2026-09-25 21:45:11 +00:00
jschoubben 5462317183 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.
2026-09-25 23:45:00 +02:00
jschoubben cb9d43b2a1 Merge pull request 'automx: the launcher's wrapper is written by the build that ships it' (#76) from fix/automx-carries-its-own-wrapper into main 2026-09-25 21:44:46 +00:00
jschoubben aa8c4253f1 automx: the launcher's wrapper is written by the build that ships it
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.
2026-09-25 23:44:34 +02:00
jschoubben 4863eef586 Merge pull request 'mailu: pin exactly what phase 1 verified' (#75) from fix/mailu-pins-what-phase-one-verified into main 2026-09-25 21:34:26 +00:00
jschoubben 75f993e92b mailu: pin exactly what phase 1 verified
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.
2026-09-25 23:34:13 +02:00
jschoubben 621246996d Merge pull request 'mailu: 2024.06 closes 110/143/587 by default; parity says open them' (#74) from fix/mailu-ports-parity into main 2026-09-25 20:56:39 +00:00
jschoubben e3d8bd0726 mailu: 2024.06 closes 110/143/587 by default; parity says open them
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.
2026-09-25 22:56:27 +02:00
jschoubben a708666bad Merge pull request 'mailu: the manifest matches the machine, provides smtp, and carries automx' (#73) from feat/mailu-becomes-real into main 2026-09-25 20:40:08 +00:00
jschoubben ed5d1386ce mailu: the manifest matches the machine, provides smtp, and carries automx
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.
2026-09-25 22:39:29 +02:00
jschoubben 3875987656 Merge pull request 'gitea: the package team may read code, and its units are reconciled' (#72) from fix/gitea-package-team-reads-code into main 2026-09-25 19:59:25 +00:00
jschoubben 311f7f1fdb gitea: the package team may read code, and its units are reconciled
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.
2026-09-25 21:59:09 +02:00
jschoubben bbd1723612 Merge pull request 'route-proxy binds the mesh's own authority for internal names; gitea's internal-API refusal joins its route' (#70) from feat/route-proxy-internal-acme into main 2026-09-25 19:55:32 +00:00
jschoubben c7964b7285 Merge pull request 'gitea: a consumer's user is actually created, and a failed create says why' (#71) from fix/gitea-provisioner-user-create into main 2026-09-25 19:54:08 +00:00
jschoubben 02ccf31a71 gitea: a consumer's user is actually created, and a failed create says why
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.
2026-09-25 21:42:38 +02:00
jschoubben c2353fc0a6 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.
2026-09-25 20:51:44 +02:00
jochen 45dd036623 The npm registry is a seat gitea holds, and gitea holds the git seat a build's source can live on
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.
2026-09-25 20:48:10 +02:00
jschoubben 962cba7c04 route-proxy: internal names are certified by the mesh's own authority
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.
2026-09-25 20:36:41 +02:00
jschoubben bfe99f8c78 Merge pull request 'minio: declare the route contribution it has always needed, scoped from PR #58' (#68) from fix/minio-declares-its-real-route-contribution into main 2026-09-25 16:19:39 +00:00
jschoubben f0aa9e5fed minio: declare the route contribution it has always needed, scoped from PR #58
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.
2026-09-25 18:19:27 +02:00
jschoubben cb38bf08a6 Merge pull request 'route-proxy: the trust step skips the fetch when the CA names no roots to get' (#67) from fix/route-proxy-trust-skips-when-there-is-nothing-to-fetch into main 2026-09-25 16:15:09 +00:00
jschoubben 95a0a5672c route-proxy: the trust step skips the fetch when the CA names no roots to get
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.
2026-09-25 18:14:58 +02:00
jschoubben d243b56942 Merge pull request 'route-proxy: the server container resolves its own built artifact' (#66) from fix/route-proxy-server-uses-its-real-artifact into main 2026-09-25 16:09:19 +00:00
jschoubben 4c5e69903f route-proxy: the server container resolves its own built artifact
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.
2026-09-25 18:09:09 +02:00
jschoubben 547937034f Merge pull request 'public-acme: the roots field is named roots, not root' (#65) from fix/public-acme-field-name-matches-what-route-proxy-reads into main 2026-09-25 16:08:39 +00:00
jschoubben 3147fac08b public-acme: the roots field is named roots, not root
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).
2026-09-25 18:08:28 +02:00
jschoubben 7a96287d99 Merge pull request 'route-proxy: declare the build context its own Dockerfile has always needed' (#64) from feat/route-proxy-declares-its-real-context into main 2026-09-25 15:58:20 +00:00
jschoubben dd5b973e66 route-proxy: declare the build context its own Dockerfile has always needed
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.
2026-09-25 17:58:10 +02:00
jschoubben 72b1413497 Merge pull request 'builder is a real built module now, not handed over' (#63) from feat/builder-is-a-real-built-module into main 2026-09-25 15:54:58 +00:00
jschoubben 8369fe22b8 builder is a real built module now, not handed over
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.
2026-09-25 17:54:46 +02:00
jschoubben 19e0a8469d Merge pull request 'minio: install mc in the runtime image, declare its region' (#62) from fix/minio-provisioner-missing-mc-cli into main 2026-09-25 15:25:18 +00:00
jschoubben 271911753f Merge pull request 'builder: consume package-registry as a real mesh grant, not a hand-faked one' (#61) from fix/builder-real-package-registry-grant into main 2026-09-25 15:25:04 +00:00
jschoubben 4e2dd0adae Merge pull request 'gitea: listens its real internal ssh port (22), not 2222' (#60) from fix/gitea-ssh-port-manifest-mismatch into main 2026-09-25 15:24:49 +00:00
jschoubben 1b02dc1f66 builder: qualify its own image with the registry host
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.
2026-09-25 17:10:56 +02:00
jschoubben 755b0a5599 builder: consume package-registry as a real mesh grant, not a hand-faked one
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.
2026-09-25 17:08:12 +02:00
jschoubben ae6dfea2c9 gitea: listens its real internal ssh port (22), not 2222
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.
2026-09-25 17:07:58 +02:00
jschoubben b3de7e7944 minio: declare region eu-west, don't leave it as live-only drift
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.
2026-09-25 15:10:30 +02:00
jschoubben 107090310d nextcloud: point S3 config at the bucket the mesh actually provisioned
OBJECTSTORE_S3_BUCKET=nextcloud was a leftover from before the module
existed — that bucket was created by hand during tonight's earlier HAL
credential stopgap. The mesh's own minio provisioner derives its own
bucket name from the consumer's access-key identity (bucketFor(as) in
minio/client.ts) rather than honouring contributes.s3-bucket.bucket — by
design, so teardown can recompute the name with nothing persisted — and
minted mesh-novox-ncloud, a different bucket. mesh_novox_ncloud's scoped
policy only covers that bucket, so every S3 write 403'd with AccessDenied
trying to touch the old one. Pointed both the request hint and the real
env var at the bucket that's actually there.
2026-09-25 14:42:57 +02:00
jschoubben 5d13a5078c minio: install mc in the runtime image — the provisioner needs it to run
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.
2026-09-25 14:37:23 +02:00
jschoubben 61eca75315 nextcloud: pull the S3 region from the binding, not a hardcoded literal
the s3-bucket binding already carries serves.region (same mechanism as
at/port); ${bound:s3-bucket:region} tracks whatever minio is actually
configured with instead of a copy that can drift
2026-09-25 14:15:40 +02:00
jschoubben df0e9017e0 nextcloud: set OBJECTSTORE_S3_REGION to match minio's actual region
minio runs with MINIO_REGION=eu-west; nextcloud's S3 config never set a
region, so every object write (avatars, file writes) failed signature
validation with AuthorizationHeaderMalformed, surfacing as Internal Server
Error on real page loads
2026-09-25 14:12:05 +02:00
jschoubben b60dd5e3e3 nextcloud: use a module-owned mesh-admin account instead of 'admin'
the migrated data has no literal 'admin' account — HAL's real admin login
is a personal account (jochens), not a generic one. Resetting that would
touch a real user's own credential, so the module gets its own dedicated
admin-group service account instead, same pattern as the minio per-module
service accounts. mesh-admin was created once by hand on novox to match
this manifest for the already-migrated data; a genuinely fresh install
seeds it automatically via NEXTCLOUD_ADMIN_USER/NEXTCLOUD_ADMIN_PASSWORD.
2026-09-25 13:51:40 +02:00
jschoubben b865978e19 nextcloud: use a named build stage for docker:cli, not ARG-in-COPY-from
the legacy (non-BuildKit) docker build this host runs doesn't expand ARGs
inside COPY --from — only FROM. Give it its own named stage instead
2026-09-25 13:49:02 +02:00
jschoubben cdbc850c52 nextcloud: declare docker:cli as a pinned build.on base
build refused to reach docker:cli implicitly (novox/hq ADR 0097); pin it by
digest and thread it through as DOCKER_CLI, redeclared in the final stage
since args declared before the first FROM don't carry past it
2026-09-25 13:48:00 +02:00
jschoubben e2b3723dd9 nextcloud: fix hardcoded sidecar port and missing docker CLI in runtime image
- MESH_NEXTCLOUD_URL hardcoded :80 instead of the mesh-assigned ${port:80}
- sidecar's occ() shells to docker exec but the docker CLI binary was never
  present in the runtime image, only the mounted socket
2026-09-25 13:47:03 +02:00
jschoubben 5f74c41a3e nextcloud: give the admin password a real _FILE variant, not an env-file
The mesh's own check caught it: an env-file-loaded secret still reaches
the process environment, readable via docker inspect and /proc (hq
04-ISSUES/041) -- the same class of exposure the file-based delivery
exists to avoid. Added MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE support to the
client, matching the pattern the minio client already uses, and mounted
the sealed admin secret directly rather than writing it into an env-file.
2026-09-25 13:42:21 +02:00
jschoubben 4329ca9392 nextcloud: deliver the admin password to the sidecar too
The sidecar's own client needs MESH_NEXTCLOUD_ADMIN_PASSWORD to list
shares over the OCS API, but the runtime container's env/volumes never
carried it -- only the server container did. Delivered the same way every
other sealed value in this manifest already is: a generated env-file with
the ${secret:admin} substitution, not a raw value in the container's env.
2026-09-25 13:41:17 +02:00
jschoubben 066254e5c4 nextcloud: pin an image whose PHP matches this Nextcloud version
The pinned digest resolved to a PHP 8.5.10 image; Nextcloud 30 refuses to
run above PHP 8.4. Repinned to the current digest for the nextcloud:30 tag
(matches HAL's own NEXTCLOUD_VERSION), which carries PHP 8.3.28 -- the
same version the data being migrated was actually running under.
2026-09-25 13:39:16 +02:00
jschoubben 440e3e446e 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.
2026-09-25 10:45:01 +02:00
jschoubben 20df40c949 minio: revert to single-node after measuring the real cost of sharding
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.
2026-09-24 23:07:02 +02:00
jschoubben 6a6dd4a7dc 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.
2026-09-24 20:31:20 +02:00
jschoubben 973d80aaa2 minio: publish both public routes now that a module can answer route twice
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.
2026-09-24 18:45:52 +02:00
jschoubben 945390e59a minio: run the real 4-node/8-drive erasure-coded cluster, not a single container
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.
2026-09-24 18:31:38 +02:00
jschoubben 49c5903861 Merge pull request 'minio: repin to pgsty's fork, build the runtime sidecar, move data off HAL's live drive' (#57) from fix/minio-repin-and-move-off-hal-data into main 2026-09-24 16:15:09 +00:00
jschoubben 35e8a3fe19 minio: repin to pgsty's fork, build the runtime sidecar, move data off HAL's live drive
minio/minio and minio/mc were pulled from all public registries on 2026-09-11;
pgsty's fork is the working replacement (hq issue 113). The runtime sidecar
had no Dockerfile and no build section at all — added, following the same
tsc-over-client/tools/provisioner shape every other converted module uses.

The data resource pointed straight at /services/minio/data/data1-1, one of
HAL's live 8-drive erasure-coded array — starting this module would have
written into production storage the mesh doesn't own. Moved to a fresh,
empty, mesh-owned directory; the actual data migration happens over the S3
API (rclone), not by sharing a disk path.
2026-09-24 17:55:49 +02:00
jschoubben ed0f4602a6 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.
2026-09-24 17:27:04 +02:00
jschoubben 13d0361640 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.
2026-09-24 17:25:16 +02:00
jschoubben 61eb201f8a 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.
2026-09-24 16:39:11 +02:00
jschoubben 5c3781d8c2 Merge pull request '4 modules: persistent data is a directory bind, never a named volume' (#54) from fix/persistent-data-is-a-directory-bind-not-a-volume into main 2026-09-24 14:24:15 +00:00
jschoubben 6a3e0ab9d3 4 modules: persistent data is a directory bind, never a named volume
hq ADR 0107 / issue 115. HAL's own postgres and lavinmq both used a
directory bind (./db-data, ./data) for exactly this data -- the mesh's
adoption of them, three weeks ago, switched to a named Docker volume
instead, and searxng/distribution followed the same pattern since.

A named volume survives ordinary container recreation, same as a
directory bind -- that was never the problem. The problem is everything
else: docker rm -fv, docker volume rm, and docker system prune --volumes
all target it (one flag away from the docker rm -f this migration already
uses routinely); it is invisible to every tool this migration has used all
night (ls, find, grep across /var/lib, /services); and nothing outside
Docker's own volume machinery can back it up or notice it growing.

mesh-store carries the sharpest version: every database migrated tonight,
including keycloak's, live inside it.

Data already copied and verified on novox before this merges:
- mesh-registry-data -> /var/lib/mesh-registry (11G, diff -rq clean)
- mesh-broker-data -> /var/lib/mesh-broker (37M, cp -a)
- mesh-broker-tls -> /var/lib/mesh-broker-tls (12K, cp -a)
- mesh-store-data -> /var/lib/mesh-store (1.7G) -- mesh-store stopped
  cleanly first, so the final copy is crash-consistent, not a live-file
  copy of a running postgres; diff -rq clean after.
- searxng/valkey: not yet assigned anywhere, manifest-only fix, nothing
  to copy.

Old named volumes left in place, not deleted, as the rollback path.
2026-09-24 16:12:03 +02:00
jschoubben 3e8ecfa4eb Merge pull request '11 modules: a container's ports say the software's port, not the machine's' (#53) from fix/091-ports-machine-side-is-the-mesh-to-assign into main 2026-09-24 13:53:34 +00:00
jschoubben 12f885ffc5 11 modules: a container's ports say the software's port, not the machine's
hq issue 091, measured 2026-09-22: 14 of 46 modules with containers fix the
machine side of a published port in their own manifest -- a fact about one
machine (which port HAL happened to publish it on) written into a
definition meant for any node. ADR 0038 already says the mesh assigns the
machine side and a module says only what it needs; the machinery already
does it (internal/inventory/ports.go's PortFor, declaration.go's
publishedOn rewrites a bare port automatically). These 14 just never
complied.

Fixed 11 of them -- stripped to the bare software port, letting assignment
take over: de-spiegel, gitea, hello-web (the demo module), mailu, mssql,
n8n, novox.be, only-office, photos, photos-eef, photos-filip.

Left alone, the two defensible kinds the issue names: postgres/lavinmq/
distribution (foundation, genesis-rewritten per ADR 0100 -- the number in
the manifest is a default, not a claim) and unifi (protocol/
device-discovery fixes the number; nothing else can find the controller).

gitea was a live one, not just a tidiness fix: its manifest said 2222:22,
but the actual adopted, running container is on 222. Harmless while held,
but the next 'take' would have recreated it on the wrong port and broken
SSH git access. Recorded 222 as novox's own setting for it (settings set
gitea -node novox) so that doesn't happen.
2026-09-24 15:45:58 +02:00
jschoubben 7551a65579 Merge pull request 'postgres: the provisioner connects to mesh-store's actual port, not a hardcoded 5432' (#52) from fix/postgres-provisioner-connects-to-the-actual-store-port into main 2026-09-24 13:38:45 +00:00
jschoubben 135101ce6e postgres: fix the port override properly — a twin variable, not a raw substitution
The first commit on this branch embedded ${seat:mesh-store:5432} directly
inside MESH_PROVISION_POSTGRES's URL. That placeholder resolves to an empty
string whenever mesh-store is on its own default port (novox/hq
internal/catalogue/seat_into.go: 'the mesh raised on the catalogue's own
ports never gives them a setting at all') -- which produces a malformed
connection string (host:/postgres) on exactly the common case, a fresh,
non-adopted mesh. It only worked here because novox's mesh-store happens to
be adopted at a non-default port.

mesh-controller's own manifest already has the right shape for this --
internal/envfile/port.go's NAME / NAME_PORT twin, composed by
mesh-controller's Placed(): the base value keeps its own port; a separate
_PORT variable carries the override, spliced in only when it says
something, and left alone -- not a fault -- when it's an unfilled
placeholder (a manifest ahead of the running controller).

Reverted the manifest to its original base value, added
MESH_PROVISION_POSTGRES_PORT as the twin, and taught client.ts (the only
consumer -- both tools/ and provisioner/ import it) the same precedence
envfile.Placed uses. Checked: no other file in the module reads
MESH_PROVISION_POSTGRES directly.
2026-09-24 15:27:16 +02:00
jschoubben 088022d63b postgres: the provisioner connects to mesh-store's actual port, not a hardcoded 5432
MESH_PROVISION_POSTGRES was a literal connection string naming port 5432 --
correct only when mesh-store happens to run on the mesh's own default. On
novox, mesh-store was adopted in place at HAL's original port (6852), and
the provisioner has been retrying-and-failing against 127.0.0.1:5432 ever
since, for every consumer including ones that already exist (gitea, umami,
mesh-catalog), not just a new grant.

Fixed with the same ${seat:mesh-store:5432} template mesh-controller's own
manifest already uses for the identical connection. No other module needed
this fix checked -- lavinmq's MESH_PROVISION_LAVINMQ already used
127.0.0.1:15672 unconditionally, but the broker's management port is fixed
by the module itself (127.0.0.1:15672 in lavinmq/module.json's own ports),
not by adoption, so it isn't the same bug.
2026-09-24 15:24:07 +02:00
jschoubben 415ad7a168 Merge pull request 'gitea: the token needs read:user, not just write:repository and write:issue' (#51) from fix/gitea-user-repos-scope into main 2026-09-24 09:49:36 +00:00
jschoubben aaf341a2d8 gitea: the token needs read:user, not just write:repository and write:issue
Deployed #49 and the watcher immediately broke: GET /user/repos answered 403,
'required=[read:user]' — confirmed live against the running forge (1.27.3).
That route sits under gitea's user scope category despite listing
repositories, not repository as assumed.

Also gives the fake forge real scope enforcement on /user/repos, which is
why the original PR's test suite didn't catch this: it only checked the
token's value was valid, never that it carried the required scope.
2026-09-24 11:43:46 +02:00
jschoubben 8a458aa5c4 Merge pull request 'The forge mints its own API token with the admin account the vault delivers' (#49) from fix/gitea-mints-its-token into main 2026-09-24 08:41:13 +00:00
jschoubben d036b43bfc Merge main into fix/gitea-mints-its-token to pick up the resolver conversion and artifact-store seat fix 2026-09-24 10:40:57 +02:00
jschoubben 5a94b78891 Merge pull request 'Convert the resolver from the module it replaces: forward, answer at 127.0.0.1, point the runtime at it' (#50) from convert/dnsmasq-from-hal into main 2026-09-23 23:13:08 +00:00
jschoubben ed094031fd The forge mints its own API token with the admin account the vault delivers
The runtime beside the forge served 0 tools: GiteaClient.fromEnv required a token
(settings or MESH_GITEA_TOKEN), nobody had one to give — the mesh raised the forge —
and putting one in settings would store a secret in plaintext in the inventory. So
the fifteen tools registered nothing and the watcher logged "not watching".

What the mesh does deliver is the admin account: a login the manifest names and a
password the vault minted and the host unsealed into a file (ADR 0086). That is
enough to mint a token, so the module does (hq issue 100, the forge's tools):
POST /users/{admin}/tokens over basic auth, scoped to write:repository and
write:issue — the least the tools and the repo watcher need — kept at 0600 in the
module's own state (/var/lib/mesh/gitea/state, a new directory resource the runtime
mounts writable), read back on the next start, and minted afresh when the forge
answers 401 to it or the kept file is gone. A forge whose data came from the
predecessor has no mesh-admin: that is reported in plain words on every poll until
it clears, once per reason, not crash-looped. A configured token still wins and is
never minted over.

The mint happens on the first call, not at registration: a contributor is
synchronous, and a forge not yet answering must not keep the runtime from serving.
One source per kept file in a process, or the watcher and the tools would each
renew on a 401 and drop the other's token by name.
2026-09-23 23:48:36 +02:00
182 changed files with 4399 additions and 1952 deletions
-56
View File
@@ -1,56 +0,0 @@
{
"module": "amqp-email-forwarder",
"version": "1",
"slug": "emailfwd",
"capabilities": [
"container-runtime"
],
"requires": [
"amqp"
],
"contributes": {},
"binds": {
"amqp": "/var/lib/amqp-email-forwarder/amqp.json"
},
"secrets": {
"amqp": "/var/lib/amqp-email-forwarder/amqp.secret"
},
"own-secrets": {
"smtp-user": "/var/lib/amqp-email-forwarder/smtp-user.secret",
"smtp-password": "/var/lib/amqp-email-forwarder/smtp-password.secret"
},
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/amqp-email-forwarder",
"mode": "0700"
},
{
"id": "app-env",
"type": "file",
"path": "/var/lib/amqp-email-forwarder/app.env",
"mode": "0600",
"content": "AMQP_HOST=${bound:amqp:at}\nAMQP_PORT=${bound:amqp:port}\nAMQP_USER=${bound:amqp:as}\nAMQP_VHOST=EMAILDELIVERY_T\nAMQP_EXCHANGE=News.TransactionalEmailing.Command\nAMQP_QUEUE=email-forwarder\nAMQP_URL=amqp://${bound:amqp:as}:${secret:amqp}@${bound:amqp:at}:${bound:amqp:port}/EMAILDELIVERY_T\nSMTP_HOST=mail.novox.be\nSMTP_PORT=587\nSMTP_USER=${secret:smtp-user}\nSMTP_PASSWORD=${secret:smtp-password}\n"
},
{
"id": "net",
"type": "network",
"name": "amqp-email-forwarder"
},
{
"id": "app",
"type": "container",
"name": "amqp-email-forwarder",
"image": "registry-api.novox.be/novox/amqp-email-forwarder@sha256:f76d34646d9d3b2098c72688a63f6ae656f1888ffcb2f90a9c8dd2a44ad7f8af",
"network": "amqp-email-forwarder",
"env-file": [
"/var/lib/amqp-email-forwarder/app.env"
],
"restart-on": [
"app-env"
],
"secrets-in-environment": "the application's own code reads AMQP_URL, SMTP_USER and SMTP_PASSWORD from the environment (amqp-email-forwarder app.js); converting is that repository's change"
}
]
}
-31
View File
@@ -1,31 +0,0 @@
# amqp-ping's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are not
# copied out of neighbouring checkouts — they are in the base image, which is published like any
# other artifact. That is what makes this buildable by the mesh from a repository and a path
# (novox/hq ADR 0069) rather than only on a workstation that happens to have the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules, so resolving `@novox/mesh-sdk` walks up to the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/amqp-ping
COPY . .
# The compiler is invoked by its real path, not through node_modules/.bin. Those are symlinks to
# a launcher that requires its library relatively, and the base image resolves them when copying —
# leaving a launcher whose relative require no longer points at anything.
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/amqp-ping/dist /app/modules/amqp-ping/dist
# Declared rather than derived from which files happen to exist: the module knows what it serves.
ENV MESH_TOOL_MODULES=/app/modules/amqp-ping/dist/index.js
-191
View File
@@ -1,191 +0,0 @@
// amqp-ping's AMQP client — the demo consumer's own code (novox/hq ADR 0039). It speaks AMQP 0-9-1
// directly over a raw TCP socket (node:net), the way redis's client speaks RESP: the module carries
// NO npm dependency beyond @novox/mesh-sdk — no amqplib, no CLI in the image. It does exactly one
// thing, the round-trip that proves the grant works: connect, authenticate with PLAIN to the vhost
// the mesh named, declare a queue, publish one message and get it back.
//
// This is the consumer half of the `amqp` interface. It connects as the login the mesh derived
// (`${bound:amqp:as}`) with the password the mesh minted (`${secret:amqp}`) to a vhost of that SAME
// name — the provider named the vhost after the login, so the consumer must too. Nothing here is
// hardcoded: user AND vhost are both the bound login, and a wrong vhost is refused by the broker.
import { createConnection, type Socket } from "node:net";
import { readFileSync } from "node:fs";
const FRAME_END = 0xce;
const PROTOCOL_HEADER = Buffer.from([0x41, 0x4d, 0x51, 0x50, 0x00, 0x00, 0x09, 0x01]); // "AMQP" 0-9-1
export interface AmqpConn {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly vhost: string;
}
/** Build the connection facts from the environment the mesh's env-file set (see the module manifest). */
export function connFromEnv(env: NodeJS.ProcessEnv = process.env): AmqpConn {
const host = env.MESH_AMQP_HOST ?? "";
const port = Number(env.MESH_AMQP_PORT ?? "5672") || 5672;
const user = env.MESH_AMQP_USER ?? "";
const vhost = env.MESH_AMQP_VHOST ?? user; // the provider names the vhost after the login
const password = env.MESH_AMQP_PASSWORD ?? readMaybe(env.MESH_AMQP_PASSWORD_FILE);
if (!host || !user || !password) {
throw new Error(`amqp-ping: connection is not fully set yet (host=${host} user=${user} password=${password ? "set" : "unset"})`);
}
return { host, port, user, password, vhost };
}
// --- wire helpers ---------------------------------------------------------------------------------
function shortstr(s: string): Buffer {
const b = Buffer.from(s, "utf8");
const o = Buffer.alloc(1 + b.length);
o.writeUInt8(b.length, 0);
b.copy(o, 1);
return o;
}
function longstr(s: Buffer | string): Buffer {
const b = Buffer.isBuffer(s) ? s : Buffer.from(s, "utf8");
const o = Buffer.alloc(4 + b.length);
o.writeUInt32BE(b.length, 0);
b.copy(o, 4);
return o;
}
function u16(n: number): Buffer {
const o = Buffer.alloc(2);
o.writeUInt16BE(n, 0);
return o;
}
function u32(n: number): Buffer {
const o = Buffer.alloc(4);
o.writeUInt32BE(n, 0);
return o;
}
function frame(type: number, channel: number, payload: Buffer): Buffer {
const o = Buffer.alloc(7 + payload.length + 1);
o.writeUInt8(type, 0);
o.writeUInt16BE(channel, 1);
o.writeUInt32BE(payload.length, 3);
payload.copy(o, 7);
o.writeUInt8(FRAME_END, 7 + payload.length);
return o;
}
function method(channel: number, classId: number, methodId: number, ...parts: Buffer[]): Buffer {
return frame(1, channel, Buffer.concat([u16(classId), u16(methodId), ...parts]));
}
interface MethodWaiter {
classId: number;
methodId: number;
resolve: (args: Buffer) => void;
reject: (e: Error) => void;
}
/**
* Connect, authenticate to the vhost, declare a queue, publish one message and get it back. Returns
* the body that came back — the caller checks it equals what went out. Throws on any protocol error,
* including the broker's `NOT_ALLOWED` refusal of a vhost the login has no permission on (the
* isolation the provider builds, seen from the consumer's side).
*/
export function roundTrip(conn: AmqpConn, queue = "amqp-ping", payload?: string): Promise<string> {
const body = Buffer.from(payload ?? `ping-${Date.now()}`);
return new Promise<string>((resolve, reject) => {
const sock: Socket = createConnection({ host: conn.host, port: conn.port });
let buf = Buffer.alloc(0);
const waiters: MethodWaiter[] = [];
let lastBody: Buffer | null = null;
let done = false;
const fail = (e: Error): void => {
if (done) return;
done = true;
sock.destroy();
reject(e);
};
const expect = (classId: number, methodId: number): Promise<Buffer> =>
new Promise((res, rej) => waiters.push({ classId, methodId, resolve: res, reject: rej }));
sock.on("error", (e) => fail(e));
sock.on("close", () => fail(new Error("amqp connection closed before the round-trip completed")));
sock.on("data", (chunk: Buffer) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
if (buf.length < 7) return;
const type = buf.readUInt8(0);
const size = buf.readUInt32BE(3);
if (buf.length < 7 + size + 1) return;
const framePayload = buf.subarray(7, 7 + size);
buf = buf.subarray(7 + size + 1);
if (type === 1) {
const classId = framePayload.readUInt16BE(0);
const methodId = framePayload.readUInt16BE(2);
const args = framePayload.subarray(4);
const w = waiters.shift();
if (!w) continue;
if (w.classId === classId && w.methodId === methodId) w.resolve(args);
else w.reject(new Error(`expected method ${w.classId}/${w.methodId}, got ${classId}/${methodId}: ${args.toString("utf8")}`));
} else if (type === 3) {
lastBody = framePayload; // a content body frame
}
// type 2 (content header) and type 8 (heartbeat) need no handling for this round-trip.
}
});
sock.on("connect", () => {
void (async () => {
try {
sock.write(PROTOCOL_HEADER);
await expect(10, 10); // Connection.Start
const response = Buffer.concat([
Buffer.from([0]), Buffer.from(conn.user, "utf8"), Buffer.from([0]), Buffer.from(conn.password, "utf8"),
]);
// Connection.Start-Ok: empty client-properties table, PLAIN, the SASL response, locale.
sock.write(method(0, 10, 11, u32(0), shortstr("PLAIN"), longstr(response), shortstr("en_US")));
const tune = await expect(10, 30); // Connection.Tune
const frameMax = tune.readUInt32BE(2) || 131072;
sock.write(method(0, 10, 31, u16(tune.readUInt16BE(0)), u32(frameMax), u16(0))); // Tune-Ok, no heartbeat
sock.write(method(0, 10, 40, shortstr(conn.vhost), shortstr(""), Buffer.from([0]))); // Connection.Open
await expect(10, 41); // Open-Ok — authenticated and into the vhost
sock.write(method(1, 20, 10, shortstr(""))); // Channel.Open
await expect(20, 11);
// Queue.Declare: reserved, queue, bits(auto-delete=1), empty arguments table.
sock.write(method(1, 50, 10, u16(0), shortstr(queue), Buffer.from([0b00001000]), u32(0)));
await expect(50, 11);
// Basic.Publish to the default exchange, routing-key = queue; then content header + body.
sock.write(method(1, 60, 40, u16(0), shortstr(""), shortstr(queue), Buffer.from([0])));
const bodySize = Buffer.alloc(8);
bodySize.writeBigUInt64BE(BigInt(body.length), 0);
sock.write(frame(2, 1, Buffer.concat([u16(60), u16(0), bodySize, u16(0)]))); // content header, no properties
sock.write(frame(3, 1, body)); // content body
await new Promise((r) => setTimeout(r, 200));
lastBody = null;
sock.write(method(1, 60, 70, u16(0), shortstr(queue), Buffer.from([1]))); // Basic.Get, no-ack
await expect(60, 71); // Get-Ok (a Get-Empty would arrive as 60/72 and reject the expect)
await new Promise((r) => setTimeout(r, 200));
const received = lastBody ? (lastBody as Buffer).toString("utf8") : "";
sock.write(method(0, 10, 50, u16(200), shortstr("bye"), u16(0), u16(0))); // Connection.Close
await expect(10, 51).catch(() => undefined);
done = true;
sock.end();
resolve(received);
} catch (e) {
fail(e instanceof Error ? e : new Error(String(e)));
}
})();
});
});
}
function readMaybe(path: string | undefined): string {
if (!path) return "";
try {
return readFileSync(path, "utf8").replace(/\n$/, "");
} catch {
return "";
}
}
-53
View File
@@ -1,53 +0,0 @@
// amqp-ping — a tiny demo consumer of the mesh `amqp` interface, run as a long-lived container by
// `mesh-tools run` (it never returns, so the container stays up). It exists to PROVE the grant end to
// end: the mesh gave it a scoped login and a vhost of that name on the lavinmq provider, and this
// connects with exactly those and round-trips a message.
//
// The connection facts arrive the way every consumer's do — the mesh writes them into an env-file the
// container reads (novox/hq ADR 0048): MESH_AMQP_HOST/PORT from the binding, MESH_AMQP_USER and
// MESH_AMQP_VHOST both from `${bound:amqp:as}` (the provider named the vhost after the login, so the
// consumer uses the login for both — the db-name lesson applied to AMQP), and MESH_AMQP_PASSWORD from
// `${secret:amqp}`.
//
// It retries: on first boot the provider may not have provisioned this consumer yet (the reconcile is
// asynchronous and cross-container), so a refused or unreachable connection is a "not yet", not a
// failure — it waits and tries again until the round-trip succeeds, then holds the connection idle
// and re-pings on a slow cadence so the container is a stable, running proof.
import { connFromEnv, roundTrip } from "./client.js";
async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
async function pingOnce(): Promise<boolean> {
try {
const conn = connFromEnv();
const sent = `ping-${Date.now()}`;
const got = await roundTrip(conn, "amqp-ping", sent);
if (got === sent) {
console.log(`[amqp-ping] round-trip ok as ${conn.user} on vhost ${conn.vhost} (${conn.host}:${conn.port})`);
return true;
}
console.error(`[amqp-ping] round-trip mismatch: sent ${sent}, got ${got}`);
return false;
} catch (err) {
console.error(`[amqp-ping] not ready yet: ${err instanceof Error ? err.message : err}`);
return false;
}
}
// Wait for the first successful round-trip — the proof this consumer's grant works — then stay up.
let first = false;
for (let i = 0; !first; i++) {
first = await pingOnce();
if (!first) await sleep(3000);
}
console.log("[amqp-ping] connected and round-tripped; holding steady");
for (;;) {
await sleep(30000);
await pingOnce();
}
// changed by the one-node test at build 66e54af151df
// changed by the one-node test at build 4fb41636cffd
// changed by the one-node test at build a69f083bf6a6
-89
View File
@@ -1,89 +0,0 @@
{
"module": "amqp-ping",
"slug": "ping",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"amqp"
],
"contributes": {},
"binds": {
"amqp": "/var/lib/amqp-ping/amqp.json"
},
"secrets": {
"amqp": "/var/lib/amqp-ping/amqp.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/amqp-ping/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/amqp-ping",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/amqp-ping",
"mode": "0700"
},
{
"id": "amqp-env",
"type": "file",
"path": "/var/lib/amqp-ping/amqp.env",
"mode": "0600",
"content": "MESH_AMQP_HOST=${bound:amqp:at}\nMESH_AMQP_PORT=${bound:amqp:port}\nMESH_AMQP_USER=${bound:amqp:as}\nMESH_AMQP_VHOST=${bound:amqp:as}\n"
},
{
"id": "net",
"type": "network",
"name": "amqp-ping"
},
{
"id": "runtime",
"type": "container",
"name": "amqp-ping",
"network": "amqp-ping",
"volumes": [
"/var/lib/mesh/amqp-ping/broker:/run/secrets/broker:ro",
"/var/lib/amqp-ping/amqp.secret:/run/secrets/amqp:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_AMQP_PASSWORD_FILE": "/run/secrets/amqp"
},
"env-file": [
"/var/lib/amqp-ping/amqp.env"
],
"restart-on": [
"amqp-env"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-amqp-ping",
"version": "0.1.0",
"description": "amqp-ping — a demo consumer of the mesh amqp interface. Connects with its scoped grant and round-trips one message to prove the broker the mesh gave it (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts"]
}
+1 -1
View File
@@ -18,7 +18,7 @@
"broker": "/var/lib/mesh/anthropic-consumer/broker" "broker": "/var/lib/mesh/anthropic-consumer/broker"
}, },
"emits": [ "emits": [
"module.anthropic-consumer.usage.session" "usage.session"
], ],
"resources": [ "resources": [
{ {
+1 -1
View File
@@ -123,7 +123,7 @@ async function emitUsage(body: Record<string, unknown>): Promise<void> {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn( const child = spawn(
process.execPath, process.execPath,
[main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)], [main, "emit", "usage.session", JSON.stringify(body)],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
child.on("exit", () => resolve()); child.on("exit", () => resolve());
+1 -1
View File
@@ -18,7 +18,7 @@
"broker": "/var/lib/mesh/anthropic-manager/broker" "broker": "/var/lib/mesh/anthropic-manager/broker"
}, },
"emits": [ "emits": [
"module.anthropic-manager.usage.read" "usage.read"
], ],
"resources": [ "resources": [
{ {
+1 -1
View File
@@ -143,7 +143,7 @@ async function emitUsage(body: Record<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process"); const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.usage.read", JSON.stringify(body)], { const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], {
stdio: "inherit", stdio: "inherit",
}); });
child.on("exit", () => resolve()); child.on("exit", () => resolve());
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1", "version": "1",
"slug": "audit", "slug": "audit",
"consumes": [ "consumes": [
"#" "**"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/audit-logger/broker" "broker": "/var/lib/audit-logger/broker"
+3 -3
View File
@@ -15,16 +15,16 @@ test("audit-logger records every event to the trail as one line each", async ()
const path = join(dir, "audit.log"); const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it. // The audit-logger's whole behaviour: consume everything, record it.
await on("#", async (event) => record(event, path)); await on("**", async (event) => record(event, path));
process.env.MESH_MODULE = "umami"; process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor"; process.env.MESH_NODE = "anchor";
await emit("module.umami.site.created", { domain: "my-app" }); await emit("site.created", { domain: "my-app" });
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 2); assert.equal(lines.length, 2);
assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]); assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami"); assert.equal(lines[0].source, "umami");
assert.equal(lines[0].node, "anchor"); assert.equal(lines[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app"); assert.equal(lines[0].body.domain, "my-app");
+2 -1
View File
@@ -14,7 +14,7 @@
}, },
"route": { "route": {
"label": "baserow", "label": "baserow",
"port": 80 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -30,6 +30,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1 -1
View File
@@ -24,7 +24,7 @@ async function pollHistory(): Promise<void> {
for (const entry of entries) { for (const entry of entries) {
if (seen.has(entry.id)) continue; if (seen.has(entry.id)) continue;
if (primed) { if (primed) {
await emit("module.bazarr.subtitle.downloaded", { await emit("subtitle.downloaded", {
kind: entry.kind, kind: entry.kind,
title: entry.title, title: entry.title,
language: entry.language, language: entry.language,
+4 -3
View File
@@ -5,7 +5,7 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.bazarr.subtitle.downloaded" "subtitle.downloaded"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker", "broker": "/var/lib/mesh/bazarr/broker",
@@ -13,6 +13,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 6767, "port": 6767,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -93,7 +94,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767", "MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key", "MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
@@ -110,7 +111,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "subs", "label": "subs",
"port": 6767 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -2
View File
@@ -45,12 +45,12 @@ async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
if (primed) { if (primed) {
// Entered the queue since last look — Bookshelf grabbed a release. // Entered the queue since last look — Bookshelf grabbed a release.
for (const [id, item] of now) { for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("module.bookshelf.book.grabbed", { title: item.title, status: item.status }); if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status });
} }
// Left the queue — imported and done, unless it was last seen failing. // Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) { for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("module.bookshelf.download.completed", { title: item.title }); await emit("download.completed", { title: item.title });
} }
} }
} }
+5 -4
View File
@@ -6,8 +6,8 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.bookshelf.book.grabbed", "book.grabbed",
"module.bookshelf.download.completed" "download.completed"
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
@@ -15,6 +15,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8787, "port": 8787,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -75,7 +76,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787", "MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
}, },
"artifact": "runtime" "artifact": "runtime"
@@ -87,7 +88,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "books", "label": "books",
"port": 8787 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+25
View File
@@ -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"]
+38 -28
View File
@@ -6,19 +6,22 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-build-machine", "name": "mesh-build-machine",
"scope": "node" "scope": "mesh"
} }
], ],
"requires": [ "requires": [
"artifact-store" "artifact-store",
], "npm-package-registry"
"emits": [
"module.builder.built"
], ],
"binds": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
},
"secrets": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
},
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/builder/broker", "broker": "/var/lib/mesh/builder/broker"
"npm-password": "/var/lib/mesh/builder/npm-password"
}, },
"resources": [ "resources": [
{ {
@@ -38,27 +41,13 @@
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/builder.env", "path": "/var/lib/mesh/builder/builder.env",
"mode": "0600", "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" "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": "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"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mesh-builder", "name": "mesh-builder",
"image": "mesh-builder@sha256:0000000000000000000000000000000000000000000000000000000000000000", "artifact": "server",
"env-file": [ "env-file": [
"/var/lib/mesh/builder/builder.env" "/var/lib/mesh/builder/builder.env"
], ],
@@ -68,11 +57,32 @@
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
"builder-env", "builder-env"
"package-binding",
"needs-npm-password"
], ],
"network": "host" "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:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
},
{
"arg": "ALPINE_BASE",
"image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
}
]
}
} }
+56
View File
@@ -0,0 +1,56 @@
{
"module": "ca-trust",
"version": "1",
"slug": "catrust",
"capabilities": [
"service-manager"
],
"requires": [
"internal-acme-ca"
],
"seats": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"claims": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "anchor",
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
},
{
"id": "trust",
"type": "service",
"unit": "mesh-ca-trust.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"anchor",
"unit"
]
}
]
}
+2 -2
View File
@@ -22,8 +22,8 @@
"broker": "/var/lib/mesh/cloudflare-dns/broker" "broker": "/var/lib/mesh/cloudflare-dns/broker"
}, },
"emits": [ "emits": [
"module.cloudflare-dns.record.created", "record.created",
"module.cloudflare-dns.record.removed" "record.removed"
], ],
"resources": [ "resources": [
{ {
+2 -2
View File
@@ -20,7 +20,7 @@ runProvisioner("public-dns", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
const fqdn = cloudflare.nameFor(p.as); const fqdn = cloudflare.nameFor(p.as);
await cloudflare.upsert(fqdn); await cloudflare.upsert(fqdn);
await announce("module.cloudflare-dns.record.created", { await announce("record.created", {
name: fqdn, name: fqdn,
target: cloudflare.ingress, target: cloudflare.ingress,
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
@@ -30,7 +30,7 @@ runProvisioner("public-dns", {
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
const fqdn = cloudflare.nameFor(p.as); const fqdn = cloudflare.nameFor(p.as);
await cloudflare.remove(fqdn); await cloudflare.remove(fqdn);
await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as }); await announce("record.removed", { name: fqdn, consumer: p.as });
}, },
}); });
+3 -2
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "de-spiegel", "label": "de-spiegel",
"port": 35621 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -23,6 +23,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 35621, "port": 35621,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -58,7 +59,7 @@
"/var/lib/de-spiegel/server.env" "/var/lib/de-spiegel/server.env"
], ],
"ports": [ "ports": [
"35621:35621" "35621"
], ],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change"
} }
+43
View File
@@ -0,0 +1,43 @@
# dhcpcd
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
private network's interface alone. It never declares an interface, an address, a route, a
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
the machine over.
## What it writes
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
takes or renews, which would silently replace the resolver `resolv-conf` names.
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
rather than relying on it.
**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line
after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in
exactly such a block (the interface, its static address), so appended at the end these two would
quietly apply to one interface only.
## Why it declares no service
dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it
drops the address the machine is reached at, and a module unassigned by mistake must not be able
to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and
a restart drops the lease. So the two lines take effect at **dhcpcd's next start**.
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
link for a moment is acceptable.
## One manager per machine
It claims `the-uplink`: a machine runs one network manager, and assigning a second module that
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
installs the package and writes the two lines, and starts nothing.
+30
View File
@@ -0,0 +1,30 @@
{
"module": "dhcpcd",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-uplink",
"scope": "node"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "dhcpcd"
},
{
"id": "config",
"type": "file",
"path": "/etc/dhcpcd.conf",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
}
]
}
+1 -1
View File
@@ -33,7 +33,7 @@ async function pollCatalog(): Promise<void> {
for (const tag of tags) { for (const tag of tags) {
const id = `${repo}:${tag}`; const id = `${repo}:${tag}`;
if (!seen.has(id)) { if (!seen.has(id)) {
if (primed) await emit("module.registry.image.pushed", { repo, tag }); if (primed) await emit("image.pushed", { repo, tag });
seen.add(id); seen.add(id);
} }
} }
+9 -2
View File
@@ -17,7 +17,7 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.registry.image.pushed" "image.pushed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/registry/broker" "broker": "/var/lib/mesh/registry/broker"
@@ -29,6 +29,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "registry",
"port": 5000, "port": 5000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -42,6 +43,12 @@
"path": "/var/lib/mesh/registry", "path": "/var/lib/mesh/registry",
"mode": "0700" "mode": "0700"
}, },
{
"id": "registry-data",
"type": "directory",
"path": "/var/lib/mesh-registry",
"mode": "0700"
},
{ {
"id": "store", "id": "store",
"type": "container", "type": "container",
@@ -51,7 +58,7 @@
"5000:5000" "5000:5000"
], ],
"volumes": [ "volumes": [
"mesh-registry-data:/var/lib/registry" "/var/lib/mesh-registry:/var/lib/registry"
] ]
} }
] ]
+2 -2
View File
@@ -20,10 +20,10 @@ async function poll(): Promise<void> {
const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address]));
if (primed) { if (primed) {
for (const [name, address] of now) { for (const [name, address] of now) {
if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address }); if (!known.has(name)) await emit("name.added", { name, address });
} }
for (const [name] of known) { for (const [name] of known) {
if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name }); if (!now.has(name)) await emit("name.removed", { name });
} }
} }
known.clear(); known.clear();
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -6,7 +6,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-intrusion-prevention", "name": "node-intrusion-prevention",
"scope": "node" "scope": "node"
} }
], ],
@@ -33,7 +33,7 @@
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.local", "path": "/etc/fail2ban/jail.local",
"mode": "0644", "mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
}, },
{ {
"id": "jail-sshd", "id": "jail-sshd",
@@ -42,6 +42,14 @@
"mode": "0644", "mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
}, },
{
"id": "log",
"type": "file",
"path": "/var/log/fail2ban.log",
"mode": "0640",
"create-once": true,
"content": ""
},
{ {
"id": "jail-recidive", "id": "jail-recidive",
"type": "file", "type": "file",
+1 -1
View File
@@ -23,7 +23,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # was assembled.
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
+137 -45
View File
@@ -4,10 +4,13 @@
// does. // does.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
/** A repository, trimmed to what the mesh cares about. */ /** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo { export interface GiteaRepo {
full_name: string; full_name: string;
/** The URL a build clones — what a module records as its source. */
clone_url?: string;
name: string; name: string;
owner: string; owner: string;
private: boolean; private: boolean;
@@ -33,6 +36,9 @@ export interface GiteaPull {
title: string; title: string;
state: string; state: string;
merged: boolean; merged: boolean;
/** The commit the merge produced — what a build of the base branch is made from. */
merge_commit_sha?: string;
merged_at?: string;
user?: string; user?: string;
head?: string; head?: string;
base?: string; base?: string;
@@ -53,44 +59,60 @@ function meshConfig(file?: string): Record<string, string> {
export class GiteaClient { export class GiteaClient {
readonly baseUrl: string; readonly baseUrl: string;
private cachedUsername: string | null = null; private readonly tokens: TokenSource;
constructor( /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */
url: string, constructor(url: string, token: string | TokenSource) {
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, ""); this.baseUrl = url.replace(/\/+$/, "");
this.tokens = typeof token === "string" ? new ConfiguredToken(token) : token;
} }
/** /**
* Build from the module's resolved environment. URL and token come from MESH_GITEA_URL / * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own
* MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the * name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order:
* URL, to the forge's loopback port. A token is required — without one there is no authenticated * one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else
* call to make, so this throws rather than hand back a client that fails on first use. * one the module mints for itself with the admin account the vault delivered and keeps in its own
* state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing,
* rather than hand back a client that fails on first use.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE);
const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); if (configured) return new GiteaClient(url, new ConfiguredToken(configured));
return new GiteaClient(url, token); return new GiteaClient(url, MintedToken.fromEnv(url, env));
} }
/**
* One authenticated call. A 401 is the forge saying the token is not one it knows — the case
* after the forge's data was restored, or after somebody revoked it — so the source is asked to
* renew once and the call is repeated with the new token. A configured token has nothing to renew
* with, and its source says so.
*/
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, { let token = await this.tokens.current();
...options, let res = await this.send(path, options, token);
headers: { if (res.status === 401) {
"Content-Type": "application/json", token = await this.tokens.renew(token);
Authorization: `token ${this.token}`, res = await this.send(path, options, token);
...(options.headers as Record<string, string> | undefined), }
},
});
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
if (res.status === 204) return null as T; if (res.status === 204) return null as T;
const text = await res.text(); const text = await res.text();
return (text ? JSON.parse(text) : null) as T; return (text ? JSON.parse(text) : null) as T;
} }
private send(path: string, options: RequestInit, token: string): Promise<Response> {
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined),
},
});
}
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
* Path is relative to /api/v1. */ * Path is relative to /api/v1. */
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
@@ -99,9 +121,23 @@ export class GiteaClient {
// ---- Repositories ---- // ---- Repositories ----
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> { async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`); const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
return (repos ?? []).map(GiteaClient.mapRepo); return (found?.data ?? []).map(GiteaClient.mapRepo);
}
/** Every repository, all pages. */
async listAllRepos(): Promise<GiteaRepo[]> {
const all: GiteaRepo[] = [];
for (let page = 1; page < 100; page++) {
const batch = await this.listRepos(page, 50);
all.push(...batch);
if (batch.length < 50) break;
}
return all;
} }
async createRepo(data: { async createRepo(data: {
@@ -193,6 +229,17 @@ export class GiteaClient {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`)); return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
} }
/** The files a merged pull request changed, as paths from the repository's root.
*
* `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
return { paths, truncated: paths.length >= limit };
}
async createPullRequest( async createPullRequest(
owner: string, owner: string,
repo: string, repo: string,
@@ -215,6 +262,7 @@ export class GiteaClient {
private static mapRepo(r: any): GiteaRepo { private static mapRepo(r: any): GiteaRepo {
return { return {
full_name: r.full_name, full_name: r.full_name,
clone_url: r.clone_url ?? undefined,
name: r.name, name: r.name,
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
private: Boolean(r.private), private: Boolean(r.private),
@@ -242,6 +290,8 @@ export class GiteaClient {
title: p.title, title: p.title,
state: p.state, state: p.state,
merged: Boolean(p.merged), merged: Boolean(p.merged),
merge_commit_sha: p.merge_commit_sha ?? undefined,
merged_at: p.merged_at ?? undefined,
user: p.user?.login, user: p.user?.login,
head: p.head?.ref, head: p.head?.ref,
base: p.base?.ref, base: p.base?.ref,
@@ -335,24 +385,34 @@ export class GiteaAdmin {
GiteaAdmin.fail("/orgs", res); GiteaAdmin.fail("/orgs", res);
} }
/** Ensure the org's package team exists, granting read+write on packages, and return its id. The /** Ensure the org's package team exists with exactly these units, and return its id. Found or
* team is found by name if it is already there, created otherwise; a lost create race is resolved * created, the units are applied either way — a team is configuration the reconcile loop owns,
* by re-listing. */ * 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<number> { async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
// 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); 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`, { const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({ name: team, ...units }),
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,
}),
}); });
if (res.status === 201) return Number(res.body?.id); if (res.status === 201) return Number(res.body?.id);
if (res.status === 422 || res.status === 409) { if (res.status === 422 || res.status === 409) {
@@ -363,14 +423,18 @@ export class GiteaAdmin {
} }
private async findTeam(org: string, team: string): Promise<number | null> { private async findTeam(org: string, team: string): Promise<number | null> {
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; if (res.status !== 200) return null;
const match = (res.body as any[] | null)?.find((t) => t?.name === team); const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null; return match ? Number(match.id) : null;
} }
/** Ensure a user exists with exactly this password. Created if absent; if already there, its /** 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<void> { async ensureUser(username: string, password: string, email: string): Promise<void> {
const res = await this.request("/admin/users", { const res = await this.request("/admin/users", {
method: "POST", method: "POST",
@@ -378,13 +442,18 @@ export class GiteaAdmin {
}); });
if (res.status === 201) return; if (res.status === 201) return;
if (res.status === 422 || res.status === 409) { if (res.status === 422 || res.status === 409) {
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { const seen = await this.request(`/users/${encodeURIComponent(username)}`);
method: "PATCH", if (seen.status === 200) {
// login_name is required by the admin edit endpoint; for a local user it is the username. const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
body: JSON.stringify({ login_name: username, password, must_change_password: false }), method: "PATCH",
}); // login_name is required by the admin edit endpoint; for a local user it is the username.
if (patch.status === 200) return; // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
GiteaAdmin.fail(`/admin/users/${username}`, patch); // 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);
}
} }
GiteaAdmin.fail("/admin/users", res); GiteaAdmin.fail("/admin/users", res);
} }
@@ -399,6 +468,29 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); 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<boolean> {
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?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);
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 /** 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. */ * an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> { async deleteUser(username: string): Promise<void> {
+98 -5
View File
@@ -16,7 +16,9 @@
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js"; import { GiteaClient } from "./client.js";
// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime. // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
// or reuses the token, so the runtime's start also shows what it did about it.
let gitea: GiteaClient | null = null; let gitea: GiteaClient | null = null;
try { try {
gitea = GiteaClient.fromEnv(); gitea = GiteaClient.fromEnv();
@@ -29,11 +31,11 @@ try {
const seen = new Set<string>(); const seen = new Set<string>();
let primed = false; let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> { async function pollRepos(client: GiteaClient): Promise<void> {
const repos = await client.listRepos(1, 50); const repos = await client.listAllRepos();
for (const repo of repos) { for (const repo of repos) {
if (!seen.has(repo.full_name)) { if (!seen.has(repo.full_name)) {
if (primed) { if (primed) {
await emit("module.gitea.repo.created", { await emit("repo.created", {
full_name: repo.full_name, full_name: repo.full_name,
owner: repo.owner, owner: repo.owner,
name: repo.name, name: repo.name,
@@ -47,13 +49,104 @@ async function pollRepos(client: GiteaClient): Promise<void> {
primed = true; primed = true;
} }
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
// What has been announced is kept beside the module's state, so a restart does not announce the
// whole history again — and the first tick on a machine with no record announces nothing, because
// everything it sees then predates the watching.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
const announced = new Set<string>();
let primedMerges = false;
// since is the moment the watching began: a merge made before it is history, whatever page of the
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
// rebuilt everything built from that repository, once per old merge (2026-09-28).
let since = "";
if (mergedRecord && existsSync(mergedRecord)) {
try {
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
const list = Array.isArray(kept) ? kept : kept.announced;
for (const sha of list) announced.add(sha);
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
primedMerges = true;
} catch {
// An unreadable record is treated as no record: prime again rather than re-announce history.
}
}
function keepAnnounced(): void {
if (!mergedRecord) return;
mkdirSync(join(mergedRecord, ".."), { recursive: true });
const tmp = mergedRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord);
}
async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos();
let changed = false;
for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
for (const pull of pulls) {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
// Announced only if merged since the watching began; recorded either way, so it is looked
// at once.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
}
announced.add(pull.merge_commit_sha);
changed = true;
}
}
if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced();
primedMerges = true;
}
if (gitea) { if (gitea) {
const client = gitea; const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null;
const tick = (fn: () => Promise<void>, everyMs: number): void => { const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`)); const run = (): void =>
void fn()
.then(() => {
if (failing !== null) console.log("[gitea] watching again");
failing = null;
})
.catch((err) => {
const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
});
setInterval(run, everyMs); setInterval(run, everyMs);
run(); run();
}; };
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
console.log("[gitea] watching for new repositories"); tick(() => pollMerged(client), 30_000);
console.log("[gitea] watching for new repositories and merged pull requests");
} }
+64 -30
View File
@@ -11,56 +11,80 @@
"name": "gitea" "name": "gitea"
}, },
"route": { "route": {
"label": "git", "web": {
"port": 3000 "label": "git",
"endpoint": "web"
},
"internal-api-refused": {
"label": "git",
"path": "/api/internal",
"deny": true,
"priority": 100000
}
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/gitea/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/gitea/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/gitea/database.secret", "postgres-database": "${dir:state}/database.secret",
"secret": { "secret": {
"internal-token": "/var/lib/gitea/internal-token.secret", "internal-token": "${dir:state}/internal-token.secret",
"admin": "/var/lib/gitea/admin.secret" "admin": "${dir:state}/admin.secret"
} }
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.gitea.repo.created", "repo.created",
"module.gitea.issue.opened", "issue.opened",
"module.gitea.pull.merged" "pull.merged"
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the forge, over http" "why": "the forge, over http"
}, },
{ {
"port": 2222, "name": "ssh",
"port": 22,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "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 \u2014 not 22, which the machine's own daemon holds and a module does not take"
} }
], ],
"serves": { "serves": {
"package-registry": { "npm-package-registry": {
"scheme": "http", "scheme": "http",
"port": 3000, "port": 3000,
"npm-path": "/api/packages/novox/npm/" "npm-path": "/api/packages/novox/npm/"
},
"git": {
"scheme": "http",
"port": 3000
} }
}, },
"receives": { "receives": {
"package-registry": "/var/lib/gitea/grants/mesh.json" "npm-package-registry": "${dir:grants}/npm.json"
}, },
"grants": { "grants": {
"package-registry": "/var/lib/gitea/grants" "npm-package-registry": "${dir:grants}"
}, },
"claims": [
{
"name": "npm-package-registry",
"scope": "mesh"
},
{
"name": "git",
"scope": "mesh"
}
],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/gitea/broker" "broker": "/var/lib/mesh/gitea/broker"
}, },
@@ -71,29 +95,33 @@
"path": "/var/lib/mesh/gitea", "path": "/var/lib/mesh/gitea",
"mode": "0700" "mode": "0700"
}, },
{
"id": "runtime-state",
"type": "directory",
"path": "/var/lib/mesh/gitea/state",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/gitea", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/gitea/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/gitea/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n" "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/gitea/gitea",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -108,14 +136,14 @@
"USER_GID": "1000" "USER_GID": "1000"
}, },
"env-file": [ "env-file": [
"/var/lib/gitea/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"3000", "3000",
"2222:22" "222:22"
], ],
"volumes": [ "volumes": [
"/services/gitea/gitea:/data" "${dir:data}:/data"
], ],
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
}, },
@@ -131,11 +159,11 @@
"MESH_GITEA_ADMIN_USER": "mesh-admin" "MESH_GITEA_ADMIN_USER": "mesh-admin"
}, },
"env-file": [ "env-file": [
"/var/lib/gitea/server.env" "${dir:state}/server.env"
], ],
"volumes": [ "volumes": [
"/services/gitea/gitea:/data", "${dir:data}:/data",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro" "${dir:state}/admin.secret:/run/secrets/admin:ro"
], ],
"args": [ "args": [
"/bin/sh", "/bin/sh",
@@ -160,8 +188,9 @@
"volumes": [ "volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"/var/lib/gitea/grants:/var/lib/gitea/grants:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro" "${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/lib/mesh/gitea/state:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -169,7 +198,8 @@
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json", "MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json" "MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "${dir:grants}/npm.json"
}, },
"artifact": "runtime", "artifact": "runtime",
"restart-on": [ "restart-on": [
@@ -179,7 +209,11 @@
], ],
"provides": [ "provides": [
{ {
"name": "package-registry", "name": "npm-package-registry",
"scope": "mesh"
},
{
"name": "git",
"scope": "mesh" "scope": "mesh"
} }
], ],
+5 -1
View File
@@ -4,8 +4,12 @@
"description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+27 -7
View File
@@ -1,9 +1,15 @@
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry` // gitea's provisioner — the adapter that makes gitea a provider of the mesh
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are // `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a // mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea
// consumer's npm credential (novox/hq ADR 0048/0076). // 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 // `/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 // 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. // `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(); 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<void> { async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is // The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step. // idempotent and needs no separate bootstrap step.
@@ -34,11 +44,21 @@ runProvisioner("package-registry", {
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true); 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 // 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. // 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); await gitea.addUserToTeam(teamId, p.as);
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
await gitea.deleteUser(p.as); 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<boolean> {
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
},
}); });
+313
View File
@@ -0,0 +1,313 @@
// What holds the module to its own token (token.ts; hq issue 100, the forge's tools): minted with
// the delivered admin account on the first call and kept at 0600, reused on the next start, minted
// afresh when the forge rejects it or the kept file is gone, and a refused admin account reported in
// plain words rather than crash-looped. A configured token still wins. And the tools register once
// there is a way to a token at all — before one exists.
//
// The forge is a fake: the four routes the module touches, with the same status codes gitea gives.
// Run against the compiled module (npm test builds first), the way the runtime loads it.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import "../dist/tools/index.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
// ---- A fake forge: what the module sends, and what gitea would answer. ----
interface Forge {
url: string;
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
admins: Map<string, string>;
close(): Promise<void>;
}
function fakeForge(): Promise<Forge> {
const forge = {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]),
};
// write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go).
const covers = (scopes: string[], required: string): boolean =>
scopes.includes(required) || scopes.includes(`write:${required.split(":")[1]}`);
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const body = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const basic = (req: IncomingMessage): string | null => {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return null;
const [user, pass] = Buffer.from(h.slice(6), "base64").toString().split(":");
return forge.admins.get(user) === pass ? user : null;
};
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
forge.mints++;
forge.lastScopes = scopes;
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
forge.scopesOf.set(sha1, scopes);
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
if (req.method === "DELETE" && tokens[2]) {
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
}
if (url.pathname === "/api/v1/user/repos") {
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
// gitea 1.27.3: GET /user/repos sits under the `user` scope category, not `repository` —
// confirmed against the live forge. A token without read:user (or write:user) is refused here.
const scopes = forge.scopesOf.get(value) ?? [];
if (!covers(scopes, "read:user")) {
return json(res, 403, {
message: `token does not have at least one of required scope(s), required=[read:user]`,
});
}
return json(res, 200, [
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]);
}
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${port}`,
get mints() { return forge.mints; },
get lastScopes() { return forge.lastScopes; },
tokens: forge.tokens,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
// ---- What the runtime's environment gives the module. ----
async function delivered(forge: Forge): Promise<{ env: NodeJS.ProcessEnv; file: string; logs: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "gitea-"));
const passwordFile = join(dir, "admin.secret");
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
const state = join(dir, "state");
return {
env: {
MESH_GITEA_URL: forge.url,
MESH_GITEA_ADMIN_USER: ADMIN,
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
MESH_GITEA_STATE_DIR: state,
},
file: join(state, "token"),
logs: [],
};
}
/** A client as a fresh process would build it: a new source over the kept file, its log captured. */
function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const source = new MintedToken({
url: env.MESH_GITEA_URL!,
admin: env.MESH_GITEA_ADMIN_USER!,
passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
file: join(env.MESH_GITEA_STATE_DIR!, "token"),
log: (l) => logs.push(l),
});
return new GiteaClient(env.MESH_GITEA_URL!, source);
}
const forge = await fakeForge();
after(() => forge.close());
test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => {
const { env, file, logs } = await delivered(forge);
const repos = await minted(env, logs).listRepos();
assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
assert.ok(logs.every((l) => !l.includes(token) && !l.includes(PASSWORD)), logs.join("\n"));
});
test("second start: reuses the kept token, mints nothing", async () => {
const { env, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const before = forge.mints;
const again: string[] = [];
await minted(env, again).listRepos();
assert.equal(forge.mints, before);
assert.ok(again.some((l) => l.startsWith("reusing the token kept at")), again.join("\n"));
assert.ok(again.every((l) => !l.includes(forge.tokens.get("mesh-tools")!)), again.join("\n"));
});
test("the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
forge.tokens.clear(); // the forge no longer knows any token — a restore from the predecessor
const repos = await client.listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const before = forge.mints;
await rm(file);
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.equal([...forge.tokens.keys()].filter((n) => n === "mesh-tools").length, 1);
assert.ok(logs.some((l) => l.includes('already holds a token named "mesh-tools"')), logs.join("\n"));
});
test("concurrent first calls share one mint", async () => {
const { env, logs } = await delivered(forge);
const client = minted(env, logs);
const before = forge.mints;
await Promise.all([client.listRepos(), client.listRepos(), client.listRepos()]);
assert.equal(forge.mints, before + 1);
});
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
try {
const client = minted(env, logs);
const before = forge.mints;
await assert.rejects(client.listRepos(), (err: unknown) => {
assert.ok(err instanceof AdminRefused, String(err));
assert.match(err.message, /refused the admin account "mesh-admin" \(401\)/);
assert.match(err.message, /admin-bootstrap step creates it/);
assert.match(err.message, /came from a predecessor/);
assert.ok(!err.message.includes(PASSWORD));
return true;
});
await assert.rejects(client.listRepos(), AdminRefused);
assert.equal(forge.mints, before);
await assert.rejects(stat(file), /ENOENT/);
// The account appears (the operator created it): the very next call mints and works.
forge.admins.set(ADMIN, PASSWORD);
assert.equal((await client.listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
} finally {
forge.admins.set(ADMIN, PASSWORD);
}
});
test("one process shares one source per kept file — the watcher and the tools never renew against each other", async () => {
const { env } = await delivered(forge);
assert.equal(MintedToken.fromEnv(env.MESH_GITEA_URL!, env), MintedToken.fromEnv(env.MESH_GITEA_URL!, env));
});
test("a second process finds the token the first renewed, and reuses it instead of minting over it", async () => {
const { env, logs } = await delivered(forge);
const first = minted(env, logs);
const second = minted(env, logs);
await first.listRepos();
await second.listRepos(); // both hold the same kept token
const before = forge.mints;
forge.tokens.clear();
await first.listRepos(); // renews: one mint
await second.listRepos(); // rejected too — but the kept file already carries the renewed one
assert.equal(forge.mints, before + 1);
assert.ok(logs.some((l) => l.includes("is newer — reusing it")), logs.join("\n"));
});
test("a configured token wins, and is reported rather than minted over when the forge rejects it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const client = GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" });
await assert.rejects(client.listRepos(), /rejected the configured Gitea token \(401\)/);
assert.equal(forge.mints, before);
});
test("nothing to mint with and no token: the client says what is missing", async () => {
assert.throws(
() => GiteaClient.fromEnv({ MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN }),
/set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/,
);
});
test("the tools register once there is a way to a token, and the first call mints it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const withAdmin = collectTools(env).find((c) => c.module === "gitea")!;
const withNothing = collectTools({}).find((c) => c.module === "gitea")!;
assert.equal(withNothing.tools.length, 0);
assert.deepEqual(
withAdmin.tools.map((t) => t.name),
[
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
);
assert.equal(forge.mints, before, "registering must not mint — the forge may not be up yet");
const result = (await withAdmin.tools.find((t) => t.name === "gitea_list_repos")!.run({})) as { repos: unknown[] };
assert.equal(result.repos.length, 1);
assert.equal(forge.mints, before + 1);
});
+253
View File
@@ -0,0 +1,253 @@
// The token the forge's tools and watcher authenticate with — and where it comes from.
//
// Nobody configures it. The forge is raised by the mesh, so there is no operator holding a token to
// paste in, and pasting one into settings would put a secret in the inventory in plaintext. What
// the mesh does deliver is the admin account: a login the manifest names and a password the vault
// minted and the host unsealed into a file (novox/hq ADR 0086). That account is enough to mint a
// token, so the module mints its own (hq issue 100, the forge's tools):
//
// - at first use, when none is kept: POST /users/{admin}/tokens over basic auth, with the two
// scopes the tools and the watcher need, and no more;
// - kept in the module's own state, a 0600 file, and read back on the next start — the forge
// hands a token's value out exactly once, so a token not kept is a token lost;
// - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not
// a fault: the forge's data was restored from a predecessor and the token the file names never
// existed there.
//
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
// reported, not repaired — somebody chose it.
//
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
// kept; never what it is.
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
export const TOKEN_NAME = "mesh-tools";
/**
* The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens):
* write:repository — create/delete repositories, pull requests (list/get/open/merge);
* write:issue — issues, comments, labels;
* read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call.
* It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two.
* Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover.
*/
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource {
/** The token to authenticate with now; minted, read or configured. */
current(): Promise<string>;
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */
renew(rejected: string): Promise<string>;
}
/** A token somebody set — in settings or the environment. Never minted over. */
export class ConfiguredToken implements TokenSource {
constructor(private readonly token: string) {}
async current(): Promise<string> {
return this.token;
}
async renew(): Promise<string> {
throw new Error(
"the forge rejected the configured Gitea token (401). It was set explicitly (settings or MESH_GITEA_TOKEN), " +
"so the module does not mint over it — fix it, or unset it and the module mints its own",
);
}
}
/** The forge would not take the admin account: it is missing, or its password is not the one the mesh holds. */
export class AdminRefused extends Error {
constructor(admin: string, status: number) {
super(
`the forge refused the admin account "${admin}" (${status}) — it does not exist there, or its password is not ` +
`the one the vault delivered. The admin-bootstrap step creates it on a forge the mesh raised; a forge whose data ` +
`came from a predecessor does not have it. Create "${admin}" on the forge with the delivered password and the ` +
`token is minted on the next call — the tools stay registered and the watcher keeps trying`,
);
this.name = "AdminRefused";
}
}
export interface MintedTokenOptions {
/** The forge, e.g. http://127.0.0.1:3000. */
readonly url: string;
/** The admin login the manifest names. */
readonly admin: string;
/** The file the host unsealed the admin password into (ADR 0086). Read at mint time, so a rotation takes. */
readonly passwordFile: string;
/** Where the token is kept: a 0600 file in the module's own state. */
readonly file: string;
readonly name?: string;
readonly scopes?: readonly string[];
readonly log?: (line: string) => void;
readonly fetch?: typeof fetch;
}
/** The token the module mints for itself, kept in its state and renewed when the forge rejects it. */
export class MintedToken implements TokenSource {
private held: string | null = null;
private readFile = false;
private inflight: Promise<string> | null = null;
private readonly name: string;
private readonly scopes: readonly string[];
private readonly log: (line: string) => void;
private readonly fetchImpl: typeof fetch;
constructor(private readonly opts: MintedTokenOptions) {
this.name = opts.name ?? TOKEN_NAME;
this.scopes = opts.scopes ?? TOKEN_SCOPES;
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
this.fetchImpl = opts.fetch ?? fetch;
}
/**
* Build from the runtime's environment: the forge's URL, the admin login and password file the
* manifest hands the runtime, and the module's state directory (MESH_GITEA_STATE_DIR, a directory
* the runtime mounts writable). Throws, naming what is missing, rather than hand back a source
* that cannot mint.
*/
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
const opts = MintedToken.optionsFromEnv(url, env);
// One source per kept file in a process. The watcher and the tools entrypoint both build a
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
// other's token by name, forever. Shared, a renewal is one renewal.
const shared = MintedToken.shared.get(opts.file);
if (shared) return shared;
const source = new MintedToken(opts);
MintedToken.shared.set(opts.file, source);
return source;
}
private static readonly shared = new Map<string, MintedToken>();
private static optionsFromEnv(url: string, env: NodeJS.ProcessEnv): MintedTokenOptions {
const admin = env.MESH_GITEA_ADMIN_USER;
const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
const stateDir = env.MESH_GITEA_STATE_DIR;
const missing = [
admin ? null : "MESH_GITEA_ADMIN_USER",
passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE",
stateDir ? null : "MESH_GITEA_STATE_DIR",
].filter((v): v is string => v !== null);
if (missing.length) {
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
}
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
}
async current(): Promise<string> {
if (this.held !== null) return this.held;
if (!this.readFile) {
this.readFile = true;
const kept = this.read();
if (kept !== null) {
this.held = kept;
this.log(`reusing the token kept at ${this.opts.file}`);
return kept;
}
}
return this.mint("no token kept — minting one");
}
async renew(rejected: string): Promise<string> {
// Another caller already renewed while this one was in flight with the old token.
if (this.held !== null && this.held !== rejected) return this.held;
// Or another process did, and kept it: use what is kept before minting over it.
const kept = this.read();
if (kept !== null && kept !== rejected) {
this.held = kept;
this.log(`the forge rejected the token held; the kept one at ${this.opts.file} is newer — reusing it`);
return kept;
}
this.held = null;
return this.mint("the forge rejected the kept token — minting a fresh one");
}
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> {
if (this.inflight === null) {
this.log(why);
this.inflight = this.doMint().finally(() => {
this.inflight = null;
});
}
return this.inflight;
}
private read(): string | null {
try {
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
return token.length ? token : null;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error(`cannot read the kept Gitea token at ${this.opts.file}: ${(err as Error).message}`);
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
private keep(token: string): void {
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
const tmp = `${this.opts.file}.tmp`;
writeFileSync(tmp, token + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, this.opts.file);
}
private async doMint(): Promise<string> {
let password: string;
try {
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
} catch (err) {
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
}
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
const res = await this.fetchImpl(tokens + path, {
method,
headers: { "Content-Type": "application/json", Authorization: authorization },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
let parsed: any = null;
if (text) {
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
return { status: res.status, body: parsed };
};
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
if (res.status === 400 || res.status === 422) {
// The forge still holds a token by this name whose value we no longer have — the kept file
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
}
res = await call("POST", "", { name: this.name, scopes: this.scopes });
}
if (res.status !== 201 && res.status !== 200) {
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
}
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
this.keep(token);
this.held = token;
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
return token;
}
}
function detail(body: unknown): string {
return typeof body === "string" ? body : JSON.stringify(body);
}
+17 -5
View File
@@ -124,7 +124,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
labels: labelIds, labels: labelIds,
}); });
// The mesh just opened an issue — announce it the moment it exists. // The mesh just opened an issue — announce it the moment it exists.
await emit("module.gitea.issue.opened", { await emit("issue.opened", {
owner, owner,
repo, repo,
number: issue.number, number: issue.number,
@@ -231,15 +231,24 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// Read the PR first, so the merged event carries a title and branches, not just a number. // Read the PR first, so the merged event carries a title and branches, not just a number.
const pull = await gitea.getPullRequest(owner, repo, number); const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
await emit("module.gitea.pull.merged", { // Read it again: the merge commit only exists now, and it is what a build is made from.
const merged = await gitea.getPullRequest(owner, repo, number);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
// every module built from the repository (novox/hq 04-ISSUES/131).
const changed = await gitea.listPullFiles(owner, repo, number);
await emit("pull.merged", {
owner, owner,
repo, repo,
number, number,
title: pull.title, title: pull.title,
head: pull.head, head: pull.head,
base: pull.base, base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method, method,
html_url: pull.html_url, html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
}); });
return { merged: true, number, method, deleted_branch: deleteBranch }; return { merged: true, number, method, deleted_branch: deleteBranch };
}, },
@@ -295,12 +304,15 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
]; ];
} }
// The tools exist only when a token can be found; without one, gitea contributes none rather than // The tools exist when the client has a way to a token: one configured, or the admin account to mint
// failing the whole runtime. // one with (token.ts). The mint itself happens on the first call, not here — a contributor is
// synchronous, and a forge not yet answering must not keep the runtime from serving. Without either
// way, gitea contributes none rather than failing the whole runtime, and says why.
registerModuleTools("gitea", (env) => { registerModuleTools("gitea", (env) => {
try { try {
return getGiteaTools(GiteaClient.fromEnv(env)); return getGiteaTools(GiteaClient.fromEnv(env));
} catch { } catch (err) {
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
return []; return [];
} }
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }
+1 -1
View File
@@ -40,7 +40,7 @@ async function pollAlerts(client: GrafanaClient): Promise<void> {
for (const key of now) { for (const key of now) {
if (!firing.has(key)) { if (!firing.has(key)) {
const a = byKey.get(key)!; const a = byKey.get(key)!;
await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); await emit("alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
} }
} }
} }
+3 -2
View File
@@ -2,7 +2,7 @@
"module": "grafana", "module": "grafana",
"version": "1", "version": "1",
"emits": [ "emits": [
"module.grafana.alert.firing" "alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/grafana-module/admin.secret", "admin": "/var/lib/grafana-module/admin.secret",
@@ -13,6 +13,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -96,7 +97,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "grafana", "label": "grafana",
"port": 3000 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+3 -2
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "hello", "label": "hello",
"port": 8080 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -19,6 +19,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -50,7 +51,7 @@
"name": "hello-web", "name": "hello-web",
"network": "hello-web", "network": "hello-web",
"ports": [ "ports": [
"8080:8080" "8080"
], ],
"volumes": [ "volumes": [
"/var/lib/hello-web/index.html:/www/index.html:ro" "/var/lib/hello-web/index.html:/www/index.html:ro"
+1 -1
View File
@@ -44,7 +44,7 @@ async function pollStates(): Promise<void> {
for (const s of states) { for (const s of states) {
const prev = lastState.get(s.entity_id); const prev = lastState.get(s.entity_id);
if (primed && prev !== undefined && prev !== s.state) { if (primed && prev !== undefined && prev !== s.state) {
await emit("module.home-assistant.state.changed", { await emit("state.changed", {
entity: s.entity_id, entity: s.entity_id,
name: nameOf(s), name: nameOf(s),
from: prev, from: prev,
+3 -2
View File
@@ -6,7 +6,7 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.home-assistant.state.changed" "state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/home-assistant/broker", "broker": "/var/lib/mesh/home-assistant/broker",
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8123, "port": 8123,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -85,7 +86,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "home-assistant", "label": "home-assistant",
"port": 8123 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -2
View File
@@ -23,7 +23,7 @@ async function pollMounts(): Promise<void> {
if (primed) { if (primed) {
for (const [mount, m] of now) { for (const [mount, m] of now) {
if (!live.has(mount)) { if (!live.has(mount)) {
await emit("module.icecast.stream.started", { await emit("stream.started", {
mount, mount,
name: m.name, name: m.name,
description: m.description, description: m.description,
@@ -33,7 +33,7 @@ async function pollMounts(): Promise<void> {
} }
for (const [mount, m] of live) { for (const [mount, m] of live) {
if (!now.has(mount)) { if (!now.has(mount)) {
await emit("module.icecast.stream.stopped", { mount, name: m.name }); await emit("stream.stopped", { mount, name: m.name });
} }
} }
} }
+3 -2
View File
@@ -5,14 +5,15 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.icecast.stream.started", "stream.started",
"module.icecast.stream.stopped" "stream.stopped"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/icecast/broker" "broker": "/var/lib/mesh/icecast/broker"
}, },
"listens": [ "listens": [
{ {
"name": "stream",
"port": 8000, "port": 8000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -9,6 +9,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "api",
"port": 8086, "port": 8086,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+11 -6
View File
@@ -14,12 +14,15 @@
"mongodb-database": { "mongodb-database": {
"name": "invoicing" "name": "invoicing"
}, },
"s3-bucket": {
"bucket": "invoicing"
},
"route": { "route": {
"label": "invoicing", "site": {
"port": 80 "label": "invoicing",
"endpoint": "web"
},
"api": {
"label": "invoicing-api",
"endpoint": "api"
}
} }
}, },
"binds": { "binds": {
@@ -33,12 +36,14 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the invoicing web frontend; a public name is a route grant later" "why": "the invoicing web frontend; a public name is a route grant later"
}, },
{ {
"name": "api",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -63,7 +68,7 @@
"type": "file", "type": "file",
"path": "/var/lib/invoicing/api.env", "path": "/var/lib/invoicing/api.env",
"mode": "0600", "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}\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", "id": "net",
+2 -1
View File
@@ -6,6 +6,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 9117, "port": 9117,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -82,7 +83,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "indexers", "label": "indexers",
"port": 9117 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+6 -6
View File
@@ -28,17 +28,17 @@ async function announce(type: string, body: Record<string, unknown>): Promise<vo
export const events = { export const events = {
userCreated: (realm: string, username: string, email?: string) => userCreated: (realm: string, username: string, email?: string) =>
announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }), announce("user.created", { realm, username, ...(email ? { email } : {}) }),
userDeleted: (realm: string, userId: string) => userDeleted: (realm: string, userId: string) =>
announce("module.keycloak.user.deleted", { realm, userId }), announce("user.deleted", { realm, userId }),
passwordReset: (realm: string, userId: string) => passwordReset: (realm: string, userId: string) =>
announce("module.keycloak.password.reset", { realm, userId }), announce("password.reset", { realm, userId }),
clientCreated: (realm: string, clientId: string, name?: string) => clientCreated: (realm: string, clientId: string, name?: string) =>
announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }), announce("client.created", { realm, clientId, ...(name ? { name } : {}) }),
groupCreated: (realm: string, name: string) => groupCreated: (realm: string, name: string) =>
announce("module.keycloak.group.created", { realm, name }), announce("group.created", { realm, name }),
roleCreated: (realm: string, name: string) => roleCreated: (realm: string, name: string) =>
announce("module.keycloak.role.created", { realm, name }), announce("role.created", { realm, name }),
}; };
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
+12 -9
View File
@@ -11,7 +11,7 @@
}, },
"route": { "route": {
"label": "keycloak", "label": "keycloak",
"port": 8080 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -25,15 +25,16 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.keycloak.user.created", "user.created",
"module.keycloak.user.deleted", "user.deleted",
"module.keycloak.password.reset", "password.reset",
"module.keycloak.client.created", "client.created",
"module.keycloak.group.created", "group.created",
"module.keycloak.role.created" "role.created"
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -88,7 +89,9 @@
"env": { "env": {
"KC_DB": "postgres", "KC_DB": "postgres",
"KC_HTTP_ENABLED": "true", "KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true" "KC_HEALTH_ENABLED": "true",
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
}, },
"env-file": [ "env-file": [
"/var/lib/keycloak/admin.env", "/var/lib/keycloak/admin.env",
@@ -118,7 +121,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "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" "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
-42
View File
@@ -1,42 +0,0 @@
# lavinmq's runtime: the tool runtime, carrying this module's compiled bootstrap, provisioner,
# tools and event consumer.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/lavinmq
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
#
# Four entrypoints and a client, because this module is four things: a run-once bootstrap that
# writes the broker's configuration before it first starts, a provisioner that grants consumers
# their own vhost and user, a set of tools, and an event consumer.
RUN node /app/node_modules/typescript/bin/tsc \
client.ts index.ts bootstrap/index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/lavinmq/dist /app/modules/lavinmq/dist
# What the runtime loads from this module in serve mode: its event consumer, its tools, and its
# provisioner — all three in one process, so the provisioner's reconcile loop runs with the broker
# connected (novox/hq issues 060/061; the provisioner used to be run as a separate container `args`
# command, which meant it served no tools and, once, ran nowhere at all). The run-once bootstrap is
# NOT listed here — it is named in its own container's `args`, because it runs to completion before
# the broker starts rather than serving. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/lavinmq/dist/index.js,/app/modules/lavinmq/dist/tools/index.js,/app/modules/lavinmq/dist/provisioner/index.js
-45
View File
@@ -1,45 +0,0 @@
// lavinmq's run-once bootstrap — lavinmq's own code (novox/hq ADR 0039), run once before the broker
// first starts (ADR 0052). lavinmq's default admin is set at first boot from a config file's
// `default_password_hash`, and that value is a HASH of the mesh-minted admin password, not the
// password itself — a form the mesh's plain-secret delivery cannot produce and no `${secret:...}`
// placeholder can compute. So this step computes it: it reads the admin password the mesh minted and
// the host unsealed, hashes it the way lavinmq expects (see client.rabbitHash), and writes the config
// file the broker container reads with `--config`. The host runs it to completion and only then
// starts the broker the manifest places after it — so the broker's first boot finds a config with an
// admin it can authenticate, and the provisioner (which reaches the management API as that admin)
// can do its work.
//
// It runs in the module's own runtime image, under the module's own account, as `mesh-tools run`
// imports it — no broker connection, because writing a config file is an offline operation and there
// is no broker to reach yet.
//
// lavinmq consults `default_user`/`default_password_hash` only on a first boot with an empty data
// dir; a later boot uses the persisted user database and ignores them. So this seeds the admin once,
// and a rotation of the admin secret does not re-key an already-initialised broker — the same
// first-boot-only shape the RabbitMQ-compatible default user has always had.
import { writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { rabbitHash } from "../client.js";
const adminUser = process.env.MESH_PROVISION_ADMIN_USER ?? process.env.MESH_LAVINMQ_ADMIN_USER ?? "mesh-admin";
const passwordFile = process.env.MESH_PROVISION_PASSWORD_FILE ?? process.env.MESH_LAVINMQ_ADMIN_PASSWORD_FILE ?? "/run/secrets/default";
const configOut = process.env.MESH_LAVINMQ_CONFIG_OUT ?? "/var/lib/lavinmq-module/lavinmq.ini";
const dataDir = process.env.MESH_LAVINMQ_DATA_DIR ?? "/var/lib/lavinmq";
const password = readFileSync(passwordFile, "utf8").replace(/\n$/, "");
if (!password) {
throw new Error(`[lavinmq:bootstrap] admin password file ${passwordFile} is empty — cannot seed the admin`);
}
// The broker reads only what it needs to authenticate its admin on first boot; bind/ports come from
// the container's entrypoint (`-b 0.0.0.0`), so this file names the admin and nothing else about the
// network.
const ini =
"[main]\n" +
`data_dir = ${dataDir}\n` +
`default_user = ${adminUser}\n` +
`default_password_hash = ${rabbitHash(password)}\n`;
writeFileSync(configOut, ini, { mode: 0o600 });
console.log(`[lavinmq:bootstrap] wrote ${configOut} with admin '${adminUser}' (password hashed for lavinmq)`);
-150
View File
@@ -1,150 +0,0 @@
// lavinmq's admin client — lavinmq's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside lavinmq does.
//
// It drives lavinmq through its HTTP management API (the RabbitMQ-compatible surface lavinmq serves
// on 15672), not a hand-rolled AMQP admin stack: the module may take NO npm dependency beyond
// @novox/mesh-sdk, and the management API is exactly the admin surface — create/remove a vhost, a
// user, and its permissions — reached with `fetch` (global on node 22) and HTTP Basic auth. One
// boundary, `api()`, and every method is built on it. This is the module's one impure seam, the way
// postgres's is `psql` and redis's is a RESP socket.
//
// **The login and password are the mesh's, not the provisioner's (novox/hq ADR 0048).** The mesh
// derives the login and hands it to both ends so they agree, and mints the password and delivers a
// copy to each. lavinmq creates exactly that user with exactly that password on a vhost of the same
// name — a name or password the provisioner invented is one the consumer could never present.
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
export interface LavinmqConn {
/** Base URL of the management API, e.g. http://lavinmq:15672 (no trailing /api). */
readonly base: string;
readonly adminUser: string;
readonly adminPassword: string;
}
export class LavinmqClient {
constructor(private readonly conn: LavinmqConn) {}
/**
* Build from the module's resolved environment. Reads MESH_LAVINMQ_* first (the documented
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
* container so the module runs unchanged there. Throws if it cannot find a management endpoint and
* an admin password — the right failure, because without them nothing it does can work.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): LavinmqClient {
const base = (env.MESH_LAVINMQ_MANAGEMENT ?? env.MESH_PROVISION_LAVINMQ ?? "").replace(/\/+$/, "");
const adminUser = env.MESH_LAVINMQ_ADMIN_USER ?? env.MESH_PROVISION_ADMIN_USER ?? "mesh-admin";
const adminPassword = env.MESH_LAVINMQ_ADMIN_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!base || !adminPassword) {
throw new Error("lavinmq management endpoint or admin password is not set — lavinmq's own code cannot reach the server");
}
return new LavinmqClient({ base, adminUser, adminPassword });
}
/** One request against the management API. A non-2xx reply rejects, carrying the body for the log. */
async api(method: string, path: string, body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
};
if (body !== undefined) headers["Content-Type"] = "application/json";
const resp = await fetch(`${this.conn.base}/api${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
throw new Error(`lavinmq management API ${method} ${path} -> ${resp.status}: ${await resp.text()}`);
}
const text = await resp.text();
return text ? JSON.parse(text) : null;
}
/** True once the management API answers — the server has finished starting. */
async ready(): Promise<boolean> {
try {
await this.api("GET", "/overview");
return true;
} catch {
return false;
}
}
/** Block until the management API answers, or throw once the budget is spent. */
async waitReady(retries = 30, delayMs = 1000): Promise<void> {
for (let i = 0; i < retries; i++) {
if (await this.ready()) return;
await new Promise((r) => setTimeout(r, delayMs));
}
throw new Error("lavinmq management API did not become ready");
}
/**
* Create (or reset to a known state) one consumer's broker: a vhost and a user both named for the
* consumer's login, with the login owning full permissions on exactly that vhost. Idempotent — a
* PUT of a vhost or user that exists is a no-op or a password reset, so a reconcile can call it
* again without harm. The consumer connects as `<login>` to vhost `<login>` and can reach nothing
* else (novox/hq ADR 0048).
*/
async createConsumer(login: string, password: string): Promise<void> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
await this.api("PUT", `/vhosts/${v}`);
await this.api("PUT", `/users/${u}`, { password, tags: "" });
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
}
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
async removeConsumer(login: string): Promise<void> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
try {
await this.api("DELETE", `/vhosts/${v}`);
} catch (err) {
console.error(`[lavinmq] delete vhost ${login} failed (continuing): ${err}`);
}
try {
await this.api("DELETE", `/users/${u}`);
} catch (err) {
console.error(`[lavinmq] delete user ${login} failed (continuing): ${err}`);
}
}
/** The vhosts, for the amqp_list_vhosts tool. */
async listVhosts(): Promise<{ name: string; messages: number }[]> {
const vhosts = (await this.api("GET", "/vhosts")) as { name: string; messages?: number }[];
return vhosts.map((v) => ({ name: v.name, messages: v.messages ?? 0 }));
}
/** The queues on one vhost (default the root vhost), for the amqp_list_queues tool. */
async listQueues(vhost = "/"): Promise<{ name: string; messages: number; consumers: number }[]> {
const queues = (await this.api("GET", `/queues/${encodeURIComponent(vhost)}`)) as
{ name: string; messages?: number; consumers?: number }[];
return queues.map((q) => ({ name: q.name, messages: q.messages ?? 0, consumers: q.consumers ?? 0 }));
}
}
/**
* The RabbitMQ-compatible SHA-256 password hash lavinmq's `default_password_hash` expects:
* base64( salt[4] || sha256( salt || utf8(password) ) ). The salt is any four bytes — random here,
* because a fixed salt buys nothing and a fresh one is free. Verified against `lavinmqctl
* hash_password`: a hash produced here is accepted by the server unchanged.
*/
export function rabbitHash(password: string, salt: Buffer = randomBytes(4)): string {
const digest = createHash("sha256").update(Buffer.concat([salt, Buffer.from(password, "utf8")])).digest();
return Buffer.concat([salt, digest]).toString("base64");
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
-25
View File
@@ -1,25 +0,0 @@
// lavinmq's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// ./provisioner separately). The broker lifecycle events are EMITTED from the provisioner, where the
// lifecycle actually happens (novox/hq ADR 0041/0042):
// module.lavinmq.amqp.provisioned — a consumer's vhost + user was created
// module.lavinmq.amqp.deprovisioned — that vhost + user was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// broker and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events";
interface AmqpEvent {
consumer?: string;
user: string;
vhost?: string;
}
await on<AmqpEvent>("module.lavinmq.amqp.provisioned", async (e) => {
console.log(`[lavinmq] broker provisioned for ${e.body.consumer ?? "?"} (user ${e.body.user}, vhost ${e.body.vhost})`);
});
await on<AmqpEvent>("module.lavinmq.amqp.deprovisioned", async (e) => {
console.log(`[lavinmq] broker deprovisioned (user ${e.body.user})`);
});
console.log("[lavinmq] auditing broker lifecycle events");
-139
View File
@@ -1,139 +0,0 @@
{
"module": "lavinmq",
"version": "1",
"provides": [
{
"name": "amqp",
"scope": "mesh"
}
],
"claims": [
{
"name": "mesh-broker",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"module.lavinmq.amqp.provisioned",
"module.lavinmq.amqp.deprovisioned"
],
"consumes": [
"module.lavinmq.amqp.provisioned",
"module.lavinmq.amqp.deprovisioned"
],
"serves": {
"amqp": {
"port": 5672
}
},
"receives": {
"amqp": "/var/lib/lavinmq-module/grants/mesh.json"
},
"grants": {
"amqp": "/var/lib/lavinmq-module/grants"
},
"own-secrets": {
"admin": "/var/lib/lavinmq-module/admin.secret",
"broker": "/var/lib/mesh/lavinmq/broker"
},
"listens": [
{
"port": 5671,
"protocol": "tcp",
"from": "mesh",
"why": "the mesh bus \u2014 amqps, every module's events and the control plane, reached over the overlay"
},
{
"port": 5672,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a queue"
}
],
"guards": [
15672
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/lavinmq",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/lavinmq-module",
"mode": "0700"
},
{
"id": "grants-dir",
"type": "directory",
"path": "/var/lib/lavinmq-module/grants",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "mesh-broker",
"image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b",
"ports": [
"5671:5671",
"5672:5672",
"127.0.0.1:15672:15672"
],
"volumes": [
"mesh-broker-data:/var/lib/lavinmq",
"mesh-broker-tls:/tls:ro"
],
"args": [
"--amqps-port=5671",
"--cert=/tls/tls.crt",
"--key=/tls/tls.key"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-lavinmq",
"artifact": "runtime",
"network": "host",
"volumes": [
"/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro",
"/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro",
"/var/lib/lavinmq-module/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json",
"MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672",
"MESH_PROVISION_ADMIN_USER": "guest",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
}
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-lavinmq",
"version": "0.1.0",
"description": "lavinmq — provides the mesh amqp interface (a per-consumer AMQP message broker). Its management client, provisioner, run-once bootstrap, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-51
View File
@@ -1,51 +0,0 @@
// lavinmq's provisioner — the adapter that makes lavinmq a provider of the mesh `amqp` 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 lavinmq creates and removes a consumer's own
// broker (novox/hq ADR 0039/0040/0048).
//
// The `amqp` interface: a consumer connects as `as` with the password the mesh minted, to a vhost
// named for that same login — its own message broker, isolated from every other consumer's by the
// vhost boundary. It is a broker of its own, not a shared account on the mesh's control-plane broker.
//
// **The login and password are the mesh's, not the provisioner's (novox/hq ADR 0048).** The mesh
// derives the login and hands it to both ends so they agree, and mints the password and delivers a
// copy to each. lavinmq creates exactly that user with exactly that password — a name or password the
// provisioner invented is one the consumer could never present.
//
// Vhost-per-login is the isolation model, the exact analog of postgres's database-per-login: the
// consumer owns one vhost, named for its login, and a user with full rights on that vhost and no
// rights anywhere else. lavinmq enforces it — a user with no permission on `/` is refused the moment
// it opens that vhost (`NOT_ALLOWED`), so a login is a broker the consumer alone can reach.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { LavinmqClient } from "../client.js";
const lavinmq = LavinmqClient.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:amqp] emit ${type} failed: ${err}`);
}
}
runProvisioner("amqp", {
async create(p: Provision): Promise<void> {
// The vhost and the user share the consumer's login, so one cannot reach another's broker.
await lavinmq.waitReady();
await lavinmq.createConsumer(p.as, p.password);
await announce("module.lavinmq.amqp.provisioned", {
consumer: p.consumer ?? "",
user: p.as,
vhost: p.as,
});
},
async remove(p: { as: string }): Promise<void> {
await lavinmq.removeConsumer(p.as);
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
},
});
-32
View File
@@ -1,32 +0,0 @@
// lavinmq's tools — lavinmq's own code (novox/hq ADR 0039), importing lavinmq's own management
// client. They return structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { LavinmqClient } from "../client.js";
export function getLavinmqTools(lavinmq: LavinmqClient): ToolDefinition[] {
return [
{
name: "amqp_list_vhosts",
description: "List the lavinmq virtual hosts — one per consumer that was granted a broker.",
input: {},
run: async () => ({ vhosts: await lavinmq.listVhosts() }),
},
{
name: "amqp_list_queues",
description: "List the queues on a vhost with message and consumer counts. Omit vhost for the root '/'.",
input: { vhost: { type: "string", description: "the vhost to list, e.g. a consumer's login; defaults to '/'" } },
run: async (args) => ({ queues: await lavinmq.listQueues(args.vhost ? String(args.vhost) : undefined) }),
},
];
}
// The tools exist only when the server can be reached from the environment; without it, lavinmq
// contributes none rather than failing the whole tool runtime.
registerModuleTools("lavinmq", (env) => {
try {
return getLavinmqTools(LavinmqClient.fromEnv(env));
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
}
+1
View File
@@ -24,6 +24,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8283, "port": 8283,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -2
View File
@@ -40,12 +40,12 @@ async function pollQueue(lidarr: LidarrClient): Promise<void> {
if (primed) { if (primed) {
// Entered the queue since last look — Lidarr grabbed a release. // Entered the queue since last look — Lidarr grabbed a release.
for (const [id, item] of now) { for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("module.lidarr.album.grabbed", { title: item.title, status: item.status }); if (!inQueue.has(id)) await emit("album.grabbed", { title: item.title, status: item.status });
} }
// Left the queue — imported and done, unless it was last seen failing. // Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) { for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("module.lidarr.download.completed", { title: item.title }); await emit("download.completed", { title: item.title });
} }
} }
} }
+18 -4
View File
@@ -1,12 +1,25 @@
{ {
"module": "lidarr", "module": "lidarr",
"version": "1", "version": "1",
"provides": [
{
"name": "lidarr-api",
"scope": "mesh"
}
],
"serves": {
"lidarr-api": {
"scheme": "http",
"port": 8686,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.lidarr.album.grabbed", "album.grabbed",
"module.lidarr.download.completed" "download.completed"
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
@@ -14,6 +27,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8686, "port": 8686,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -74,7 +88,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:8686", "MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config" "MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
}, },
"artifact": "runtime" "artifact": "runtime"
@@ -86,7 +100,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "lidarr", "label": "lidarr",
"port": 8686 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -2
View File
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
# resolved away. # resolved away.
WORKDIR /app/modules/mailu WORKDIR /app/modules/mailu
COPY . . 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 --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} 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 # 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 # provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all. # 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
+44
View File
@@ -0,0 +1,44 @@
# 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==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
# 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
# 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
+49
View File
@@ -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 <<EOT
$SQL_CMD
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
EOT
)
PROVIDER_ID=$((PROVIDER_ID+10))
done
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
+21
View File
@@ -0,0 +1,21 @@
[automx2]
# A typical production setup would use loglevel = WARNING
loglevel = WARNING
# Echo SQL commands into log? Used for debugging.
db_echo = false
# In-memory SQLite database
# db_uri = sqlite:///:memory:
# SQLite database in a UNIX-like file system
db_uri = sqlite:////data/db.sqlite
# MySQL database on a remote server. This example does not use an encrypted
# connection and is therefore *not* recommended for production use.
#db_uri = mysql://username:password@server.example.com/db
# Number of proxy servers between automx2 and the client (default: 0).
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
# connections, proxy_count probably needs to be changed.
proxy_count = 1
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
if [ ! -e /data/db.sqlite ]; then
# DB SETUP
echo "SETTING UP DB"
./setup-db
echo "ADDING DOMAINS"
# Add the domains
./add-domains
fi
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -e
# LDAP-Server
LDAP=$(cat <<EOT
CREATE TABLE ldapserver(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
port INT NOT NULL,
use_ssl INT NOT NULL,
search_base TEXT NOT NULL,
search_filter TEXT NOT NULL,
attr_uid TEXT NOT NULL,
attr_cn TEXT NOT NULL,
bind_password TEXT NOT NULL,
bind_user TEXT NOT NULL
);
EOT
)
# Provider
PROVIDER=$(cat <<EOT
CREATE TABLE provider(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL
);
EOT
)
# Server
SERVER=$(cat <<EOT
CREATE TABLE server(
id INT PRIMARY KEY NOT NULL,
prio INT NOT NULL DEFAULT 10,
name TEXT NOT NULL,
port INT NOT NULL,
type TEXT NOT NULL,
socket_type TEXT NOT NULL,
user_name TEXT NOT NULL,
authentication TEXT NOT NULL
);
EOT
)
# Domain
DOMAIN=$(cat <<EOT
CREATE TABLE domain(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
provider_id INT NOT NULL,
ldapserver_id INT NULL,
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
FOREIGN KEY(provider_id) REFERENCES provider(id)
);
CREATE UNIQUE INDEX domain_name ON domain(name);
EOT
)
# Server-Domain
SERVER_DOMAIN=$(cat <<EOT
CREATE TABLE server_domain(
server_id INT NOT NULL,
domain_id INT NOT NULL,
FOREIGN KEY(server_id) REFERENCES server(id),
FOREIGN KEY(domain_id) REFERENCES domain(id)
);
EOT
)
## TODO Foreign keys
SQL_CMD=$(cat <<EOT
$LDAP
$PROVIDER
$SERVER
$DOMAIN
$SERVER_DOMAIN
EOT
)
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# vim:ts=4:sw=4:noet
#
# 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}"
echo "Setup dir $dir"
set -e
if [ -d "${dir}" ]; then
echo >&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
+8
View File
@@ -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
+29
View File
@@ -130,10 +130,39 @@ export class MailuClient {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); 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<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
}
async deleteUser(email: string): Promise<void> { async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`); await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
} }
/**
* 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): Promise<boolean> {
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 };
return user.enabled !== false;
}
async listAliases(): Promise<MailuAlias[]> { async listAliases(): Promise<MailuAlias[]> {
const aliases = await this.api<any[]>("GET", "/alias"); const aliases = await this.api<any[]>("GET", "/alias");
return (aliases ?? []).map((a) => ({ return (aliases ?? []).map((a) => ({
+2 -2
View File
@@ -36,8 +36,8 @@ function watcher(created: string, deleted: string): (keys: string[]) => Promise<
}; };
} }
const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted"); const watchUsers = watcher("user.created", "user.deleted");
const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted"); const watchAliases = watcher("alias.created", "alias.deleted");
async function pollUsers(): Promise<void> { async function pollUsers(): Promise<void> {
await watchUsers((await mailu.listUsers()).map((u) => u.email)); await watchUsers((await mailu.listUsers()).map((u) => u.email));
+232 -93
View File
@@ -14,30 +14,53 @@
"name": "mailu" "name": "mailu"
}, },
"route": { "route": {
"label": "mail", "web": {
"port": 7080 "label": "mail",
"endpoint": "web-tls",
"scheme": "https",
"insecure": true
},
"acme": {
"label": "mail",
"path": "/.well-known/acme-challenge",
"endpoint": "web",
"priority": 100
},
"autoconfig": {
"label": "autoconfig",
"endpoint": "autoconfig"
},
"autodiscover": {
"label": "autodiscover",
"endpoint": "autoconfig"
},
"automx": {
"label": "automx",
"endpoint": "autoconfig"
}
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/mailu/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/mailu/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/mailu/database.secret", "postgres-database": "${dir:state}/database.secret",
"secret": { "secret": {
"secret-key": "/var/lib/mailu/secret-key.secret", "secret-key": "${dir:state}/secret-key.secret",
"admin": "/var/lib/mailu/admin.secret", "admin": "${dir:state}/admin.secret",
"api-token": "/var/lib/mailu/api-token.secret" "api-token": "${dir:state}/api-token.secret"
} }
}, },
"emits": [ "emits": [
"module.mailu.user.created", "user.created",
"module.mailu.user.deleted", "user.deleted",
"module.mailu.alias.created", "alias.created",
"module.mailu.alias.deleted" "alias.deleted"
], ],
"listens": [ "listens": [
{ {
"name": "smtp",
"port": 25, "port": 25,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -45,6 +68,23 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3",
"port": 110,
"protocol": "tcp",
"from": "anywhere",
"why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change",
"fixed": true
},
{
"name": "imap",
"port": 143,
"protocol": "tcp",
"from": "anywhere",
"why": "IMAP with STARTTLS, kept at parity",
"fixed": true
},
{
"name": "smtps",
"port": 465, "port": 465,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -52,13 +92,15 @@
"fixed": true "fixed": true
}, },
{ {
"name": "submission",
"port": 587, "port": 587,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
"why": "submission", "why": "submission; also what the smtp provision serves consumers",
"fixed": true "fixed": true
}, },
{ {
"name": "imaps",
"port": 993, "port": 993,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -66,10 +108,33 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3s",
"port": 995,
"protocol": "tcp",
"from": "anywhere",
"why": "POP3 over TLS, kept at parity",
"fixed": true
},
{
"name": "web",
"port": 7080, "port": 7080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "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"
},
{
"name": "web-tls",
"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"
},
{
"name": "autoconfig",
"port": 4243,
"protocol": "tcp",
"from": "mesh",
"why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here"
} }
], ],
"own-secrets": { "own-secrets": {
@@ -85,125 +150,125 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mailu", "mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "data-automx",
"type": "directory",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "config-env", "id": "config-env",
"type": "file", "type": "file",
"path": "/var/lib/mailu/mailu.env", "path": "${dir:state}/mailu.env",
"mode": "0644", "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": "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", "id": "secret-env",
"type": "file", "type": "file",
"path": "/var/lib/mailu/secret.env", "path": "${dir:state}/secret.env",
"mode": "0600", "mode": "0600",
"content": "SECRET_KEY=${secret:secret-key}\n" "content": "SECRET_KEY=${secret:secret-key}\n"
}, },
{ {
"id": "database-env", "id": "database-env",
"type": "file", "type": "file",
"path": "/var/lib/mailu/database.env", "path": "${dir:state}/database.env",
"mode": "0600", "mode": "0600",
"content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n" "content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n"
}, },
{ {
"id": "admin-env", "id": "admin-env",
"type": "file", "type": "file",
"path": "/var/lib/mailu/admin.env", "path": "${dir:state}/admin.env",
"mode": "0600", "mode": "0600",
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n" "content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
}, },
{ {
"id": "data-certs", "id": "data-certs",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/certs",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-data", "id": "data-data",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/data",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-dkim", "id": "data-dkim",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/dkim",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-mail", "id": "data-mail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/mail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-mailqueue", "id": "data-mailqueue",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/mailqueue", "mode": "0755"
"mode": "0700"
}, },
{ {
"id": "data-filter", "id": "data-filter",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/filter", "mode": "0700"
},
{
"id": "data-clamav",
"type": "directory",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-redis", "id": "data-redis",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/redis",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-webmail", "id": "data-webmail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/webmail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-dav", "id": "data-dav",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/dav",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-fetchmail", "id": "data-fetchmail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/data/fetchmail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-nginx", "id": "data-overrides-nginx",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/nginx",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-dovecot", "id": "data-overrides-dovecot",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/dovecot",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-postfix", "id": "data-overrides-postfix",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/postfix",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-rspamd", "id": "data-overrides-rspamd",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/rspamd",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-roundcube", "id": "data-overrides-roundcube",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/roundcube",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -215,164 +280,191 @@
"id": "resolver", "id": "resolver",
"type": "container", "type": "container",
"name": "mailu-resolver", "name": "mailu-resolver",
"image": "ghcr.io/mailu/unbound@sha256:142aaad82ad1b0d5b59a5f1303778dba61a3e0a540f5d969c48862bcc99f6f5d", "image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env", "${dir:state}/mailu.env",
"/var/lib/mailu/secret.env" "${dir:state}/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", "id": "redis",
"type": "container", "type": "container",
"name": "mailu-redis", "name": "mailu-redis",
"image": "redis@sha256:1db42ccef14898aa29bae778452d567534b59c107129cbc1163fb552de184d3c", "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d",
"network": "mailu", "network": "mailu",
"volumes": [ "volumes": [
"/services/mailu/data/redis:/data" "${dir:data-redis}:/data"
] ]
}, },
{ {
"id": "admin", "id": "admin",
"type": "container", "type": "container",
"name": "mailu-admin", "name": "mailu-admin",
"image": "ghcr.io/mailu/admin@sha256:dcac20e9cbdad560faef9653b1b5ac0d9266f4098dc00f0e7f0d35f4e70ed8f1", "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env", "${dir:state}/mailu.env",
"/var/lib/mailu/secret.env", "${dir:state}/secret.env",
"/var/lib/mailu/database.env", "${dir:state}/database.env",
"/var/lib/mailu/admin.env" "${dir:state}/admin.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/data:/data", "${dir:data-data}:/data",
"/services/mailu/data/dkim:/dkim" "${dir: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", "id": "imap",
"type": "container", "type": "container",
"name": "mailu-imap", "name": "mailu-imap",
"image": "ghcr.io/mailu/dovecot@sha256:46d18ba51032be8ebd6841aa49c1ef8762c729038c5fd86a081b5b884d478af9", "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env" "${dir:state}/mailu.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/mail:/mail", "${dir:data-mail}:/mail",
"/services/mailu/data/overrides/dovecot:/overrides:ro" "${dir:data-overrides-dovecot}:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "smtp", "id": "smtp",
"type": "container", "type": "container",
"name": "mailu-smtp", "name": "mailu-smtp",
"image": "ghcr.io/mailu/postfix@sha256:bbf882880f68849511710b35237a933f3fe80c4b28bf48ff20205dbd1f1433d7", "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env" "${dir:state}/mailu.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/mailqueue:/queue", "${dir:data-mailqueue}:/queue",
"/services/mailu/data/overrides/postfix:/overrides:ro" "${dir:data-overrides-postfix}:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "antispam", "id": "antispam",
"type": "container", "type": "container",
"name": "mailu-antispam", "name": "mailu-antispam",
"image": "ghcr.io/mailu/rspamd@sha256:e87ab93dd252cc69499caa5317dd10d445fd4291a7ecf6bca09793c7d475a0c8", "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env" "${dir:state}/mailu.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/filter:/var/lib/rspamd", "${dir:data-filter}:/var/lib/rspamd",
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro" "${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "antivirus", "id": "antivirus",
"type": "container", "type": "container",
"name": "mailu-antivirus", "name": "mailu-antivirus",
"image": "ghcr.io/mailu/clamav@sha256:01d30483e4a8a20a54566addb1f9b00ebb51e8a103f9226602379c412cf5fb62", "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a",
"network": "mailu", "network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
],
"volumes": [ "volumes": [
"/services/mailu/data/filter:/data" "${dir:data-clamav}:/var/lib/clamav"
], ],
"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": "webmail", "id": "webmail",
"type": "container", "type": "container",
"name": "mailu-webmail", "name": "mailu-webmail",
"image": "ghcr.io/mailu/roundcube@sha256:19ccc9c21b2420dabb893ffa707ef90785c785e53dcb6bb9f98da01598412c43", "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env", "${dir:state}/mailu.env",
"/var/lib/mailu/secret.env" "${dir:state}/secret.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/webmail:/data", "${dir:data-webmail}:/data",
"/services/mailu/data/overrides/roundcube:/overrides:ro" "${dir: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", "id": "webdav",
"type": "container", "type": "container",
"name": "mailu-webdav", "name": "mailu-webdav",
"image": "ghcr.io/mailu/radicale@sha256:e13cbad3791c0a6841b5d387e57e49a117808dcef87b8c9969f671ae9c3b67c0", "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env", "${dir:state}/mailu.env",
"/var/lib/mailu/secret.env" "${dir:state}/secret.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/dav:/data" "${dir: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", "id": "fetchmail",
"type": "container", "type": "container",
"name": "mailu-fetchmail", "name": "mailu-fetchmail",
"image": "ghcr.io/mailu/fetchmail@sha256:7dcd1392882925d612ab2d0230d437f0c660989d572283c48b0d0f2d491adce7", "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env", "${dir:state}/mailu.env",
"/var/lib/mailu/secret.env" "${dir:state}/secret.env"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/data/fetchmail:/data" "${dir: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", "id": "front",
"type": "container", "type": "container",
"name": "mailu-front", "name": "mailu-front",
"image": "ghcr.io/mailu/nginx@sha256:09f28ab6d36367fcacc7994f7021f132ac845bdc05f04bf80906102d11aaa057", "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"/var/lib/mailu/mailu.env" "${dir:state}/mailu.env"
], ],
"ports": [ "ports": [
"25", "25",
"110",
"143",
"465", "465",
"587", "587",
"993", "993",
"7080:80" "995",
"7080:80",
"7443:443"
], ],
"volumes": [ "volumes": [
"/services/mailu/data/certs:/certs", "${dir:data-certs}:/certs",
"/services/mailu/data/overrides/nginx:/overrides:ro" "${dir:data-overrides-nginx}:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
@@ -390,21 +482,40 @@
"network": "mailu", "network": "mailu",
"volumes": [ "volumes": [
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro", "${dir:state}/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "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_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap", "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": "${dir:grants}/mesh.json"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "automx",
"type": "container",
"name": "mailu-automx",
"artifact": "automx",
"network": "mailu",
"env-file": [
"${dir:state}/mailu.env"
],
"ports": [
"4243"
],
"volumes": [
"${dir:data-automx}:/data"
]
} }
], ],
"build": { "build": {
@@ -418,6 +529,10 @@
"arg": "RUNTIME_BASE", "arg": "RUNTIME_BASE",
"module": "mesh-tools", "module": "mesh-tools",
"artifact": "runtime" "artifact": "runtime"
},
{
"arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
} }
], ],
"artifacts": [ "artifacts": [
@@ -425,7 +540,31 @@
"name": "runtime", "name": "runtime",
"kind": "image", "kind": "image",
"from": "Dockerfile" "from": "Dockerfile"
},
{
"name": "automx",
"kind": "image",
"from": "automx/Dockerfile"
} }
] ]
},
"provides": [
{
"name": "smtp",
"scope": "mesh"
}
],
"serves": {
"smtp": {
"port": 587,
"domain": "novox.be",
"name": "mail.novox.be"
}
},
"receives": {
"smtp": "${dir:grants}/mesh.json"
},
"grants": {
"smtp": "${dir:grants}"
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+70
View File
@@ -0,0 +1,70 @@
// 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 `<account>@<domain>`: 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<Record<string, unknown>> }): 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<void> {
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.applyProvisioned(email, p.password);
}
},
async remove(p: { as: string }): Promise<void> {
// 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(() => {});
},
// 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<boolean> {
return mailu.holdsUser(addressOf(p));
},
});
+1
View File
@@ -6,6 +6,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "api",
"port": 59125, "port": 59125,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+6 -1
View File
@@ -22,7 +22,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # was assembled.
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module # **A module may need something the base image does not carry.** The base holds what every module
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# to listen for what the builder announces. Serve binds the broker first, then imports these, so # to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to. # `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+25 -11
View File
@@ -1,6 +1,6 @@
// mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072). // mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072).
// //
// The builder announces what it built; this places it in the graph and announces what that means. // The build-machine role announces what it built; this places it in the graph and announces what that means.
// The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so // The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so
// it never has to interpret an artifact or ask this module anything. // it never has to interpret an artifact or ask this module anything.
// //
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which // The schema is not brought up here. The mesh prepares this module's state before it starts this
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the // version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
// overlay would block the very apply that brings the overlay up. // at start made a schema that could not be reached a crash loop instead of a stop, with the graph
await graph.migrate(); // keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */ /** What the builder says when it has built something. */
interface Built { interface Built {
@@ -47,7 +49,15 @@ interface Built {
replay?: boolean; replay?: boolean;
} }
await on("module.builder.built", async (event) => { /**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = event.body as Built; const body = event.body as Built;
if (!body.module || !body.commit) { if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a // Said rather than dropped: a build that announced itself without saying what it built is a
@@ -69,7 +79,7 @@ await on("module.builder.built", async (event) => {
// it was missing, and the mesh is told nothing happened, because nothing did. // it was missing, and the mesh is told nothing happened, because nothing did.
if (body.replay) return; if (body.replay) return;
await emit("module.mesh-catalog.registered", { await emit("registered", {
module: body.module, commit: body.commit, upgraded, module: body.module, commit: body.commit, upgraded,
}); });
@@ -77,19 +87,23 @@ await on("module.builder.built", async (event) => {
// through modules that did not change, forever (ADR 0072). // through modules that did not change, forever (ADR 0072).
if (!upgraded) return; if (!upgraded) return;
await emit("module.mesh-catalog.upgraded", { await emit("upgraded", {
module: body.module, commit: body.commit, previous, module: body.module, commit: body.commit, previous,
}); });
// What can be built now — stale, and waiting on nothing that is itself stale. // What can be built now — stale, and waiting on nothing that is itself stale.
for (const next of await graph.buildable()) { for (const next of await graph.buildable()) {
await emit("module.mesh-catalog.rebuild-needed", { await emit("rebuild-needed", {
module: next.module, module: next.module,
builtAt: next.commit, builtAt: next.commit,
because: next.because, because: next.because,
}); });
} }
}); };
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
// //
@@ -101,4 +115,4 @@ await on("module.builder.built", async (event) => {
// Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the // Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the
// answer is idempotent: registering a build already held changes nothing and announces nothing. // answer is idempotent: registering a build already held changes nothing and announces nothing.
// Asked AFTER subscribing, so a build arriving during the replay is not lost between the two. // Asked AFTER subscribing, so a build arriving during the replay is not lost between the two.
await emit("module.mesh-catalog.catching-up", {}); await emit("catching-up", {});
+8 -5
View File
@@ -7,7 +7,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-catalogue", "name": "mesh-catalog",
"scope": "mesh" "scope": "mesh"
} }
], ],
@@ -29,13 +29,16 @@
"broker": "/var/lib/mesh/mesh-catalog/broker" "broker": "/var/lib/mesh/mesh-catalog/broker"
}, },
"consumes": [ "consumes": [
"module.builder.built" "mesh-build-machine.built",
"mesh-controller.built-before"
], ],
"emits": [ "emits": [
"module.mesh-catalog.registered", "registered",
"module.mesh-catalog.upgraded", "upgraded",
"module.mesh-catalog.rebuild-needed" "rebuild-needed",
"catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
+17
View File
@@ -0,0 +1,17 @@
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
//
// **The mesh runs this before the version that needs it, and does not start that version if it
// fails** — and the refusal reaches this module and nothing else on the machine
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
// nothing anywhere said so.
//
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// process exiting non-zero is how the host knows not to start the runtime.
import { Graph } from "../store.js";
const graph = Graph.fromEnv();
await graph.migrate();
console.log("[mesh-catalog] the module graph's schema is what this version needs");
await graph.close();
+2 -1
View File
@@ -12,6 +12,7 @@
"pg.d.ts", "pg.d.ts",
"store.ts", "store.ts",
"index.ts", "index.ts",
"tools/index.ts" "tools/index.ts",
"prepare/index.ts"
] ]
} }
+3 -3
View File
@@ -16,15 +16,15 @@ interface SecretEvent {
rotations?: number; rotations?: number;
} }
await on<SecretEvent>("module.mesh-vault.secret.provisioned", async (e) => { await on<SecretEvent>("secret.provisioned", async (e) => {
console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`); console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`);
}); });
await on<SecretEvent>("module.mesh-vault.secret.rotated", async (e) => { await on<SecretEvent>("secret.rotated", async (e) => {
console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`); console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`);
}); });
await on<SecretEvent>("module.mesh-vault.secret.deprovisioned", async (e) => { await on<SecretEvent>("secret.deprovisioned", async (e) => {
console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`); console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`);
}); });
+6 -6
View File
@@ -11,14 +11,14 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.mesh-vault.secret.provisioned", "secret.provisioned",
"module.mesh-vault.secret.rotated", "secret.rotated",
"module.mesh-vault.secret.deprovisioned" "secret.deprovisioned"
], ],
"consumes": [ "consumes": [
"module.mesh-vault.secret.provisioned", "mesh-vault.secret.provisioned",
"module.mesh-vault.secret.rotated", "mesh-vault.secret.rotated",
"module.mesh-vault.secret.deprovisioned" "mesh-vault.secret.deprovisioned"
], ],
"receives": { "receives": {
"secret": "/var/lib/mesh-vault/grants/mesh.json" "secret": "/var/lib/mesh-vault/grants/mesh.json"
+1 -1
View File
@@ -43,6 +43,6 @@ runProvisioner("secret", {
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
if (!ledger.withdraw(p.as)) return; if (!ledger.withdraw(p.as)) return;
console.log(`[mesh-vault] withdrawn: ${p.as}`); console.log(`[mesh-vault] withdrawn: ${p.as}`);
await announce("module.mesh-vault.secret.deprovisioned", { as: p.as }); await announce("secret.deprovisioned", { as: p.as });
}, },
}); });
@@ -1,4 +1,4 @@
# verdaccio's runtime: the tool runtime, carrying this module's compiled code. # minio's runtime: the tool runtime, carrying this module's compiled code.
# #
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in # **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the # the base images, published like any other artifact — which is what makes this buildable by the
@@ -9,22 +9,32 @@
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. # image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE ARG BUILD_BASE
ARG RUNTIME_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 FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own # Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler # node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image # is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away. # resolved away.
WORKDIR /app/modules/verdaccio WORKDIR /app/modules/minio
COPY . . 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 tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/verdaccio/dist /app/modules/verdaccio/dist 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 # 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 — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its # the convention novox/hq issues 060/061 settled.
# provisioner (`run`) served no tools and emitted no events; a container that named no command ENV MESH_TOOL_MODULES=/app/modules/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/verdaccio/dist/index.js,/app/modules/verdaccio/dist/tools/index.js
+17 -4
View File
@@ -125,6 +125,18 @@ export class MinioClient {
throw new Error(`minio bucketExists ${bucket}: ${status}`); 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<boolean> {
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<void> { async createBucket(bucket: string): Promise<void> {
const { status, text } = await this.request("PUT", `/${bucket}`); const { status, text } = await this.request("PUT", `/${bucket}`);
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
@@ -251,6 +263,7 @@ export class MinioClient {
method: string, method: string,
path: string, path: string,
query: Record<string, string> = {}, query: Record<string, string> = {},
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
): Promise<{ status: number; headers: Headers; text: string }> { ): Promise<{ status: number; headers: Headers; text: string }> {
const { amzDate, dateStamp } = this.stamp(); const { amzDate, dateStamp } = this.stamp();
const host = new URL(this.baseUrl).host; 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 canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
const res = await fetch(url, { const res = await fetch(url, {
@@ -275,8 +288,8 @@ export class MinioClient {
return { status: res.status, headers: res.headers, text }; return { status: res.status, headers: res.headers, text };
} }
private signingKey(dateStamp: string): Buffer { private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); const kDate = hmac(`AWS4${secretKey}`, dateStamp);
const kRegion = hmac(kDate, this.region); const kRegion = hmac(kDate, this.region);
const kService = hmac(kRegion, "s3"); const kService = hmac(kRegion, "s3");
return hmac(kService, "aws4_request"); return hmac(kService, "aws4_request");
+66 -14
View File
@@ -7,25 +7,48 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"requires": [
"route"
],
"contributes": {
"route": {
"api": {
"label": "files-api",
"endpoint": "s3"
},
"console": {
"label": "files",
"endpoint": "console"
}
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.minio.bucket.created", "bucket.created",
"module.minio.bucket.removed" "bucket.removed"
], ],
"listens": [ "listens": [
{ {
"name": "s3",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the S3 endpoint" "why": "the S3 endpoint"
},
{
"name": "console",
"port": 9001,
"protocol": "tcp",
"from": "mesh",
"why": "the admin console"
} }
], ],
"serves": { "serves": {
"s3-bucket": { "s3-bucket": {
"scheme": "http", "scheme": "http",
"region": "us-east-1", "region": "eu-west",
"port": 9000 "port": 9000
} }
}, },
@@ -68,20 +91,20 @@
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/minio/data/data1-1", "path": "/var/lib/minio-store",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "net", "id": "net",
"type": "network", "type": "network",
"name": "minio" "name": "minio-net"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "minio", "name": "minio",
"image": "quay.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e", "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
"network": "minio", "network": "minio-net",
"args": [ "args": [
"server", "server",
"/data", "/data",
@@ -92,22 +115,24 @@
"/var/lib/minio/root.env" "/var/lib/minio/root.env"
], ],
"ports": [ "ports": [
"9000" "9000",
"9001"
], ],
"volumes": [ "volumes": [
"/services/minio/data/data1-1:/data", "/var/lib/minio-store:/data",
"/var/lib/minio/root.secret:/run/secrets/root:ro" "/var/lib/minio/root.secret:/run/secrets/root:ro"
], ],
"env": { "env": {
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
"MINIO_REGION": "eu-west"
} }
}, },
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-minio", "name": "mesh-minio",
"image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "minio-net",
"network": "minio",
"volumes": [ "volumes": [
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
"/var/lib/minio/grants:/var/lib/minio/grants:ro", "/var/lib/minio/grants:/var/lib/minio/grants:ro",
@@ -117,9 +142,36 @@
"MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ENDPOINT": "http://minio:9000",
"MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json"
} },
"artifact": "runtime"
} }
] ],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+8 -2
View File
@@ -30,7 +30,7 @@ runProvisioner("s3-bucket", {
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
await minio.createAccessKey(bucket, accessKeyId, p.password); await minio.createAccessKey(bucket, accessKeyId, p.password);
await announce("module.minio.bucket.created", { await announce("bucket.created", {
bucket, bucket,
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
accessKey: accessKeyId, accessKey: accessKeyId,
@@ -51,7 +51,13 @@ runProvisioner("s3-bucket", {
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
} }
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); await announce("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<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
}, },
}); });
+1 -1
View File
@@ -22,7 +22,7 @@ const store = UsageStore.fromEnv();
// to reach the provider over the overlay would block the very apply that brings the overlay up. // to reach the provider over the overlay would block the very apply that brings the overlay up.
await store.migrate(); await store.migrate();
await on("module.*.usage.*", async (event) => { await on("*.usage.*", async (event) => {
const body = event.body as { rows?: UsageRow[]; raw?: unknown }; const body = event.body as { rows?: UsageRow[]; raw?: unknown };
for (const row of body.rows ?? []) { for (const row of body.rows ?? []) {
try { try {
+1 -1
View File
@@ -20,7 +20,7 @@
"postgres-database": "/var/lib/model-usage/database.secret" "postgres-database": "/var/lib/model-usage/database.secret"
}, },
"consumes": [ "consumes": [
"module.*.usage.*" "*.usage.*"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/model-usage/broker" "broker": "/var/lib/mesh/model-usage/broker"
+28
View File
@@ -109,6 +109,34 @@ print(EJSON.stringify({ ok: 1 }));
await this.evalJs<{ ok: number }>(js); 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<boolean> {
// 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", 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 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);
}
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** 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. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
+2 -2
View File
@@ -14,11 +14,11 @@ interface DatabaseEvent {
user?: string; user?: string;
} }
await on<DatabaseEvent>("module.mongodb.database.provisioned", async (e) => { await on<DatabaseEvent>("database.provisioned", async (e) => {
console.log(`[mongodb] database provisioned for ${e.body.consumer} (db ${e.body.database})`); console.log(`[mongodb] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
}); });
await on<DatabaseEvent>("module.mongodb.database.deprovisioned", async (e) => { await on<DatabaseEvent>("database.deprovisioned", async (e) => {
console.log(`[mongodb] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); console.log(`[mongodb] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
}); });
+17 -18
View File
@@ -11,15 +11,16 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"module.mongodb.database.provisioned", "database.provisioned",
"module.mongodb.database.deprovisioned" "database.deprovisioned"
], ],
"consumes": [ "consumes": [
"module.mongodb.database.provisioned", "mongodb.database.provisioned",
"module.mongodb.database.deprovisioned" "mongodb.database.deprovisioned"
], ],
"listens": [ "listens": [
{ {
"name": "database",
"port": 27017, "port": 27017,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -32,13 +33,13 @@
} }
}, },
"receives": { "receives": {
"mongodb-database": "/var/lib/mongodb/grants/mesh.json" "mongodb-database": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"mongodb-database": "/var/lib/mongodb/grants" "mongodb-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "/var/lib/mongodb/root.secret", "root": "${dir:state}/root.secret",
"broker": "/var/lib/mesh/mongodb/broker" "broker": "/var/lib/mesh/mongodb/broker"
}, },
"secrets-owner": "999:999", "secrets-owner": "999:999",
@@ -52,19 +53,17 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mongodb", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/mongodb/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/mongodb/db-data",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -75,7 +74,7 @@
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mongo", "name": "mongodb-server",
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3", "image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
"network": "mongodb", "network": "mongodb",
"env": { "env": {
@@ -86,8 +85,8 @@
"27017" "27017"
], ],
"volumes": [ "volumes": [
"/services/mongodb/db-data:/data/db", "${dir:data}:/data/db",
"/var/lib/mongodb/root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
] ]
}, },
{ {
@@ -97,14 +96,14 @@
"network": "mongodb", "network": "mongodb",
"volumes": [ "volumes": [
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro", "/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro",
"/var/lib/mongodb/grants:/var/lib/mongodb/grants:ro", "${dir:grants}:${dir:grants}:ro",
"/var/lib/mongodb/root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
], ],
"env": { "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_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json" "MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"artifact": "runtime" "artifact": "runtime"
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+7 -2
View File
@@ -34,7 +34,7 @@ runProvisioner("mongodb-database", {
// Database and owning user share the consumer's login, so the consumer owns exactly its own. // Database and owning user share the consumer's login, so the consumer owns exactly its own.
const database = p.as; const database = p.as;
await mongo.createDatabaseAndUser(database, p.as, p.password); await mongo.createDatabaseAndUser(database, p.as, p.password);
await announce("module.mongodb.database.provisioned", { await announce("database.provisioned", {
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
database, database,
user: p.as, user: p.as,
@@ -43,6 +43,11 @@ runProvisioner("mongodb-database", {
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
await mongo.dropDatabaseAndUser(p.as, p.as); await mongo.dropDatabaseAndUser(p.as, p.as);
await announce("module.mongodb.database.deprovisioned", { database: p.as }); await announce("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<boolean> {
return mongo.canAuthenticateAs(p.as, p.as, p.password);
}, },
}); });
+94 -3
View File
@@ -15,6 +15,7 @@
// The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README. // The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -87,9 +88,20 @@ export class MosquittoClient {
"-u", this.conn.adminUser, "-u", this.conn.adminUser,
"-P", this.conn.adminPassword, "-P", this.conn.adminPassword,
]; ];
const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { let stdout: string;
maxBuffer: 16 << 20, 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}`); const failure = ctlError(`${stdout}\n${stderr}`);
if (failure) { if (failure) {
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`); throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
@@ -140,6 +152,11 @@ export class MosquittoClient {
if (await this.clientExists(username)) { if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password); 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 { } else {
await this.ctl("createClient", username, "-p", password); await this.ctl("createClient", username, "-p", password);
} }
@@ -163,6 +180,28 @@ 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<boolean> {
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}`);
// 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. */ /** Remove a client and the per-client role created for it, idempotently. */
async deleteScopedClient(username: string): Promise<void> { async deleteScopedClient(username: string): Promise<void> {
await ignoreMissing(this.ctl("deleteClient", username)); await ignoreMissing(this.ctl("deleteClient", username));
@@ -249,3 +288,55 @@ function readSecretFile(path: string | undefined): string | undefined {
return 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<number> {
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);
});
});
}

Some files were not shown because too many files have changed in this diff Show More