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
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
38 changed files with 759 additions and 631 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",
+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"
}
],
+16 -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.
+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": [
+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"
}
]
}
+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
@@ -7,7 +7,7 @@
],
"claims": [
{
"name": "the-catalogue",
"name": "mesh-catalog",
"scope": "mesh"
}
],
+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",
+10 -12
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"
},
{
@@ -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@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 -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"
},
+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"
}
}
+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",
+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"]
}