Compare commits

...
Author SHA1 Message Date
jschoubben 048f1b8284 ssh-client module: the mesh owns ~/.ssh, config from the hub (to-be 29)
Requires openssh; creates ~/.ssh (0700, owned by the operator account via
${machine:account}); writes every other node's Host block (HostName + User
<account>) into a marked region of ~/.ssh/config (home-scoped, into:block), so
`ssh <node>` reaches each peer as the right account and the operator's own
config is kept. Universal-tier: assigned wherever a person logs in; a node with
no account gets no config.
2026-09-27 17:50:57 +02: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 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 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 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 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 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
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 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 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 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
64 changed files with 1151 additions and 659 deletions
+5 -5
View File
@@ -6,19 +6,19 @@
],
"claims": [
{
"name": "the-build-machine",
"scope": "node"
"name": "mesh-build-machine",
"scope": "mesh"
}
],
"requires": [
"artifact-store",
"package-registry"
"npm-package-registry"
],
"binds": {
"package-registry": "/var/lib/mesh/builder/package-registry.json"
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
},
"secrets": {
"package-registry": "/var/lib/mesh/builder/package-registry.secret"
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
},
"emits": [
"module.builder.built"
+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"
}
]
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,7 +6,7 @@
],
"claims": [
{
"name": "the-intrusion-prevention",
"name": "node-intrusion-prevention",
"scope": "node"
}
],
@@ -33,7 +33,7 @@
"type": "file",
"path": "/etc/fail2ban/jail.local",
"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\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
},
{
"id": "jail-sshd",
+27 -2
View File
@@ -390,7 +390,7 @@ export class GiteaAdmin {
}
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;
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null;
@@ -414,7 +414,9 @@ export class GiteaAdmin {
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
// login_name is required by the admin edit endpoint; for a local user it is the username.
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
// password, so the provisioner's check reports it lost; applying again must undo both.
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
});
if (patch.status === 200) return;
GiteaAdmin.fail(`/admin/users/${username}`, patch);
@@ -433,6 +435,29 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
}
/**
* Whether a consumer's user logs in with exactly this password and is still a member of the
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
* API, and membership through the admin API. `false` for a refused login or a missing member; any
* other answer rejects (novox/hq issue 120).
*/
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<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
* an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> {
+39 -23
View File
@@ -24,14 +24,14 @@
}
},
"binds": {
"postgres-database": "/var/lib/gitea/database.json",
"route": "/var/lib/gitea/route.json"
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"postgres-database": "/var/lib/gitea/database.secret",
"postgres-database": "${dir:state}/database.secret",
"secret": {
"internal-token": "/var/lib/gitea/internal-token.secret",
"admin": "/var/lib/gitea/admin.secret"
"internal-token": "${dir:state}/internal-token.secret",
"admin": "${dir:state}/admin.secret"
}
},
"capabilities": [
@@ -53,22 +53,36 @@
"port": 22,
"protocol": "tcp",
"from": "mesh",
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
"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": {
"package-registry": {
"npm-package-registry": {
"scheme": "http",
"port": 3000,
"npm-path": "/api/packages/novox/npm/"
},
"git": {
"scheme": "http",
"port": 3000
}
},
"receives": {
"package-registry": "/var/lib/gitea/grants/mesh.json"
"npm-package-registry": "${dir:grants}/npm.json"
},
"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": {
"broker": "/var/lib/mesh/gitea/broker"
},
@@ -88,26 +102,24 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/gitea",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/gitea/grants",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/gitea/server.env",
"path": "${dir:state}/server.env",
"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"
},
{
"id": "data",
"type": "directory",
"path": "/services/gitea/gitea",
"mode": "0700",
"owner": "1000:1000"
},
@@ -122,14 +134,14 @@
"USER_GID": "1000"
},
"env-file": [
"/var/lib/gitea/server.env"
"${dir:state}/server.env"
],
"ports": [
"3000",
"222:22"
],
"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"
},
@@ -145,11 +157,11 @@
"MESH_GITEA_ADMIN_USER": "mesh-admin"
},
"env-file": [
"/var/lib/gitea/server.env"
"${dir:state}/server.env"
],
"volumes": [
"/services/gitea/gitea:/data",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
"${dir:data}:/data",
"${dir:state}/admin.secret:/run/secrets/admin:ro"
],
"args": [
"/bin/sh",
@@ -174,8 +186,8 @@
"volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"/var/lib/gitea/grants:/var/lib/gitea/grants:ro",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/lib/mesh/gitea/state:/run/state"
],
"env": {
@@ -185,7 +197,7 @@
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
"MESH_RECEIVES": "${dir:grants}/npm.json"
},
"artifact": "runtime",
"restart-on": [
@@ -195,7 +207,11 @@
],
"provides": [
{
"name": "package-registry",
"name": "npm-package-registry",
"scope": "mesh"
},
{
"name": "git",
"scope": "mesh"
}
],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+21 -6
View File
@@ -1,9 +1,15 @@
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a
// consumer's npm credential (novox/hq ADR 0048/0076).
// gitea's provisioner — the adapter that makes gitea a provider of the mesh
// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the
// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea
// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076).
//
// The `package-registry` interface: a consumer authenticates to the npm registry at
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
// `receives` path and another registration below — not widening this one. `git`, which gitea also
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
// and a clone credential is not yet decided (ADR 0111).
//
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
// `novox`; a consumer is a gitea *user* placed on that org's package team.
@@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages";
const gitea = GiteaAdmin.fromEnv();
runProvisioner("package-registry", {
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
// path in code would drift from it. One variable carries one path, so a second registration in this
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
runProvisioner("npm-package-registry", {
async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step.
@@ -46,4 +56,9 @@ runProvisioner("package-registry", {
async remove(p: { as: string }): Promise<void> {
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);
},
});
+9 -6
View File
@@ -14,12 +14,15 @@
"mongodb-database": {
"name": "invoicing"
},
"s3-bucket": {
"bucket": "invoicing"
},
"route": {
"label": "invoicing",
"port": 80
"site": {
"label": "invoicing",
"port": 80
},
"api": {
"label": "invoicing-api",
"port": 9000
}
}
},
"binds": {
@@ -63,7 +66,7 @@
"type": "file",
"path": "/var/lib/invoicing/api.env",
"mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\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",
+4 -2
View File
@@ -88,7 +88,9 @@
"env": {
"KC_DB": "postgres",
"KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true"
"KC_HEALTH_ENABLED": "true",
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
},
"env-file": [
"/var/lib/keycloak/admin.env",
@@ -118,7 +120,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+33
View File
@@ -94,6 +94,39 @@ export class LavinmqClient {
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
}
/**
* Whether a consumer's user exists with exactly this password and full permissions on its own
* vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the
* password is checked by hashing it with the stored salt rather than by logging in. `false` when
* the user or its permission is gone or the password differs; an unreachable API rejects
* (novox/hq issue 120).
*/
async holdsConsumer(login: string, password: string): Promise<boolean> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`);
if (!user?.password_hash) return false;
if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) {
throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`);
}
const stored = Buffer.from(user.password_hash, "base64");
if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false;
const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`);
return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*";
}
/** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */
private async getOrNull<T>(path: string): Promise<T | null> {
const resp = await fetch(`${this.conn.base}/api${path}`, {
headers: {
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
},
});
if (resp.status === 404) return null;
if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`);
return (await resp.json()) as T;
}
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
async removeConsumer(login: string): Promise<void> {
const v = encodeURIComponent(login);
+7 -7
View File
@@ -81,12 +81,6 @@
"path": "/var/lib/mesh-broker",
"mode": "0700"
},
{
"id": "broker-tls",
"type": "directory",
"path": "/var/lib/mesh-broker-tls",
"mode": "0700"
},
{
"id": "server",
"type": "container",
@@ -147,5 +141,11 @@
"from": "Dockerfile"
}
]
}
},
"accesses": [
{
"path": "/var/lib/mesh-broker-tls",
"mode": "read"
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+5
View File
@@ -48,4 +48,9 @@ runProvisioner("amqp", {
await lavinmq.removeConsumer(p.as);
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return lavinmq.holdsConsumer(p.as, p.password);
},
});
+29
View File
@@ -130,10 +130,39 @@ export class MailuClient {
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> {
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[]> {
const aliases = await this.api<any[]>("GET", "/alias");
return (aliases ?? []).map((a) => ({
+54 -71
View File
@@ -41,15 +41,15 @@
}
},
"binds": {
"postgres-database": "/var/lib/mailu/database.json",
"route": "/var/lib/mailu/route.json"
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"postgres-database": "/var/lib/mailu/database.secret",
"postgres-database": "${dir:state}/database.secret",
"secret": {
"secret-key": "/var/lib/mailu/secret-key.secret",
"admin": "/var/lib/mailu/admin.secret",
"api-token": "/var/lib/mailu/api-token.secret"
"secret-key": "${dir:state}/secret-key.secret",
"admin": "${dir:state}/admin.secret",
"api-token": "${dir:state}/api-token.secret"
}
},
"emits": [
@@ -140,143 +140,125 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/mailu",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/mailu/grants",
"mode": "0700"
},
{
"id": "data-automx",
"type": "directory",
"path": "/services/mailu/data/automx",
"mode": "0700"
},
{
"id": "config-env",
"type": "file",
"path": "/var/lib/mailu/mailu.env",
"path": "${dir:state}/mailu.env",
"mode": "0644",
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\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",
"type": "file",
"path": "/var/lib/mailu/secret.env",
"path": "${dir:state}/secret.env",
"mode": "0600",
"content": "SECRET_KEY=${secret:secret-key}\n"
},
{
"id": "database-env",
"type": "file",
"path": "/var/lib/mailu/database.env",
"path": "${dir:state}/database.env",
"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"
},
{
"id": "admin-env",
"type": "file",
"path": "/var/lib/mailu/admin.env",
"path": "${dir:state}/admin.env",
"mode": "0600",
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
},
{
"id": "data-certs",
"type": "directory",
"path": "/services/mailu/data/certs",
"mode": "0700"
},
{
"id": "data-data",
"type": "directory",
"path": "/services/mailu/data/data",
"mode": "0700"
},
{
"id": "data-dkim",
"type": "directory",
"path": "/services/mailu/data/dkim",
"mode": "0700"
},
{
"id": "data-mail",
"type": "directory",
"path": "/services/mailu/data/mail",
"mode": "0700"
},
{
"id": "data-mailqueue",
"type": "directory",
"path": "/services/mailu/data/mailqueue",
"mode": "0755"
},
{
"id": "data-filter",
"type": "directory",
"path": "/services/mailu/data/filter",
"mode": "0700"
},
{
"id": "data-clamav",
"type": "directory",
"path": "/services/mailu/data/clamav",
"mode": "0700"
},
{
"id": "data-redis",
"type": "directory",
"path": "/services/mailu/data/redis",
"mode": "0700"
},
{
"id": "data-webmail",
"type": "directory",
"path": "/services/mailu/data/webmail",
"mode": "0700"
},
{
"id": "data-dav",
"type": "directory",
"path": "/services/mailu/data/dav",
"mode": "0700"
},
{
"id": "data-fetchmail",
"type": "directory",
"path": "/services/mailu/data/data/fetchmail",
"mode": "0700"
},
{
"id": "data-overrides-nginx",
"type": "directory",
"path": "/services/mailu/data/overrides/nginx",
"mode": "0700"
},
{
"id": "data-overrides-dovecot",
"type": "directory",
"path": "/services/mailu/data/overrides/dovecot",
"mode": "0700"
},
{
"id": "data-overrides-postfix",
"type": "directory",
"path": "/services/mailu/data/overrides/postfix",
"mode": "0700"
},
{
"id": "data-overrides-rspamd",
"type": "directory",
"path": "/services/mailu/data/overrides/rspamd",
"mode": "0700"
},
{
"id": "data-overrides-roundcube",
"type": "directory",
"path": "/services/mailu/data/overrides/roundcube",
"mode": "0700"
},
{
@@ -291,8 +273,8 @@
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
"${dir:state}/mailu.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",
"ip": "192.168.203.254"
@@ -304,7 +286,7 @@
"image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d",
"network": "mailu",
"volumes": [
"/services/mailu/data/redis:/data"
"${dir:data-redis}:/data"
]
},
{
@@ -314,14 +296,14 @@
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env",
"/var/lib/mailu/database.env",
"/var/lib/mailu/admin.env"
"${dir:state}/mailu.env",
"${dir:state}/secret.env",
"${dir:state}/database.env",
"${dir:state}/admin.env"
],
"volumes": [
"/services/mailu/data/data:/data",
"/services/mailu/data/dkim:/dkim"
"${dir:data-data}:/data",
"${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",
"dns": [
@@ -335,11 +317,11 @@
"image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env"
"${dir:state}/mailu.env"
],
"volumes": [
"/services/mailu/data/mail:/mail",
"/services/mailu/data/overrides/dovecot:/overrides:ro"
"${dir:data-mail}:/mail",
"${dir:data-overrides-dovecot}:/overrides:ro"
],
"dns": [
"192.168.203.254"
@@ -352,11 +334,11 @@
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env"
"${dir:state}/mailu.env"
],
"volumes": [
"/services/mailu/data/mailqueue:/queue",
"/services/mailu/data/overrides/postfix:/overrides:ro"
"${dir:data-mailqueue}:/queue",
"${dir:data-overrides-postfix}:/overrides:ro"
],
"dns": [
"192.168.203.254"
@@ -369,11 +351,11 @@
"image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env"
"${dir:state}/mailu.env"
],
"volumes": [
"/services/mailu/data/filter:/var/lib/rspamd",
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
"${dir:data-filter}:/var/lib/rspamd",
"${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro"
],
"dns": [
"192.168.203.254"
@@ -386,7 +368,7 @@
"image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a",
"network": "mailu",
"volumes": [
"/services/mailu/data/clamav:/var/lib/clamav"
"${dir:data-clamav}:/var/lib/clamav"
],
"dns": [
"192.168.203.254"
@@ -399,12 +381,12 @@
"image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
"${dir:state}/mailu.env",
"${dir:state}/secret.env"
],
"volumes": [
"/services/mailu/data/webmail:/data",
"/services/mailu/data/overrides/roundcube:/overrides:ro"
"${dir:data-webmail}:/data",
"${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",
"dns": [
@@ -418,11 +400,11 @@
"image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
"${dir:state}/mailu.env",
"${dir:state}/secret.env"
],
"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",
"dns": [
@@ -436,11 +418,11 @@
"image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
"${dir:state}/mailu.env",
"${dir:state}/secret.env"
],
"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",
"dns": [
@@ -454,7 +436,7 @@
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env"
"${dir:state}/mailu.env"
],
"ports": [
"25",
@@ -468,8 +450,8 @@
"7443:443"
],
"volumes": [
"/services/mailu/data/certs:/certs",
"/services/mailu/data/overrides/nginx:/overrides:ro"
"${dir:data-certs}:/certs",
"${dir:data-overrides-nginx}:/overrides:ro"
],
"dns": [
"192.168.203.254"
@@ -490,8 +472,8 @@
"network": "mailu",
"volumes": [
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
"/var/lib/mailu/grants:/var/lib/mailu/grants:ro",
"${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/run/docker.sock:/var/run/docker.sock"
],
@@ -502,7 +484,7 @@
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
"MESH_MAILU_DOMAIN": "novox.be",
"MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json"
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
@@ -516,13 +498,13 @@
"artifact": "automx",
"network": "mailu",
"env-file": [
"/var/lib/mailu/mailu.env"
"${dir:state}/mailu.env"
],
"ports": [
"4243"
],
"volumes": [
"/services/mailu/data/automx:/data"
"${dir:data-automx}:/data"
]
}
],
@@ -565,13 +547,14 @@
"serves": {
"smtp": {
"port": 587,
"domain": "novox.be"
"domain": "novox.be",
"name": "mail.novox.be"
}
},
"receives": {
"smtp": "/var/lib/mailu/grants/mesh.json"
"smtp": "${dir:grants}/mesh.json"
},
"grants": {
"smtp": "/var/lib/mailu/grants"
"smtp": "${dir:grants}"
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+6 -1
View File
@@ -48,7 +48,7 @@ runProvisioner("smtp", {
try {
await mailu.createUser(email, p.password);
} catch {
await mailu.changePassword(email, p.password);
await mailu.applyProvisioned(email, p.password);
}
},
@@ -62,4 +62,9 @@ runProvisioner("smtp", {
// named-account consumer is an operator action until the harness carries values here.
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mailu.holdsUser(addressOf(p));
},
});
+1 -1
View File
@@ -7,7 +7,7 @@
],
"claims": [
{
"name": "the-catalogue",
"name": "mesh-catalog",
"scope": "mesh"
}
],
+17 -4
View File
@@ -125,6 +125,18 @@ export class MinioClient {
throw new Error(`minio bucketExists ${bucket}: ${status}`);
}
/**
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
*/
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<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> {
const { status, text } = await this.request("PUT", `/${bucket}`);
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
@@ -251,6 +263,7 @@ export class MinioClient {
method: string,
path: string,
query: Record<string, string> = {},
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
): Promise<{ status: number; headers: Headers; text: string }> {
const { amzDate, dateStamp } = this.stamp();
const host = new URL(this.baseUrl).host;
@@ -262,8 +275,8 @@ export class MinioClient {
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
const res = await fetch(url, {
@@ -275,8 +288,8 @@ export class MinioClient {
return { status: res.status, headers: res.headers, text };
}
private signingKey(dateStamp: string): Buffer {
const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp);
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
const kRegion = hmac(kDate, this.region);
const kService = hmac(kRegion, "s3");
return hmac(kService, "aws4_request");
+4 -3
View File
@@ -95,14 +95,14 @@
{
"id": "net",
"type": "network",
"name": "minio"
"name": "minio-net"
},
{
"id": "server",
"type": "container",
"name": "minio",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
"network": "minio",
"network": "minio-net",
"args": [
"server",
"/data",
@@ -122,6 +122,7 @@
],
"env": {
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
"MINIO_REGION": "eu-west"
}
},
@@ -129,7 +130,7 @@
"id": "runtime",
"type": "container",
"name": "mesh-minio",
"network": "minio",
"network": "minio-net",
"volumes": [
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+6
View File
@@ -53,6 +53,12 @@ runProvisioner("s3-bucket", {
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
},
});
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
+28
View File
@@ -109,6 +109,34 @@ print(EJSON.stringify({ ok: 1 }));
await this.evalJs<{ ok: number }>(js);
}
/**
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<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
* user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
+12 -14
View File
@@ -32,13 +32,13 @@
}
},
"receives": {
"mongodb-database": "/var/lib/mongodb/grants/mesh.json"
"mongodb-database": "${dir:grants}/mesh.json"
},
"grants": {
"mongodb-database": "/var/lib/mongodb/grants"
"mongodb-database": "${dir:grants}"
},
"own-secrets": {
"root": "/var/lib/mongodb/root.secret",
"root": "${dir:state}/root.secret",
"broker": "/var/lib/mesh/mongodb/broker"
},
"secrets-owner": "999:999",
@@ -52,19 +52,17 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/mongodb",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/mongodb/grants",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/mongodb/db-data",
"mode": "0700"
},
{
@@ -75,7 +73,7 @@
{
"id": "server",
"type": "container",
"name": "mongo",
"name": "mongodb-server",
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
"network": "mongodb",
"env": {
@@ -86,8 +84,8 @@
"27017"
],
"volumes": [
"/services/mongodb/db-data:/data/db",
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
"${dir:data}:/data/db",
"${dir:state}/root.secret:/run/secrets/root:ro"
]
},
{
@@ -97,14 +95,14 @@
"network": "mongodb",
"volumes": [
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro",
"/var/lib/mongodb/grants:/var/lib/mongodb/grants:ro",
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@mongo:27017/admin?authSource=admin",
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+5
View File
@@ -45,4 +45,9 @@ runProvisioner("mongodb-database", {
await mongo.dropDatabaseAndUser(p.as, p.as);
await announce("module.mongodb.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<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.
import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -87,9 +88,20 @@ export class MosquittoClient {
"-u", this.conn.adminUser,
"-P", this.conn.adminPassword,
];
const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
maxBuffer: 16 << 20,
});
let stdout: string;
let stderr: string;
try {
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
maxBuffer: 16 << 20,
timeout: 30_000,
}));
} catch (err) {
// A failed run's message repeats its argv, the admin password (-P) included; say what failed
// without it.
const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string };
const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500);
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`);
}
const failure = ctlError(`${stdout}\n${stderr}`);
if (failure) {
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
@@ -140,6 +152,11 @@ export class MosquittoClient {
if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password);
// A disabled client is refused like a wrong password, so the check the provisioner runs
// reports it lost; applying again must enable it, or the two would disagree for ever.
if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) {
await this.ctl("enableClient", username);
}
} else {
await this.ctl("createClient", username, "-p", password);
}
@@ -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. */
async deleteScopedClient(username: string): Promise<void> {
await ignoreMissing(this.ctl("deleteClient", username));
@@ -249,3 +288,55 @@ function readSecretFile(path: string | undefined): string | undefined {
return undefined;
}
}
/**
* Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code,
* and disconnect. A clean session under a throwaway client id, so no consumer session is taken over.
*/
function mqttConnack(host: string, port: number, username: string, password: string): Promise<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);
});
});
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+5
View File
@@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", {
await mosquitto.deleteScopedClient(p.as);
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mosquitto.holdsClient(p.as, p.password);
},
});
+54 -4
View File
@@ -75,14 +75,18 @@ export class MssqlClient {
* prints (split across output lines for a large result, and reassembled here) is parsed. An
* empty result yields no output at all — an empty array.
*/
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
async query(
select: string,
database = "master",
variables: Record<string, string> = {},
): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database);
const stdout = await this.sqlcmd(wrapped, database, variables);
return parseJsonRows(stdout);
}
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async sqlcmd(sql: string, database: string): Promise<string> {
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
@@ -101,7 +105,9 @@ export class MssqlClient {
"-W",
"-Q", sql,
],
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
);
return stdout;
}
@@ -121,6 +127,9 @@ export class MssqlClient {
);
} else {
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
// A disabled login is refused like a wrong password; the check the provisioner runs reports it
// lost, so applying again must enable it or the two would disagree for ever.
await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`);
}
const dbs = await this.query(
@@ -138,10 +147,51 @@ export class MssqlClient {
);
if (users.length === 0) {
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
} else {
// Re-point an existing user at the login when its SID is not the login's: a database restored
// from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that
// is already mapped is left alone.
const orphaned = await this.query(
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` +
`AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`,
database,
);
if (orphaned.length > 0) {
await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database);
}
}
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
}
/**
* Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of
* `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
// minted value, which carries no quote.
const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master",
{ MESHHOLDSPW: password },
);
if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
// right name and the wrong SID, and cannot be reached through the login.
const owner = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` +
`JOIN sys.server_principals sp ON dp.sid = sp.sid ` +
`WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` +
`AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`,
database,
);
return Number(owner[0]?.ok) === 1;
}
/** Drop a database and its login, idempotently, after evicting live connections. */
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query(
+1 -2
View File
@@ -68,7 +68,6 @@
{
"id": "data",
"type": "directory",
"path": "/services/mssql/data",
"mode": "0700",
"owner": "10001:0"
},
@@ -90,7 +89,7 @@
"1433"
],
"volumes": [
"/services/mssql/data:/var/opt/mssql"
"${dir:data}:/var/opt/mssql"
],
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
},
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+5
View File
@@ -44,4 +44,9 @@ runProvisioner("mssql-database", {
await mssql.dropDatabaseAndLogin(p.as, p.as);
await announce("module.mssql.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mssql.holdsLogin(p.as, p.as, p.password);
},
});
+36
View File
@@ -0,0 +1,36 @@
{
"module": "networkmanager",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-uplink",
"scope": "node"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "networkmanager"
},
{
"id": "config",
"type": "file",
"path": "/etc/NetworkManager/conf.d/50-mesh.conf",
"mode": "0644",
"content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n"
},
{
"id": "service",
"type": "service",
"unit": "NetworkManager.service",
"reload-on": [
"config"
]
}
]
}
+13 -17
View File
@@ -11,29 +11,26 @@
"postgres-database": {
"name": "nextcloud"
},
"s3-bucket": {
"bucket": "nextcloud"
},
"route": {
"label": "drive",
"port": 80
}
},
"binds": {
"postgres-database": "/var/lib/nextcloud-module/database.json",
"s3-bucket": "/var/lib/nextcloud-module/store.json",
"route": "/var/lib/nextcloud-module/route.json"
"postgres-database": "${dir:state}/database.json",
"s3-bucket": "${dir:state}/store.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"postgres-database": "/var/lib/nextcloud-module/database.secret",
"s3-bucket": "/var/lib/nextcloud-module/store.secret"
"postgres-database": "${dir:state}/database.secret",
"s3-bucket": "${dir:state}/store.secret"
},
"emits": [
"module.nextcloud.user.created",
"module.nextcloud.share.created"
],
"own-secrets": {
"admin": "/var/lib/nextcloud-module/admin.secret",
"admin": "${dir:state}/admin.secret",
"broker": "/var/lib/mesh/nextcloud/broker"
},
"capabilities": [
@@ -57,20 +54,19 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/nextcloud-module",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/nextcloud-module/server.env",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=nextcloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
},
{
"id": "html",
"type": "directory",
"path": "/services/nextcloud/html",
"mode": "0750",
"owner": "33:33"
},
@@ -80,13 +76,13 @@
"name": "nextcloud",
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
"env-file": [
"/var/lib/nextcloud-module/server.env"
"${dir:state}/server.env"
],
"ports": [
"80"
],
"volumes": [
"/services/nextcloud/html:/var/www/html"
"${dir:html}:/var/www/html"
],
"secrets-in-environment": "the image honours POSTGRES_PASSWORD_FILE and NEXTCLOUD_ADMIN_PASSWORD_FILE (entrypoint file_env); OBJECTSTORE_S3_SECRET has none and needs a generated config fragment; convertible, awaiting a bed"
},
@@ -106,7 +102,7 @@
"volumes": [
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
"/var/lib/nextcloud-module/admin.secret:/run/secrets/admin:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
+2 -2
View File
@@ -6,7 +6,7 @@
],
"claims": [
{
"name": "the-packet-filter",
"name": "node-packet-filter",
"scope": "node"
}
],
@@ -30,7 +30,7 @@
"id": "stock-unit-stop",
"type": "file",
"path": "/etc/systemd/system/nftables.service.d/mesh.conf",
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
"mode": "0644"
},
{
+14 -21
View File
@@ -15,10 +15,10 @@
}
},
"binds": {
"route": "/var/lib/only-office/route.json"
"route": "${dir:state}/route.json"
},
"own-secrets": {
"jwt": "/var/lib/only-office/jwt.secret"
"jwt": "${dir:state}/jwt.secret"
},
"listens": [
{
@@ -32,56 +32,49 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/only-office",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/only-office/server.env",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "JWT_ENABLED=true\nJWT_SECRET=${secret:jwt}\nJWT_HEADER=Authorization\nJWT_IN_BODY=true\nALLOW_PRIVATE_IP_ADDRESS=true\n"
},
{
"id": "logs",
"type": "directory",
"path": "/services/only-office/logs",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/only-office/data",
"mode": "0700"
},
{
"id": "lib",
"type": "directory",
"path": "/services/only-office/lib",
"mode": "0700"
},
{
"id": "db",
"type": "directory",
"path": "/services/only-office/db",
"mode": "0700"
},
{
"id": "rabbitmq",
"type": "directory",
"path": "/services/only-office/rabbitmq",
"mode": "0700"
},
{
"id": "redis",
"type": "directory",
"path": "/services/only-office/redis",
"mode": "0700"
},
{
"id": "fonts",
"type": "directory",
"path": "/services/only-office/fonts",
"mode": "0700"
},
{
@@ -96,19 +89,19 @@
"image": "onlyoffice/documentserver@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212",
"network": "only-office",
"env-file": [
"/var/lib/only-office/server.env"
"${dir:state}/server.env"
],
"ports": [
"80"
"9070:80"
],
"volumes": [
"/services/only-office/logs:/var/log/onlyoffice",
"/services/only-office/data:/var/www/onlyoffice/Data",
"/services/only-office/lib:/var/lib/onlyoffice",
"/services/only-office/db:/var/lib/postgresql",
"/services/only-office/rabbitmq:/var/lib/rabbitmq",
"/services/only-office/redis:/var/lib/redis",
"/services/only-office/fonts:/usr/share/fonts/truetype/custom"
"${dir:logs}:/var/log/onlyoffice",
"${dir:data}:/var/www/onlyoffice/Data",
"${dir:lib}:/var/lib/onlyoffice",
"${dir:db}:/var/lib/postgresql",
"${dir:rabbitmq}:/var/lib/rabbitmq",
"${dir:redis}:/var/lib/redis",
"${dir:fonts}:/usr/share/fonts/truetype/custom"
],
"secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible"
}
+1 -4
View File
@@ -10,9 +10,6 @@
"route"
],
"contributes": {
"s3-bucket": {
"bucket": "photos"
},
"mongodb-database": {
"name": "photos"
},
@@ -56,7 +53,7 @@
"type": "file",
"path": "/var/lib/photos/server.env",
"mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\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=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
},
{
"id": "net",
+23 -5
View File
@@ -6,11 +6,17 @@
"container-runtime"
],
"listens": [
{
"port": 9090,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
},
{
"port": 9443,
"protocol": "tcp",
"from": "mesh",
"why": "the container dashboard, over its own tls"
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
}
],
"resources": [
@@ -23,19 +29,19 @@
{
"id": "data",
"type": "directory",
"path": "/services/portainer/data",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "portainer",
"image": "portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa",
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
"ports": [
"9443"
"9090:9000",
"9443:9443"
],
"volumes": [
"/services/portainer/data:/data",
"${dir:data}:/data",
"/var/run/docker.sock:/var/run/docker.sock"
]
},
@@ -90,5 +96,17 @@
"from": "Dockerfile"
}
]
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "portainer",
"port": 9090
}
},
"binds": {
"route": "/var/lib/mesh/portainer/route.json"
}
}
+28 -2
View File
@@ -88,9 +88,11 @@ export class PostgresClient {
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
if (roles.rows.length === 0) {
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
} else {
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
// VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the
// provisioner runs would report it lost, and only clearing the expiry makes applying it again work.
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
}
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
if (dbs.rows.length === 0) {
@@ -99,6 +101,30 @@ export class PostgresClient {
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
}
/**
* Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its
* credential, checked by connecting as it. Read-only. `false` only when the server says so (the
* role, the password or the database is wrong or gone); an unreachable server rejects instead,
* because being unable to ask is not evidence of loss (novox/hq issue 120).
*/
async canConnectAs(database: string, role: string, password: string): Promise<boolean> {
try {
await run(
"psql",
["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database,
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"],
{ env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 },
);
return true;
} catch (err) {
const text = `${(err as { stderr?: string }).stderr ?? ""}`;
if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) {
return false;
}
throw err;
}
}
/** Drop a database and its owning role, idempotently, after evicting live connections. */
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
await this.query(
+2 -1
View File
@@ -73,7 +73,8 @@
"id": "store-data",
"type": "directory",
"path": "/var/lib/mesh-store",
"mode": "0700"
"mode": "0700",
"owner": "999:70"
},
{
"id": "server",
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+5
View File
@@ -45,4 +45,9 @@ runProvisioner("postgres-database", {
await postgres.dropDatabaseAndRole(p.as, p.as);
await announce("module.postgres.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return postgres.canConnectAs(p.as, p.as, p.password);
},
});
+22 -1
View File
@@ -8,7 +8,7 @@
// order requests were sent, which is what the queue below relies on.
import { createConnection, type Socket } from "node:net";
import { randomBytes } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
@@ -113,6 +113,27 @@ export class RedisClient {
await this.command("ACL", "DELUSER", username);
}
/**
* Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks
* `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among
* them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its
* users live in memory and a restart forgets them. This is how the provisioner notices
* (novox/hq issue 120).
*/
async holdsAclUser(username: string, password: string): Promise<boolean> {
const reply = await this.command("ACL", "GETUSER", username);
if (!Array.isArray(reply)) return false;
const field = (name: string): RespValue | undefined => {
const i = reply.indexOf(name);
return i >= 0 ? reply[i + 1] : undefined;
};
const flags = field("flags");
const passwords = field("passwords");
if (!Array.isArray(flags) || !flags.includes("on")) return false;
if (!Array.isArray(passwords)) return false;
return passwords.includes(createHash("sha256").update(password).digest("hex"));
}
close(): void {
if (this.socket) {
this.socket.destroy();
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
+7
View File
@@ -43,4 +43,11 @@ runProvisioner("redis-cache", {
await redis.deleteAclUser(p.as);
await announce("module.redis.cache.deprovisioned", { username: p.as });
},
// This server keeps its ACL users in memory only, so a restart of it forgets every consumer while
// this provisioner keeps running. Asked every minute, so a forgotten user is made again instead
// of every consumer failing to authenticate in silence (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return redis.holdsAclUser(p.as, p.password);
},
});
+16 -6
View File
@@ -2,12 +2,22 @@
"module": "resolv-conf",
"version": "1",
"slug": "resolv",
"requires": ["wildcard-resolution"],
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
"requires": [
"wildcard-resolution"
],
"claims": [
{
"name": "node-resolver-config",
"scope": "node"
}
],
"resources": [
{"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it — the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know — a resolver's second line is asked only when the first does not\n# answer at all — and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"}
{
"id": "resolv",
"type": "file",
"path": "/etc/resolv.conf",
"mode": "0644",
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
}
]
}
+32 -12
View File
@@ -2,18 +2,38 @@
"module": "resolved-split-dns",
"version": "1",
"slug": "splitdns",
"requires": ["wildcard-resolution"],
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
"requires": [
"wildcard-resolution"
],
"claims": [
{
"name": "node-resolver-config",
"scope": "node"
}
],
"resources": [
{"id": "drop-in", "type": "directory", "path": "/etc/systemd/resolved.conf.d", "mode": "0755"},
{"id": "route", "type": "file",
"path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644",
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine — a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"},
{"id": "resolved", "type": "service", "unit": "systemd-resolved.service",
"state": "running", "boot": "enabled", "restart-on": ["route"]}
{
"id": "drop-in",
"type": "directory",
"path": "/etc/systemd/resolved.conf.d",
"mode": "0755"
},
{
"id": "route",
"type": "file",
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
"mode": "0644",
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
},
{
"id": "resolved",
"type": "service",
"unit": "systemd-resolved.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"route"
]
}
]
}
+12
View File
@@ -159,3 +159,15 @@ cd modules/route-adapter && npm test
They hold it to what ADR 0104 says holds it: one file per contribution, a file removed when its
contribution goes, every file it did not write left alone — and the two facts a route file has to
get right, the port the contributor publishes and the address of the machine it is on.
## A body limit
A contribution may say `max-request-body`, in bytes, and the adapter writes it as the predecessor's
own `buffering` middleware, named after the router so the two halves cannot drift. A route that says
nothing gets no middleware and the predecessor's default stands.
This is the one thing the file shape *can* say that a policy cannot, which is why it is written
rather than skipped: the predecessor already served its own registry name this way. A limit that is
not a whole positive number of bytes takes the route with it — written without the limit, the
predecessor would carry exactly what the module said not to carry, and this module would report
success doing it.
+51 -1
View File
@@ -75,6 +75,15 @@ export interface Route {
from: string;
/** Where the predecessor's proxy is to send it. */
target: string;
/**
* The largest request body, in bytes, the predecessor may carry to it — the contribution's
* `max-request-body`. Absent is whatever the predecessor does by default.
*
* Unlike a policy, this file shape *can* say it: the predecessor has a buffering middleware, and
* its own registry route used exactly this. A registry takes image layers in single requests of
* gigabytes, so a route that could not say it would be a name nothing could be pushed to.
*/
maxRequestBody?: number;
}
/** What one pass changed. */
@@ -176,12 +185,40 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
}
// Where the mesh says that machine is. Empty means this one, and this one is reached from
// inside the predecessor's container by the machine's own name, not by loopback.
// A limit it cannot honour is a route it does not write — skipped and named, like a port that
// is not one. Written without the limit instead, the predecessor would carry exactly what the
// module said not to carry, and this adapter would report success.
const askedLimit = entry.values?.["max-request-body"];
const limit = asBodyLimit(askedLimit);
if (limit === null) {
skipped.push(
`${from} asked for route ${name} with a max-request-body of ${JSON.stringify(askedLimit)}, ` +
`which is not a whole positive number of bytes`,
);
continue;
}
const at = typeof entry.at === "string" && entry.at.trim() !== "" ? entry.at.trim() : machine;
routes.push({ name, from, target: `http://${at}:${port}` });
routes.push({ name, from, target: `http://${at}:${port}`, ...(limit === undefined ? {} : { maxRequestBody: limit }) });
}
return { routes, skipped };
}
/**
* The body limit a contribution asked for: a number, `undefined` for silence, `null` for unusable.
*
* Three answers rather than two, because "said nothing" and "said something wrong" must not become
* the same route.
*/
function asBodyLimit(value: unknown): number | undefined | null {
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
return null;
}
return value;
}
/** The file one route is written to. The prefix is how the mesh recognises its own. */
export function fileNameFor(name: string): string {
return `mesh-${name}.yml`;
@@ -201,6 +238,10 @@ export function routerNameFor(name: string): string {
*/
export function routeFile(route: Route, settings: Settings): string {
const id = routerNameFor(route.name);
// The body limit is a middleware in the predecessor's vocabulary — its `buffering`, with the one
// field the predecessor's own registry route set — named after the router so the two halves cannot
// drift, and written only when the contribution asked for it.
const limited = route.maxRequestBody !== undefined;
return [
marker,
`# ${route.from} contributed this route. It is removed when that contribution goes.`,
@@ -210,10 +251,19 @@ export function routeFile(route: Route, settings: Settings): string {
` entryPoints: [${settings.entrypoint}]`,
` rule: Host(\`${route.name}\`)`,
` service: ${id}`,
...(limited ? [` middlewares: [${id}-body]`] : []),
" tls:",
` certResolver: ${settings.resolver}`,
" domains:",
` - main: ${route.name}`,
...(limited
? [
" middlewares:",
` ${id}-body:`,
" buffering:",
` maxRequestBodyBytes: ${route.maxRequestBody}`,
]
: []),
" services:",
` ${id}:`,
" loadBalancer:",
+42 -2
View File
@@ -22,7 +22,9 @@ async function predecessor(already: Record<string, string> = {}): Promise<Settin
}
/** The contributions file the mesh writes, in the shape the mesh's own proxy also reads. */
function contributed(...given: { from: string; node?: string; at?: string; name: string; port: number }[]) {
function contributed(
...given: { from: string; node?: string; at?: string; name: string; port: number; limit?: unknown }[]
) {
return {
contributions: 1,
requirement: "route",
@@ -30,7 +32,7 @@ function contributed(...given: { from: string; node?: string; at?: string; name:
from: g.from,
node: g.node ?? "control-node",
at: g.at ?? "",
values: { name: g.name, port: g.port },
values: { name: g.name, port: g.port, ...(g.limit === undefined ? {} : { "max-request-body": g.limit }) },
})),
};
}
@@ -232,3 +234,41 @@ test("it refuses when the predecessor's directory is not there, and says why", a
const settings = { ...defaults, dynamic: join(await mkdtemp(join(tmpdir(), "route-adapter-")), "absent") };
await assert.rejects(reconcile([], settings), /is not there.*`dynamic` setting.*mounts it/s);
});
// **A registry is why a route needs to say this.** Image layers arrive as single requests of
// gigabytes, and the predecessor served its own registry name with a `buffering` middleware for
// exactly that reason. The contribution carries the limit as `max-request-body`, the adapter writes
// the middleware the predecessor already understands, named after the router so the two halves
// cannot drift — and writes nothing of the kind for a route that did not ask.
test("a body limit is written as the predecessor's buffering middleware", async () => {
const settings = await predecessor();
const changed = await pass(settings, contributed(
{ from: "registry", name: "images.example", port: 5001, limit: 21474836480 },
{ from: "forge", name: "git.example", port: 2999 },
));
assert.deepEqual(changed.written, ["mesh-git.example.yml", "mesh-images.example.yml"]);
const written = await readFile(join(settings.dynamic, "mesh-images.example.yml"), "utf8");
assert.match(written, /^ {6}middlewares: \[mesh-images-example-body\]$/m);
assert.match(written, /^ {2}middlewares:\n {4}mesh-images-example-body:\n {6}buffering:\n {8}maxRequestBodyBytes: 21474836480$/m);
// The route that asked for nothing carries no middleware — the predecessor's default stands.
const plain = await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8");
assert.doesNotMatch(plain, /middlewares|buffering/);
});
// A limit it cannot honour is a route it does not write. Written without it, the predecessor would
// carry exactly what the module said not to carry, and this module would report success.
test("a body limit that is not a whole number of bytes is skipped and named", () => {
for (const limit of ["20g", 0, -1, 1.5, true, null]) {
const { routes, skipped } = routesFrom(
contributed({ from: "registry", name: "images.example", port: 5001, limit }), defaults.machine);
assert.deepEqual(routes, [], `a limit of ${JSON.stringify(limit)} was served`);
assert.equal(skipped.length, 1);
assert.match(skipped[0]!, /max-request-body/);
}
// And a limit the mesh's own proxy would accept is carried through, as a number.
const { routes } = routesFrom(
contributed({ from: "registry", name: "images.example", port: 5001, limit: 1024 }), defaults.machine);
assert.equal(routes[0]?.maxRequestBody, 1024);
});
+182 -59
View File
@@ -2,72 +2,195 @@
"module": "showcase",
"version": "1",
"slug": "show",
"capabilities": ["container-runtime"],
"provides": [{ "name": "greeting", "scope": "mesh" }],
"serves": { "greeting": { "path": "/greeting" } },
"requires": ["postgres-database"],
"binds": { "postgres-database": "/var/lib/showcase/database.json" },
"secrets": { "postgres-database": "/var/lib/showcase/database.secret" },
"own-secrets": { "broker": "/var/lib/mesh/showcase/broker" },
"claims": [{ "name": "the-showcase", "scope": "node" }],
"emits": ["module.showcase.acknowledged"],
"consumes": ["module.showcase.greeted"],
"capabilities": [
"container-runtime"
],
"provides": [
{
"name": "greeting",
"scope": "mesh"
}
],
"serves": {
"greeting": {
"path": "/greeting"
}
},
"requires": [
"postgres-database"
],
"binds": {
"postgres-database": "/var/lib/showcase/database.json"
},
"secrets": {
"postgres-database": "/var/lib/showcase/database.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/showcase/broker"
},
"claims": [
{
"name": "the-showcase",
"scope": "node"
}
],
"emits": [
"module.showcase.acknowledged"
],
"consumes": [
"module.showcase.greeted"
],
"listens": [
{ "port": 8080, "protocol": "tcp", "from": "mesh",
"why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" }
{
"port": 8080,
"protocol": "tcp",
"from": "mesh",
"why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)"
}
],
"build": {
"artifacts": [
{ "name": "code", "kind": "bundle", "language": "typescript",
"entrypoints": ["index.js", "tools/index.js", "provisioner/index.js",
"daemon/index.js", "step/index.js", "report/index.js"] },
{ "name": "files", "kind": "archive", "from": "files" },
{ "name": "helper", "kind": "upstream",
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" }
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js",
"daemon/index.js",
"step/index.js",
"report/index.js"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
},
{
"name": "helper",
"kind": "upstream",
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"
}
]
},
"resources": [
{ "id": "account", "type": "user", "name": "showcase", "shell": "/usr/bin/nologin",
"home": "/var/lib/showcase" },
{ "id": "logs", "type": "access", "path": "/var/log", "mode": "0755" },
{ "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/showcase", "mode": "0700" },
{ "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" },
{ "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600",
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" },
{ "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" },
{ "id": "net", "type": "network", "name": "showcase" },
{ "id": "tooling", "type": "package", "package": "jq" },
{ "id": "migrate", "type": "process", "name": "showcase-migrate", "artifact": "code",
"run": ["node", "step/index.js"], "run-once": true,
"env-file": ["/var/lib/showcase/showcase.env"] },
{ "id": "server", "type": "process", "name": "showcase", "artifact": "code",
"run": ["node", "daemon/index.js"], "user": "showcase",
"env-file": ["/var/lib/showcase/showcase.env"],
"restart-on": ["settings"] },
{ "id": "reporting", "type": "process", "name": "showcase-report", "artifact": "code",
"run": ["node", "report/index.js"], "schedule": "0 3 * * *",
"env-file": ["/var/lib/showcase/showcase.env"] },
{ "id": "tools", "type": "container", "name": "mesh-showcase", "artifact": "helper",
{
"id": "account",
"type": "user",
"name": "showcase",
"shell": "/usr/bin/nologin",
"home": "/var/lib/showcase"
},
{
"id": "logs",
"type": "access",
"path": "/var/log",
"mode": "0755"
},
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/showcase",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/showcase",
"mode": "0755"
},
{
"id": "settings",
"type": "file",
"path": "/var/lib/showcase/showcase.env",
"mode": "0600",
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n"
},
{
"id": "packed",
"type": "archive",
"path": "/opt/showcase",
"artifact": "files"
},
{
"id": "net",
"type": "network",
"name": "showcase"
},
{
"id": "tooling",
"type": "package",
"package": "jq"
},
{
"id": "migrate",
"type": "process",
"name": "showcase-migrate",
"artifact": "code",
"run": [
"node",
"step/index.js"
],
"run-once": true,
"env-file": [
"/var/lib/showcase/showcase.env"
]
},
{
"id": "server",
"type": "process",
"name": "showcase",
"artifact": "code",
"run": [
"node",
"daemon/index.js"
],
"user": "showcase",
"env-file": [
"/var/lib/showcase/showcase.env"
],
"restart-on": [
"settings"
]
},
{
"id": "reporting",
"type": "process",
"name": "showcase-report",
"artifact": "code",
"run": [
"node",
"report/index.js"
],
"schedule": "0 3 * * *",
"env-file": [
"/var/lib/showcase/showcase.env"
]
},
{
"id": "tools",
"type": "container",
"name": "mesh-showcase",
"artifact": "helper",
"network": "showcase",
"volumes": ["/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"],
"env": { "MESH_BROKER_FILE": "/run/secrets/broker" },
"args": ["sleep", "infinity"] }
"volumes": [
"/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"args": [
"sleep",
"infinity"
]
}
],
"seats": [
{
"name": "the-showcase",
"scope": "node"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"module": "ssh-client",
"version": "1",
"resources": [
{
"id": "openssh",
"type": "package",
"package": "openssh"
},
{
"id": "ssh-dir",
"type": "directory",
"path": "${machine:account-home}/.ssh",
"owner": "${machine:account}",
"mode": "0700"
}
],
"facts": {
"ssh-config": {
"path": ".ssh/config",
"home": true,
"shared": true,
"template": "# The mesh's Host blocks — every other node, so `ssh <node>` reaches it as the\n# right account. This region is replaced whenever a node joins, leaves or is\n# renamed; the rest of this file is yours and is kept untouched.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
}
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"module": "sshd",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"listens": [
{
"port": 22,
"protocol": "tcp",
"from": "anywhere",
"why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "openssh"
},
{
"id": "config",
"type": "file",
"path": "/etc/ssh/sshd_config.d/10-mesh.conf",
"mode": "0644",
"content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n"
},
{
"id": "run",
"type": "service",
"unit": "sshd.service",
"state": "running",
"restart-on": [
"config"
]
}
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"module": "systemd-networkd",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-uplink",
"scope": "node"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "systemd"
},
{
"id": "config",
"type": "file",
"path": "/etc/systemd/network/00-mesh0.network",
"mode": "0644",
"content": "# Managed by the mesh (module systemd-networkd). Replaced on every push; edit\n# the catalogue instead.\n#\n# This machine's uplink is systemd-networkd's, and the mesh asks one thing of it\n# here (novox/hq ADR 0117): leave the private network's interface alone. mesh0\n# is the mesh's; the mesh brings it up and configures it itself. The mesh never\n# declares a link, an address, a route, a wireless network or its credentials,\n# nor a network file for any of this machine's own interfaces \u2014 those are\n# the operator's, and the link they make is the only channel the mesh reaches\n# this machine over.\n#\n# 00-: networkd applies the first .network file, in alphanumeric order across\n# every directory, that matches an interface, and ignores every later one even\n# if it matches too (systemd.network(5), [Match]). A catch-all of the operator's\n# \u2014 Name=*, Type=ether, a file with no [Match] at all \u2014 sorted before\n# this one would claim mesh0 first. 00 sorts before every numbered prefix the\n# man page recommends.\n#\n# Unmanaged=yes: \"no attempts are made to bring up or configure matching links,\n# equivalent to when there are no matching network files\" (systemd.network(5),\n# [Link], since 233). A match that ends the search, and does nothing else.\n#\n# No DNS setting, because none is needed: networkd never writes\n# /etc/resolv.conf. What it learns from a lease it hands only to\n# systemd-resolved, and the resolver file stays whatever resolv-conf wrote.\n# Whether resolved runs, and what it does with that, is the resolver\n# configuration's question, not the uplink's.\n#\n# The service is reloaded when this file changes, never restarted: a restart\n# drops the links networkd holds, this machine's channel to the mesh among them.\n[Match]\nName=mesh0\n\n[Link]\nUnmanaged=yes\n"
},
{
"id": "service",
"type": "service",
"unit": "systemd-networkd.service",
"reload-on": [
"config"
]
}
]
}
-30
View File
@@ -1,30 +0,0 @@
# verdaccio'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
# the base images, published like any other artifact — which 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 (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
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. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/verdaccio
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/verdaccio/dist /app/modules/verdaccio/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/verdaccio/dist/index.js,/app/modules/verdaccio/dist/tools/index.js
-91
View File
@@ -1,91 +0,0 @@
// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq
// ADR 0039). Both this module's tools and its events entrypoint import it, and nothing outside
// verdaccio does.
import { readFileSync } from "node:fs";
export interface VerdaccioPackage {
name: string;
version?: string;
description?: string;
time?: string;
}
export interface PackageInfo {
name: string;
latest?: string;
versions: string[];
description?: string;
modified?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class VerdaccioClient {
readonly baseUrl: string;
// A bearer token is optional: package listing and reading are public on most registries, so the
// token is sent only when configured, for a registry that gates reads behind auth.
constructor(
url: string,
private readonly token?: string,
) {
this.baseUrl = url.replace(/\/+$/, "");
}
/**
* Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local
* port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient {
const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`);
if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL");
return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN);
}
private async getJson<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: {
Accept: "application/json",
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
});
if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
/**
* Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows.
* Each entry carries the latest version and the time it was last published.
*/
async listPackages(): Promise<VerdaccioPackage[]> {
const raw = await this.getJson<any[]>("/-/verdaccio/data/packages");
return (raw ?? []).map((p) => ({
name: p.name,
version: p.version ?? p["dist-tags"]?.latest,
description: p.description,
time: p.time?.modified ?? p.time,
}));
}
/**
* The full detail of one package — its dist-tags, every published version, and timestamps —
* from the standard npm packument endpoint (`GET /<name>`).
*/
async getPackageInfo(name: string): Promise<PackageInfo> {
const doc = await this.getJson<any>(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`);
return {
name: doc.name ?? name,
latest: doc["dist-tags"]?.latest,
versions: Object.keys(doc.versions ?? {}),
description: doc.description,
modified: doc.time?.modified,
};
}
}
-45
View File
@@ -1,45 +0,0 @@
// verdaccio's events. The tool runtime imports this once the broker is bound.
//
// Emits (novox/hq ADR 0041/0042):
// module.verdaccio.package.published — a new package version was published to the registry
//
// A genuinely useful signal: a package was just published, so anything on the mesh that pins,
// mirrors or announces dependency releases can react without polling the registry. Verdaccio has
// no publish webhook, so the module discovers it by diffing the package list's latest versions.
//
// The polling is deliberately unhurried: a publish a minute late is still the event, whereas
// hammering the registry for immediacy nobody asked for is not.
import { emit } from "@novox/mesh-sdk/events";
import { VerdaccioClient } from "./client.js";
const verdaccio = VerdaccioClient.fromEnv();
// The latest version we have seen per package name. Primed silently on the first look so a registry
// that was already populated when this started does not announce its whole catalog as freshly
// published.
const latest = new Map<string, string>();
let primed = false;
async function pollPackages(): Promise<void> {
const packages = await verdaccio.listPackages();
for (const pkg of packages) {
if (!pkg.version) continue;
const known = latest.get(pkg.name);
if (known !== pkg.version) {
// A name we have not seen, or a name whose latest version moved — both are a publish.
if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version });
latest.set(pkg.name, pkg.version);
}
}
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollPackages, 60_000);
console.log("[verdaccio] watching the registry for newly published packages");
-130
View File
@@ -1,130 +0,0 @@
{
"module": "verdaccio",
"version": "1",
"slug": "verdacc",
"capabilities": [
"container-runtime"
],
"emits": [
"module.verdaccio.package.published"
],
"own-secrets": {
"broker": "/var/lib/mesh/verdaccio/broker"
},
"listens": [
{
"port": 4873,
"protocol": "tcp",
"from": "mesh",
"why": "the package registry, for installs and publishes"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/verdaccio",
"mode": "0700"
},
{
"id": "conf",
"type": "directory",
"path": "/services/verdaccio/conf",
"mode": "0755",
"owner": "10001:10001"
},
{
"id": "storage",
"type": "directory",
"path": "/services/verdaccio/storage",
"mode": "0700",
"owner": "10001:10001"
},
{
"id": "config",
"type": "file",
"path": "/services/verdaccio/conf/config.yaml",
"mode": "0644",
"content": "storage: /verdaccio/storage\nauth:\n htpasswd:\n file: /verdaccio/conf/htpasswd\n max_users: 10\nuplinks:\n npmjs:\n url: https://registry.npmjs.org/\npackages:\n \"**\":\n access: $all\n publish: $authenticated\n proxy: npmjs\nserver:\n keepAliveTimeout: 60\n maxBodySize: 10mb\nmiddlewares:\n audit:\n enabled: true\nlog:\n type: stdout\n format: pretty\n level: http\n"
},
{
"id": "server",
"type": "container",
"name": "verdaccio",
"image": "verdaccio/verdaccio@sha256:7b067a47ae51fb9dff3dcdce60ec0a2cbd7650c208cb4b9f6d37cb1b09b39d43",
"ports": [
"4873"
],
"volumes": [
"/services/verdaccio/storage:/verdaccio/storage",
"/services/verdaccio/conf:/verdaccio/conf"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/verdaccio/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-verdaccio",
"network": "host",
"volumes": [
"/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro",
"/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_VERDACCIO_URL": "http://127.0.0.1:4873",
"MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "npm",
"port": 4873
}
},
"binds": {
"route": "/var/lib/mesh/verdaccio/route.json"
},
"provides": [
{
"name": "package-registry",
"scope": "mesh"
}
],
"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-verdaccio",
"version": "0.1.0",
"description": "verdaccio — private npm registry. Its API client, 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"
}
}
-35
View File
@@ -1,35 +0,0 @@
// verdaccio's tools — its own code (novox/hq ADR 0039), importing verdaccio's own 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 { VerdaccioClient } from "../client.js";
export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] {
return [
{
name: "verdaccio_list_packages",
description: "List every package hosted on the private npm registry, with each one's latest version.",
input: {},
run: async () => {
const packages = await verdaccio.listPackages();
return { count: packages.length, packages };
},
},
{
name: "verdaccio_package_info",
description: "Details of one package on the registry: its latest tag, all published versions, and description.",
input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } },
run: async (args) => verdaccio.getPackageInfo(String(args.name)),
},
];
}
// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none
// rather than failing the whole runtime.
registerModuleTools("verdaccio", (env) => {
try {
return getVerdaccioTools(VerdaccioClient.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", "tools/index.ts"]
}