Compare commits

...
57 Commits
Author SHA1 Message Date
mesh-admin c227592e7c Merge pull request 'influxdb: its admin password and operator token are its own secrets' (#180) from fix/influxdb-admin-credentials-are-accepted into main 2026-09-30 14:19:02 +00:00
jschoubben 50ae89e718 influxdb: its admin password and operator token are its own secrets, so an existing instance's can be accepted
They came from `requires: secret`, minted by the vault — right for a fresh
setup (the image's INIT_* variables read them once), wrong for an instance
that already exists: setup is skipped, the minted values match nothing,
and the provisioner holds a token the server never issued (issue 100).
InfluxDB will not take a chosen token value, so the operator token must be
accepted from the instance (`secret accept ace influxdb admin-token`); the
password can be either. As own secrets both are minted for a fresh install
exactly as before, and accepted where the data already knows them.
Found migrating ace's influxdb.
2026-09-30 16:18:59 +02:00
mesh-admin 3ae63f10c5 Merge pull request 'mosquitto: its directories are placed, not stated, and it runs the build in use' (#144) from feat/mosquitto-placed into main 2026-09-30 14:14:13 +00:00
mesh-admin e7799529e4 Merge pull request 'influxdb: place its directories, hand secrets over as files, name its UI' (#152) from feat/influxdb-for-ace into main 2026-09-30 14:10:50 +00:00
mesh-admin b9068c67bc Merge pull request 'redis: place its directories and run the build in use' (#161) from feat/redis-for-ace into main 2026-09-30 14:10:48 +00:00
mesh-admin 4bf705fea7 Merge pull request 'mssql: place its directories and name the software's port, not a machine's' (#160) from feat/mssql-for-ace into main 2026-09-30 13:55:25 +00:00
mesh-admin 00893d4944 Merge pull request 'letta: placed state, a route, accepted keys, and a runtime that can log in' (#171) from feat/letta-for-ace into main 2026-09-30 13:55:23 +00:00
jschoubben a2b9a9a411 Merge pull request 'dnsmasq: pass the DNSSEC bit down from the validating upstreams' (#179) from fix/resolver-passes-the-dnssec-bit into main 2026-09-30 13:17:38 +00:00
jschoubben 9523105df4 dnsmasq: pass the DNSSEC bit down from the validating upstreams
A program that checks its resolver validates — Mailu's admin does, at
start — could not use the mesh's resolver, and the one it ships instead
knows no mesh name (novox/hq issue 171). proxy-dnssec copies the AD bit
from 1.1.1.1 and 8.8.8.8, both of which validate.
2026-09-30 15:17:34 +02:00
jschoubben 4449f44cf1 Merge pull request 'mailu: admin asks the machine's resolver, not Mailu's own' (#178) from fix/mailu-admin-asks-the-machines-resolver into main 2026-09-30 13:13:29 +00:00
jschoubben 47f6e7d78b mailu: admin asks the machine's resolver, not Mailu's own
admin is the one container here that reaches something by a mesh name:
the database, bound in database.env. A mesh name is answered by the
machine's resolver, which the runtime hands every container that does
not name its own (novox/hq ADR 0148); Mailu's unbound knows no mesh
name, and since the mesh stopped copying names into containers admin
could not find its database. The others keep unbound — rspamd needs a
validating resolver for DNSBL lookups and asks for no mesh name.
2026-09-30 15:13:25 +02:00
jschoubben 7ab522ba31 Merge pull request 'dnsmasq: answer a container's query, which arrives on the runtime's bridge' (#176) from fix/110-the-resolver-answers-a-container into main 2026-09-30 12:31:30 +00:00
jschoubben 204bfbbaf9 dnsmasq: answer a container's query, which arrives on the runtime's bridge
dnsmasq admits a query by the interface it arrives on when told interface=,
and by the address it is sent to when told listen-address=. A container's
query is sent to the machine's private address but arrives on docker0, so
interface=mesh0 dropped it silently on every machine (novox/hq issue 110).
Name the address, not the interface.
2026-09-30 14:31:26 +02:00
jschoubben e0c09f46b6 Merge pull request 'dnsmasq: the runtime is reloaded when its dns file changes, and may then be restarted safely' (#175) from fix/110-the-runtime-reads-its-dns into main 2026-09-30 12:22:10 +00:00
jschoubben e0faf012be dnsmasq: the runtime is reloaded when its dns file changes, and may then be restarted safely
novox/hq 04-ISSUES/110. The module writes the runtime's `dns` key and
deliberately ordered no restart, because a restart stops every container.
It also ordered no reload, and the key holds only for containers created
after the runtime next starts. On two of four machines the runtime
predated the file — one since August — so every container there was
handed a public resolver and no mesh name resolved, while everything read
as fine. The third machine works only because its runtime happened to
restart later.

The file now also sets live-restore, which the runtime reads on a reload,
and the module declares the runtime reloaded when the file changes (ADR
0102: reload, don't restart). A reload still does not make `dns` take
effect; what it does is make the one restart that key needs keep every
container running. That restart stays the operator's, once per machine,
and is harmless from the second time on.

The mesh restarts nothing here. Undeclared, the runtime's unit goes back
to the state it was found in (ADR 0118).
2026-09-30 14:22:03 +02:00
mesh-admin 1b19c79d63 Merge pull request 'postgres: the provisioner dials the port the mesh gave the store' (#174) from fix/postgres-provisioner-dials-the-port-it-was-given into main 2026-09-30 12:06:25 +00:00
jschoubben 4ec2ae1f7f postgres: the provisioner dials the port the mesh gave the store
MESH_PROVISION_POSTGRES named 127.0.0.1:5432 literally; the seat twin
(${seat:mesh-store:5432}) corrected it only on the machine holding the
mesh-store seat. On any other machine — ace, where the module provides
postgres-database without the seat — the twin is empty and the sidecar
would have dialled whatever else holds 5432 (HAL's postgres). ${port:5432}
is the machine port on every node; on novox the twin still says the same
6852, so nothing moves there.
2026-09-30 14:01:53 +02:00
jschoubben 1080f45012 mosquitto: a consumer's grant is the topics it asks for, and its binding says the port
mqtt-topic served nothing: with two listens the mesh could not say which port a consumer dials, so a
consumer had to type 1883 into its config. It now serves the MQTT listener's port (the machine's,
once assigned) and the scheme, so `${bound:mqtt-topic:port}` fills.

The provisioner confined every consumer to `<as>/#`, which leaves nothing for the consumers the
broker exists for: Home Assistant discovers under homeassistant/# and tasmota/discovery/#, and
Node-RED's flows follow the devices' own topics. A consumer now contributes `topics` (MQTT topic
filters) to its mqtt-topic requirement and is granted exactly those; with none, its own subtree as
before. Settings merge into contributions, so an operator narrows a grant per assignment. The role
is brought to exactly the wanted ACLs (stale ones removed), `holds` checks the ACLs too, and an
invalid list is refused, never quietly narrowed. Only the role named for the consumer is touched:
a client carried from the predecessor's password file keeps its own.
2026-09-30 13:01:14 +02:00
jschoubben 323ef9ec7e influxdb: provide influxdb-api, one mesh-made v1 credential per consumer
grafana's data source and Node-RED's influxdb nodes reached ace's
InfluxDB by a LAN IP or a public name nobody routes, with a credential
somebody made by hand. Now a consumer requires influxdb-api and is told
where it is, which org and default bucket it serves, and signs in with
the password the mesh minted for the pair.

The credential is a v1-compatibility authorization, made per grant by
the new provisioner: InfluxDB 2.x generates API tokens itself and
ignores one the caller sends, so a v2 token could only be accepted by
hand per pair; a v1 authorization takes a caller-chosen password (8-72
characters, the mesh mints 40) and reads/writes every bucket as a
database of its name over InfluxQL and line protocol. A consumer
contributes `access` (read, write, read-write) and, for writing, the
buckets; a missing bucket is made and never deleted. Only
authorizations named mesh_* and marked [mesh] are ever changed or
removed; anything else of that name is refused and left alone.

The org and default bucket are served facts the assignment's settings
set, reaching both the consumers and the provisioner's config.json.
2026-09-30 12:55:38 +02:00
jschoubben 8f459c7023 letta: placed state, a route, accepted keys, and a runtime that can log in
The module named /var/lib/letta, a layout no definition may carry (ADR
0112); state is now a placed directory holding the bindings and secrets.

letta had no route, while the server it replaces is reached by its public
name (a workflow calls it there). It now contributes one for its `web`
endpoint; reach is the assignment's.

The server needs an OpenAI key for agents on OpenAI models, and nothing
gave it one: `openai-api-key` is an own-secret, accepted from the
operator (a key someone chose, not one the mesh can mint). The
server-password is accepted the same way where a server already has
clients. Both, and the database password inside LETTA_PG_URI, stay in the
server's environment: letta 0.6.x reads settings from the environment
only, and its startup.sh starts an embedded PostgreSQL unless
LETTA_PG_URI is set - the declared reason now says so.

The runtime's tools never authenticated: the client sent only a Bearer
token, and 0.6.x's --secure mode checks X-BARE-PASSWORD ("password <it>")
and answers 401 otherwise. The client now sends both. Its password comes
from the runtime config file (the key client.ts reads first) instead of an
env-file, so the runtime container no longer carries a secret in its
environment.

Image: the same 0.6.8 image, now pinned by the index digest ace runs
rather than its amd64 manifest.

Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. Throwaway containers: a fresh 0.6.8 with its
embedded PG and two blocks made through the API; stopped, copied, dumped
from the copy; restored (schema letta + vector pre-made by the superuser,
--no-owner --role, search_path set on the database as the original had
it) into a grant-shaped database on the postgres module's pgvector image
(PG17); started in this shape: alembic finds nothing to do, both blocks
are there, a wrong password is refused with 401, and the patched client
lists agents. Test containers and data removed.
2026-09-30 12:22:12 +02:00
jschoubben 1247b8c27e redis: place its directories and run the build in use
The manifest stated /services/redis/data and /var/lib/redis-module - novox's
old layout, paths no definition may carry (ADR 0112). State is now the
assignment's root, grants and data are placed, and the config file, the
secret file, receives and grants all name them as ${dir:...}. Paths inside
the sidecar are its own view and are unchanged.

The data directory and config are owned 999:1000: the image's redis user is
uid 999 in gid 1000 (checked in both builds), which is who owns ace's data
today; 999:999 named a group the image does not use.

Image pinned to the 7.4.11-alpine build ace runs (2026-09-17); the old pin was
the same version, built in August. Older-than-running is never the pin.

Nothing is assigned it anywhere today, so no machine changes.

Verified: catalogue tests pass with MESH_CATALOGUE on this tree; the
declaration composes for ace with every path under /var/lib/redis. The pinned
image ran as a throwaway with a 0600 999:1000 config and a 0700 data dir:
unauthenticated PING is refused (NOAUTH), authenticated SET/GET works,
appendonly is on, the server runs as redis.
2026-09-30 11:57:46 +02:00
jschoubben 0fce3ebf5d mssql: place its directories and name the software's port, not a machine's
The manifest stated /var/lib/mssql, its grants and its SA file by path, and
declared it listens on 4848 - the port one machine's predecessor published,
which is an assignment's fact (ADR 0112, 0138). ace is moving its own
SQL Server (80 GB of work databases) onto the mesh, so the module has to be
the same on every machine.

- state is the assignment's root (place "."), grants is placed, and every
  reference (sa.env, the env-file, the SA and grants mounts, receives,
  grants, own-secrets) names them as ${dir:...}.
- the database endpoint listens on 1433, the port SQL Server uses; a machine
  that must keep an older number pins it in its assignment.
- the image pin is unchanged: it is the digest ace runs today (CU27,
  16.0.4295), the same as novox.

novox is untouched: rendered with novox's own setting ({"ports":{"1433":4848}})
through the controller's Declaration and Rules, every resource - paths,
container names, volumes, env-file, the 4848:1433 mapping, owners, modes - is
byte-identical to what main renders; the only difference is the firewall
rule's comment text (still port 4848, from the mesh).

Verified: catalogue tests pass with MESH_CATALOGUE on this tree. The pinned
image ran as a throwaway on a 0700 10001:0 data dir with a root-owned 0600
env-file (dummy SA), answered sqlcmd as sa; a scratch database stopped,
copied with cp -a, checksummed and started on the copy kept its rows and
CHECKSUM_AGG.
2026-09-30 11:57:45 +02:00
jschoubben fe0ed3b74e influxdb: place its directories, hand secrets over as files, name its UI
The manifest named /services/influxdb and /var/lib/influxdb-module — one
machine's paths — and passed the admin password and token through the
environment. ace is moving its 2022 instance onto the mesh, so the module
has to be what it is on any machine.

- data, config and state are placed directories; the data keeps 1000:1000,
  the image's influxdb user, which is who owns ace's data today.
- the init secrets reach the image through its own
  DOCKER_INFLUXDB_INIT_{PASSWORD,ADMIN_TOKEN}_FILE; the vault's files are
  mounted read-only. secrets-in-environment is gone.
- the sidecar reads its token from the same file (MESH_INFLUXDB_TOKEN_FILE,
  added to client.ts) and reaches the server at its assigned machine port
  (${port:8086}) instead of assuming 8086. The unused config-dir mount,
  which held the CLI's copy of the admin token, is dropped.
- the api endpoint contributes a route: the web UI is how people use it,
  and reach is the assignment's to say.

Verified: catalogue tests pass with MESH_CATALOGUE pointed at this tree.
The pinned 2.9.1 image, run on a scratch copy of ace's 2.4.0 data, opens
it, runs its metadata migrations (backing up the pre-upgrade bolt/sqlite)
and hashes the two stored tokens; /health passes. A fresh setup through
the _FILE variables, with dummy secrets as root-owned 0600 files, accepts
the token (200 on /api/v2/buckets) and the password (204 on /signin).
client.ts typechecks strict and reads the token file, tolerating the
endpoints key in its config.
2026-09-29 23:42:18 +02:00
jschoubben a32394ec22 mosquitto: its directories are placed, not stated, and it runs the build in use
The module stated /var/lib/mosquitto-module and /services/mosquitto/data —
novox's layout, a path no definition may carry (ADR 0112). State, grants and
data are now placed directories (${dir:state}, ${dir:grants}, ${dir:data}),
the admin secret lives beside the broker account under the mesh's own state,
and the receives/grants maps follow the grants directory. Paths inside the
sidecar are its own view and are unchanged.

Image pinned to the 2.1.2 build ace's predecessor runs (2026-09-17); the old
pin was the same version, built in June.

Found preparing ace, whose broker carries a password-file user (an IoT switch
and home-assistant). Carrying it is a data step, not a manifest one: the
migration repo has scripts/mosquitto-pwdfile-to-dynsec.py, which moves $7$
PBKDF2 entries into the dynsec store hash-for-hash (tested end to end).
2026-09-29 23:05:41 +02:00
mesh-admin 8064e5da8f Merge pull request 'searxng: its settings are a file the mesh writes, not the image's defaults' (#143) from feat/searxng-settings-as-a-file into main 2026-09-29 20:45:13 +00:00
jschoubben 63a255c5cb searxng: bind its route where its state now lives
The route binding still named /var/lib/searxng-module, the directory the
previous commit placed elsewhere — the host would have written it into a
directory nothing declares. Same shape as gitea and nextcloud.
2026-09-29 22:41:56 +02:00
jschoubben 7ad1fbd5c6 searxng: its settings are a file the mesh writes, not the image's defaults
The module ran searxng on the image's built-in settings, which serve html
only — so the module's own search tool (format=json) was refused by the
software it fronts. And there was no way to configure it per machine: the
only file settings reach was the sidecar's.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The reason it was at start — that a step blocking the apply would block the very apply bringing the
overlay up — stopped being true when a step's failure became its module's business rather than the
machine's (ADR 0136).
2026-09-28 15:40:28 +02:00
mesh-admin 4d9b4fdfa6 Merge pull request 'The catalogue declares the event it emits on starting' (#129) from fix/the-catalogue-declares-the-event-it-emits into main 2026-09-28 07:49:06 +00:00
jschoubben eff11b1d4d The catalogue declares the event it emits on starting
Its runtime announces that it has just started and may have missed builds — the event the control
plane follows to replay them — and its manifest did not declare it. A module's authority on the bus
is derived from what it declares, so the publish was refused and the runtime died on start, in a
loop, with the mesh's graph never catching up.
2026-09-28 09:49:03 +02:00
mesh-admin 4ead13d4d4 Merge pull request 'A merge says which files it changed' (#128) from feat/a-merge-rebuilds-what-it-changed into main 2026-09-28 07:20:05 +00:00
jschoubben 3b77dde666 A merge says which files it changed
Every module built from a repository was rebuilt for a change to any of them: one merge in this
repository meant twenty-six builds, which is what exhausted a public registry's pull limit. The
forge lists the files a merge changed and the event carries them, from the watcher and from the
merge tool alike; a merge that changed more files than were asked for says so, and the mesh then
treats the whole repository as changed rather than guessing.
2026-09-28 09:17:38 +02:00
75 changed files with 1291 additions and 222 deletions
+2 -1
View File
@@ -14,7 +14,7 @@
}, },
"route": { "route": {
"label": "baserow", "label": "baserow",
"port": 80 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -30,6 +30,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -13,6 +13,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 6767, "port": 6767,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -110,7 +111,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "subs", "label": "subs",
"port": 6767 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -15,6 +15,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8787, "port": 8787,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -87,7 +88,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "books", "label": "books",
"port": 8787 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+56
View File
@@ -0,0 +1,56 @@
{
"module": "ca-trust",
"version": "1",
"slug": "catrust",
"capabilities": [
"service-manager"
],
"requires": [
"internal-acme-ca"
],
"seats": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"claims": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "anchor",
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
},
{
"id": "trust",
"type": "service",
"unit": "mesh-ca-trust.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"anchor",
"unit"
]
}
]
}
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "de-spiegel", "label": "de-spiegel",
"port": 35621 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -23,6 +23,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 35621, "port": 35621,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -29,6 +29,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "registry",
"port": 5000, "port": 5000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -33,7 +33,7 @@
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.local", "path": "/etc/fail2ban/jail.local",
"mode": "0644", "mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\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" "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
}, },
{ {
"id": "jail-sshd", "id": "jail-sshd",
@@ -42,6 +42,14 @@
"mode": "0644", "mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
}, },
{
"id": "log",
"type": "file",
"path": "/var/log/fail2ban.log",
"mode": "0640",
"create-once": true,
"content": ""
},
{ {
"id": "jail-recidive", "id": "jail-recidive",
"type": "file", "type": "file",
+11
View File
@@ -229,6 +229,17 @@ export class GiteaClient {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`)); return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
} }
/** The files a merged pull request changed, as paths from the repository's root.
*
* `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
return { paths, truncated: paths.length >= limit };
}
async createPullRequest( async createPullRequest(
owner: string, owner: string,
repo: string, repo: string,
+6
View File
@@ -95,6 +95,10 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// at once. // at once.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since; const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) { if (primedMerges && fresh) {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
await emit("pull.merged", { await emit("pull.merged", {
owner: repo.owner, owner: repo.owner,
repo: repo.name, repo: repo.name,
@@ -106,6 +110,8 @@ async function pollMerged(client: GiteaClient): Promise<void> {
merged_at: pull.merged_at, merged_at: pull.merged_at,
clone_url: repo.clone_url, clone_url: repo.clone_url,
html_url: pull.html_url, html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
}); });
// Said, because a trigger that fires silently is indistinguishable from one that did not // Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
+3 -1
View File
@@ -13,7 +13,7 @@
"route": { "route": {
"web": { "web": {
"label": "git", "label": "git",
"port": 3000 "endpoint": "web"
}, },
"internal-api-refused": { "internal-api-refused": {
"label": "git", "label": "git",
@@ -44,12 +44,14 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the forge, over http" "why": "the forge, over http"
}, },
{ {
"name": "ssh",
"port": 22, "port": 22,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+5
View File
@@ -233,6 +233,9 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from. // Read it again: the merge commit only exists now, and it is what a build is made from.
const merged = await gitea.getPullRequest(owner, repo, number); const merged = await gitea.getPullRequest(owner, repo, number);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
// every module built from the repository (novox/hq 04-ISSUES/131).
const changed = await gitea.listPullFiles(owner, repo, number);
await emit("pull.merged", { await emit("pull.merged", {
owner, owner,
repo, repo,
@@ -244,6 +247,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
merged_at: merged.merged_at, merged_at: merged.merged_at,
method, method,
html_url: pull.html_url, html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
}); });
return { merged: true, number, method, deleted_branch: deleteBranch }; return { merged: true, number, method, deleted_branch: deleteBranch };
}, },
+2 -1
View File
@@ -13,6 +13,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -96,7 +97,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "grafana", "label": "grafana",
"port": 3000 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "hello", "label": "hello",
"port": 8080 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -19,6 +19,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8123, "port": 8123,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -85,7 +86,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "home-assistant", "label": "home-assistant",
"port": 8123 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+1
View File
@@ -13,6 +13,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "stream",
"port": 8000, "port": 8000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -2
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb WORKDIR /app/modules/influxdb
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -21,4 +21,4 @@ COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a # Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. # the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
+118 -3
View File
@@ -17,6 +17,25 @@ export interface InfluxBucket {
retentionSeconds?: number; retentionSeconds?: number;
} }
/** One permission of an authorization, as InfluxDB represents it: an action on a resource type,
* in one org, optionally narrowed to one resource by id (no id = every resource of that type). */
export interface InfluxPermission {
action: "read" | "write";
resource: { type: string; orgID?: string; id?: string; name?: string; org?: string };
}
/** A v1-compatibility ("legacy") authorization: a username (InfluxDB calls it `token`) and a
* password the caller chooses, scoped by permissions. The one credential InfluxDB 2.x lets a
* caller set to a value it did not generate — which is what a mesh-minted password needs. */
export interface LegacyAuthorization {
id: string;
token: string;
orgID: string;
status?: "active" | "inactive";
description?: string;
permissions: InfluxPermission[];
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> { function meshConfig(file?: string): Record<string, string> {
if (!file) return {}; if (!file) return {};
@@ -24,13 +43,20 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; } catch { return {}; }
} }
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
function tokenFromFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim() || undefined; }
catch { return undefined; }
}
export class InfluxDBClient { export class InfluxDBClient {
readonly baseUrl: string; readonly baseUrl: string;
constructor( constructor(
url: string, url: string,
private readonly token: string, private readonly token: string,
private readonly org: string, readonly org: string,
) { ) {
this.baseUrl = url.replace(/\/$/, ""); this.baseUrl = url.replace(/\/$/, "");
} }
@@ -43,8 +69,10 @@ export class InfluxDBClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE); const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`; const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN; // The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN"); // only for a workstation running the tools by hand.
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh"; const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org); return new InfluxDBClient(url, token, org);
} }
@@ -61,6 +89,93 @@ export class InfluxDBClient {
return res; return res;
} }
/** Like request, but the answer is returned whatever its status, for the caller to read. */
private async raw(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${this.baseUrl}${path}`, {
...init,
headers: { Authorization: `Token ${this.token}`, ...(init?.headers ?? {}) },
});
}
private async send(path: string, method: string, body?: unknown): Promise<Response> {
return this.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** The id of the org of this name, or undefined when there is none. */
async orgID(name: string): Promise<string | undefined> {
const res = await this.raw(`/api/v2/orgs?org=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/orgs: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { orgs?: { id: string; name: string }[] };
return body.orgs?.find((o) => o.name === name)?.id;
}
/** The bucket of exactly this name in the org, or undefined. */
async findBucket(orgID: string, name: string): Promise<InfluxBucket | undefined> {
const res = await this.raw(`/api/v2/buckets?orgID=${encodeURIComponent(orgID)}&name=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/buckets: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { buckets?: { id: string; name: string; orgID?: string }[] };
const b = body.buckets?.find((x) => x.name === name);
return b ? { id: b.id, name: b.name, orgID: b.orgID } : undefined;
}
/** Create a bucket that keeps its data for ever — retention is the operator's choice, never the mesh's. */
async createBucket(orgID: string, name: string, description: string): Promise<InfluxBucket> {
const b = (await (await this.send("/api/v2/buckets", "POST", {
orgID, name, description, retentionRules: [],
})).json()) as { id: string; name: string; orgID?: string };
return { id: b.id, name: b.name, orgID: b.orgID };
}
/** The v1 authorization whose username is exactly this, or undefined. */
async findLegacy(username: string): Promise<LegacyAuthorization | undefined> {
const path = `/private/legacy/authorizations?token=${encodeURIComponent(username)}`;
const res = await this.raw(path);
// InfluxDB answers a filter matching nothing with 404, not an empty list.
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { authorizations?: LegacyAuthorization[] };
return body.authorizations?.find((a) => a.token === username);
}
async createLegacy(a: Omit<LegacyAuthorization, "id">): Promise<LegacyAuthorization> {
return (await (await this.send("/private/legacy/authorizations", "POST", a)).json()) as LegacyAuthorization;
}
/** Set a v1 authorization's password. InfluxDB keeps only a hash of it, so it can be set, never read. */
async setLegacyPassword(id: string, password: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}/password`, "POST", { password });
}
async updateLegacy(id: string, patch: { status?: "active" | "inactive"; description?: string }): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "PATCH", patch);
}
async deleteLegacy(id: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "DELETE");
}
/**
* Whether this username and password sign in on the v1 API — the consumer's own view. Asked with
* a statement that reads nothing (`SHOW DATABASES` lists only what the credential may read), sent
* with Basic auth so the password is never in a URL. 401 is a wrong password or no such user;
* anything else that is not a server error means InfluxDB knew who was asking.
*/
async legacySignsIn(username: string, password: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/query?q=${encodeURIComponent("SHOW DATABASES")}`, {
headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` },
});
await res.arrayBuffer();
if (res.status === 401) return false;
if (res.status >= 500) throw new Error(`InfluxDB v1 /query: ${res.status}`);
return true;
}
/** Server health — the one endpoint that needs no token, but we send it anyway. */ /** Server health — the one endpoint that needs no token, but we send it anyway. */
async health(): Promise<InfluxHealth> { async health(): Promise<InfluxHealth> {
return (await (await this.request("/health")).json()) as InfluxHealth; return (await (await this.request("/health")).json()) as InfluxHealth;
+186
View File
@@ -0,0 +1,186 @@
// What the `influxdb-api` provision means in InfluxDB: one v1-compatibility authorization per
// consumer, in the org this module serves, under the username and password the mesh gave both ends,
// allowed exactly the access the consumer contributed. The provisioner (provisioner/index.ts) is the
// sdk harness calling these; they are here, apart from it, so they can be exercised against a fake
// InfluxDB without a broker or a contributions file.
//
// **Why a v1 authorization and not a v2 API token.** The mesh mints the consumer's password and
// hands it to both ends (novox/hq ADR 0048); the provider sets it, and never hands one back. An
// InfluxDB 2.x API token is generated by the server — `POST /api/v2/authorizations` ignores a token
// the caller sends — so a token could only ever be the operator's to accept, one per pair, by hand.
// A v1 authorization is a username and a password the caller chooses (8–72 characters; the mesh
// mints 40), stored hashed, and it reads and writes through InfluxQL (`/query`) and line protocol
// (`/write`), which every bucket answers under its own name as a database (InfluxDB maps each
// bucket to a database of the same name by itself). That is what grafana's InfluxDB data source
// speaks, and what Node-RED's influxdb nodes speak in their 1.x mode — so the mesh can make every
// consumer's credential, rotate it and withdraw it, with no person in the loop.
//
// **What a consumer contributes.** `access`: "read" (the default), "write" or "read-write".
// `buckets`: the buckets it may use, by name. A reader that names none may read every bucket of the
// org — a dashboard is pointed at data, it does not own it. A writer must name its buckets: writing
// everywhere, the org's system buckets included, is never what a consumer means. A named bucket
// that does not exist is created, keeping its data for ever; the mesh never deletes a bucket.
//
// **Only what the mesh made is touched.** An authorization this module creates is named with the
// mesh's identity prefix and its description starts with MARK. One with the same username that
// lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
// Every other authorization, token, user and bucket in the instance is left exactly as it was.
import type { InfluxDBClient, InfluxPermission, LegacyAuthorization } from "./client.js";
/** How a description marks an authorization as the mesh's own work. */
export const MARK = "[mesh]";
/** The prefix the mesh gives every consumer identity (novox/hq ADR 0049). */
const IDENTITY_PREFIX = "mesh_";
/** One consumer, as the harness hands it over. */
export interface ApiGrant {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
readonly consumer?: string;
}
export type Access = "read" | "write" | "read-write";
/** What a contribution asks for, checked. Refused when it cannot be served as asked. */
export function askedFor(values: Readonly<Record<string, unknown>>): { access: Access; buckets: string[] } {
const access = values.access ?? "read";
if (access !== "read" && access !== "write" && access !== "read-write") {
throw new Error(`contributes an access of ${JSON.stringify(access)} — it is "read", "write" or "read-write"`);
}
const raw = values.buckets ?? [];
if (!Array.isArray(raw) || raw.some((b) => typeof b !== "string" || b.trim() === "")) {
throw new Error(`contributes buckets of ${JSON.stringify(raw)} — a list of bucket names`);
}
const buckets = [...new Set((raw as string[]).map((b) => b.trim()))].sort();
if (access !== "read" && buckets.length === 0) {
throw new Error(`asks to write and names no bucket (\`buckets\`) — a writer names what it writes to`);
}
if (buckets.some((b) => b.startsWith("_"))) {
throw new Error(`names a system bucket (${buckets.filter((b) => b.startsWith("_")).join(", ")}) — those are InfluxDB's own`);
}
return { access: access as Access, buckets };
}
/** The permissions a grant resolves to, given each named bucket's id. */
export function permissionsFor(orgID: string, access: Access, bucketIDs: string[]): InfluxPermission[] {
const actions: ("read" | "write")[] = access === "read-write" ? ["read", "write"] : [access];
const out: InfluxPermission[] = [];
for (const action of actions) {
if (bucketIDs.length === 0) {
out.push({ action, resource: { type: "buckets", orgID } });
continue;
}
for (const id of bucketIDs) out.push({ action, resource: { type: "buckets", orgID, id } });
}
return out;
}
/** A permission as a comparable string: what InfluxDB answers carries names and links besides. */
function key(p: InfluxPermission): string {
return `${p.action}:${p.resource.type}:${p.resource.orgID ?? ""}:${p.resource.id ?? "*"}`;
}
function samePermissions(a: readonly InfluxPermission[], b: readonly InfluxPermission[]): boolean {
const x = a.map(key).sort();
const y = b.map(key).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
export function marked(a: Pick<LegacyAuthorization, "token" | "description">): boolean {
return a.token.startsWith(IDENTITY_PREFIX) && (a.description ?? "").startsWith(MARK);
}
function describe(g: ApiGrant): string {
return `${MARK} made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`;
}
export class ApiGrants {
constructor(private readonly influx: InfluxDBClient, readonly org: string) {}
private async orgID(): Promise<string> {
const id = await this.influx.orgID(this.org);
if (!id) throw new Error(`InfluxDB has no org ${JSON.stringify(this.org)} — the org this module serves must exist`);
return id;
}
/** The ids of the named buckets, creating any that are missing when `create` says so. Undefined
* when one is missing and may not be created (a read-only question). */
private async bucketIDs(orgID: string, names: string[], create: ApiGrant | undefined): Promise<string[] | undefined> {
const ids: string[] = [];
for (const name of names) {
let b = await this.influx.findBucket(orgID, name);
if (!b) {
if (!create) return undefined;
b = await this.influx.createBucket(orgID, name, `${MARK} made by the mesh for ${create.as}; the mesh never deletes it`);
}
ids.push(b.id);
}
return ids.sort();
}
/** Create the consumer's authorization, or bring the mesh's existing one back to what the grant
* says. Idempotent: a second apply of the same grant changes nothing beyond re-asserting the
* password, which InfluxDB can be told but never asked. */
async ensure(g: ApiGrant): Promise<"created" | "updated" | "unchanged"> {
if (!g.as.startsWith(IDENTITY_PREFIX)) {
throw new Error(`${g.as} is not a mesh identity — the mesh names every consumer ${IDENTITY_PREFIX}<node>_<module>`);
}
const { access, buckets } = askedFor(g.values);
const orgID = await this.orgID();
const found = await this.influx.findLegacy(g.as);
if (found && !marked(found)) {
throw new Error(
`InfluxDB already has a v1 authorization ${g.as} the mesh did not make — left alone; ` +
`delete it if the mesh should own that name`);
}
const want = permissionsFor(orgID, access, (await this.bucketIDs(orgID, buckets, g))!);
if (found && found.orgID === orgID && samePermissions(found.permissions, want)) {
// Only what differs is written. The password cannot be read back, so it is tried instead.
let changed = false;
if (found.status === "inactive") {
await this.influx.updateLegacy(found.id, { status: "active" });
changed = true;
}
if (!(await this.influx.legacySignsIn(g.as, g.password))) {
await this.influx.setLegacyPassword(found.id, g.password);
changed = true;
}
return changed ? "updated" : "unchanged";
}
// InfluxDB cannot change an authorization's permissions in place, so the mesh's own is made
// again. Only ever one the mesh made: a foreign one was refused above.
if (found) await this.influx.deleteLegacy(found.id);
const made = await this.influx.createLegacy({
token: g.as, orgID, status: "active", description: describe(g), permissions: want,
});
await this.influx.setLegacyPassword(made.id, g.password);
return found ? "updated" : "created";
}
/** Whether InfluxDB still holds this consumer's authorization exactly as the grant says: present,
* the mesh's, active, allowed what was asked and nothing more, and signing in with the mesh's
* password. Reads only — a missing bucket is "not held", never created here. */
async holds(g: ApiGrant): Promise<boolean> {
const { access, buckets } = askedFor(g.values);
const orgID = await this.influx.orgID(this.org);
if (!orgID) return false;
const found = await this.influx.findLegacy(g.as);
if (!found || !marked(found) || found.status === "inactive" || found.orgID !== orgID) return false;
const ids = await this.bucketIDs(orgID, buckets, undefined);
if (!ids || !samePermissions(found.permissions, permissionsFor(orgID, access, ids))) return false;
return this.influx.legacySignsIn(g.as, g.password);
}
/** Withdraw a consumer's authorization — only one the mesh made. Its buckets and their data stay. */
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
const found = await this.influx.findLegacy(as);
if (!found) return "absent";
if (!marked(found)) return "not ours";
await this.influx.deleteLegacy(found.id);
return "removed";
}
}
+59 -30
View File
@@ -1,20 +1,43 @@
{ {
"module": "influxdb", "module": "influxdb",
"version": "1", "version": "1",
"provides": [
{
"name": "influxdb-api",
"scope": "mesh"
}
],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/influxdb/broker" "broker": "/var/lib/mesh/influxdb/broker",
"admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret"
}, },
"listens": [ "listens": [
{ {
"name": "api",
"port": 8086, "port": 8086,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "queries and writes, over http" "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
} }
], ],
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
}
},
"receives": {
"influxdb-api": "${dir:grants}/mesh.json"
},
"grants": {
"influxdb-api": "${dir:grants}"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -25,46 +48,50 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/influxdb-module", "mode": "0700",
"mode": "0700" "place": "."
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/influxdb-module/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD=${secret:admin}\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${secret:admin-token}\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/influxdb/data",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/influxdb/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "influxdb", "name": "influxdb",
"image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa", "image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa",
"env-file": [ "env-file": [
"/var/lib/influxdb-module/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"8086" "8086"
], ],
"volumes": [ "volumes": [
"/services/influxdb/data:/var/lib/influxdb2", "${dir:data}:/var/lib/influxdb2",
"/services/influxdb/config:/etc/influxdb2" "${dir:config}:/etc/influxdb2",
], "${dir:state}/admin.secret:/run/secrets/admin:ro",
"secrets-in-environment": "the image honours DOCKER_INFLUXDB_INIT_PASSWORD_FILE and _ADMIN_TOKEN_FILE; convertible, awaiting a bed that proves it" "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
@@ -82,13 +109,15 @@
"volumes": [ "volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro", "/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro", "/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
"/services/influxdb/config:/var/lib/influxdb/config:ro" "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086", "MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json", "MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config" "MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
@@ -96,6 +125,15 @@
"artifact": "runtime" "artifact": "runtime"
} }
], ],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "influxdb",
"endpoint": "api"
}
},
"build": { "build": {
"on": [ "on": [
{ {
@@ -116,14 +154,5 @@
"from": "Dockerfile" "from": "Dockerfile"
} }
] ]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"admin": "/var/lib/influxdb-module/admin.secret",
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
}
} }
} }
+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"name": "@novox/module-influxdb", "name": "@novox/module-influxdb",
"version": "0.1.0", "version": "0.1.0",
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).", "description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner and tools live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts grants.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
+54
View File
@@ -0,0 +1,54 @@
// influxdb's provisioner — the adapter that makes influxdb a provider of the mesh `influxdb-api`
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
// sdk harness's; this writes only the per-service half: how InfluxDB creates, checks and removes a
// consumer's credential (novox/hq ADR 0039/0040/0048). What that credential is, and why it is a v1
// authorization, is in ../grants.ts.
//
// The `influxdb-api` interface: a consumer reaches `${bound:influxdb-api:scheme}://…:at:…:port`,
// signs in as `${bound:influxdb-api:as}` with the password the mesh minted for the pair, and reads
// or writes the org's buckets as databases of the same name — `${bound:influxdb-api:bucket}` being
// the one this instance serves by default. The org and the default bucket are the assignment's
// settings, which reach both what is served and this module's config.json, so the org a consumer is
// told and the org its credential is made in cannot disagree.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { InfluxDBClient } from "../client.js";
import { ApiGrants } from "../grants.js";
let grants: ApiGrants | undefined;
try {
const influx = InfluxDBClient.fromEnv();
grants = new ApiGrants(influx, influx.org);
} catch (err) {
// No admin token: nothing can be provisioned, and the tools loaded beside this must still serve.
console.error(`[provisioner:influxdb-api] not started: ${err instanceof Error ? err.message : err}`);
}
if (grants) serve(grants);
function serve(grants: ApiGrants): void {
runProvisioner("influxdb-api", {
async create(p: Provision): Promise<void> {
const done = await grants.ensure(p);
if (done !== "unchanged") {
console.log(`[provisioner:influxdb-api] ${done} v1 authorization ${p.as} in org ${grants.org}`);
}
},
async remove(p: { as: string }): Promise<void> {
const done = await grants.remove(p.as);
if (done === "not ours") {
console.error(`[provisioner:influxdb-api] ${p.as}: an authorization of that name exists that the mesh did not make — left alone`);
} else if (done === "removed") {
console.log(`[provisioner:influxdb-api] removed v1 authorization ${p.as}; its buckets and their data stay`);
}
},
// Asked every minute by the harness: whether InfluxDB still holds this consumer's authorization
// exactly as the mesh gave it, so one deleted, disabled or re-passworded behind the mesh's back is
// made whole again (hq issue 120).
async holds(p: Provision): Promise<boolean> {
return grants.holds(p);
},
});
}
+246
View File
@@ -0,0 +1,246 @@
// What holds influxdb to the `influxdb-api` provision (grants.ts): one v1 authorization per consumer,
// under the username and password the mesh gave, allowed only what the consumer contributed; made
// once and brought back on every apply; buckets created when missing and never deleted; and an
// authorization the mesh did not make — same name or not — never adopted, changed or deleted.
//
// InfluxDB is a fake: the routes the module touches, answering with the status codes and shapes
// InfluxDB 2.9 gives (a filter matching nothing is a 404, a password outside 8–72 characters a 400,
// an inactive authorization or a wrong password a 401 on /query). Run against the compiled module
// (npm test builds first), the way the runtime loads it.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InfluxDBClient } from "../dist/client.js";
import { ApiGrants, MARK, askedFor, marked } from "../dist/grants.js";
type Rec = Record<string, any>;
const ADMIN = "operator-token";
const orgs = new Map<string, string>([["zurag", "org1"]]);
let buckets: Rec[] = [];
let auths: Rec[] = [];
let calls: string[] = [];
let seq = 0;
function body(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
});
}
function send(res: ServerResponse, status: number, value?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(value === undefined ? "" : JSON.stringify(value));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://fake");
const p = url.pathname;
calls.push(`${req.method} ${p}`);
if (p === "/query") {
const basic = (req.headers.authorization ?? "").replace(/^Basic /, "");
const [u, pw] = Buffer.from(basic, "base64").toString().split(":");
const a = auths.find((x) => x.token === u);
if (!a || a.status !== "active" || a.password === undefined || a.password !== pw) {
return send(res, 401, { code: "unauthorized", message: "Unauthorized" });
}
return send(res, 200, { results: [{ statement_id: 0 }] });
}
if (req.headers.authorization !== `Token ${ADMIN}`) return send(res, 401, { code: "unauthorized" });
if (p === "/api/v2/orgs") {
const id = orgs.get(url.searchParams.get("org") ?? "");
if (!id) return send(res, 404, { code: "not found", message: "organization name not found" });
return send(res, 200, { orgs: [{ id, name: url.searchParams.get("org") }] });
}
if (p === "/api/v2/buckets" && req.method === "GET") {
const found = buckets.filter((b) => b.orgID === url.searchParams.get("orgID") && b.name === url.searchParams.get("name"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "bucket not found" });
return send(res, 200, { buckets: found });
}
if (p === "/api/v2/buckets" && req.method === "POST") {
const b = { ...(await body(req)), id: `b${++seq}` };
buckets.push(b);
return send(res, 201, b);
}
if (p === "/private/legacy/authorizations" && req.method === "GET") {
const found = auths.filter((a) => a.token === url.searchParams.get("token"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "authorization not found" });
// Never answers with the password: InfluxDB keeps only its hash.
return send(res, 200, { authorizations: found.map(({ password, ...a }) => ({ ...a, links: {} })) });
}
if (p === "/private/legacy/authorizations" && req.method === "POST") {
const a = await body(req);
if (auths.some((x) => x.token === a.token)) return send(res, 409, { code: "conflict", message: "token already exists" });
const made = { ...a, id: `a${++seq}`, status: a.status ?? "active" };
auths.push(made);
return send(res, 201, made);
}
const m = /^\/private\/legacy\/authorizations\/([^/]+)(\/password)?$/.exec(p);
const a = m && auths.find((x) => x.id === m[1]);
if (!a) return send(res, 404, { code: "not found" });
if (m![2] && req.method === "POST") {
const { password } = await body(req);
if (typeof password !== "string" || password.length < 8 || password.length > 72) {
return send(res, 400, { code: "invalid", message: "passwords must be between 8 and 72 characters long" });
}
a.password = password;
return send(res, 204);
}
if (req.method === "PATCH") {
Object.assign(a, await body(req));
return send(res, 200, a);
}
if (req.method === "DELETE") {
auths = auths.filter((x) => x !== a);
return send(res, 204);
}
send(res, 405);
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
after(() => server.close());
const port = (server.address() as { port: number }).port;
const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "zurag"), "zurag");
const PW = "mesh-minted-password-of-forty-characters";
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
}
/** Node-RED on ace: writes one bucket. */
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
}
function only(token: string): Rec {
const found = auths.filter((a) => a.token === token);
assert.equal(found.length, 1, `exactly one authorization ${token}, found ${found.length}`);
return found[0];
}
function perms(a: Rec): string[] {
return a.permissions.map((p: Rec) => `${p.action}:${p.resource.type}:${p.resource.id ?? "*"}`).sort();
}
beforeEach(() => {
buckets = [{ id: "zb", orgID: "org1", name: "zurag" }];
auths = [];
calls = [];
});
test("what a contribution may ask for, and what is refused", () => {
assert.deepEqual(askedFor({}), { access: "read", buckets: [] });
assert.deepEqual(askedFor({ access: "read-write", buckets: ["b", "a", "a"] }), { access: "read-write", buckets: ["a", "b"] });
assert.throws(() => askedFor({ access: "admin" }), /access/);
assert.throws(() => askedFor({ access: "write" }), /names no bucket/);
assert.throws(() => askedFor({ buckets: "zurag" }), /list of bucket names/);
assert.throws(() => askedFor({ access: "write", buckets: ["_monitoring"] }), /system bucket/);
});
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
assert.equal(await grants.ensure(grafana()), "created");
const a = only("mesh_ace_grafana");
assert.equal(a.orgID, "org1");
assert.equal(a.status, "active");
assert.ok(a.description.startsWith(MARK));
assert.deepEqual(perms(a), ["read:buckets:*"]);
assert.equal(a.password, PW);
assert.equal(await grants.holds(grafana()), true);
});
test("a writer is allowed its own buckets only, and a missing one is made — never deleted", async () => {
assert.equal(await grants.ensure(nodered(PW, { access: "write", buckets: ["zurag", "printer"] })), "created");
const made = buckets.find((b) => b.name === "printer");
assert.ok(made, "the missing bucket was created");
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
});
test("applying the same grant again writes nothing", async () => {
await grants.ensure(grafana());
calls = [];
assert.equal(await grants.ensure(grafana()), "unchanged");
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
only("mesh_ace_grafana");
});
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
await grants.ensure(nodered());
const id = only("mesh_ace_nodered").id;
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
});
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
await grants.ensure(grafana());
only("mesh_ace_grafana").status = "inactive";
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "updated");
assert.equal(await grants.holds(grafana()), true);
only("mesh_ace_grafana").password = "somebody-else-set-this";
assert.equal(await grants.holds(grafana()), false);
await grants.ensure(grafana());
assert.equal(await grants.holds(grafana()), true);
auths = [];
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "created");
});
test("holds only reads, and a bucket gone missing is not held rather than made", async () => {
await grants.ensure(nodered());
buckets = [];
calls = [];
assert.equal(await grants.holds(nodered()), false);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(buckets.length, 0);
});
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
const before = JSON.stringify(auths);
await assert.rejects(grants.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(auths), before);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
assert.equal(auths.length, 1, "never deleted");
});
test("the predecessor's own v1 users and tokens are never touched", async () => {
auths = [{ id: "hal", token: "grafana", orgID: "org1", status: "active", description: "",
permissions: [{ action: "read", resource: { type: "buckets", orgID: "org1" } }], password: "old-password" }];
await grants.ensure(grafana());
assert.equal(auths.find((a) => a.id === "hal")!.password, "old-password");
assert.equal(await grants.remove("grafana"), "not ours");
assert.equal(marked({ token: "grafana", description: `${MARK} x` }), false, "the mark needs the mesh's name too");
});
test("an org the instance does not have, or a non-mesh name, makes nothing", async () => {
const elsewhere = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "nope"), "nope");
await assert.rejects(elsewhere.ensure(grafana()), /no org "nope"/);
await assert.rejects(grants.ensure({ ...grafana(), as: "grafana" }), /not a mesh identity/);
assert.equal(auths.length, 0);
});
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
await grants.ensure(grafana());
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
assert.equal(auths.length, 0);
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
});
+6 -1
View File
@@ -8,5 +8,10 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "tools/index.ts"] "include": [
"client.ts",
"grants.ts",
"provisioner/index.ts",
"tools/index.ts"
]
} }
+4 -2
View File
@@ -17,11 +17,11 @@
"route": { "route": {
"site": { "site": {
"label": "invoicing", "label": "invoicing",
"port": 80 "endpoint": "web"
}, },
"api": { "api": {
"label": "invoicing-api", "label": "invoicing-api",
"port": 9000 "endpoint": "api"
} }
} }
}, },
@@ -36,12 +36,14 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the invoicing web frontend; a public name is a route grant later" "why": "the invoicing web frontend; a public name is a route grant later"
}, },
{ {
"name": "api",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -6,6 +6,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 9117, "port": 9117,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -82,7 +83,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "indexers", "label": "indexers",
"port": 9117 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
}, },
"route": { "route": {
"label": "keycloak", "label": "keycloak",
"port": 8080 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -34,6 +34,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+8 -4
View File
@@ -1,10 +1,10 @@
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools // The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
// import it; nothing outside letta does. // import it; nothing outside letta does.
// //
// Letta authenticates with a single server password, presented as a Bearer token. That password is // Letta authenticates with a single server password. That password is a mesh own-secret handed to
// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this // both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand. // mounts (its `password` key) — so the module's tools are live without anything configured by hand.
// The runtime config file may still override the URL or password. // Where a server already has clients, the password is accepted rather than minted.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -58,6 +58,10 @@ export class LettaClient {
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
// later servers read.
"X-BARE-PASSWORD": `password ${this.password}`,
Authorization: `Bearer ${this.password}`, Authorization: `Bearer ${this.password}`,
...(options.headers as Record<string, string> | undefined), ...(options.headers as Record<string, string> | undefined),
}, },
+22 -25
View File
@@ -5,29 +5,37 @@
"container-runtime" "container-runtime"
], ],
"requires": [ "requires": [
"postgres-database" "postgres-database",
"route"
], ],
"contributes": { "contributes": {
"postgres-database": { "postgres-database": {
"name": "letta" "name": "letta"
},
"route": {
"label": "letta",
"endpoint": "web"
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/letta/database.json" "postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/letta/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"server-password": "/var/lib/letta/server-password.secret", "server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret",
"broker": "/var/lib/mesh/letta/broker" "broker": "/var/lib/mesh/letta/broker"
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8283, "port": 8283,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the Letta agent server REST API and web UI; a public name is a route grant later" "why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
} }
], ],
"resources": [ "resources": [
@@ -40,15 +48,15 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/letta", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/letta/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n" "content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
}, },
{ {
"id": "net", "id": "net",
@@ -59,31 +67,24 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "letta", "name": "letta",
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b", "image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
"network": "letta", "network": "letta",
"env-file": [ "env-file": [
"/var/lib/letta/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"8283" "8283"
], ],
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted" "secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/letta/config.json", "path": "/var/lib/mesh/letta/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json" "merge": "json"
}, },
{
"id": "runtime-env",
"type": "file",
"path": "/var/lib/letta/runtime.env",
"mode": "0600",
"content": "MESH_LETTA_PASSWORD=${secret:server-password}\n"
},
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
@@ -98,14 +99,10 @@
"MESH_LETTA_URL": "http://letta:8283", "MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json" "MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
}, },
"env-file": [
"/var/lib/letta/runtime.env"
],
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime", "artifact": "runtime"
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
} }
], ],
"build": { "build": {
+2 -1
View File
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8686, "port": 8686,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -86,7 +87,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "lidarr", "label": "lidarr",
"port": 8686 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+17 -10
View File
@@ -16,27 +16,27 @@
"route": { "route": {
"web": { "web": {
"label": "mail", "label": "mail",
"port": 7443, "endpoint": "web-tls",
"scheme": "https", "scheme": "https",
"insecure": true "insecure": true
}, },
"acme": { "acme": {
"label": "mail", "label": "mail",
"path": "/.well-known/acme-challenge", "path": "/.well-known/acme-challenge",
"port": 7080, "endpoint": "web",
"priority": 100 "priority": 100
}, },
"autoconfig": { "autoconfig": {
"label": "autoconfig", "label": "autoconfig",
"port": 4243 "endpoint": "autoconfig"
}, },
"autodiscover": { "autodiscover": {
"label": "autodiscover", "label": "autodiscover",
"port": 4243 "endpoint": "autoconfig"
}, },
"automx": { "automx": {
"label": "automx", "label": "automx",
"port": 4243 "endpoint": "autoconfig"
} }
} }
}, },
@@ -60,6 +60,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "smtp",
"port": 25, "port": 25,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -67,6 +68,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3",
"port": 110, "port": 110,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -74,6 +76,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "imap",
"port": 143, "port": 143,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -81,6 +84,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "smtps",
"port": 465, "port": 465,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -88,6 +92,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "submission",
"port": 587, "port": 587,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -95,6 +100,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "imaps",
"port": 993, "port": 993,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -102,6 +108,7 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3s",
"port": 995, "port": 995,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -109,18 +116,21 @@
"fixed": true "fixed": true
}, },
{ {
"name": "web",
"port": 7080, "port": 7080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy" "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy"
}, },
{ {
"name": "web-tls",
"port": 7443, "port": 7443,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here" "why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
}, },
{ {
"name": "autoconfig",
"port": 4243, "port": 4243,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -305,10 +315,7 @@
"${dir:data-data}:/data", "${dir:data-data}:/data",
"${dir:data-dkim}:/dkim" "${dir:data-dkim}:/dkim"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"dns": [
"192.168.203.254"
]
}, },
{ {
"id": "imap", "id": "imap",
+1
View File
@@ -6,6 +6,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "api",
"port": 59125, "port": 59125,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+6 -1
View File
@@ -22,7 +22,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # was assembled.
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module # **A module may need something the base image does not carry.** The base holds what every module
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# to listen for what the builder announces. Serve binds the broker first, then imports these, so # to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to. # `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+20 -6
View File
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which // The schema is not brought up here. The mesh prepares this module's state before it starts this
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the // version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
// overlay would block the very apply that brings the overlay up. // at start made a schema that could not be reached a crash loop instead of a stop, with the graph
await graph.migrate(); // keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */ /** What the builder says when it has built something. */
interface Built { interface Built {
@@ -47,7 +49,15 @@ interface Built {
replay?: boolean; replay?: boolean;
} }
await on("mesh-build-machine.built", async (event) => { /**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = event.body as Built; const body = event.body as Built;
if (!body.module || !body.commit) { if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a // Said rather than dropped: a build that announced itself without saying what it built is a
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
because: next.because, because: next.because,
}); });
} }
}); };
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
// //
+5 -2
View File
@@ -29,13 +29,16 @@
"broker": "/var/lib/mesh/mesh-catalog/broker" "broker": "/var/lib/mesh/mesh-catalog/broker"
}, },
"consumes": [ "consumes": [
"mesh-build-machine.built" "mesh-build-machine.built",
"mesh-controller.built-before"
], ],
"emits": [ "emits": [
"registered", "registered",
"upgraded", "upgraded",
"rebuild-needed" "rebuild-needed",
"catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
+17
View File
@@ -0,0 +1,17 @@
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
//
// **The mesh runs this before the version that needs it, and does not start that version if it
// fails** — and the refusal reaches this module and nothing else on the machine
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
// nothing anywhere said so.
//
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// process exiting non-zero is how the host knows not to start the runtime.
import { Graph } from "../store.js";
const graph = Graph.fromEnv();
await graph.migrate();
console.log("[mesh-catalog] the module graph's schema is what this version needs");
await graph.close();
+2 -1
View File
@@ -12,6 +12,7 @@
"pg.d.ts", "pg.d.ts",
"store.ts", "store.ts",
"index.ts", "index.ts",
"tools/index.ts" "tools/index.ts",
"prepare/index.ts"
] ]
} }
+4 -2
View File
@@ -14,11 +14,11 @@
"route": { "route": {
"api": { "api": {
"label": "files-api", "label": "files-api",
"port": 9000 "endpoint": "s3"
}, },
"console": { "console": {
"label": "files", "label": "files",
"port": 9001 "endpoint": "console"
} }
} }
}, },
@@ -31,12 +31,14 @@
], ],
"listens": [ "listens": [
{ {
"name": "s3",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the S3 endpoint" "why": "the S3 endpoint"
}, },
{ {
"name": "console",
"port": 9001, "port": 9001,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -20,6 +20,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "database",
"port": 27017, "port": 27017,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/mosquitto WORKDIR /app/modules/mosquitto
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \ RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
+38 -24
View File
@@ -20,6 +20,8 @@ import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
const run = promisify(execFile); const run = promisify(execFile);
export interface MqttConn { export interface MqttConn {
@@ -141,14 +143,19 @@ export class MosquittoClient {
} }
/** /**
* Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The * Create (or reset to a known state) a client granted exactly these topic filters, idempotently.
* client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and * The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and
* receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped * nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a
* ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs. * consumer's `topics` narrows what it may do. By default the filters are the consumer's own
* subtree, `<as>/#` (see topics.ts). Called again for an existing client, it resets the password
* and re-asserts the ACLs.
*
* Only the role named for this client is ever changed. A client or role the mesh did not make —
* a device carried from the predecessor's password file, its `legacy-full-access` role — is never
* read, changed or removed here.
*/ */
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> { async createScopedClient(username: string, password: string, filters: readonly string[]): Promise<void> {
const role = username; // one role per client, named for it const role = username; // one role per client, named for it
const pattern = `${topicPrefix}/#`;
if (await this.clientExists(username)) { if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password); await this.ctl("setClientPassword", username, password);
@@ -161,17 +168,18 @@ export class MosquittoClient {
await this.ctl("createClient", username, "-p", password); await this.ctl("createClient", username, "-p", password);
} }
// A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are // createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run
// all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it // against a role/ACL it created on a previous reconcile. That rejection is the intended terminal
// created on a previous reconcile. That rejection is the intended terminal state — the ACL is // state, so it is swallowed.
// deterministic (`<prefix>/#`, allow), so re-adding the identical entry is a no-op — so it is
// swallowed. (Until the exit code was fixed this was invisible: the tool returned 0 and the
// rejection was lost; now it surfaces, and each of these adds must tolerate its own idempotent
// re-run explicitly.)
await ignoreExisting(this.ctl("createRole", role)); await ignoreExisting(this.ctl("createRole", role));
for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) { const wanted = wantedAcls(filters);
// allow (1) this client to send to, receive on, and subscribe under its own subtree. const current = parseRoleAcls(await this.ctl("getRole", role));
await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow")); for (const acl of missingAcls(current, wanted)) {
await ignoreExisting(this.ctl("addRoleACL", role, acl.type, acl.topic, "allow"));
}
// What the consumer no longer asks for — added before it narrowed its topics — is taken away.
for (const acl of staleAcls(current, wanted)) {
await ignoreMissing(this.ctl("removeRoleACL", role, acl.type, acl.topic));
} }
// Bind the role only when it is not already bound — addClientRole is the one call whose // Bind the role only when it is not already bound — addClientRole is the one call whose
// idempotent re-run cannot be recognised by message (see clientHasRole). // idempotent re-run cannot be recognised by message (see clientHasRole).
@@ -181,25 +189,31 @@ export class MosquittoClient {
} }
/** /**
* Whether a consumer's client accepts exactly this password and still carries its own role. * Whether a consumer's client accepts exactly this password, still carries its own role, and that
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it, * role grants exactly these filters. Read-only. The password is checked the way the consumer is
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised. * checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted,
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120). * 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects
* (novox/hq issue 120).
*/ */
async holdsClient(username: string, password: string): Promise<boolean> { async holdsClient(username: string, password: string, filters: readonly string[]): Promise<boolean> {
const code = await mqttConnack(this.conn.host, this.conn.port, username, password); const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
if (code === 4 || code === 5) return false; if (code === 4 || code === 5) return false;
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects, // The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
// unlike clientHasRole, which reads every failure as "no role". // unlike clientHasRole, which reads every failure as "no role".
let out: string; let client: string;
let role: string;
try { try {
out = await this.ctl("getClient", username); client = await this.ctl("getClient", username);
role = await this.ctl("getRole", username);
} catch (err) { } catch (err) {
if (/not\s*found|does not exist|no such/i.test(String(err))) return false; if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
throw err; throw err;
} }
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out); if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false;
const current = parseRoleAcls(role);
const wanted = wantedAcls(filters);
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
} }
/** Remove a client and the per-client role created for it, idempotently. */ /** Remove a client and the per-client role created for it, idempotently. */
+20 -17
View File
@@ -20,26 +20,31 @@
"mosquitto.topic.deprovisioned" "mosquitto.topic.deprovisioned"
], ],
"serves": { "serves": {
"mqtt-topic": {} "mqtt-topic": {
"scheme": "mqtt",
"port": 1883
}
}, },
"receives": { "receives": {
"mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json" "mqtt-topic": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"mqtt-topic": "/var/lib/mosquitto-module/grants" "mqtt-topic": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mosquitto-module/admin.secret", "admin": "/var/lib/mesh/mosquitto/admin",
"broker": "/var/lib/mesh/mosquitto/broker" "broker": "/var/lib/mesh/mosquitto/broker"
}, },
"listens": [ "listens": [
{ {
"name": "mqtt",
"port": 1883, "port": 1883,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "modules on any machine that were granted a topic namespace" "why": "modules on any machine that were granted a topic namespace"
}, },
{ {
"name": "mqtt-websockets",
"port": 8081, "port": 8081,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -56,26 +61,24 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mosquitto-module", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants-dir", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/mosquitto-module/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/mosquitto/data",
"mode": "0700", "mode": "0700",
"owner": "1883:1883" "owner": "1883:1883"
}, },
{ {
"id": "server-conf", "id": "server-conf",
"type": "file", "type": "file",
"path": "/var/lib/mosquitto-module/mosquitto.conf", "path": "${dir:state}/mosquitto.conf",
"mode": "0600", "mode": "0600",
"owner": "1883:1883", "owner": "1883:1883",
"content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n" "content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n"
@@ -91,8 +94,8 @@
"name": "mosquitto-bootstrap", "name": "mosquitto-bootstrap",
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"/services/mosquitto/data:/mosquitto/data", "${dir:data}:/mosquitto/data",
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro" "/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_PROVISION_MQTT": "mosquitto:1883", "MESH_PROVISION_MQTT": "mosquitto:1883",
@@ -110,15 +113,15 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mosquitto", "name": "mosquitto",
"image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797", "image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408",
"network": "mosquitto", "network": "mosquitto",
"ports": [ "ports": [
"1883", "1883",
"8081" "8081"
], ],
"volumes": [ "volumes": [
"/services/mosquitto/data:/mosquitto/data", "${dir:data}:/mosquitto/data",
"/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
] ]
}, },
{ {
@@ -128,8 +131,8 @@
"network": "mosquitto", "network": "mosquitto",
"volumes": [ "volumes": [
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro", "/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
"/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro", "${dir:grants}:/var/lib/mosquitto-module/grants:ro",
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro" "/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"name": "@novox/module-mosquitto", "name": "@novox/module-mosquitto",
"version": "0.1.0", "version": "0.1.0",
"description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).", "description": "mosquitto \u2014 provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1"
}, },
+24 -6
View File
@@ -5,7 +5,14 @@
// //
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and // The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and
// publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security // publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security
// role scoped to exactly that subtree. // role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its
// work needs (a home-automation hub needs the devices' topics); then the role grants exactly those
// (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created
// or changed until it is fixed.
//
// What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port
// of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is
// the pair credential the mesh delivers to it.
// //
// **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the // **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the
// login and hands it to both ends so they agree, and mints the password and delivers a copy to each. // login and hands it to both ends so they agree, and mints the password and delivers a copy to each.
@@ -15,6 +22,7 @@
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { MosquittoClient } from "../client.js"; import { MosquittoClient } from "../client.js";
import { topicFilters } from "../topics.js";
const mosquitto = MosquittoClient.fromEnv(); const mosquitto = MosquittoClient.fromEnv();
@@ -29,13 +37,20 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
runProvisioner("mqtt-topic", { runProvisioner("mqtt-topic", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
// The topic subtree is scoped to the consumer's own login, so one cannot read another's topics. // By default the consumer's own subtree, so one cannot read another's topics; what it
const topicPrefix = p.as; // contributed as `topics` otherwise.
await mosquitto.createScopedClient(p.as, p.password, topicPrefix); const granted = topicFilters(p.values, p.as);
if ("problem" in granted) {
// Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until
// its contribution is valid, rather than being given a grant it did not ask for.
throw new Error(`${p.as}: ${granted.problem}`);
}
await mosquitto.createScopedClient(p.as, p.password, granted.filters);
await announce("topic.provisioned", { await announce("topic.provisioned", {
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
username: p.as, username: p.as,
topicPrefix, topicPrefix: granted.own ? p.as : "",
topics: granted.filters.join(" "),
}); });
}, },
@@ -46,6 +61,9 @@ runProvisioner("mqtt-topic", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> { async holds(p: Provision): Promise<boolean> {
return mosquitto.holdsClient(p.as, p.password); const granted = topicFilters(p.values, p.as);
// An invalid list was never applied; create refuses it again, loudly, on every pass.
if ("problem" in granted) return false;
return mosquitto.holdsClient(p.as, p.password, granted.filters);
}, },
}); });
+72
View File
@@ -0,0 +1,72 @@
// What a consumer of mqtt-topic is granted (topics.ts): its own subtree unless it contributed
// `topics`; a contributed list is granted exactly, refused whole when it is not topic filters; and
// the role is brought to exactly the wanted ACLs — missing ones added, stale ones removed — read from
// `mosquitto_ctrl dynsec getRole` as eclipse-mosquitto 2.1.2 prints it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { filterProblem, missingAcls, parseRoleAcls, staleAcls, topicFilters, wantedAcls } from "../topics.ts";
test("a consumer that contributed nothing gets its own subtree", () => {
assert.deepEqual(topicFilters({}, "mesh_ace_hass"), { ok: true, filters: ["mesh_ace_hass/#"], own: true });
assert.deepEqual(topicFilters(undefined, "x"), { ok: true, filters: ["x/#"], own: true });
// Settings merge into every contribution: keys that are not `topics` change nothing.
assert.deepEqual(topicFilters({ endpoints: { web: {} } }, "x"), { ok: true, filters: ["x/#"], own: true });
});
test("a contributed list is granted exactly, duplicates once", () => {
assert.deepEqual(topicFilters({ topics: ["#"] }, "x"), { ok: true, filters: ["#"], own: false });
assert.deepEqual(topicFilters({ topics: ["stat/+/POWER", "tele/#", "tele/#", "/octoprint/x"] }, "x"), {
ok: true,
filters: ["stat/+/POWER", "tele/#", "/octoprint/x"],
own: false,
});
});
test("a list that is not topic filters is refused whole", () => {
for (const topics of [[], "#", [""], ["a/#/b"], ["a#"], ["a/b+"], [42], ["a\u0000b"], {}]) {
const out = topicFilters({ topics } as Record<string, unknown>, "x");
assert.equal(out.ok, false, JSON.stringify(topics));
}
assert.equal(filterProblem("+/+/#"), undefined);
assert.equal(filterProblem("#"), undefined);
});
const GET_ROLE = `Warning: You are running mosquitto_ctrl without encryption.
This means all of the configuration changes you are making are visible on the network, including passwords.
Rolename: u1
ACLs: publishClientSend : allow : # (priority: 0)
subscribePattern : allow : u1/# (priority: 0)
publishClientReceive : deny : secret topic/with space (priority: -1)
`;
test("getRole's ACL lines are read, the warning and headings are not", () => {
assert.deepEqual(parseRoleAcls(GET_ROLE), [
{ type: "publishClientSend", allow: true, topic: "#" },
{ type: "subscribePattern", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
]);
assert.deepEqual(parseRoleAcls("Rolename: empty\nACLs:\n"), []);
});
test("the role is brought to exactly the wanted ACLs", () => {
const current = parseRoleAcls(GET_ROLE);
const wanted = wantedAcls(["u1/#"]);
assert.deepEqual(wanted, [
{ type: "publishClientSend", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
{ type: "subscribePattern", allow: true, topic: "u1/#" },
]);
assert.deepEqual(missingAcls(current, wanted), [
{ type: "publishClientSend", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
]);
assert.deepEqual(staleAcls(current, wanted), [
{ type: "publishClientSend", allow: true, topic: "#" },
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
]);
assert.deepEqual(staleAcls(wanted, wanted), []);
assert.deepEqual(missingAcls(wanted, wanted), []);
});
+107
View File
@@ -0,0 +1,107 @@
// Which topics a consumer of `mqtt-topic` may use — the one choice a consumer makes about its grant.
//
// **By default, its own subtree and nothing else.** A consumer connects as the login the mesh derived
// (`as`) and may publish, receive and subscribe under `<as>/#` — isolated from every other consumer,
// which is the point of a per-consumer client (novox/hq ADR 0039/0048).
//
// **A consumer whose work IS the shared topic space says so.** Home Assistant discovers devices
// under `homeassistant/#` and `tasmota/discovery/#` and follows whatever state topics they announce;
// Node-RED's flows subscribe to the topics devices publish on (`stat/<device>/POWER`, …). Confined
// to `<as>/#` neither could do its job. So a consumer contributes `topics` to its `mqtt-topic`
// requirement — a list of MQTT topic filters — and the provisioner grants exactly those, both ways.
// Because assignment settings merge into every contribution, an operator narrows (or widens) the
// list per machine with the same key, without editing a manifest.
//
// Pure, so it is tested without a broker (test/topics.test.ts).
/** The dynsec ACL types a granted filter carries: send to it, receive from it, subscribe to it. */
export const GRANTED_ACL_TYPES = ["publishClientSend", "publishClientReceive", "subscribePattern"] as const;
/** One ACL on a role, as `mosquitto_ctrl dynsec getRole` reports it. */
export interface Acl {
type: string;
allow: boolean;
topic: string;
}
export type Filters = { ok: true; filters: string[]; own: boolean } | { ok: false; problem: string };
/**
* The topic filters a consumer is granted: what it contributed as `topics`, or its own subtree when
* it contributed nothing. Refused — never silently narrowed or widened — when the list is not a
* list of valid MQTT topic filters: a grant that quietly differs from what was asked is a consumer
* that fails somewhere far from the cause.
*/
export function topicFilters(values: Readonly<Record<string, unknown>> | undefined, as: string): Filters {
const given = values?.topics;
if (given === undefined || given === null) {
return { ok: true, filters: [`${as}/#`], own: true };
}
if (!Array.isArray(given) || given.length === 0) {
return { ok: false, problem: `topics must be a non-empty list of MQTT topic filters, not ${JSON.stringify(given)}` };
}
const out: string[] = [];
for (const f of given) {
if (typeof f !== "string") {
return { ok: false, problem: `topics holds ${JSON.stringify(f)}, which is not a topic filter` };
}
const problem = filterProblem(f);
if (problem) return { ok: false, problem: `topic filter ${JSON.stringify(f)}: ${problem}` };
if (!out.includes(f)) out.push(f);
}
return { ok: true, filters: out, own: out.length === 1 && out[0] === `${as}/#` };
}
/** Why a string is not a valid MQTT topic filter (MQTT 3.1.1 §4.7), or undefined when it is one. */
export function filterProblem(filter: string): string | undefined {
if (filter.length === 0) return "it is empty";
if (Buffer.byteLength(filter, "utf8") > 65535) return "it is longer than MQTT allows";
if (filter.includes("\u0000")) return "it contains a NUL character";
const levels = filter.split("/");
for (let i = 0; i < levels.length; i++) {
const level = levels[i];
if (level.includes("#") && (level !== "#" || i !== levels.length - 1)) {
return "'#' must be a whole level, and the last one";
}
if (level.includes("+") && level !== "+") return "'+' must be a whole level";
}
return undefined;
}
/** The ACLs a role must carry to grant these filters: every granted type, allowed, on every filter. */
export function wantedAcls(filters: readonly string[]): Acl[] {
const out: Acl[] = [];
for (const topic of filters) {
for (const type of GRANTED_ACL_TYPES) out.push({ type, allow: true, topic });
}
return out;
}
/**
* The ACLs `mosquitto_ctrl dynsec getRole` lists, one per line under its "ACLs:" heading:
* `ACLs: publishClientSend : allow : # (priority: 0)`
* ` subscribePattern : allow : u1/# (priority: 0)`
*/
export function parseRoleAcls(output: string): Acl[] {
const out: Acl[] = [];
const line = /^(?:ACLs:)?\s*([A-Za-z]+)\s*:\s*(allow|deny)\s*:\s*(.*?)\s+\(priority:\s*-?\d+\)\s*$/;
for (const raw of output.split(/\r?\n/)) {
const m = raw.match(line);
if (m) out.push({ type: m[1], allow: m[2] === "allow", topic: m[3] });
}
return out;
}
const key = (a: Acl): string => `${a.type}\u0000${a.allow ? "allow" : "deny"}\u0000${a.topic}`;
/** ACLs a role carries that it should not: in `current` and not in `wanted`. */
export function staleAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
const want = new Set(wanted.map(key));
return current.filter((a) => !want.has(key(a)));
}
/** ACLs a role should carry and does not. */
export function missingAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
const have = new Set(current.map(key));
return wanted.filter((a) => !have.has(key(a)));
}
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"] "include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
} }
+12 -12
View File
@@ -20,23 +20,24 @@
], ],
"listens": [ "listens": [
{ {
"port": 4848, "name": "database",
"port": 1433,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "modules on any machine that were granted a database. 4848, not 1433: the machine this replaces has served it there since it was installed, and every consumer was handed that number" "why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin"
} }
], ],
"serves": { "serves": {
"mssql-database": {} "mssql-database": {}
}, },
"receives": { "receives": {
"mssql-database": "/var/lib/mssql/grants/mesh.json" "mssql-database": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"mssql-database": "/var/lib/mssql/grants" "mssql-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"sa": "/var/lib/mssql/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "/var/lib/mesh/mssql/broker" "broker": "/var/lib/mesh/mssql/broker"
}, },
"resources": [ "resources": [
@@ -49,19 +50,18 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/mssql", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/mssql/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "sa-env", "id": "sa-env",
"type": "file", "type": "file",
"path": "/var/lib/mssql/sa.env", "path": "${dir:state}/sa.env",
"mode": "0600", "mode": "0600",
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n" "content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
}, },
@@ -83,7 +83,7 @@
"image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090", "image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090",
"network": "mssql", "network": "mssql",
"env-file": [ "env-file": [
"/var/lib/mssql/sa.env" "${dir:state}/sa.env"
], ],
"ports": [ "ports": [
"1433" "1433"
@@ -100,8 +100,8 @@
"network": "mssql", "network": "mssql",
"volumes": [ "volumes": [
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro", "/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro", "${dir:grants}:/var/lib/mssql/grants:ro",
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro" "${dir:state}/sa.secret:/run/secrets/sa:ro"
], ],
"env": { "env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master", "MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
+2 -1
View File
@@ -14,7 +14,7 @@
}, },
"route": { "route": {
"label": "n8n", "label": "n8n",
"port": 5682 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -29,6 +29,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 5682, "port": 5682,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -21,6 +21,7 @@
"consumes": [], "consumes": [],
"listens": [ "listens": [
{ {
"name": "bus",
"port": 4222, "port": 4222,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -13,7 +13,7 @@
}, },
"route": { "route": {
"label": "drive", "label": "drive",
"port": 80 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -38,6 +38,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -12,6 +12,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 1880, "port": 1880,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -81,7 +82,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "nodered", "label": "nodered",
"port": 1880 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -10,7 +10,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "@", "label": "@",
"port": 4000 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -18,6 +18,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 4000, "port": 4000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -15,6 +15,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 6789, "port": 6789,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -12,6 +12,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "api",
"port": 11434, "port": 11434,
"protocol": "tcp", "protocol": "tcp",
"from": "machine", "from": "machine",
+2 -1
View File
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 3579, "port": 3579,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -89,7 +90,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "ombi", "label": "ombi",
"port": 3579 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "office", "label": "office",
"port": 9070 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -22,6 +22,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 9070, "port": 9070,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "eef", "label": "eef",
"port": 4012 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -19,6 +19,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 4012, "port": 4012,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "filip", "label": "filip",
"port": 4013 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -19,6 +19,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 4013, "port": 4013,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+3 -1
View File
@@ -18,7 +18,7 @@
}, },
"route": { "route": {
"label": "photos", "label": "photos",
"port": 4001 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -32,12 +32,14 @@
}, },
"listens": [ "listens": [
{ {
"name": "api",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the photos backend API; the client sites on the module network call it" "why": "the photos backend API; the client sites on the module network call it"
}, },
{ {
"name": "web",
"port": 4001, "port": 4001,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1
View File
@@ -18,6 +18,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "stream",
"port": 32400, "port": 32400,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+3 -1
View File
@@ -7,12 +7,14 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 9090, "port": 9090,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "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" "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"
}, },
{ {
"name": "web-tls",
"port": 9443, "port": 9443,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -103,7 +105,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "portainer", "label": "portainer",
"port": 9090 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2 -1
View File
@@ -26,6 +26,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "database",
"port": 5432, "port": 5432,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -104,7 +105,7 @@
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
], ],
"env": { "env": {
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable", "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}", "MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+1
View File
@@ -16,6 +16,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 7878, "port": 7878,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -86,7 +87,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "movies", "label": "movies",
"port": 7878 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+15 -16
View File
@@ -27,19 +27,20 @@
} }
}, },
"receives": { "receives": {
"redis-cache": "/var/lib/redis-module/grants/mesh.json" "redis-cache": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"redis-cache": "/var/lib/redis-module/grants" "redis-cache": "${dir:grants}"
}, },
"secrets": { "secrets": {
"secret": "/var/lib/redis-module/default.secret" "secret": "${dir:state}/default.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/redis/broker" "broker": "/var/lib/mesh/redis/broker"
}, },
"listens": [ "listens": [
{ {
"name": "cache",
"port": 6379, "port": 6379,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -56,29 +57,27 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/redis-module", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants-dir", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/redis-module/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/redis/data",
"mode": "0700", "mode": "0700",
"owner": "999:999" "owner": "999:1000"
}, },
{ {
"id": "server-conf", "id": "server-conf",
"type": "file", "type": "file",
"path": "/var/lib/redis-module/redis.conf", "path": "${dir:state}/redis.conf",
"mode": "0600", "mode": "0600",
"content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n", "content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n",
"owner": "999:999" "owner": "999:1000"
}, },
{ {
"id": "net", "id": "net",
@@ -89,14 +88,14 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "redis", "name": "redis",
"image": "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf", "image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7",
"network": "redis", "network": "redis",
"ports": [ "ports": [
"6379" "6379"
], ],
"volumes": [ "volumes": [
"/services/redis/data:/data", "${dir:data}:/data",
"/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro" "${dir:state}/redis.conf:/etc/redis/redis.conf:ro"
], ],
"args": [ "args": [
"/etc/redis/redis.conf" "/etc/redis/redis.conf"
@@ -112,8 +111,8 @@
"network": "redis", "network": "redis",
"volumes": [ "volumes": [
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro", "/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro", "${dir:grants}:/var/lib/redis-module/grants:ro",
"/var/lib/redis-module/default.secret:/run/secrets/default:ro" "${dir:state}/default.secret:/run/secrets/default:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
+2
View File
@@ -27,12 +27,14 @@
}, },
"listens": [ "listens": [
{ {
"name": "http",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
}, },
{ {
"name": "https",
"port": 443, "port": 443,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
+23 -21
View File
@@ -5,11 +5,12 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"secret": "/var/lib/searxng-module/secret.secret", "secret": "/var/lib/mesh/searxng/secret",
"broker": "/var/lib/mesh/searxng/broker" "broker": "/var/lib/mesh/searxng/broker"
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -26,22 +27,14 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/searxng-module", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "valkey-data", "id": "valkey-data",
"type": "directory", "type": "directory",
"path": "/var/lib/searxng-module/valkey-data",
"mode": "0700" "mode": "0700"
}, },
{
"id": "server-env",
"type": "file",
"path": "/var/lib/searxng-module/server.env",
"mode": "0600",
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
},
{ {
"id": "net", "id": "net",
"type": "network", "type": "network",
@@ -62,30 +55,39 @@
"warning" "warning"
], ],
"volumes": [ "volumes": [
"/var/lib/searxng-module/valkey-data:/data" "${dir:valkey-data}:/data"
] ]
}, },
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.yml",
"mode": "0600",
"merge": "json",
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "searxng", "name": "searxng",
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371", "image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
"network": "searxng", "network": "searxng",
"env-file": [
"/var/lib/searxng-module/server.env"
],
"ports": [ "ports": [
"8080" "8080"
], ],
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done" "volumes": [
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
],
"restart-on": [
"settings"
]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/searxng/config.json", "path": "/var/lib/mesh/searxng/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n"
"merge": "json"
}, },
{ {
"id": "runtime", "id": "runtime",
@@ -113,11 +115,11 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "searxng", "label": "searxng",
"port": 8080 "endpoint": "web"
} }
}, },
"binds": { "binds": {
"route": "/var/lib/searxng-module/route.json" "route": "${dir:state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+1
View File
@@ -43,6 +43,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8989, "port": 8989,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -91,7 +92,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "series", "label": "series",
"port": 8989 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+2
View File
@@ -7,6 +7,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "ssh",
"port": 22, "port": 22,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -31,6 +32,7 @@
"type": "service", "type": "service",
"unit": "sshd.service", "unit": "sshd.service",
"state": "running", "state": "running",
"boot": "enabled",
"restart-on": [ "restart-on": [
"config" "config"
] ]
+1
View File
@@ -26,6 +26,7 @@
}, },
"listens": [ "listens": [
{ {
"name": "acme",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -1
View File
@@ -12,6 +12,7 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8181, "port": 8181,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -85,7 +86,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "tautulli", "label": "tautulli",
"port": 8181 "endpoint": "web"
} }
}, },
"binds": { "binds": {
+4 -3
View File
@@ -15,7 +15,7 @@
}, },
"route": { "route": {
"label": "umami", "label": "umami",
"port": 3000 "endpoint": "web"
} }
}, },
"binds": { "binds": {
@@ -49,10 +49,11 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "mesh",
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere" "why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
} }
], ],
"resources": [ "resources": [
+9
View File
@@ -6,54 +6,63 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8443, "port": 8443,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later" "why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
}, },
{ {
"name": "inform",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "device inform \u2014 how APs and switches check in and are adopted" "why": "device inform \u2014 how APs and switches check in and are adopted"
}, },
{ {
"name": "stun",
"port": 3478, "port": 3478,
"protocol": "udp", "protocol": "udp",
"from": "mesh", "from": "mesh",
"why": "STUN, so managed devices can find the controller through NAT" "why": "STUN, so managed devices can find the controller through NAT"
}, },
{ {
"name": "discovery",
"port": 10001, "port": 10001,
"protocol": "udp", "protocol": "udp",
"from": "mesh", "from": "mesh",
"why": "device discovery \u2014 the controller finds unadopted devices on the network" "why": "device discovery \u2014 the controller finds unadopted devices on the network"
}, },
{ {
"name": "discovery-l2",
"port": 1902, "port": 1902,
"protocol": "udp", "protocol": "udp",
"from": "mesh", "from": "mesh",
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900" "why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
}, },
{ {
"name": "portal-tls",
"port": 8843, "port": 8843,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the guest captive portal over https" "why": "the guest captive portal over https"
}, },
{ {
"name": "portal",
"port": 8880, "port": 8880,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the guest captive portal over http" "why": "the guest captive portal over http"
}, },
{ {
"name": "speedtest",
"port": 6789, "port": 6789,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "mobile-app speed-test throughput measurement" "why": "mobile-app speed-test throughput measurement"
}, },
{ {
"name": "syslog",
"port": 5514, "port": 5514,
"protocol": "udp", "protocol": "udp",
"from": "mesh", "from": "mesh",