Compare commits

..
Author SHA1 Message Date
jschoubben 949f5f02c9 Merge pull request 'records: a phrase that wraps, and one under emphasis, is found' (#185) from fix/a-phrase-that-wraps into main 2026-09-30 16:13:47 +00:00
jschoubben 3b95d00afc records: a phrase that wraps, and one under emphasis, is found
The record is prose wrapped at a hundred columns; matched line by line, the first live search for a
sentence of ADR 0025 found nothing. A line is matched together with the next, emphasis marks are
ignored, and a hit still names the line it starts on.
2026-09-30 18:09:21 +02:00
mesh-admin cbf9e9b7a3 Merge pull request 'supabase: own secrets, and unique ids for its containers' (#184) from fix/supabase-own-secrets into main 2026-09-30 15:58:24 +00:00
jschoubben f476255284 supabase: own secrets, and unique ids for its containers
The manifest declared its secrets as secrets.secret.<name> and required a
"secret" provision, so the controller saw no own secrets and every accept
was refused (the influxdb defect of #180). The directories functions and
storage shared their ids with the containers of the same name, which the
host refuses as two resources with one identity. The containers are now
edge-functions and storage-api; the placed directories keep their ids and
paths.
2026-09-30 17:57:57 +02:00
jschoubben 6ad59540d4 Merge pull request 'records: the record is read where it is written' (#183) from feat/the-record-is-read into main
Reviewed-on: #183
2026-09-30 15:55:06 +00:00
jschoubben a4892d3ff6 Merge branch 'main' into feat/the-record-is-read 2026-09-30 15:54:54 +00:00
jschoubben 6512878eef records: the record is read where it is written
A module keeping a checkout of a repository of decisions, designs and issues from the git seat,
current on every announced merge and on a timer, answering records_search / records_read /
records_list / records_status / records_sync at the commit it read (novox/hq ADR 0025, ADR 0153).
The repository is a setting; it names no mesh.
2026-09-30 17:45:39 +02:00
mesh-admin 8a0ebe16a3 Merge pull request 'supabase: the self-hosted stack as one module, its secrets rendered into files' (#169) from feat/supabase-for-ace into main 2026-09-30 15:32:33 +00:00
mesh-admin 96fb441f30 Merge pull request 'baserow: placed directories, the database password from a file, and the build ace runs' (#170) from feat/baserow-for-ace into main 2026-09-30 15:32:13 +00:00
mesh-admin b8c701ec94 Merge pull request 'matrix: Conduit and Element, told their own name by the route' (#166) from feat/matrix-for-ace into main 2026-09-30 15:32:09 +00:00
mesh-admin 65ba4e6170 Merge pull request 'mosquitto, influxdb: the provisioner sees its grants where the mesh writes them' (#182) from fix/a-provisioner-sees-its-grants-where-the-mesh-writes-them into main 2026-09-30 15:19:37 +00:00
jschoubben 5b70ffd78f mosquitto, influxdb: the provisioner sees its grants where the mesh writes them
The received grants file names each pair secret by its HOST path. A sidecar
that mounts the placed grants directory at another path inside its
container sees mesh.json and not the secrets beside it — mosquitto's
provisioner reported ENOENT for a file that was there, and nodered's mqtt
step failed against a login that was never created. Mounted at its own
path now, as postgres does.
2026-09-30 17:19:33 +02:00
mesh-admin d3ebb01180 Merge pull request 'nodered: settings are files the mesh writes; editor locked with adminAuth; pin 5.0.7' (#149) from feat/nodered-for-ace into main 2026-09-30 15:06:39 +00:00
jschoubben 0020fa17cd Merge pull request 'mesh-console: the mesh's tools on the machine a person sits at' (#181) from feat/the-console into main
Reviewed-on: #181
2026-09-30 14:47:22 +00:00
jschoubben 22c6032144 Merge branch 'main' into feat/the-console 2026-09-30 14:47:11 +00:00
mesh-admin 36b35900a2 Merge pull request 'jackett: its config dir is placed, and its tools find their own key' (#150) from feat/jackett-for-ace into main 2026-09-30 14:40:49 +00:00
mesh-admin 9c97a8a134 Merge pull request 'icecast: its passwords are a file the mesh writes, not the image's environment' (#146) from feat/icecast-for-ace into main 2026-09-30 14:38:14 +00:00
mesh-admin 1bfedd2a9e Merge pull request 'unifi: placed data, mesh-assigned ports, an https route, its password as a file' (#148) from feat/unifi-for-ace into main 2026-09-30 14:38:12 +00:00
jschoubben 769f0724ca mesh-console: the mesh's tools on the machine a person sits at
The tool runtime's own client, mesh serve, started by the mesh on the credential it sealed to the
machine (novox/hq ADR 0152, design 34): invokes every tool, listens from the machine only, holds no
state. Checked with module check before it was ever registered (hq issue 148).
2026-09-30 16:20:06 +02:00
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 9d716ed875 jackett: provide its Torznab API as jackett-api
sonarr, radarr, lidarr and bookshelf reached jackett as http://jackett:9117 (a HAL
container name) or https://indexers.zurag.be (its public route), typed into each app by
hand. The mesh has neither: an app now requires jackett-api and its downloads step writes
the bound address into the app.

Serves scheme, port and url-base; `at` and the machine port come from the binding. Mesh
scope, like sonarr-api: an indexer proxy shares no files with its consumers.

The pair credential is jackett's one API key. The mesh cannot mint it, so the operator
accepts it per consumer pair (ADR 0092), as #156 does for sonarr-api; a consumer's step
refuses a minted value and names the accept.
2026-09-30 13:05:36 +02:00
jschoubben 3c7aafdc21 nodered: its MQTT broker comes from the mesh
Node-RED's one broker node pointed at zurag.be:1884, where nothing listens. nodered now requires
mqtt-topic (asking for every topic: flows follow the devices' own) and a run-once `mqtt` step —
declared last, restarted when the binding, credential or settings change — points the mesh's broker
nodes at the bound broker through Node-RED's admin API with the module's api-token: the node the
step makes itself when none is named, or the ones an assignment names in `mqtt.brokers`. Only host,
port, TLS and the login change; the broker is asked first whether it takes the login; the deploy is
against the revision read ("nodes", so only that node restarts) and a digest makes a rerun a no-op.
A broker node nobody named is never touched. settings.js keeps `mqtt` and `topics` out of Node-RED.
2026-09-30 13:01:14 +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 ac8556c590 baserow: placed directories, the database password from a file, and the build ace runs
The module named /var/lib/baserow and /services/baserow/data, a layout no
definition may carry (ADR 0112). State and data are now placed directories;
bindings and the grant's secret live in the placed state.

The all-in-one image's entrypoint honours DATABASE_PASSWORD_FILE (file_env
in /baserow.sh), so the grant's password is mounted rather than put in an
env-file, and "secrets-in-environment" is gone (ADR 0086). SECRET_KEY is no
longer minted: the image keeps it, and its JWT signing key, in the data
directory (.secret, .jwt_signing_key) and imports them on start, so a moved
data directory carries the keys its sessions and tokens were made with.
DISABLE_EMBEDDED_PSQL makes a missing grant fail loudly instead of starting
an empty embedded database.

BASEROW_PUBLIC_URL was http://localhost. Baserow answers only the host of
that URL - any other Host is looked up as a published builder site and gets
404, /api/_health/ included - so it is now https://${bound:route:name}
(depends on mesh-controller #149).

The runtime's tools could never have worked: its config was "{}", and the
client's Host override was silently dropped by Node's fetch, so calls by
container name would 404 even with credentials. The client now uses
node:http (which sends the Host it is given, with a Content-Length -
Baserow reads a chunked body as empty) and re-authenticates once when a
cached JWT is refused (access tokens last minutes, the runtime weeks). The
password is the accepted `admin` secret; the email is an assignment
setting merged into the same file, the host is the route's name.

Image pinned to the develop-latest build ace runs today (Baserow 2.3.4,
built 2026-09-18). The old pin (built 2026-09-04) is older than ace's data.

Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. In throwaway containers of the pinned image: a
fresh embedded-PG instance with a user, workspace and 5-row table; stopped,
copied, dumped from the copy (start-only-db); restored with --no-owner
--role into a grant-shaped database on the pgvector image the postgres
module pins (PG17); started with this shape (root 0600 password file,
embedded PSQL disabled, copied data dir without postgres/): health 200,
the user logs in, the 5 rows are there, SECRET_KEY and the JWT key are
imported from the data dir. The patched client lists applications and rows
through the container name with the public Host, and recovers from a
refused token. Test containers and data removed.
2026-09-30 12:16:04 +02:00
jschoubben 62cecc8a2b supabase: the self-hosted stack as one module, its secrets rendered into files
ace runs Supabase under HAL as upstream's 13-container compose: a 2.2 GB
database (1.8 GB of it the dormant `novox` schema, 5.8 M rows in its largest
table), Kong at supabase.zurag.be, the pooler on 5433/6543. This is that stack
as a catalogue module, same images (the digests ace runs), same container
names so an assignment holds the running ones and a take replaces them.

The database stays inside the module. Supabase is a Postgres distribution: its
own image with pgsodium, pg_graphql, pg_net, vault and timescale preloaded, a
superuser (supabase_admin), a dozen reserved roles and a second database
(_supabase). A postgres-database grant - one database, one unprivileged role -
cannot hold it, so ace's data directory moves as a copy, not a dump/restore.

What HAL did by shell and environment the mesh now renders as files:
- kong.yml carries the anon/service keys and the dashboard login (owned by
  kong's uid 100), instead of an entrypoint that eval'd the environment;
- GoTrue reads a dotenv file (auth -c), PostgREST a config file, Vector its yml
  with the Logflare key in it, the database POSTGRES_PASSWORD_FILE and a
  jwt.sql rendered with the secret (owned by postgres, uid 105). None of these
  five containers has a secret in its environment.
- realtime, storage, meta, functions, analytics, studio and supavisor read
  their credentials from the environment only; each declares
  secrets-in-environment with the reason (ADR 0086).
- Upstreams are container names (supabase-db, supabase-kong, ...) instead of
  compose service names, which the mesh does not have.
- SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL are
  https://${bound:route:name} (mesh-controller #149); on ace HAL rendered them
  as "https://supabase." - broken today.
- Vector reads the docker socket, as upstream does, but now includes only this
  module's containers instead of every container's logs on the machine.

Three things compose did that a declaration cannot, done as steps: a run-once
seed copies the image's /etc/postgresql-custom into the placed config
directory with cp -n (a named volume did that implicitly; never overwrites the
pgsodium root key), and two run-once gates wait for the database and for
Logflare, which compose expressed as depends_on: service_healthy.

The pooler bootstrap (pooler.exs) takes the tenant id and pool sizes from
settings.json, the module's one merge:json file, so ace keeps its tenant
"zurag"; and it repoints an existing tenant whose database host is not
supabase-db - HAL created ace's with host "db", which no longer resolves.

Secrets (vault, requires "secret"): postgres, jwt, anon-key,
service-role-key, dashboard-user, dashboard, logflare, pooler-vault,
key-base-a + key-base-b (concatenated: Phoenix wants 64+ bytes, a minted
secret is 40), openai. Three cannot be minted on any machine: anon-key and
service-role-key are JWTs signed with jwt, and pooler-vault must be exactly
32 bytes (AES-256-GCM, found in the bed). They are accepted. On ace every
secret the data already knows is accepted (all but key-base-a/b).

Not carried: Kong's 8443 and Logflare's 4000 on all interfaces (nothing
outside the module uses them); realtime's DB_ENC_KEY stays upstream's constant
(realtime deletes and re-seeds that tenant from its environment every start,
and the key must be exactly 16 bytes).

Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller
main and #149 (on main the render is refused for "name", never written
empty). The #149 resolution with stub providers, turned into a throwaway
stack of all 13 pinned digests with dummy secrets and the rendered files at
their owners and modes: the database initialised through the rendered scripts
(jwt setting applied, _analytics/_supavisor created, roles' password from
POSTGRES_PASSWORD_FILE); through Kong: REST 200 with the anon key and 401
without, auth health and settings 200, storage buckets 200, GraphQL 200, pg-meta
200, an edge function 200, Studio 401 without and 200 with the dashboard login,
realtime tenant health 200; the pooler in session and transaction mode as
postgres.zurag; a tenant set to host "db" was repointed to supabase-db by the
bootstrap and connections worked.
2026-09-30 12:12:27 +02:00
jschoubben 157fab5dc8 matrix: Conduit and Element, told their own name by the route
ace runs a Conduit homeserver (matrix.zurag.be, 5.4 GB of RocksDB, federating)
and Element Web under HAL, configured by environment with the domain
templated in, and Element's config.json carrying matrix.zurag.be literally.

A homeserver's server_name is its permanent identity - every user id, room id
and signature in the database carries it - and it is the name the module is
served under. So it comes from ${bound:route:name-homeserver} (mesh-controller
#149), rendered into a conduit.toml the container reads through CONDUIT_CONFIG,
and into Element's config.json (base_url, default_server_name, the room
directory). Without #149 the render is refused ("name-homeserver"), never
written empty. Two routes, one per endpoint: homeserver (label matrix, 6167)
and element (label element, 80).

Federation needs no 8448: Conduit answers /.well-known/matrix/server with
<name>:443, so peers federate through the route. HAL published 8448 on all
interfaces, but the router never forwarded it; checked from outside, the
well-known, federation version and client versions all answer on 443.

Registration defaults to off. HAL ran with CONDUIT_ALLOW_REGISTRATION=true,
which on ace means anyone on the internet can create an account with the
dummy flow (seen: /register offers m.login.dummy) - on 0.10.13, whose
successor 0.10.14 fixes an account-takeover by any local user. Existing
accounts are unaffected; the operator decides whether to reopen it.

Element's config.json is the one merge:json file (it tolerates `endpoints`),
so a machine can add keys. HAL's map_style_url is not carried: it embedded
a map-tile API key, which belongs in an assignment if wanted.

Images are the digests ace runs (Conduit 0.10.13, Element 1.12.28).

Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller
main and #149; a resolution on #149 renders both names (matrix.zurag.be,
element.zurag.be) into both files and both contributions; throwaway
containers of both pinned digests with the rendered files (root 0644, :ro):
client versions 200, well-known says matrix.zurag.be:443, register refused
M_FORBIDDEN, Element 200 serving the rendered config.json.
2026-09-30 11:59:56 +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 c1a65e2354 nodered: the sidecar dials the port it was given
The sidecar runs on the host network and dialled 127.0.0.1:1880, the
software's port; the mesh publishes nodered on a machine port it assigns,
so the tools reached whatever else holds 1880, or nothing (hq 088).
2026-09-29 23:50:11 +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 75eee9d4a0 jackett: its config dir is placed, and its tools find their own key
The manifest named /services/jackett/config and /var/lib/mesh/jackett/config.json — host
paths ADR 0112 takes out of definitions. The config dir is now a pathless directory
(${dir:config}) and the runtime's config and route binding live in a placed state dir, as
searxng does.

The image is pinned to v0.24.2627-ls34, the digest ace runs today; the old pin
(v0.24.2517-ls16) was older than the running version.

The runtime reached jackett at a fixed 127.0.0.1:9117; it now uses ${port:9117}, the
machine port the mesh actually assigned.

The tools never loaded: the client needed an API key nobody set. Like sonarr/radarr read
config.xml, it now reads APIKey from Jackett's own ServerConfig.json (the config dir is
already mounted read-only), so no secret goes into an assignment. jackett_indexers called
/api/v2.0/indexers, which is the web UI's endpoint and answers an API key with a redirect;
it now reads the Torznab t=indexers feed, and treats Torznab's 200-with-<error> as a
failure.

Verified: catalogue key tests (MESH_CATALOGUE set, not skipped); a throwaway container of
the pinned image on a fresh 0700 1000:1000 config dir serves its UI; the client discovers
the key from the generated ServerConfig.json, lists 617 indexers through the Torznab feed,
searches via /results, and a wrong key is refused; client.ts typechecks under --strict.
2026-09-29 23:41:21 +02:00
jschoubben 0c91e08bad nodered: its settings are files the mesh writes, and its editor is locked
The catalogue ran the image's defaults: no adminAuth, so a routed Node-RED
editor (which runs arbitrary code) was open to anyone who reached it, and
the module's own tools had no token to present to an install that was locked.

- settings.js (fixed, 0600, uid 1000) carries adminAuth: user admin checked
  against the admin secret -- a minted password, or the bcrypt hash an
  existing install held (accepted), so current logins keep working -- and a
  static bearer token (api-token) the sidecar presents. It loads settings.json
  beside it, the one mergeable file; endpoints is dropped there, and an
  optional timeZone sets process.env.TZ (assignments cannot set env).
- The sidecar's runtime config is no longer merged; it carries the token.
- Directories are placed (state, data), the route binds into state.
- Image pinned to 5.0.7 (a649dd71), what ace runs; the old pin was 5.0.6.
- deployFlows asks for API v2: v1 answers 204 with no body, which the client
  tried to parse as JSON.

Verified: catalogue tests pass against this tree. A throwaway 5.0.7 container
started with the generated files: anonymous /flows 401, bearer api-token 200,
bad token 401, password grant 200/403 with a minted password and with a
bcrypt-hash-accepted one; endpoints and timeZone do not reach /settings;
timeZone Europe/Brussels overrides TZ=Etc/UTC; v1 deploy 204, v2 deploy
answers {rev}.
2026-09-29 23:41:18 +02:00
jschoubben 37c212d5b4 unifi: placed data, mesh-assigned ports, an https route, and its password as a file
The module stated /services/unifi/data and fixed machine ports (8443:8443 and
eight more) — one installation's layout and numbers, which a definition may not
carry (ADR 0038, 0112). Data is now a placed directory (${dir:data}, 1000:1000,
0700), the container publishes its own ports and the mesh assigns the machine
side; an assignment pins them where devices already know them. The L2 endpoint
names the port the software uses (1900), not the one a machine published it on.

The sidecar dialled https://127.0.0.1:8443, true only while the machine port
equals the container's; it now asks for ${port:8443}. Its controller password
was a setting (plaintext in the mesh DB); it is now an own-secret written into
the one mergeable file (ADR 0086). The username stays a setting.

The web UI is contributed as a route to the "web" endpoint over https with
insecure upstream (the controller's own self-signed tls), as mailu's web-tls —
what HAL's hand-written traefik file for unifi does today.

Image pinned to the manifest list ace runs (8.0.24-ls221); the old pin was its
amd64 child, so the image is unchanged.

Verified: catalogue tests pass with MESH_CATALOGUE set; a throwaway container
of the pinned image on a fresh 1000:1000/0700 data dir answers /status (8.0.24,
up) and /inform; the sidecar client built from a config.json carrying site,
password, username and an endpoints key reaches it and is refused only on the
dummy credentials.
2026-09-29 23:39:50 +02:00
jschoubben 718fb12ef7 icecast: its passwords are a file the mesh writes, not the image's environment
The image seds ICECAST_*_PASSWORD from the environment into /etc/icecast.xml;
ADR 0086 wants secrets as files. icecast starts as root, reads its config, then
drops to uid 100, so a root-owned 0600 icecast.xml rendered with ${secret:...}
and mounted read-only works and the entrypoint's seds never fire (no env set).
The "secrets-in-environment" exemption and server.env are gone.

Also: directories are placed (state, logs owned 100:101 so the image's VOLUME
/var/log/icecast is not an anonymous volume per container, as HAL learned);
the server and sidecar share a module network, so the sidecar reaches
http://icecast:8000 instead of assuming machine port 8000 on the host; the
stream endpoint is routed (label "icecast"), as HAL served it via traefik.
Secrets remain mesh-vault grants (requires secret), now under ${dir:state}.

Verified: catalogue tests with MESH_CATALOGUE pointed at this tree; a
throwaway container of the pinned digest (the one ace runs) with the rendered
file (dummy secrets, root 0600, :ro): runs as icecast, status-json 200,
admin 401 without / 200 with the admin secret, a source PUT with the source
secret mounts, a listener receives it, a wrong source password gets 401, logs
land in the uid-100 directory.
2026-09-29 23:39:36 +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
74 changed files with 3413 additions and 2319 deletions
+54 -23
View File
@@ -2,12 +2,15 @@
// module's tools and anything else baserow-specific import it; nothing outside baserow does. // module's tools and anything else baserow-specific import it; nothing outside baserow does.
// //
// Baserow authenticates a person with email + password, exchanged for a JWT at /api/user/token-auth/. // Baserow authenticates a person with email + password, exchanged for a JWT at /api/user/token-auth/.
// Those credentials are the mesh's own: a person signs up in Baserow (the standard image creates no // The standard image creates no admin from env, so the account is one a person made in Baserow: its
// admin from env), and the credential is placed in the runtime config file the mesh mounts. Until // password is the module's `admin` secret, accepted from the operator, and its email and the public
// that happens fromEnv throws and the module simply exposes no tools — the same dormant-until- // host Baserow answers to reach the runtime config file the mesh mounts (the email from the
// configured shape gitea uses for its token. // assignment's settings). Until both are there fromEnv throws and the module exposes no tools — the
// same dormant-until-configured shape gitea uses for its token.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
export interface BaserowApplication { export interface BaserowApplication {
id: number; id: number;
@@ -68,35 +71,63 @@ export class BaserowClient {
return h; return h;
} }
/** Exchange email + password for a JWT, caching it for the client's lifetime. Handles both the /**
* One HTTP exchange. Not `fetch`: Node's fetch drops a caller's Host header and sends the URL's
* own, and Baserow answers only the host of its BASEROW_PUBLIC_URL — any other Host is looked up
* as a published builder site and gets 404, `/api/_health/` included. A co-located caller reaching
* it by container name must present the public host, so the request is made with node:http, which
* sends the Host it is given.
*/
private send(path: string, method: string, headers: Record<string, string>, body?: string): Promise<{ status: number; text: string }> {
const url = new URL(`${this.baseUrl}${path}`);
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
// A length, never chunked: Baserow's server reads a chunked body as empty.
const sent = body === undefined ? headers : { ...headers, "Content-Length": String(Buffer.byteLength(body)) };
return new Promise((resolve, reject) => {
const req = request(url, { method, headers: sent }, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => (text += chunk));
res.on("end", () => resolve({ status: res.statusCode ?? 0, text }));
res.on("error", reject);
});
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
});
}
/** Exchange email + password for a JWT, caching it until Baserow refuses it. Handles both the
* older `{ token }` and the newer `{ access_token }` response shapes. */ * older `{ token }` and the newer `{ access_token }` response shapes. */
async authenticate(): Promise<string> { async authenticate(): Promise<string> {
if (this.token) return this.token; if (this.token) return this.token;
const res = await fetch(`${this.baseUrl}/api/user/token-auth/`, { const res = await this.send(
method: "POST", "/api/user/token-auth/",
headers: this.headers(), "POST",
body: JSON.stringify({ email: this.email, password: this.password }), this.headers(),
}); JSON.stringify({ email: this.email, password: this.password }),
if (!res.ok) throw new Error(`baserow auth failed: ${res.status} ${await res.text()}`); );
const data = (await res.json()) as { token?: string; access_token?: string }; if (res.status < 200 || res.status >= 300) throw new Error(`baserow auth failed: ${res.status} ${res.text}`);
const data = JSON.parse(res.text) as { token?: string; access_token?: string };
const token = data.access_token ?? data.token; const token = data.access_token ?? data.token;
if (!token) throw new Error("baserow auth returned no token"); if (!token) throw new Error("baserow auth returned no token");
this.token = token; this.token = token;
return token; return token;
} }
private async authed<T>(path: string, options: RequestInit = {}): Promise<T> { /** An authenticated GET. A refused token is dropped and the call made once more with a fresh one:
* Baserow's access tokens expire after minutes, and the runtime lives for weeks. */
private async authed<T>(path: string): Promise<T> {
for (let attempt = 0; ; attempt++) {
const token = await this.authenticate(); const token = await this.authenticate();
const res = await fetch(`${this.baseUrl}${path}`, { const res = await this.send(path, "GET", this.headers({ Authorization: `JWT ${token}` }));
...options, if (res.status === 401 && attempt === 0) {
headers: this.headers({ this.token = null;
Authorization: `JWT ${token}`, continue;
...(options.headers as Record<string, string> | undefined), }
}), if (res.status < 200 || res.status >= 300) throw new Error(`baserow ${path}: ${res.status} ${res.text}`);
}); return (res.text ? JSON.parse(res.text) : null) as T;
if (!res.ok) throw new Error(`baserow ${path}: ${res.status} ${await res.text()}`); }
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
} }
/** The applications (databases) the account can see, across all its workspaces. */ /** The applications (databases) the account can see, across all its workspaces. */
+15 -16
View File
@@ -18,14 +18,14 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/baserow/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/baserow/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/baserow/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"secret-key": "/var/lib/baserow/secret-key.secret", "admin": "${dir:state}/admin.secret",
"broker": "/var/lib/mesh/baserow/broker" "broker": "/var/lib/mesh/baserow/broker"
}, },
"listens": [ "listens": [
@@ -34,7 +34,7 @@
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the Baserow web UI and REST API; a public name is a route grant later" "why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's"
} }
], ],
"resources": [ "resources": [
@@ -47,22 +47,21 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/baserow", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/baserow/data",
"mode": "0755", "mode": "0755",
"owner": "9999:9999" "owner": "9999:9999"
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/baserow/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD=${secret:postgres-database}\nSECRET_KEY=${secret:secret-key}\nBASEROW_PUBLIC_URL=http://localhost\n" "content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
}, },
{ {
"id": "net", "id": "net",
@@ -73,25 +72,25 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "baserow", "name": "baserow",
"image": "baserow/baserow@sha256:834424a10413798567f76428f255dc259445b7f8dcec56598c05b4073bb2a124", "image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080",
"network": "baserow", "network": "baserow",
"env-file": [ "env-file": [
"/var/lib/baserow/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"80" "80"
], ],
"volumes": [ "volumes": [
"/services/baserow/data:/baserow/data" "${dir:data}:/baserow/data",
], "${dir:state}/database.secret:/run/secrets/database:ro"
"secrets-in-environment": "baserow reads DATABASE_PASSWORD and SECRET_KEY with os.getenv and has no _FILE twin (settings/base.py); not convertible" ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/baserow/config.json", "path": "/var/lib/mesh/baserow/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json" "merge": "json"
}, },
{ {
+1 -1
View File
@@ -94,7 +94,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}", "MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key", "MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
+1 -1
View File
@@ -76,7 +76,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}", "MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
}, },
"artifact": "runtime" "artifact": "runtime"
File diff suppressed because one or more lines are too long
+47 -22
View File
@@ -1,6 +1,26 @@
{ {
"module": "icecast", "module": "icecast",
"version": "1", "version": "1",
"requires": [
"route",
"secret"
],
"contributes": {
"route": {
"label": "icecast",
"endpoint": "stream"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"secrets": {
"secret": {
"source": "${dir:state}/source.secret",
"admin": "${dir:state}/admin.secret",
"relay": "${dir:state}/relay.secret"
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -17,7 +37,7 @@
"port": 8000, "port": 8000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "streams in from sources and out to listeners" "why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route"
} }
], ],
"resources": [ "resources": [
@@ -30,28 +50,43 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/icecast-module", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "server-env", "id": "logs",
"type": "directory",
"mode": "0700",
"owner": "100:101"
},
{
"id": "server-conf",
"type": "file", "type": "file",
"path": "/var/lib/icecast-module/server.env", "path": "${dir:state}/icecast.xml",
"mode": "0600", "mode": "0600",
"content": "ICECAST_SOURCE_PASSWORD=${secret:source}\nICECAST_ADMIN_PASSWORD=${secret:admin}\nICECAST_RELAY_PASSWORD=${secret:relay}\nICECAST_ADMIN_USERNAME=admin\n" "content": "<icecast>\n <!-- Written by the mesh (modules/icecast). Passwords arrive as secrets rendered into this file,\n never as environment: the image's entrypoint seds ICECAST_* variables into the file only\n when they are set, and none are. -->\n <location>Earth</location>\n <admin>icemaster@localhost</admin>\n <limits>\n <clients>100</clients>\n <sources>2</sources>\n <queue-size>524288</queue-size>\n <client-timeout>30</client-timeout>\n <header-timeout>15</header-timeout>\n <source-timeout>10</source-timeout>\n <burst-on-connect>1</burst-on-connect>\n <burst-size>65535</burst-size>\n </limits>\n <authentication>\n <source-password>${secret:source}</source-password>\n <relay-password>${secret:relay}</relay-password>\n <admin-user>admin</admin-user>\n <admin-password>${secret:admin}</admin-password>\n </authentication>\n <!-- The name icecast writes into playlists (.m3u/.xspf: http://<hostname>:<port>/<mount>) and\n would announce to YP (none configured). A machine's own name belongs to its assignment, and\n an assignment merges only into JSON; this XML cannot take it, so the neutral default stays. -->\n <hostname>localhost</hostname>\n <listen-socket>\n <port>8000</port>\n </listen-socket>\n <http-headers>\n <header name=\"Access-Control-Allow-Origin\" value=\"*\" />\n </http-headers>\n <fileserve>1</fileserve>\n <paths>\n <basedir>/usr/share/icecast</basedir>\n <logdir>/var/log/icecast</logdir>\n <webroot>/usr/share/icecast/web</webroot>\n <adminroot>/usr/share/icecast/admin</adminroot>\n <alias source=\"/\" destination=\"/status.xsl\"/>\n </paths>\n <logging>\n <accesslog>access.log</accesslog>\n <errorlog>error.log</errorlog>\n <loglevel>3</loglevel>\n <logsize>10000</logsize>\n </logging>\n <security>\n <chroot>0</chroot>\n <!-- Starts as root, reads this 0600 root-owned file, then drops to the image's icecast user\n (uid 100, group icecast 101) before serving. -->\n <changeowner>\n <user>icecast</user>\n <group>icecast</group>\n </changeowner>\n </security>\n</icecast>\n"
},
{
"id": "net",
"type": "network",
"name": "icecast"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "icecast", "name": "icecast",
"image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c", "image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c",
"env-file": [ "network": "icecast",
"/var/lib/icecast-module/server.env"
],
"ports": [ "ports": [
"8000" "8000"
], ],
"secrets-in-environment": "the image seds ICECAST_*_PASSWORD into icecast.xml and has no _FILE; convertible by mounting a generated icecast.xml, not yet done" "volumes": [
"${dir:state}/icecast.xml:/etc/icecast.xml:ro",
"${dir:logs}:/var/log/icecast"
],
"restart-on": [
"server-conf"
]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
@@ -65,14 +100,14 @@
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-icecast", "name": "mesh-icecast",
"network": "host", "network": "icecast",
"volumes": [ "volumes": [
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro", "/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro" "/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://127.0.0.1:8000", "MESH_ICECAST_URL": "http://icecast:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json" "MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
@@ -101,15 +136,5 @@
"from": "Dockerfile" "from": "Dockerfile"
} }
] ]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"source": "/var/lib/icecast-module/source.secret",
"admin": "/var/lib/icecast-module/admin.secret",
"relay": "/var/lib/icecast-module/relay.secret"
}
} }
} }
+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";
}
}
+58 -30
View File
@@ -1,11 +1,19 @@
{ {
"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": [
{ {
@@ -13,9 +21,23 @@
"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",
@@ -26,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",
@@ -83,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"
@@ -97,6 +125,15 @@
"artifact": "runtime" "artifact": "runtime"
} }
], ],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "influxdb",
"endpoint": "api"
}
},
"build": { "build": {
"on": [ "on": [
{ {
@@ -117,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"
]
} }
+53 -16
View File
@@ -2,7 +2,8 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it. // talks its /api/v2.0 REST API, and only jackett's tools import it.
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface JackettIndexer { export interface JackettIndexer {
id: string; id: string;
@@ -43,18 +44,36 @@ export class JackettClient {
/** /**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and * Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present — without them there is nothing to talk to, so this throws and the * the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* module contributes no tools rather than failing half-configured. * or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL; const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
return new JackettClient(url, apiKey); return new JackettClient(url, apiKey);
} }
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> { private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`); const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey); url.searchParams.set("apikey", this.apiKey);
@@ -64,18 +83,36 @@ export class JackettClient {
return res.json(); return res.json();
} }
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ /**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> { async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
const list = Array.isArray(raw) ? raw : []; url.searchParams.set("apikey", this.apiKey);
return list.map((i: any) => ({ url.searchParams.set("t", "indexers");
id: i.id, url.searchParams.set("configured", configuredOnly ? "true" : "false");
name: i.name, const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
type: i.type, if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
configured: i.configured ?? false, const xml = await res.text();
siteLink: i.site_link, // Torznab reports failures (a wrong key among them) as 200 with an <error> body.
lastError: i.last_error || undefined, const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
})); if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
} }
/** /**
+27 -9
View File
@@ -1,6 +1,19 @@
{ {
"module": "jackett", "module": "jackett",
"version": "1", "version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -10,7 +23,7 @@
"port": 9117, "port": 9117,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the indexer proxy" "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
} }
], ],
"resources": [ "resources": [
@@ -20,10 +33,15 @@
"path": "/var/lib/mesh/jackett", "path": "/var/lib/mesh/jackett",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/jackett/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -31,7 +49,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "jackett", "name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77", "image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -41,13 +59,13 @@
"9117" "9117"
], ],
"volumes": [ "volumes": [
"/services/jackett/config:/config" "${dir:config}:/config"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/jackett/config.json", "path": "${dir:state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -59,12 +77,12 @@
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/services/jackett/config:/var/lib/jackett/config:ro" "${dir:config}:/var/lib/jackett/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:9117", "MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
}, },
@@ -87,7 +105,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/jackett/route.json" "route": "${dir:state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+1 -1
View File
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [ return [
{ {
name: "jackett_indexers", name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and any last error.", description: "List the indexers Jackett proxies, with their type and site.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => { run: async (args) => {
const indexers = await jackett.getIndexers(!args.all); const indexers = await jackett.getIndexers(!args.all);
+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),
}, },
+21 -25
View File
@@ -5,21 +5,28 @@
"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": [
@@ -28,7 +35,7 @@
"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": [
@@ -41,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",
@@ -60,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",
@@ -99,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": {
+1 -14
View File
@@ -1,19 +1,6 @@
{ {
"module": "lidarr", "module": "lidarr",
"version": "1", "version": "1",
"provides": [
{
"name": "lidarr-api",
"scope": "mesh"
}
],
"serves": {
"lidarr-api": {
"scheme": "http",
"port": 8686,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -88,7 +75,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}", "MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config" "MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
}, },
"artifact": "runtime" "artifact": "runtime"
+2 -5
View File
@@ -120,7 +120,7 @@
"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", "name": "web-tls",
@@ -315,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",
+110
View File
@@ -0,0 +1,110 @@
{
"module": "matrix",
"version": "1",
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "client",
"port": 6167,
"protocol": "tcp",
"from": "mesh",
"why": "Conduit's client-server and federation APIs over plain HTTP. Both arrive through the route on 443: Conduit answers /.well-known/matrix/server with <its name>:443, so other homeservers federate through the proxy and nothing needs the traditional 8448"
},
{
"name": "web",
"port": 80,
"protocol": "tcp",
"from": "mesh",
"why": "Element Web, the static browser client, served by the image's nginx; reached through its route"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"homeserver": {
"label": "matrix",
"endpoint": "client"
},
"element": {
"label": "element",
"endpoint": "web"
}
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "db",
"type": "directory",
"mode": "0700"
},
{
"id": "conduit-conf",
"type": "file",
"path": "${dir:state}/conduit.toml",
"mode": "0644",
"content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n"
},
{
"id": "element-conf",
"type": "file",
"path": "${dir:state}/element.json",
"mode": "0644",
"merge": "json",
"content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n"
},
{
"id": "net",
"type": "network",
"name": "matrix"
},
{
"id": "homeserver",
"type": "container",
"name": "matrix",
"image": "matrixconduit/matrix-conduit@sha256:b0d24248e94f944ca49f90f10c429e3d65f4472bdde25661ecea9840134fb133",
"network": "matrix",
"env": {
"CONDUIT_CONFIG": "/etc/conduit/conduit.toml"
},
"ports": [
"6167"
],
"volumes": [
"${dir:db}:/var/lib/matrix-conduit",
"${dir:state}/conduit.toml:/etc/conduit/conduit.toml:ro"
],
"restart-on": [
"conduit-conf"
]
},
{
"id": "element",
"type": "container",
"name": "element-web",
"image": "vectorim/element-web@sha256:a8f415462ab8d2600a592ba1b92bea51efe5a4d10eb738aab9bed769f7099613",
"network": "matrix",
"ports": [
"80"
],
"volumes": [
"${dir:state}/element.json:/app/config.json:ro"
],
"restart-on": [
"element-conf"
]
}
]
}
+13
View File
@@ -0,0 +1,13 @@
# The console (novox/hq ADR 0152, design 34): the mesh's tools for whoever is on a machine, served
# over MCP on that machine's loopback.
#
# **Nothing is compiled here.** The console is the tool runtime's own client — `mesh serve` — which
# the runtime image already carries beside the runtime it runs modules with. This recipe changes the
# program the image starts and nothing else, so the console is exactly the client a person can run by
# hand, started by the mesh instead, on the credential the mesh sealed to the machine.
#
# One base, named rather than pinned: the mesh answers with the copy it holds (novox/hq issue 044).
ARG RUNTIME_BASE
FROM ${RUNTIME_BASE}
ENTRYPOINT ["node", "dist/mesh.js"]
+38
View File
@@ -0,0 +1,38 @@
# mesh-console
The mesh's tools, on the machine a person sits at, served by a module the mesh assigned there
(novox/hq [ADR 0152](https://git.novox.be/novox/hq), design 34).
Assign it to a machine and an agent on that machine has the mesh's tools at
`http://127.0.0.1:<port>/mcp` — MCP over HTTP, `initialize`, `tools/list`, `tools/call`. A person at
a terminal reaches the same endpoint with `mesh tools --console http://127.0.0.1:<port>` and
`mesh call <module>.<tool> --console …`, with no credential of their own: the console holds it.
## What it is
The tool runtime's own client, `mesh serve`, started by the mesh on the credential it sealed to the
machine for `<node>.mesh-console`. The manifest says three things nothing else in the catalogue says
together:
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
- a listener `from: machine` — loopback only, and the filter opens nothing for it;
- no `emits`, no `consumes`, no `tools` — nothing on the bus can address it.
**Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the
machine is the account that owns the mesh there (ADR 0034, ADR 0144). There is no token and no login,
and `mesh serve` refuses to bind anything but a loopback address.
## What it lists
What the running modules answer: every tool runtime serves a `tools` verb for its module, and the
console asks the catalogue which modules the mesh holds and each module what it serves. A module that
did not answer — not assigned, not up, or built before the runtime answered `tools` — is named in the
list's `_meta.notAnswering` and can still be called by `<module>.<tool>`.
The mesh's own verbs (`status`, `push`, `assign`) are the `mesh-controller` seat's tools under
ADR 0132 and are not served on the bus yet; they appear here when they are.
## Port
The manifest declares port 4270 and the mesh assigns the machine port as it does for any listener;
the console binds `127.0.0.1:${port:4270}`. `node show <machine>` says which port a machine was given.
+64
View File
@@ -0,0 +1,64 @@
{
"module": "mesh-console",
"version": "1",
"slug": "console",
"capabilities": [
"container-runtime"
],
"invokes": [
"*"
],
"own-secrets": {
"broker": "/var/lib/mesh/mesh-console/broker"
},
"listens": [
{
"name": "mcp",
"port": 4270,
"protocol": "tcp",
"from": "machine",
"why": "the mesh's tools for whoever is on this machine, over MCP on loopback; the machine's login is the authority (novox/hq ADR 0152)"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/mesh-console",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "mesh-console",
"network": "host",
"args": [
"serve"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
},
"volumes": [
"/var/lib/mesh/mesh-console/broker:/run/secrets/broker:ro"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+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. */
+19 -18
View File
@@ -20,16 +20,19 @@
"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": [
@@ -58,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"
@@ -93,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",
@@ -112,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"
] ]
}, },
{ {
@@ -130,12 +131,12 @@
"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}:${dir: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",
"MESH_RECEIVES": "/var/lib/mosquitto-module/grants/mesh.json", "MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "mosquitto:1883", "MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin", "MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin" "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
+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"]
} }
+11 -12
View File
@@ -21,23 +21,23 @@
"listens": [ "listens": [
{ {
"name": "database", "name": "database",
"port": 4848, "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": [
@@ -50,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"
}, },
@@ -84,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"
@@ -101,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",
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nodered WORKDIR /app/modules/nodered
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 tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/nodered/dist /app/modules/nodered/dist
# 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/nodered/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/nodered/dist/tools/index.js
# NOT dist/mqtt/index.js: that is a step the host runs to completion, named by the `mqtt`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+38 -3
View File
@@ -3,7 +3,8 @@
// //
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow // Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
// configuration, GET /nodes for installed node modules. A default install has no auth; when // configuration, GET /nodes for installed node modules. A default install has no auth; when
// adminAuth is on, a bearer token (minted at /auth/token) is required. // adminAuth is on, a bearer token is required — the module's settings accept the mesh-minted
// api-token, which the runtime config file carries as `token`.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -81,6 +82,35 @@ export class NodeRedClient {
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] })); return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
} }
/** The whole flow configuration with its revision (API v2), for a deploy that must not clobber
* a change made meanwhile. */
async flowsWithRev(): Promise<{ rev: string; flows: any[] }> {
const body = await this.req("/flows", { headers: this.headers({ "Node-RED-API-Version": "v2" }) });
return { rev: String(body?.rev ?? ""), flows: Array.isArray(body?.flows) ? body.flows : [] };
}
/** A node's stored credentials as Node-RED shows them: plain fields, and `has_<field>` for secret ones. */
async credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }> {
return (await this.req(`/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { headers: this.headers() })) ?? {};
}
/**
* Deploy the flow configuration read at `rev`. Node-RED answers 409 when the flows changed since,
* rather than overwriting what someone deployed in between. A node carrying `credentials` has them
* stored (encrypted) and counts as changed, so a "nodes" deploy restarts it and nothing else.
*/
async deployFlowsAt(rev: string, config: any[], type = "nodes"): Promise<void> {
await this.req("/flows", {
method: "POST",
headers: this.headers({
"Content-Type": "application/json",
"Node-RED-API-Version": "v2",
"Node-RED-Deployment-Type": type,
}),
body: JSON.stringify({ rev, flows: config }),
});
}
/** /**
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to * Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
* Node-RED's deployment types — "full" (default), "nodes", or "flows". * Node-RED's deployment types — "full" (default), "nodes", or "flows".
@@ -88,8 +118,13 @@ export class NodeRedClient {
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> { async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
const body = await this.req("/flows", { const body = await this.req("/flows", {
method: "POST", method: "POST",
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }), // v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
body: JSON.stringify(config), headers: this.headers({
"Content-Type": "application/json",
"Node-RED-API-Version": "v2",
"Node-RED-Deployment-Type": type,
}),
body: JSON.stringify({ flows: config }),
}); });
return { rev: body?.rev, nodeCount: config.length }; return { rev: body?.rev, nodeCount: config.length };
} }
+85 -7
View File
@@ -5,6 +5,8 @@
"flows.deployed" "flows.deployed"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/nodered/admin",
"api-token": "/var/lib/mesh/nodered/api-token",
"broker": "/var/lib/mesh/nodered/broker" "broker": "/var/lib/mesh/nodered/broker"
}, },
"capabilities": [ "capabilities": [
@@ -26,26 +28,63 @@
"path": "/var/lib/mesh/nodered", "path": "/var/lib/mesh/nodered",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/nodered/data",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
{
"id": "written",
"type": "directory",
"mode": "0700"
},
{
"id": "settings-code",
"type": "file",
"path": "${dir:state}/settings.js",
"mode": "0600",
"owner": "1000:1000",
"content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the\n// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\ndelete settings.mqtt;\ndelete settings.topics;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "1000:1000",
"merge": "json",
"content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "nodered", "name": "nodered",
"image": "nodered/node-red@sha256:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff", "image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace",
"env": { "env": {
"TZ": "Etc/UTC" "TZ": "Etc/UTC"
}, },
"ports": [ "ports": [
"1880" "1880"
], ],
"args": [
"--settings",
"/config/settings.js"
],
"volumes": [ "volumes": [
"/services/nodered/data:/data" "${dir:data}:/data",
"${dir:state}/settings.js:/config/settings.js:ro",
"${dir:state}/settings.json:/config/settings.json:ro"
],
"restart-on": [
"settings-code",
"settings"
] ]
}, },
{ {
@@ -53,8 +92,7 @@
"type": "file", "type": "file",
"path": "/var/lib/mesh/nodered/config.json", "path": "/var/lib/mesh/nodered/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
"merge": "json"
}, },
{ {
"id": "runtime", "id": "runtime",
@@ -67,26 +105,66 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NODERED_URL": "http://127.0.0.1:1880", "MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json" "MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "mqtt",
"type": "container",
"name": "mesh-nodered-mqtt",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro",
"${dir:written}:/var/lib/nodered-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/settings.json:/run/provisions/settings.json:ro"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/nodered-provisions"
},
"args": [
"run",
"/app/modules/nodered/dist/mqtt/index.js"
],
"restart-on": [
"bound-mqtt-topic",
"secret-mqtt-topic",
"settings"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"mqtt-topic",
"route" "route"
], ],
"contributes": { "contributes": {
"mqtt-topic": {
"topics": [
"#"
]
},
"route": { "route": {
"label": "nodered", "label": "nodered",
"endpoint": "web" "endpoint": "web"
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/nodered/route.json" "route": "${dir:state}/route.json",
"mqtt-topic": "${dir:state}/mqtt-topic.json"
},
"secrets": {
"mqtt-topic": "${dir:state}/mqtt-topic.secret"
}, },
"build": { "build": {
"on": [ "on": [
+232
View File
@@ -0,0 +1,232 @@
// Node-RED's MQTT broker config node, pointed at the broker the mesh bound — `mqtt-topic`.
//
// **Why a step.** Node-RED keeps a broker as a config node in its flows (`flows.json`) and the login
// and password in its encrypted credentials file, both of them Node-RED's to write. So this reads the
// binding and the pair credential and makes the broker node say the same thing through Node-RED's
// admin API — `GET /flows`, then `POST /flows` with the changed node and its `credentials`, deployed
// as "nodes" so only what changed restarts — with the module's own `api-token`.
//
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
//
// **Only the connection, and only when it differs.** Host, port, TLS off (the broker serves plain
// MQTT), login, password. Every other field of the node — client id, keepalive, birth/close/will
// messages — is left as it is. Node-RED never hands a stored password back, so the step keeps a
// digest of what it last wrote: equal host/port/login and an equal digest is "already as the mesh
// says".
//
// **Nothing loses its connection without someone seeing it.** The broker is asked first whether it
// takes the delivered login; if not, nothing is written and the step fails saying why.
//
// Pure logic over two seams (Node-RED, the broker), tested against fakes (test/mqtt.test.ts).
import { createHash } from "node:crypto";
import type { Probe } from "./probe.js";
export const PROVISION = "mqtt-topic";
/** The id and name of the broker node the step makes when none is named. */
export const MESH_BROKER_ID = "mesh-mqtt-topic";
export const MESH_BROKER_NAME = "mesh: mqtt-topic";
/** What the mesh wrote at `binds.mqtt-topic`. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Outcome =
| { what: string; result: "unchanged"; note?: string }
| { what: string; result: "written"; fields: string[]; note?: string }
| { what: string; result: "refused"; problem: string };
/** A flow node; a broker config node carries `broker`, `port`, `usetls`. */
export interface FlowNode {
id: string;
type: string;
[key: string]: unknown;
}
/** Node-RED's admin API, as the step uses it. */
export interface NodeRed {
/** The whole flow configuration and its revision (API v2). */
flows(): Promise<{ rev: string; flows: FlowNode[] }>;
/** A node's stored credentials as Node-RED shows them: the user, and only whether a password is set. */
credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }>;
/** Deploy the configuration against the revision it was read at; "nodes" restarts only what changed. */
deploy(rev: string, flows: FlowNode[]): Promise<void>;
}
export interface Marks {
get(name: string): Promise<string | undefined>;
set(name: string, digest: string): Promise<void>;
}
export interface Deps {
nodered: NodeRed;
probe: Probe;
marks: Marks;
}
export interface Wanted {
host: string;
port: number;
user: string;
password: string;
}
export function digest(...parts: (string | number)[]): string {
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/**
* The broker and login the mesh says Node-RED uses. A loopback `at` — what the mesh hands a machine
* that is not on the private network — is refused: from Node-RED's own container it is Node-RED.
*/
export function wanted(binding: Binding | undefined, credential: string | undefined): { ok: true; want: Wanted } | { ok: false; problem: string } {
if (!binding) return { ok: false, problem: `no binding for ${PROVISION} was delivered — the mesh writes it before this step runs` };
const host = typeof binding.at === "string" ? binding.at.trim() : "";
if (!host) return { ok: false, problem: `the ${PROVISION} binding names no host (at)` };
if (isLoopback(host)) {
return {
ok: false,
problem:
`the ${PROVISION} binding says the broker is at ${host}, which from Node-RED's own container is Node-RED ` +
`itself; put the machine on the private network so the broker has an address Node-RED can dial`,
};
}
const port = Number(binding.serves?.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
}
const scheme = binding.serves?.scheme;
if (scheme !== undefined && scheme !== "mqtt") return { ok: false, problem: `the ${PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
const user = typeof binding.as === "string" ? binding.as.trim() : "";
if (!user) return { ok: false, problem: `the ${PROVISION} binding names no login (as)` };
const password = (credential ?? "").replace(/\n$/, "");
if (!password) return { ok: false, problem: `the ${PROVISION} credential is empty or was not delivered` };
return { ok: true, want: { host, port, user, password } };
}
/** The broker node ids an assignment named in settings (`mqtt.brokers`), or none. */
export function namedBrokers(settings: unknown): string[] {
const brokers = (settings as { mqtt?: { brokers?: unknown } } | undefined)?.mqtt?.brokers;
return Array.isArray(brokers) ? brokers.filter((b): b is string => typeof b === "string" && b.length > 0) : [];
}
/** A new broker node, Node-RED 5's defaults, pointed at the broker. */
export function newBrokerNode(want: Wanted): FlowNode {
return {
id: MESH_BROKER_ID, type: "mqtt-broker", name: MESH_BROKER_NAME,
broker: want.host, port: String(want.port), clientid: "", autoConnect: true, usetls: false,
protocolVersion: "4", keepalive: "60", cleansession: true, autoUnsubscribe: true,
birthTopic: "", birthQos: "0", birthRetain: "false", birthPayload: "", birthMsg: {},
closeTopic: "", closeQos: "0", closeRetain: "false", closePayload: "", closeMsg: {},
willTopic: "", willQos: "0", willRetain: "false", willPayload: "", willMsg: {},
userProps: "", sessionExpiry: "",
};
}
const markFor = (id: string, w: Wanted): string => digest("nodered-mqtt", id, w.host, w.port, w.user, w.password);
function scrub(err: unknown, secret: string): string {
let text = err instanceof Error ? err.message : String(err);
for (const form of new Set([secret, encodeURIComponent(secret)])) text = text.split(form).join("***");
return text;
}
/**
* Bring the mesh's broker nodes in line with the binding: one outcome per node. Never throws. A node
* the settings name that is not in the flows is refused (the others are still put right).
*/
export async function reconcileBrokers(deps: Deps, binding: Binding | undefined, credential: string | undefined, named: readonly string[]): Promise<Outcome[]> {
const w = wanted(binding, credential);
if ("problem" in w) return [{ what: "mqtt", result: "refused", problem: w.problem }];
const want = w.want;
let note: string | undefined;
try {
const probe = await deps.probe(want.host, want.port, want.user, want.password, "#");
if (probe.connack === 4 || probe.connack === 5) {
return [{
what: "mqtt",
result: "refused",
problem:
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.user} with the delivered password ` +
`(CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was written`,
}];
}
if (probe.connack !== 0) return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` }];
if (probe.suback === 0x80) note = `warning: ${want.user} may not subscribe to every topic; flows subscribing outside its grant will get nothing`;
} catch (err) {
return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written` }];
}
const outcomes: Outcome[] = [];
for (let attempt = 0; attempt < 2; attempt++) {
outcomes.length = 0;
try {
const { rev, flows } = await deps.nodered.flows();
const targets = named.length > 0 ? [...named] : [MESH_BROKER_ID];
const changed: { id: string; fields: string[] }[] = [];
for (const id of targets) {
let node = flows.find((n) => n.id === id);
if (node && node.type !== "mqtt-broker") {
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `node ${id} is a ${node.type}, not an mqtt-broker` });
continue;
}
if (!node) {
if (id !== MESH_BROKER_ID) {
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `the settings name broker node ${id}, and Node-RED's flows have no such node` });
continue;
}
node = newBrokerNode(want);
flows.push(node);
node.credentials = { user: want.user, password: want.password };
changed.push({ id, fields: ["node"] });
continue;
}
const fields: string[] = [];
if (String(node.broker ?? "") !== want.host) fields.push("broker");
if (Number(node.port ?? 0) !== want.port) fields.push("port");
if (node.usetls === true) fields.push("usetls");
const creds = await deps.nodered.credentials("mqtt-broker", id);
if ((creds.user ?? "") !== want.user) fields.push("user");
if (!creds.has_password || (await deps.marks.get(`broker-${id}`)) !== markFor(id, want)) fields.push("password");
if (fields.length === 0) {
outcomes.push(note ? { what: `broker ${id}`, result: "unchanged", note } : { what: `broker ${id}`, result: "unchanged" });
continue;
}
node.broker = want.host;
node.port = String(want.port);
node.usetls = false;
node.credentials = { user: want.user, password: want.password };
changed.push({ id, fields });
}
if (changed.length > 0) {
await deps.nodered.deploy(rev, flows);
for (const c of changed) {
await deps.marks.set(`broker-${c.id}`, markFor(c.id, want));
outcomes.push({ what: `broker ${c.id}`, result: "written", fields: c.fields, ...(note ? { note } : {}) });
}
}
return outcomes;
} catch (err) {
// A deploy against a revision someone else changed meanwhile (409) is read again once.
if (attempt === 0 && /\b409\b/.test(String(err))) continue;
return [...outcomes, { what: "mqtt", result: "refused", problem: scrub(err, want.password) }];
}
}
return outcomes;
}
+102
View File
@@ -0,0 +1,102 @@
// nodered's MQTT step — run once by the host after Node-RED starts, and again whenever the
// `mqtt-topic` binding, its pair credential or the settings change (the container's `restart-on`,
// novox/hq ADR 0099). It points the mesh's broker config nodes at the broker the mesh bound, through
// Node-RED's admin API (connection.ts). It connects to no mesh broker.
//
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of nodered's (novox/hq ADR 0136). Never prints a password.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { NodeRedClient } from "../client.js";
import { namedBrokers, reconcileBrokers, type Binding, type Marks } from "./connection.js";
import { probeBroker } from "./probe.js";
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
const writtenDir = process.env.MESH_WRITTEN_DIR ?? "/var/lib/nodered-provisions";
const waitSeconds = Number(process.env.MESH_NODERED_WAIT_SECONDS ?? "180");
const readIfThere = (path: string): Promise<string | undefined> => readFile(path, "utf8").catch(() => undefined);
const parse = <T>(raw: string | undefined): T | undefined => {
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as T;
} catch {
return undefined;
}
};
const marks: Marks = {
async get(name) {
return (await readIfThere(join(writtenDir, `${name}.digest`)))?.trim() || undefined;
},
async set(name, value) {
await mkdir(writtenDir, { recursive: true, mode: 0o700 });
const path = join(writtenDir, `${name}.digest`);
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
await rename(`${path}.tmp`, path);
},
};
let client: NodeRedClient;
try {
client = NodeRedClient.fromEnv();
} catch (err) {
console.error(`[nodered-mqtt] ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
/** Node-RED answers the admin API once its flows are loaded and the token is good. */
async function ready(): Promise<boolean> {
const until = Date.now() + waitSeconds * 1000;
for (;;) {
try {
await client.flowsWithRev();
return true;
} catch (err) {
if (/\b(401|403)\b/.test(String(err))) {
console.error("[nodered-mqtt] Node-RED refuses the api-token — settings.js and this step disagree");
return false;
}
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 2000));
}
}
if (!(await ready())) {
console.error(`[nodered-mqtt] Node-RED's admin API did not answer at ${client.baseUrl} within ${waitSeconds}s`);
process.exit(1);
}
const binding = parse<Binding>(await readIfThere(join(dir, "mqtt-topic.json")));
const secret = await readIfThere(join(dir, "mqtt-topic.secret"));
const settings = parse<unknown>(await readIfThere(join(dir, "settings.json")));
const outcomes = await reconcileBrokers(
{
nodered: {
flows: () => client.flowsWithRev(),
credentials: (type, id) => client.credentials(type, id),
deploy: (rev, flows) => client.deployFlowsAt(rev, flows, "nodes"),
},
probe: probeBroker,
marks,
},
binding,
secret,
namedBrokers(settings),
);
let failed = 0;
for (const o of outcomes) {
if (o.result === "unchanged") console.log(`[nodered-mqtt] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
else if (o.result === "written") console.log(`[nodered-mqtt] ${o.what}: wrote ${o.fields.join(", ")}${o.note ? ` — ${o.note}` : ""}`);
else {
failed++;
console.error(`[nodered-mqtt] ${o.what}: ${o.problem}`);
}
}
process.exitCode = failed > 0 ? 1 : 0;
+117
View File
@@ -0,0 +1,117 @@
// Ask the broker, before Node-RED is told anything, whether it takes the login and password the
// mesh delivered — and whether that login may subscribe to every topic, as flows expect.
//
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so no flow's session is taken
// over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT. No dependency:
// the handful of bytes MQTT needs for this are written here.
import { randomBytes } from "node:crypto";
import { connect } from "node:net";
export interface ProbeResult {
/** 0 accepted; 4 bad username or password; 5 not authorised. */
connack: number;
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
suback?: number;
}
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
function str(v: string): Buffer {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
}
function packet(type: number, body: Buffer): Buffer {
let remaining = body.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
}
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
if (buf.length < 2) return undefined;
let length = 0;
let multiplier = 1;
let i = 1;
for (;;) {
if (i >= buf.length) return undefined;
const byte = buf[i++];
length += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) break;
multiplier *= 128;
if (i > 4) throw new Error("malformed MQTT remaining length");
}
if (buf.length < i + length) return undefined;
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
}
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
const connectBody = Buffer.concat([
str("MQTT"),
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
str(username),
str(password),
]);
return new Promise((resolve, reject) => {
const socket = connect({ host, port });
let buf = Buffer.alloc(0);
const result: ProbeResult = { connack: -1 };
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
}, 10_000);
const finish = (): void => {
clearTimeout(timer);
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
else socket.destroy();
resolve(result);
};
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
let p;
try {
p = firstPacket(buf);
} catch (err) {
clearTimeout(timer);
socket.destroy();
reject(err);
return;
}
if (!p) return;
buf = buf.subarray(p.used);
const kind = p.type >> 4;
if (kind === 2) {
result.connack = p.body[1] ?? -1;
if (result.connack !== 0 || !subscribe) return finish();
// SUBSCRIBE, packet id 1, one filter at QoS 0.
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
} else if (kind === 9) {
result.suback = p.body[2];
return finish();
}
}
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.on("close", () => {
if (result.connack === -1) {
clearTimeout(timer);
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
}
});
});
};
+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"name": "@novox/module-nodered", "name": "@novox/module-nodered",
"version": "0.1.0", "version": "0.1.0",
"description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0039).", "description": "nodered \u2014 flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/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.0" "@novox/mesh-sdk": "^0.1.0"
}, },
+124
View File
@@ -0,0 +1,124 @@
// What holds nodered's MQTT step (mqtt/connection.ts): the broker nodes the mesh owns — the one it
// makes, or the ones settings name — are made to use the broker and login the mesh bound, only after
// the broker takes that login; every other field of a node is kept; nothing is deployed when nothing
// differs; a node that is not named is never touched; a loopback broker address is refused.
//
// Node-RED and the broker are fakes answering as the real ones do (admin API v2 of nodered/node-red
// 5.0.7, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
import { MESH_BROKER_ID, namedBrokers, reconcileBrokers, type Binding, type FlowNode, type Marks, type NodeRed } from "../mqtt/connection.ts";
import type { Probe } from "../mqtt/probe.ts";
const MINTED = "mesh-minted-password";
const binding = (at = "ace.internal"): Binding => ({ provision: "mqtt-topic", from: "ace", at, as: "mesh_ace_nodered", serves: { scheme: "mqtt", port: 1883 } });
/** ace's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
function aceFlows(): FlowNode[] {
return [
{ id: "2b0aece9c5f3b307", type: "mqtt-broker", name: "MQTT Broker", broker: "zurag.be", port: "1884", clientid: "", usetls: false, protocolVersion: "4", keepalive: "60" },
{ id: "fe0cae96f1e3ae4d", type: "mqtt in", topic: "stat/sonoff_office_light_switch/RESULT", broker: "2b0aece9c5f3b307", z: "t" },
{ id: "other-broker", type: "mqtt-broker", name: "someone else's", broker: "test.mosquitto.org", port: "1883" },
];
}
function fakeNodeRed(flows: FlowNode[], creds: Record<string, { user?: string; password?: string }> = {}) {
let rev = "r1";
const deploys: FlowNode[][] = [];
const nodered: NodeRed = {
async flows() {
return { rev, flows: structuredClone(flows) };
},
async credentials(_type, id) {
const c = creds[id] ?? {};
return { user: c.user, has_password: Boolean(c.password) };
},
async deploy(at, next) {
if (at !== rev) throw new Error("Node-RED /flows: 409 version_mismatch");
for (const n of next) {
if (n.credentials) creds[n.id] = { ...(creds[n.id] ?? {}), ...(n.credentials as object) };
}
flows.splice(0, flows.length, ...next.map(({ credentials: _c, ...n }) => n as FlowNode));
deploys.push(next);
rev = `r${deploys.length + 1}`;
},
};
return { nodered, deploys, flows, creds };
}
const marks = (): Marks & { store: Map<string, string> } => {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
};
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
test("ace: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
const f = fakeNodeRed(aceFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
const m = marks();
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), `${MINTED}\n`, ["2b0aece9c5f3b307"]);
assert.deepEqual(out, [{ what: "broker 2b0aece9c5f3b307", result: "written", fields: ["broker", "port", "user", "password"] }]);
const node = f.flows.find((n) => n.id === "2b0aece9c5f3b307");
assert.deepEqual(node, { ...aceFlows()[0], broker: "ace.internal", port: "1883", usetls: false });
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_ace_nodered", password: MINTED });
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), aceFlows()[2]);
assert.deepEqual(f.creds["other-broker"], { user: "x", password: "y" });
// Only the changed node carried credentials in the deploy.
assert.deepEqual(f.deploys[0].filter((n) => n.credentials).map((n) => n.id), ["2b0aece9c5f3b307"]);
// Again: nothing differs, nothing is deployed.
const again = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.deepEqual(again, [{ what: "broker 2b0aece9c5f3b307", result: "unchanged" }]);
assert.equal(f.deploys.length, 1);
});
test("fresh: with nothing named, the step makes its own broker node", async () => {
const f = fakeNodeRed([]);
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, []);
assert.deepEqual(out, [{ what: `broker ${MESH_BROKER_ID}`, result: "written", fields: ["node"] }]);
assert.equal(f.flows[0].type, "mqtt-broker");
assert.equal(f.flows[0].broker, "ace.internal");
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_ace_nodered", password: MINTED });
});
test("a login the broker does not take is never written", async () => {
const f = fakeNodeRed(aceFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: async () => ({ connack: 5 }), marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.equal(out[0].result, "refused");
assert.equal(f.deploys.length, 0);
});
test("a named node that is not there, or a loopback broker, is refused", async () => {
const f = fakeNodeRed(aceFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, ["gone"]);
assert.match((out[0] as { problem: string }).problem, /no such node/);
const lo = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding("127.0.0.1"), MINTED, []);
assert.match((lo[0] as { problem: string }).problem, /Node-RED itself/);
assert.equal(f.deploys.length, 0);
});
test("a deploy that raced another is read again once", async () => {
const f = fakeNodeRed(aceFlows());
let first = true;
const racing: NodeRed = {
...f.nodered,
async flows() {
const got = await f.nodered.flows();
if (first) {
first = false;
return { ...got, rev: "stale" };
}
return got;
},
};
const out = await reconcileBrokers({ nodered: racing, probe: takes, marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.equal(out[0].result, "written");
assert.equal(f.deploys.length, 1);
});
test("settings name broker nodes under mqtt.brokers", () => {
assert.deepEqual(namedBrokers({ mqtt: { brokers: ["a", "", 3, "b"] } }), ["a", "b"]);
assert.deepEqual(namedBrokers({ endpoints: {} }), []);
assert.deepEqual(namedBrokers(undefined), []);
});
+7 -1
View File
@@ -8,5 +8,11 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "tools/index.ts"] "include": [
"client.ts",
"tools/index.ts",
"mqtt/probe.ts",
"mqtt/connection.ts",
"mqtt/index.ts"
]
} }
+1 -1
View File
@@ -81,7 +81,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}", "MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password", "MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json", "MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config" "MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/ombi WORKDIR /app/modules/ombi
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts 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}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist
# 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/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+10 -58
View File
@@ -28,15 +28,10 @@
"path": "/var/lib/mesh/ombi", "path": "/var/lib/mesh/ombi",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/ombi/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -44,7 +39,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "ombi", "name": "ombi",
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac", "image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -54,7 +49,7 @@
"3579" "3579"
], ],
"volumes": [ "volumes": [
"${dir:config}:/config" "/services/ombi/config:/config"
] ]
}, },
{ {
@@ -73,59 +68,24 @@
"volumes": [ "volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro", "/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro", "/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro" "/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}", "MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key", "MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json" "MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "servarr",
"type": "container",
"name": "mesh-ombi-servarr",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/servarr/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/servarr/lidarr-api.secret:ro"
],
"env": {
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_SERVARR_DIR": "/run/servarr"
},
"args": [
"run",
"/app/modules/ombi/dist/servarr/index.js"
],
"restart-on": [
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"lidarr-api", "route"
"radarr-api",
"route",
"sonarr-api"
], ],
"contributes": { "contributes": {
"route": { "route": {
@@ -134,15 +94,7 @@
} }
}, },
"binds": { "binds": {
"route": "${dir:state}/route.json", "route": "/var/lib/mesh/ombi/route.json"
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
-5
View File
@@ -4,11 +4,6 @@
"description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/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.0" "@novox/mesh-sdk": "^0.1.0"
}, },
-59
View File
@@ -1,59 +0,0 @@
// ombi's Servarr step — run once by the host after ombi's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**, for the reason route-adapter gives: everything it does is a function of
// files the mesh writes, and the host already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, an
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi's
// (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR. Never prints a key.
import { join } from "node:path";
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
if (!apiKey) {
console.error("[ombi-servarr] no ombi API key — ombi's own `api-key` secret has not been accepted");
process.exit(1);
}
const ombi = { url, apiKey };
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
console.error(`[ombi-servarr] ombi did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
ombi,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[ombi-servarr] ${outcome.app}: already as the mesh says; connection tested`);
break;
case "written":
console.log(`[ombi-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
break;
case "refused":
failed++;
console.error(`[ombi-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
-304
View File
@@ -1,304 +0,0 @@
// Where ombi reaches Sonarr, Radarr and Lidarr — decided by the mesh, written into ombi by ombi's
// own API.
//
// **Why this exists.** ombi keeps its connection to each Servarr app in its own database
// (OmbiSettings.db), not in a file, so the mesh has nowhere to write `${bound:sonarr-api:at}` for it.
// ombi requires `sonarr-api`, `radarr-api` and `lidarr-api`; the mesh delivers, for each, a binding
// (where the app is: `at`, and what it serves: `port`, `scheme`, `url-base`) and a pair credential
// (the app's API key, accepted by the operator — a Servarr app has exactly one key and the mesh
// cannot mint it). This step reads those files and makes ombi's settings say the same thing.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. The
// quality profile, root folder, language profile, tags, "enabled" and every other choice an operator
// made in ombi's settings screen are left exactly as they are: the mesh knows where the app is, not
// what ombi should do with it. Radarr's 4K instance is a different Radarr and is not touched.
//
// **A credential the app refuses is never written.** Until the operator accepts the app's API key
// for this pair, the mesh delivers a value it minted itself, which no Servarr app will ever accept
// (novox/hq ADR 0092). Writing it would replace a working key in ombi with a dead one. So the key is
// tried against the app first; refused, nothing for that app is written and the step fails naming
// the `secret accept` that fixes it.
//
// Pure logic and a small HTTP seam, so it is tested against fake servers (test/servarr.test.ts).
import { readFile } from "node:fs/promises";
/** One Servarr app ombi connects to, and the shape of that connection in ombi's API. */
export interface ServarrApp {
/** The app, as ombi's API names it: /Settings/<app>, /Tester/<app>. */
app: "sonarr" | "radarr" | "lidarr";
/** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's own status endpoint, which answers 401 to a wrong key. */
statusPath: string;
/**
* Where the one connection sits in ombi's settings document. Radarr's is `{radarr, radarr4K}`
* (two Radarr instances); only `radarr` is this provision's.
*/
within?: string;
}
export const APPS: readonly ServarrApp[] = [
{ app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status", within: "radarr" },
{ app: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
];
/** The connection fields ombi keeps for an app — the only ones this step ever writes. */
export interface Connection {
ip: string;
port: number;
ssl: boolean;
/** ombi's name for the app's URL base; null when the app is served at the root. */
subDir: string | null;
apiKey: string;
}
/** What the mesh wrote at `binds.<provision>`: the binding document (controller's boundFile). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
/**
* The connection the mesh says ombi should use, from the binding and the pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from ombi's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is ombi's
* container itself, not the app.
*/
export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
}
const at = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) {
return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
}
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${spec.provision} binding says ${spec.app} is at ${at}, which from ombi's own container is ` +
`ombi itself. The mesh hands loopback to a machine that is not on the private network; put it ` +
`on the private network so ${spec.app} has an address ombi can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which ombi cannot dial` };
}
const key = (credential ?? "").trim();
if (!key) {
return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
}
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", subDir: subDirOf(serves["url-base"]), apiKey: key },
};
}
/** ombi's `subDir`: the URL base with its slashes trimmed, null when there is none. */
export function subDirOf(urlBase: unknown): string | null {
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
return trimmed === "" ? null : trimmed;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** Which connection fields differ between what ombi holds and what the mesh says. Names only. */
export function differing(current: Record<string, unknown> | undefined, want: Connection): (keyof Connection)[] {
const now = current ?? {};
const out: (keyof Connection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
if (subDirOf(now.subDir) !== want.subDir) out.push("subDir");
if (String(now.apiKey ?? "") !== want.apiKey) out.push("apiKey");
return out;
}
/** ombi's settings for the app with the connection laid over them and nothing else changed. */
export function withConnection(current: Record<string, unknown> | undefined, want: Connection): Record<string, unknown> {
return { ...(current ?? {}), ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, apiKey: want.apiKey };
}
/** The app's base URL as the step dials it — the same host and port ombi will be given. */
export function appUrl(want: Connection): string {
const scheme = want.ssl ? "https" : "http";
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${scheme}://${host}:${want.port}${want.subDir ? `/${want.subDir}` : ""}`;
}
/** How one app came out. */
export type Outcome =
| { app: string; result: "unchanged" }
| { app: string; result: "written"; fields: string[] }
| { app: string; result: "refused"; problem: string };
/** The HTTP the step needs, so a test can stand fakes in for ombi and the apps. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export interface Ombi {
url: string;
apiKey: string;
}
async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, {
method,
headers: {
ApiKey: ombi.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
// The body is ombi's error, never a request echo, so it carries no key.
throw new Error(`ombi ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
/**
* Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error
* when it could not be asked — unreachable, or answering something that is neither.
*/
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": want.apiKey, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`);
}
/** The remedy for a refused key, in the controller's own words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` +
`into ombi. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
`key for this pair — \`secret accept <this node> ombi ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.app}'s ApiKey>\``
);
}
/**
* Bring ombi's connection to one app in line with the mesh: check the key against the app, compare,
* write only the connection fields when they differ, then have ombi test the connection from its own
* container. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileApp(
http: Http,
ombi: Ombi,
spec: ServarrApp,
binding: Binding | undefined,
credential: string | undefined,
): Promise<Outcome> {
const w = wanted(spec, binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem };
const want = w.connection;
try {
if (!(await appTakes(http, spec, want))) {
return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) };
}
} catch (err) {
return {
app: spec.app,
result: "refused",
problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`,
};
}
try {
const document = (await ombiCall(http, ombi, "GET", `/Settings/${spec.app}`)) as Record<string, unknown> | undefined;
const current = spec.within ? (document?.[spec.within] as Record<string, unknown> | undefined) : document;
const fields = differing(current, want);
if (fields.length > 0) {
const next = withConnection(current, want);
const body = spec.within ? { ...(document ?? {}), [spec.within]: next } : next;
const saved = await ombiCall(http, ombi, "POST", `/Settings/${spec.app}`, body);
if (saved === false) {
return { app: spec.app, result: "refused", problem: `ombi declined to save its ${spec.app} settings` };
}
}
// ombi's own test, from ombi's own container — the path the step's check above did not take.
const tested = (await ombiCall(http, ombi, "POST", `/Tester/${spec.app}`, withConnection(current, want))) as
| { isValid?: boolean; expectedSubDir?: string | null }
| undefined;
if (!tested?.isValid) {
const hint = tested?.expectedSubDir ? ` (ombi expected the base path ${tested.expectedSubDir})` : "";
return {
app: spec.app,
result: "refused",
problem:
`ombi cannot reach ${spec.app} at ${want.ip}:${want.port} from its own container${hint}` +
(fields.length > 0 ? `; its settings were written (${fields.join(", ")})` : ""),
};
}
return fields.length > 0 ? { app: spec.app, result: "written", fields } : { app: spec.app, result: "unchanged" };
} catch (err) {
return { app: spec.app, result: "refused", problem: message(err) };
}
}
/** Wait for ombi to answer, because the step runs right after its container starts. */
export async function ombiReady(http: Http, ombi: Ombi, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1/Status`, { method: "GET" });
if (res.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string | undefined): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
-147
View File
@@ -1,147 +0,0 @@
// What holds ombi's Servarr step (servarr/settings.ts): the connection ombi keeps for each app is
// made to say what the mesh bound — host, port, TLS, base path, key — and nothing else it keeps is
// touched; nothing is written when nothing differs; Radarr's 4K instance is left alone; and a key the
// app refuses (the mesh's own minted value, before the operator accepts the app's key) is never
// written, with the `secret accept` that fixes it named.
//
// ombi and the apps are fakes: the routes the step touches, answering as the real ones do (checked
// against lscr.io/linuxserver/ombi 4.53.10 and the catalogue's pinned sonarr/radarr/lidarr).
import { test } from "node:test";
import assert from "node:assert/strict";
import { APPS, differing, reconcileApp, subDirOf, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
const LIDARR = APPS.find((a) => a.app === "lidarr") as ServarrApp;
const THE_KEY = "the-apps-own-key";
function binding(provision: string, port: number, at = "ace.internal"): Binding {
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_ombi", serves: { scheme: "http", port, "url-base": "" } } as Binding;
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** ombi's settings store and the apps' key check, behind one fetch. */
function fakes(settings: Record<string, unknown>, opts: { appKey?: string; reachable?: boolean } = {}) {
const calls: Call[] = [];
const appKey = opts.appKey ?? THE_KEY;
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
if (u.pathname.endsWith("/system/status")) {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
}
if (init?.headers?.ApiKey !== "ombi-key") return reply(401);
const m = u.pathname.match(/^\/api\/v1\/(Settings|Tester)\/(\w+)$/);
if (!m) return reply(404);
const [, kind, app] = m;
if (kind === "Settings" && method === "GET") return reply(200, settings[app]);
if (kind === "Settings" && method === "POST") {
settings[app] = body;
return reply(200, true);
}
const tried = body as { apiKey?: string };
return reply(200, { isValid: tried.apiKey === appKey, expectedSubDir: null });
},
};
return { http, calls, settings };
}
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
const operatorSonarr = () => ({
enabled: true, apiKey: "old-key", qualityProfile: "3", seasonFolders: true, rootPath: "10",
qualityProfileAnime: "7", rootPathAnime: "9", languageProfile: 1, ssl: false, subDir: null,
ip: "sonarr", port: 8989, id: 5,
});
test("it writes the connection the mesh bound, and keeps every other setting ombi had", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), `${THE_KEY}\n`);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port", "apiKey"] });
assert.deepEqual(f.settings.sonarr, {
...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY, ssl: false, subDir: null,
});
// Checked against the app itself, at the bound address, before anything was written.
assert.equal(f.calls[0].url, "http://ace.internal:20101/api/v3/system/status");
});
test("nothing is written when ombi already says what the mesh says", async () => {
const f = fakes({ sonarr: { ...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY } });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
});
test("a key the app refuses is never written, and the accept that fixes it is named", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> ombi sonarr-api --provider ace/);
assert.doesNotMatch((out as { problem: string }).problem, /a-value-the-mesh-minted/);
assert.deepEqual(f.settings.sonarr, operatorSonarr(), "ombi's working settings were left alone");
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
});
test("an app it cannot reach is reported, and ombi is left alone", async () => {
const f = fakes({ sonarr: operatorSonarr() }, { reachable: false });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
assert.deepEqual(f.settings.sonarr, operatorSonarr());
});
test("radarr's connection is written inside its combined document, and the 4K instance is untouched", async () => {
const fourK = { enabled: true, apiKey: "4k-key", ip: "radarr4k", port: 7879, defaultQualityProfile: "9", id: 7 };
const f = fakes({ radarr: { radarr: { enabled: true, apiKey: "old", ip: "radarr", port: 7878, defaultRootPath: "/movies", id: 6 }, radarr4K: fourK } });
const out = await reconcileApp(f.http, OMBI, RADARR, binding("radarr-api", 20102), THE_KEY);
assert.equal(out.result, "written");
const doc = f.settings.radarr as { radarr: Record<string, unknown>; radarr4K: unknown };
assert.deepEqual(doc.radarr4K, fourK);
assert.equal(doc.radarr.ip, "ace.internal");
assert.equal(doc.radarr.port, 20102);
assert.equal(doc.radarr.defaultRootPath, "/movies");
});
test("lidarr is checked on its own API version", async () => {
const f = fakes({ lidarr: { enabled: true, apiKey: null, ip: null, port: 0, id: 0 } });
const out = await reconcileApp(f.http, OMBI, LIDARR, binding("lidarr-api", 20103), THE_KEY);
assert.equal(out.result, "written");
assert.equal(f.calls[0].url, "http://ace.internal:20103/api/v1/system/status");
});
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
const w = wanted(SONARR, binding("sonarr-api", 20101, "127.0.0.1"), THE_KEY);
assert.equal(w.ok, false);
assert.match((w as { problem: string }).problem, /private network/);
});
test("the base path is ombi's subDir, slashes trimmed; empty is none", () => {
assert.equal(subDirOf(""), null);
assert.equal(subDirOf("/sonarr/"), "sonarr");
assert.deepEqual(
differing({ ip: "h", port: 1, ssl: false, subDir: "", apiKey: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, apiKey: "k" }),
[],
);
});
test("an https binding sets ombi's ssl flag", () => {
const b = binding("sonarr-api", 443);
(b.serves as Record<string, unknown>).scheme = "https";
const w = wanted(SONARR, b, THE_KEY);
assert.equal(w.ok && w.connection.ssl, true);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"] "include": ["client.ts", "index.ts", "tools/index.ts"]
} }
+1 -1
View File
@@ -105,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 -1
View File
@@ -82,7 +82,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8080}", "MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password", "MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json", "MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config" "MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
+1 -14
View File
@@ -1,19 +1,6 @@
{ {
"module": "radarr", "module": "radarr",
"version": "1", "version": "1",
"provides": [
{
"name": "radarr-api",
"scope": "mesh"
}
],
"serves": {
"radarr-api": {
"scheme": "http",
"port": 7878,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -88,7 +75,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}", "MESH_RADARR_URL": "http://127.0.0.1:7878",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config" "MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
}, },
"artifact": "runtime" "artifact": "runtime"
+26
View File
@@ -0,0 +1,26 @@
# records' runtime: the tool runtime, carrying this module's compiled reader and its tools.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069).
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/records
COPY . .
RUN node /app/node_modules/typescript/bin/tsc records.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# **A module may need something the base image does not carry.** The reader keeps a checkout of the
# repository it reads (novox/hq ADR 0153) — a git working copy, kept current, not a derived copy — and
# the base image has no git. Certificates too, because the origin may be reached over TLS.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/records/dist /app/modules/records/dist
# Both entrypoints, loaded in serve mode: the consumer that pulls on a merge, and the tools.
ENV MESH_TOOL_MODULES=/app/modules/records/dist/index.js,/app/modules/records/dist/tools/index.js
+41
View File
@@ -0,0 +1,41 @@
# records
The record, read where it is written (novox/hq [ADR 0025](https://git.novox.be/novox/hq), ADR 0153).
A module that keeps a checkout of a repository of decisions, designs and issues — the mesh's own
`hq`, or any repository of markdown on the forge — and answers questions about it over the bus, so
whoever holds the console sees `records_search` beside every other tool and a symptom can be looked up
in the design record without knowing it is there.
**A checkout, not a copy.** The same bytes the repository holds, at a commit every answer names,
brought up to date on every merge the forge announces (`gitea.pull.merged`) and every ten minutes
besides. Nothing is indexed, transformed or summarised, so nothing can drift from the source except by
lagging behind it, and the lag is in `records_status`.
## Tools
| tool | answers |
|---|---|
| `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read |
| `records_read` `{path}` | one document, whole |
| `records_list` `{folder?}` | what a folder holds |
| `records_status` | repository, forge, commit and its date, last sync, document count, last error |
| `records_sync` | bring the checkout up to date now |
## Configuring it
The module names no mesh (ADR 0112). It requires the `git` provision — the forge — and reads the
repository its **settings** name:
```
settings set records repository.json # {"repository": "<owner>/<name>"}
```
Public repositories only: it asks for no credential. Until a repository is set, it serves no tools and
says so in its log.
## The check ADR 0025 names
Search the mesh, through the console, for a phrase that appears only in one design document here, and
get it back. `records_search {"query": "…"}` is that search; its test does the same against a
repository it makes.
+34
View File
@@ -0,0 +1,34 @@
// records' consumer: keep the checkout current (novox/hq ADR 0153).
//
// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for
// the merges it did not hear about — a restart during a merge, a repository the forge does not emit
// for. The timer is unhurried: the record changes when thinking changes, not by the minute.
import { on } from "@novox/mesh-sdk/events";
import { recordsFromEnv, type Records } from "./records.js";
let records: Records | null = null;
try {
records = await recordsFromEnv();
} catch (err) {
console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`);
}
if (records) {
const reader = records;
void reader.sync().then(async () => {
const s = await reader.standing();
console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`);
});
setInterval(() => void reader.sync(), 10 * 60 * 1000).unref();
// A merge on the forge into the repository this reads: pull now. The event names the repository
// by owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => {
const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`;
if (merged !== reader.repository) return;
console.log(`[records] ${merged} merged; syncing`);
await reader.sync();
});
}
export { records };
+100
View File
@@ -0,0 +1,100 @@
{
"module": "records",
"version": "1",
"slug": "records",
"capabilities": [
"container-runtime"
],
"requires": [
"git"
],
"binds": {
"git": "/var/lib/mesh/records/git.json"
},
"own-secrets": {
"broker": "/var/lib/mesh/records/broker"
},
"consumes": [
"gitea.pull.merged"
],
"tools": [
"records_search",
"records_read",
"records_list",
"records_status",
"records_sync"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/records",
"mode": "0700"
},
{
"id": "checkout",
"type": "directory",
"path": "/var/lib/records",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/mesh/records/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "origin",
"type": "file",
"path": "/var/lib/mesh/records/origin",
"mode": "0600",
"content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n"
},
{
"id": "runtime",
"type": "container",
"name": "records",
"network": "host",
"volumes": [
"/var/lib/mesh/records/broker:/run/secrets/broker:ro",
"/var/lib/mesh/records/config.json:/run/config/config.json:ro",
"/var/lib/mesh/records/origin:/run/config/origin:ro",
"/var/lib/records:/var/lib/records"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECORDS_CONFIG_FILE": "/run/config/config.json",
"MESH_RECORDS_ORIGIN_FILE": "/run/config/origin",
"MESH_RECORDS_DIR": "/var/lib/records"
},
"artifact": "runtime",
"restart-on": [
"config",
"origin"
]
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@novox/module-records",
"version": "0.1.0",
"description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+277
View File
@@ -0,0 +1,277 @@
/**
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
*
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
* except by lagging behind it, and the lag is a number in every answer.
*
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
* checkout stands. The reasoning is in the documents; this only finds them.
*/
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { join, normalize, relative, sep } from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
/** One place a phrase was found. */
export interface Hit {
/** The document, relative to the repository's root. */
path: string;
/** The line it was found on, from 1. */
line: number;
/** The nearest heading above it, so a hit reads as where in the document it is. */
heading: string;
/** The line itself, trimmed. */
text: string;
}
export interface Standing {
repository: string;
origin: string;
/** The commit the checkout is at, or empty before the first clone. */
commit: string;
/** When that commit was made, as the repository says. */
committed: string;
/** When this reader last brought the checkout up to date. */
fetched: string;
/** How many markdown documents the checkout holds. */
documents: number;
/** Why the last sync failed, if it did; the checkout stands where it was. */
lastError?: string;
}
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
export const MOST_HITS = 50;
/** A document longer than this is answered in part, and says so. */
export const MOST_BYTES = 200_000;
export class Records {
/** Where the checkout lives; the mesh gives the module the directory. */
readonly dir: string;
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
readonly origin: string;
/** The repository's path on it, `owner/name`, from this module's settings. */
readonly repository: string;
private syncing: Promise<void> | undefined;
private fetched = "";
private lastError: string | undefined;
constructor(dir: string, origin: string, repository: string) {
this.dir = dir;
this.origin = origin;
this.repository = repository;
}
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
* secret this module has not asked for. */
get url(): string {
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
}
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
* `standing()` and the checkout stands where it was, which is still an answer. */
sync(): Promise<void> {
if (!this.syncing) {
this.syncing = this.doSync().finally(() => {
this.syncing = undefined;
});
}
return this.syncing;
}
private async doSync(): Promise<void> {
try {
const cloned = await exists(join(this.dir, ".git"));
if (!cloned) {
await fs.mkdir(this.dir, { recursive: true });
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
} else {
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
// rather than merging into something a hand may have touched.
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
}
this.fetched = new Date().toISOString();
this.lastError = undefined;
} catch (e) {
this.lastError = e instanceof Error ? e.message : String(e);
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
}
}
async standing(): Promise<Standing> {
let commit = "";
let committed = "";
if (await exists(join(this.dir, ".git"))) {
try {
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
} catch {
// A checkout without a commit yet: said as empty rather than thrown.
}
}
const documents = commit ? (await this.documents()).length : 0;
return {
repository: this.repository,
origin: this.origin,
commit,
committed,
fetched: this.fetched,
documents,
...(this.lastError ? { lastError: this.lastError } : {}),
};
}
/** Every markdown document, relative to the root, in a stable order. */
async documents(): Promise<string[]> {
const out: string[] = [];
const walk = async (at: string): Promise<void> => {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name === ".git" || e.name === "node_modules") continue;
const full = join(at, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
}
};
await walk(this.dir);
return out.sort();
}
/**
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
* design record is found by its own words and a reader deciding which words matter would be a
* second opinion about somebody else's document. Bounded, and says when it was.
*
* **The record is wrapped prose, and a phrase does not know where the line ends.** Every document
* here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break
* as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing.
* So a line is matched together with the one after it, joined by a space, and emphasis marks
* are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on.
*/
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim();
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
const cap = Math.max(1, Math.min(limit, MOST_HITS));
const hits: Hit[] = [];
let more = false;
for (const path of await this.documents()) {
const text = await fs.readFile(join(this.dir, path), "utf8");
let heading = "";
const lines = text.split("\n");
const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
const here = flat[i]!;
const next = i + 1 < flat.length ? flat[i + 1]! : "";
// On this line, or across the break into the next — but not a phrase that begins on the
// next line alone, which is that line's hit.
const onThis = here.includes(needle);
const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle);
if (onThis || acrossTheBreak) {
if (hits.length >= cap) {
more = true;
break;
}
hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() });
}
}
if (more) break;
}
const commit = (await this.standing()).commit;
return { hits, more, commit };
}
/** One document, whole, or its first part with a note when it is very long. The path is kept
* inside the checkout: `..` and absolute paths are refused, not resolved. */
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
const clean = normalize(path).split(sep).join("/");
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
throw new Error(`"${path}" is not a path inside the repository`);
}
let content: string;
try {
content = await fs.readFile(join(this.dir, clean), "utf8");
} catch {
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
}
const truncated = content.length > MOST_BYTES;
return {
path: clean,
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
truncated,
commit: (await this.standing()).commit,
};
}
/** What a folder holds: its sub-folders and its documents, one level. */
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
if (clean.startsWith("..") || clean.startsWith("/")) {
throw new Error(`"${folder}" is not a folder inside the repository`);
}
const at = clean ? join(this.dir, clean) : this.dir;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
throw new Error(`the repository holds no folder ${clean || "/"}`);
}
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
return { folder: clean, folders, documents };
}
}
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
const dir = env.MESH_RECORDS_DIR;
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
const origin = (await fs.readFile(originFile, "utf8")).trim();
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
const configFile = env.MESH_RECORDS_CONFIG_FILE;
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
let config: { repository?: unknown } = {};
try {
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
} catch (e) {
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
}
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(
"this module reads the repository its settings name, and none is set: " +
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
);
}
return new Records(dir, origin, repository);
}
/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */
function plain(line: string): string {
return line.replace(/[*_`]/g, "");
}
async function exists(path: string): Promise<boolean> {
try {
await fs.stat(path);
return true;
} catch {
return false;
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* The reader against a real repository: a checkout, a phrase found where it is written, a document
* read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears
* only in one design document, and get it back.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Records } from "../records.ts";
function aRepository(): string {
const dir = mkdtempSync("/tmp/records-origin-");
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" });
git("init", "--quiet", "--initial-branch=main");
git("config", "user.email", "t@example.invalid");
git("config", "user.name", "t");
mkdirSync(join(dir, "02-DECISIONS"));
mkdirSync(join(dir, "03-DESIGN"));
writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n");
writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n");
writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\nA phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n");
git("add", "-A");
git("commit", "--quiet", "-m", "first");
return dir;
}
test("a phrase that appears in one design document comes back from where it is written", async () => {
const origin = aRepository();
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!);
// A file:// origin has no `.git` suffix; point the clone at the directory itself.
Object.defineProperty(records, "url", { get: () => origin });
await records.sync();
const found = await records.search("never look alike");
assert.equal(found.hits.length, 1);
assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md");
assert.equal(found.hits[0]!.heading, "The stores");
assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at");
// Wrapped prose: a phrase across the line break is found, once, at the line it starts on; and
// emphasis marks are not part of the words.
const wrapped = await records.search("wrapped at the column where");
assert.deepEqual(wrapped.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 7]]);
const emphasised = await records.search("reachable is not the same as surfacing");
assert.deepEqual(emphasised.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 8]]);
const alsoOnOneLine = await records.search("under emphasis");
assert.equal(alsoOnOneLine.hits.length, 1, "a phrase on one line is not also counted from the line before it");
const doc = await records.read("02-DECISIONS/0001-a-decision.md");
assert.match(doc.content, /We decided the thing/);
assert.equal(doc.truncated, false);
const root = await records.list();
assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]);
assert.deepEqual(root.documents, ["README.md"]);
const standing = await records.standing();
assert.equal(standing.documents, 3);
assert.equal(standing.lastError, undefined);
// A merge on the origin, pulled: the checkout follows the source and names the new commit.
writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n");
execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" });
execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" });
await records.sync();
const after = await records.search("nobody wrote before");
assert.equal(after.hits.length, 1);
assert.notEqual(after.commit, found.commit);
});
test("a path outside the repository is refused, and an empty search is too", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq");
await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/);
await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/);
await assert.rejects(() => records.search(" "), /empty one/);
assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git");
});
test("a sync that fails leaves the checkout standing and says why", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq");
await records.sync();
const standing = await records.standing();
assert.equal(standing.commit, "");
assert.ok(standing.lastError, "a failed sync is said, not swallowed");
});
+62
View File
@@ -0,0 +1,62 @@
// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153).
//
// Five questions, each answered from the checkout at the commit it names: where does a phrase
// appear, what does one document say, what does a folder hold, where does the checkout stand, and
// bring it up to date now. The reasoning stays in the documents; the tools only find them.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { recordsFromEnv, type Records } from "../records.js";
export function getRecordsTools(records: Records): ToolDefinition[] {
return [
{
name: "records_search",
description:
"Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
input: {
query: { type: "string", description: "the phrase, matched case-insensitively as written" },
limit: { type: "number", description: "at most this many places (default 50)" },
},
run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined),
},
{
name: "records_read",
description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } },
run: async (args) => records.read(String(args.path ?? "")),
},
{
name: "records_list",
description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
input: { folder: { type: "string", description: "a folder inside the repository (optional)" } },
run: async (args) => records.list(args.folder ? String(args.folder) : ""),
},
{
name: "records_status",
description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
input: {},
run: async () => records.standing(),
},
{
name: "records_sync",
description: "Bring the checkout up to date now, and say where it stands.",
input: {},
run: async () => {
await records.sync();
return records.standing();
},
},
];
}
// The reader is made once, at load, from the environment the runtime resolves; the contributor is
// synchronous and is called at every collection. Without a repository to read there is nothing to
// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a
// contributor returning [] is normal.
let reader: Records | null = null;
try {
reader = await recordsFromEnv();
} catch (err) {
console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`);
}
registerModuleTools("records", () => (reader ? getRecordsTools(reader) : []));
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["records.ts", "index.ts", "tools/index.ts"]
}
+14 -16
View File
@@ -27,13 +27,13 @@
} }
}, },
"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"
@@ -57,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",
@@ -90,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"
@@ -113,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",
+1 -1
View File
@@ -100,7 +100,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", "MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json" "MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/sonarr WORKDIR /app/modules/sonarr
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts 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}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/sonarr/dist /app/modules/sonarr/dist
# 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/sonarr/dist/index.js,/app/modules/sonarr/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/sonarr/dist/index.js,/app/modules/sonarr/dist/tools/index.js
# NOT dist/downloads/index.js: that is a step the host runs to completion, named by the `downloads`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
-188
View File
@@ -1,188 +0,0 @@
// The downloads step — run once by the host after the app's server starts, and again whenever a
// binding, a pair credential or the step's settings change (the container's `restart-on`,
// novox/hq ADR 0099). Byte-identical in sonarr, radarr, lidarr and bookshelf; see settings.ts.
//
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
// already knows when they change. It connects to no broker.
//
// Exits non-zero when anything could not be put right — a refused credential, an unreachable
// provider, an entry the app would not save — so the node reports the step failed and the host
// runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of the module's (novox/hq ADR 0136).
//
// Reads, in MESH_DOWNLOADS_DIR, `<provision>.json` (the binding), `<provision>.secret` (the pair
// credential) and the settings file; remembers in MESH_DOWNLOADS_MEMORY the jackett hosts it has
// pointed feeds at, so a jackett that moves takes its feeds with it. Never prints a credential.
import { mkdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
CLIENTS,
JACKETT,
acceptRemedy,
appConfig,
appReady,
listEntries,
providerTakes,
readIfThere,
readJson,
reconcileClient,
reconcileIndexers,
scrub,
stepSettings,
wanted,
type App,
type Binding,
type Http,
type Entry,
type Outcome,
type Took,
} from "./settings.js";
const module = process.env.MESH_DOWNLOADS_APP ?? "app";
const dir = process.env.MESH_DOWNLOADS_DIR ?? "/run/downloads";
const settingsFile = process.env.MESH_DOWNLOADS_SETTINGS ?? join(dir, "downloads.json");
const memoryFile = process.env.MESH_DOWNLOADS_MEMORY ?? "/var/lib/downloads/memory.json";
const waitSeconds = Number(process.env.MESH_DOWNLOADS_WAIT_SECONDS ?? "180");
const tag = `[${module}-downloads]`;
const http: Http = { fetch: (u, init) => fetch(u, init) };
const secrets: string[] = [];
const say = (line: string) => console.log(scrub(`${tag} ${line}`, secrets));
const fail = (line: string) => console.error(scrub(`${tag} ${line}`, secrets));
const config = appConfig((await readIfThere(process.env.MESH_DOWNLOADS_APP_CONFIG)) ?? "");
if (!config.apiKey) {
fail(`no API key in ${module}'s config.xml yet — ${module} writes it on its first start; the step runs again on the next apply`);
process.exit(1);
}
secrets.push(config.apiKey);
const app: App = {
module,
url: `${(process.env.MESH_DOWNLOADS_APP_URL ?? "http://127.0.0.1").replace(/\/$/, "")}${config.urlBase}`,
apiKey: config.apiKey,
api: process.env.MESH_DOWNLOADS_API ?? "v3",
};
const settings = stepSettings(await readJson(settingsFile));
if (!(await appReady(http, app, waitSeconds * 1000))) {
fail(`${module} did not answer at ${app.url} within ${waitSeconds}s`);
process.exit(1);
}
const outcomes: Outcome[] = [];
// The download clients.
let clients: Entry[];
try {
clients = await listEntries(http, app, "downloadclient");
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
process.exit(1);
}
for (const kind of CLIENTS) {
const credential = await readIfThere(join(dir, `${kind.provision}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(kind.provision, (await readJson(join(dir, `${kind.provision}.json`))) as Binding | undefined, credential, true);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) {
outcomes.push({ what: kind.provision, result: "refused", problem: w.problem });
continue;
}
const ep = w.endpoint;
try {
if (!(await providerTakes(http, kind.provision, ep)).took) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: acceptRemedy(app, settings.node, kind.provision, kind.provider, kind.secretName, ep.from),
});
continue;
}
} catch (err) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: `${kind.provider} could not be asked whether it takes the credential at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
continue;
}
outcomes.push(...(await reconcileClient(http, app, kind, settings.names[kind.provision], ep, clients)));
}
// The indexers, through jackett.
{
const credential = await readIfThere(join(dir, `${JACKETT}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(JACKETT, (await readJson(join(dir, `${JACKETT}.json`))) as Binding | undefined, credential, false);
if ("problem" in w) {
outcomes.push({ what: JACKETT, result: "refused", problem: w.problem });
} else {
const ep = w.endpoint;
let took: Took | undefined;
try {
took = await providerTakes(http, JACKETT, ep);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "refused",
problem: `jackett could not be asked whether it takes the key at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
}
if (took && !took.took) {
outcomes.push({ what: JACKETT, result: "refused", problem: acceptRemedy(app, settings.node, JACKETT, "jackett", "API key", ep.from) });
} else if (took) {
const memory = ((await readJson(memoryFile)) ?? {}) as Record<string, { hosts?: string[] } | undefined>;
const remembered = Array.isArray(memory[JACKETT]?.hosts) ? (memory[JACKETT]?.hosts as string[]) : [];
try {
const indexers = await listEntries(http, app, "indexer");
outcomes.push(
...(await reconcileIndexers(http, app, ep, took.configured ?? new Map(), settings.indexers, remembered, indexers)),
);
} catch (err) {
outcomes.push({ what: JACKETT, result: "refused", problem: err instanceof Error ? err.message : String(err) });
}
// Remember where the feeds now point, so they are still recognised as the mesh's if jackett
// moves. Written whole and renamed, so a crash leaves the old memory, never half of one.
const hosts = [...new Set([...remembered, ep.host.toLowerCase()])].sort();
if (hosts.join() !== [...remembered].sort().join()) {
try {
await mkdir(dirname(memoryFile), { recursive: true });
await writeFile(`${memoryFile}.tmp`, JSON.stringify({ ...memory, [JACKETT]: { hosts } }, null, 2) + "\n", { mode: 0o600 });
await rename(`${memoryFile}.tmp`, memoryFile);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "notice",
note: `could not remember ${ep.host} as a jackett host (${err instanceof Error ? err.message : String(err)}); if jackett moves, list it in downloads.jackett-api.adopt-hosts`,
});
}
}
}
}
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
say(`${o.what}: already as the mesh says${o.untested ? "" : "; connection tested"}`);
break;
case "written":
say(`${o.what}: wrote ${o.fields.join(", ")}${o.untested ? "" : "; connection tested"}`);
break;
case "created":
say(`${o.what}: registered; connection tested`);
break;
case "notice":
say(`${o.what}: ${o.note}`);
break;
case "refused":
failed++;
fail(`${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
-778
View File
@@ -1,778 +0,0 @@
// Where a Servarr app reaches its download clients and its indexer proxy — decided by the mesh,
// written into the app by the app's own API.
//
// **One file, four copies.** sonarr, radarr, lidarr and bookshelf (a Readarr fork) keep their
// download clients and indexers behind the same API — `/api/v3/…` for the first two, `/api/v1/…`
// for the others — so this step is the same code in each. Each module builds from its own
// directory (novox/hq ADR 0069), so each carries a byte-identical copy under `downloads/`;
// `test/downloads.test.ts` checks the copies agree wherever the siblings are checked out beside
// it. Change all four together.
//
// **Why this exists.** The app keeps its connection to nzbget, qBittorrent and jackett in its own
// database, not in a file, so the mesh has nowhere to write `${bound:nzbget-api:at}` for it. The
// module requires `nzbget-api`, `qbittorrent-api` and `jackett-api`; the mesh delivers, for each,
// a binding (where the provider is — `at` — and what it serves: `port`, `scheme`, `url-base`, and
// for a download client the `username`) and a pair credential (nzbget's ControlPassword,
// qBittorrent's WebUI password, jackett's API key — each the provider's one and only, accepted by
// the operator per pair; the mesh cannot mint them). This step reads those files and makes the
// app's entries say the same thing.
//
// **Which entries are the mesh's.** Never a guess from what an entry looks like:
// - a download client is the mesh's when its name is the one the module's settings give for
// that provision (`downloads.<provision>.name`) and its kind is that provider's. On a fresh
// machine the step registers it under that name; on a migrated one the assignment names the
// entry the migration adopts ("NZBGet" on ace).
// - a Torznab indexer is the mesh's when it reads a jackett feed
// (`…/api/v2.0/indexers/<id>/results/torznab`) on a host the mesh put there — the bound `at`,
// one it bound before (remembered by the step), or one the settings adopt
// (`downloads.jackett-api.adopt-hosts`, which is how a migration names the entries that pointed
// at the old container). `downloads.jackett-api.indexers` lists the jackett indexers the app
// should have; one missing is registered.
// Everything else — every entry a person made, an nzbget elsewhere, a seedbox's jackett, a Newznab
// indexer — is left exactly as it is. **Nothing is ever deleted.**
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path, user name and
// the credential. Categories, priorities, "enabled", seed criteria and every other choice made in
// the app are left alone. The stored credential cannot be read back (the app masks it), so the
// step asks the app to test the entry with the mesh's host and port and the credential it already
// holds: passing, the credential is already the provider's one and only; failing, the delivered
// credential is written.
//
// **A credential the provider refuses is never written.** Until the operator accepts the
// provider's secret for this pair, the mesh delivers a value it minted itself, which no provider
// will ever accept (novox/hq ADR 0092). Writing it would replace a working password with a dead
// one. So it is tried against the provider first; refused, nothing of that provision is written
// and the step fails naming the `secret accept` that fixes it.
//
// Pure logic and a small HTTP seam, so it is tested against fake servers (test/downloads.test.ts).
// Never prints a credential, an API key, or a URL carrying one.
import { readFile } from "node:fs/promises";
/** A download-client provision and the shape of its entry in the app. */
export interface ClientKind {
provision: "nzbget-api" | "qbittorrent-api";
/** The app's `implementation` for it. */
implementation: "Nzbget" | "QBittorrent";
/** The provider, as a person calls it. */
provider: string;
/** What the pair credential is, in the provider's words. */
secretName: string;
}
export const CLIENTS: readonly ClientKind[] = [
{ provision: "nzbget-api", implementation: "Nzbget", provider: "nzbget", secretName: "ControlPassword" },
{ provision: "qbittorrent-api", implementation: "QBittorrent", provider: "qBittorrent", secretName: "WebUI password" },
];
export const JACKETT = "jackett-api";
/** What the mesh wrote at `binds.<provision>`: the binding document. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** Where a provider is, as the mesh bound it, and the credential for it. */
export interface Endpoint {
scheme: "http" | "https";
host: string;
port: number;
/** "" at the root, otherwise "/base" — one leading slash, none trailing. */
urlBase: string;
/** The user a download client logs in as; "" for jackett, which takes a key. */
username: string;
credential: string;
/** The provider's node, for the `secret accept` remedy. */
from: string;
}
export type Wanted = { ok: true; endpoint: Endpoint } | { ok: false; problem: string };
/**
* The endpoint the mesh says to use, from a binding and its pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from the app's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is the app's
* container itself.
*/
export function wanted(
provision: string,
binding: Binding | undefined,
credential: string | undefined,
needsUser: boolean,
): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${provision} was delivered — the mesh writes it before this step runs` };
}
const host = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!host) return { ok: false, problem: `the ${provision} binding names no host (at)` };
if (isLoopback(host)) {
return {
ok: false,
problem:
`the ${provision} binding says the provider is at ${host}, which from the app's own container is the ` +
`app itself. The mesh hands loopback to a machine that is not on the private network; put it on the ` +
`private network so the provider has an address the app can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${provision} binding serves scheme ${scheme}, which the app cannot dial` };
}
const username = typeof serves.username === "string" ? serves.username.trim() : "";
if (needsUser && !username) {
return { ok: false, problem: `the ${provision} binding serves no username for the app to log in as` };
}
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${provision} credential is empty or was not delivered` };
return {
ok: true,
endpoint: {
scheme,
host,
port,
urlBase: normBase(serves["url-base"]),
username,
credential: key,
from: typeof binding.from === "string" ? binding.from : "",
},
};
}
/** A URL base as "" or "/x/y": slashes trimmed, one put back in front. */
export function normBase(v: unknown): string {
const s = typeof v === "string" ? v.trim().replace(/^\/+|\/+$/g, "") : "";
return s === "" ? "" : `/${s}`;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
function hostForUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/** The provider's base URL, as the step dials it and as the app is given it. */
export function baseUrl(ep: Endpoint): string {
return `${ep.scheme}://${hostForUrl(ep.host)}:${ep.port}${ep.urlBase}`;
}
// ---------------------------------------------------------------------------------------------
// HTTP
/** The HTTP the step needs, so a test can stand fakes in for the app and the providers. */
export interface Http {
fetch(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; text(): Promise<string> }>;
}
/** The app, as the step reaches it from the host. */
export interface App {
/** The module, for messages and the remedy: sonarr, radarr, lidarr, bookshelf. */
module: string;
/** Its own URL, base path included. */
url: string;
apiKey: string;
/** v3 (sonarr, radarr) or v1 (lidarr, bookshelf). */
api: string;
}
interface Answer {
status: number;
body: unknown;
}
async function appCall(http: Http, app: App, method: string, path: string, body?: unknown): Promise<Answer> {
const res = await http.fetch(`${app.url.replace(/\/$/, "")}/api/${app.api}${path}`, {
method,
headers: {
"X-Api-Key": app.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: unknown = undefined;
if (text) {
try {
parsed = JSON.parse(text) as unknown;
} catch {
parsed = text;
}
}
return { status: res.status, body: parsed };
}
async function appGet(http: Http, app: App, path: string): Promise<unknown> {
const a = await appCall(http, app, "GET", path);
if (a.status < 200 || a.status >= 300) throw new Error(`${app.module} GET ${path} answered ${a.status}`);
return a.body;
}
/** One of the app's validation failures: the only parts of an answer the step ever prints. */
interface Failure {
property: string;
message: string;
warning: boolean;
}
function failuresOf(body: unknown): Failure[] {
const list = Array.isArray(body) ? body : [];
return list.map((f) => {
const o = (f ?? {}) as Record<string, unknown>;
return {
property: String(o.propertyName ?? ""),
message: String(o.errorMessage ?? ""),
warning: o.isWarning === true,
};
});
}
/**
* One shape rather than a union on `ok`: the Dockerfile compiles without strict, where a boolean
* discriminant does not narrow.
*/
interface Verdict {
ok: boolean;
/** Warnings the app raised on a pass; errors (and any warnings) on a failure. */
failures: Failure[];
status: number;
saved?: Entry;
}
function verdict(a: Answer): Verdict {
if (a.status >= 200 && a.status < 300) return { ok: true, failures: [], status: a.status };
const failures = failuresOf(a.body);
if (a.status === 400 && failures.length > 0 && failures.every((f) => f.warning)) return { ok: true, failures, status: a.status };
return { ok: false, failures, status: a.status };
}
/** The app's own test of an entry, from the app's own container. */
async function appTest(http: Http, app: App, resource: string, entry: Entry): Promise<Verdict> {
return verdict(await appCall(http, app, "POST", `/${resource}/test`, entry));
}
/**
* Save an entry. The app tests an enabled entry before saving it; a test that passes with only
* warnings ("a category is recommended") is saved with `forceSave`, the same as pressing "save
* anyway" in its screen. An error is never forced.
*/
async function appSave(http: Http, app: App, resource: string, entry: Entry, create: boolean): Promise<Verdict> {
const path = create ? `/${resource}` : `/${resource}/${entry.id}`;
const method = create ? "POST" : "PUT";
let a = await appCall(http, app, method, path, entry);
let v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
a = await appCall(http, app, method, `${path}?forceSave=true`, entry);
v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
return { ok: false, failures: failuresOf(a.body), status: a.status };
}
}
if (!v.ok) return v;
return { ...v, saved: (a.body ?? undefined) as Entry | undefined };
}
// ---------------------------------------------------------------------------------------------
// Entries
/** An entry as the app's API gives it: a download client or an indexer. */
export interface Entry {
id?: number;
name?: string;
implementation?: string;
enable?: boolean;
enableRss?: boolean;
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
supportsRss?: boolean;
supportsSearch?: boolean;
fields?: { name: string; value?: unknown; [k: string]: unknown }[];
[k: string]: unknown;
}
export function field(e: Entry, name: string): unknown {
return e.fields?.find((f) => f.name === name)?.value;
}
/** The entry with these fields' values replaced — added when the app's shape lacks one. */
export function withFields(e: Entry, values: Record<string, unknown>): Entry {
const fields = (e.fields ?? []).map((f) => (f.name in values ? { ...f, value: values[f.name] } : { ...f }));
for (const [name, value] of Object.entries(values)) {
if (!fields.some((f) => f.name === name)) fields.push({ name, value });
}
return { ...e, fields };
}
function enabled(e: Entry): boolean {
return e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true || e.enableInteractiveSearch === true;
}
/** What one provision, or one entry of it, came to. */
export type Outcome =
| { what: string; result: "unchanged"; untested?: boolean }
| { what: string; result: "written"; fields: string[]; untested?: boolean }
| { what: string; result: "created"; fields: string[] }
| { what: string; result: "notice"; note: string }
| { what: string; result: "refused"; problem: string };
function said(fs: Failure[]): string {
return fs.length === 0 ? "no reason given" : fs.map((f) => (f.property ? `${f.property}: ${f.message}` : f.message)).join("; ");
}
/**
* Bring one of the mesh's entries in line: `connection` is the non-secret fields as the mesh says
* them, `differs` which of them the entry does not already say, `secretField` where the credential
* goes. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
current: Entry,
connection: Record<string, unknown>,
differs: string[],
secretField: string,
credential: string,
testable = true,
): Promise<Outcome> {
try {
const base = withFields(current, connection); // the credential as the app holds it — masked
if (!testable) {
// Nothing to test against (jackett no longer has the indexer it names): the connection is
// still the mesh's to state, and the key goes with it when the address changes.
if (differs.length === 0) return { what, result: "unchanged", untested: true };
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: [...differs, secretField], untested: true };
}
const first = await appTest(http, app, resource, base);
if (first.ok) {
// The credential the app holds already works against the provider at the mesh's address —
// and the provider has exactly one, so it is the delivered one.
if (differs.length === 0) return { what, result: "unchanged" };
const s = await appSave(http, app, resource, base, false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: differs };
}
// The app's test failed with what it holds. The delivered credential was already checked
// against the provider, so writing it is safe; the app tests an enabled entry again on save.
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) {
return {
what,
result: "refused",
problem: `${app.module} tested it at the mesh's address and would not save it (nothing was written): ${said(s.failures)}`,
};
}
const stored = (await appGet(http, app, `/${resource}/${current.id}`)) as Entry;
const second = await appTest(http, app, resource, stored);
const fields = [...differs, secretField];
if (second.ok) return { what, result: "written", fields };
const why = `written (${fields.join(", ")}), and ${app.module}'s own test still fails: ${said(second.failures)}`;
return enabled(stored) ? { what, result: "refused", problem: why } : { what, result: "notice", note: `${why} — it is disabled, so nothing uses it` };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
/**
* Register a new entry from the app's own template for that kind: its defaults, the name, the
* connection and the credential. A category the provider does not have (nzbget refuses one it was
* not told about) is left empty rather than failing the registration, and said.
*/
export async function createEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
implementation: string,
name: string,
values: Record<string, unknown>,
): Promise<Outcome> {
try {
const schema = (await appGet(http, app, `/${resource}/schema`)) as Entry[];
const template = (schema ?? []).find((s) => s.implementation === implementation);
if (!template) return { what, result: "refused", problem: `${app.module} has no ${implementation} to register` };
let entry: Entry = withFields({ ...template, id: undefined, name }, values);
if (resource === "downloadclient") entry.enable = true;
else {
entry.enableRss = template.supportsRss !== false;
entry.enableAutomaticSearch = template.supportsSearch !== false;
entry.enableInteractiveSearch = template.supportsSearch !== false;
}
let s = await appSave(http, app, resource, entry, true);
let note = "";
if (!s.ok && s.failures.length > 0 && s.failures.every((f) => /category/i.test(f.property))) {
const emptied: Record<string, unknown> = {};
for (const f of entry.fields ?? []) if (/category$/i.test(f.name) && !/imported/i.test(f.name)) emptied[f.name] = "";
entry = withFields(entry, emptied);
s = await appSave(http, app, resource, entry, true);
note = " with its category left empty — the provider has none by the app's default name; set one in both";
}
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not register it: ${said(s.failures)}` };
const created = s.saved;
if (created?.id !== undefined) {
const t = await appTest(http, app, resource, (await appGet(http, app, `/${resource}/${created.id}`)) as Entry);
if (!t.ok) {
return { what, result: "refused", problem: `registered as "${name}"${note}, and ${app.module}'s own test fails: ${said(t.failures)}` };
}
}
return note
? { what, result: "notice", note: `registered as "${name}"${note}` }
: { what, result: "created", fields: Object.keys(values) };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
// ---------------------------------------------------------------------------------------------
// Providers: does it take the credential?
/**
* Does the provider take this credential? `took` it does (for jackett, with the ids of the indexers
* it has configured); `refused` it said no; a thrown error when it could not be asked.
*/
export interface Took {
took: boolean;
configured?: Map<string, string>;
}
export async function providerTakes(http: Http, provision: string, ep: Endpoint): Promise<Took> {
const base = baseUrl(ep);
if (provision === "nzbget-api") {
const auth = Buffer.from(`${ep.username}:${ep.credential}`).toString("base64");
const res = await http.fetch(`${base}/jsonrpc/version`, { method: "GET", headers: { Authorization: `Basic ${auth}` } });
await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`nzbget answered ${res.status}`);
}
if (provision === "qbittorrent-api") {
const form = `username=${encodeURIComponent(ep.username)}&password=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(`${base}/api/v2/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const text = (await res.text()).trim();
if (res.status === 401 || /^fails\.?$/i.test(text)) return { took: false };
if (res.status === 403) {
throw new Error(
"qBittorrent answered 403: it has banned this address after failed logins (WebUI\\BanDuration); it lifts by itself",
);
}
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`qBittorrent answered ${res.status}`);
}
if (provision === JACKETT) {
const url = `${base}/api/v2.0/indexers/all/results/torznab/api?t=indexers&configured=true&apikey=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(url, { method: "GET" });
const text = await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status < 200 || res.status >= 300) throw new Error(`jackett answered ${res.status}`);
// jackett answers a wrong key 200 with an error document: code 100, "Invalid API Key".
if (/<error\b[^>]*\bcode="100"/i.test(text)) return { took: false };
if (/<error\b/i.test(text)) throw new Error("jackett answered with an error document");
return { took: true, configured: configuredIndexers(text) };
}
throw new Error(`no check for ${provision}`);
}
/** jackett's configured indexers, id → title, from its `t=indexers` feed. */
export function configuredIndexers(xml: string): Map<string, string> {
const out = new Map<string, string>();
const re = /<indexer\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/indexer>/g;
for (let m = re.exec(xml); m; m = re.exec(xml)) {
const title = /<title>([\s\S]*?)<\/title>/.exec(m[2])?.[1]?.trim() ?? m[1];
out.set(m[1], decodeXml(title));
}
return out;
}
function decodeXml(s: string): string {
return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
}
/** The remedy for a refused credential, in the controller's own words (ADR 0092). */
export function acceptRemedy(app: App, node: string, provision: string, provider: string, secretName: string, from: string): string {
return (
`${provider} refuses the ${provision} credential the mesh delivered, so nothing of ${provision} was written ` +
`into ${app.module}. ${provider} has one ${secretName} and the mesh cannot make it: accept it for this pair — ` +
`\`secret accept ${node || "<this node>"} ${app.module} ${provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${provider}'s ${secretName}>\``
);
}
// ---------------------------------------------------------------------------------------------
// Download clients
/** The non-secret fields of a download client as the mesh says them. */
export function clientConnection(ep: Endpoint): Record<string, unknown> {
return { host: ep.host, port: ep.port, useSsl: ep.scheme === "https", urlBase: ep.urlBase === "" ? null : ep.urlBase, username: ep.username };
}
/** Which of them the entry does not already say. Names only. */
export function clientDiffers(e: Entry, ep: Endpoint): string[] {
const out: string[] = [];
if (String(field(e, "host") ?? "").toLowerCase() !== ep.host.toLowerCase()) out.push("host");
if (Number(field(e, "port") ?? 0) !== ep.port) out.push("port");
if (Boolean(field(e, "useSsl")) !== (ep.scheme === "https")) out.push("useSsl");
if (normBase(field(e, "urlBase")) !== ep.urlBase) out.push("urlBase");
if (String(field(e, "username") ?? "") !== ep.username) out.push("username");
return out;
}
/**
* Bring the app's download client for one provision in line: the entry by that name and kind is
* the mesh's and is updated; none, it is registered. An entry by that name of another kind, or by
* that name in another case, is somebody else's and is refused rather than touched.
*/
export async function reconcileClient(
http: Http,
app: App,
kind: ClientKind,
name: string,
ep: Endpoint,
entries: Entry[],
): Promise<Outcome[]> {
const what = `${kind.provision} ("${name}")`;
const exact = entries.filter((e) => e.name === name);
const alike = entries.filter((e) => e.name !== name && String(e.name ?? "").toLowerCase() === name.toLowerCase());
if (exact.length > 0 && exact[0].implementation !== kind.implementation) {
return [{
what,
result: "refused",
problem: `the download client named "${name}" is a ${exact[0].implementation}, not ${kind.implementation}; it is not the mesh's and was left alone. Name the mesh's entry otherwise in downloads.${kind.provision}.name`,
}];
}
if (exact.length === 0 && alike.length > 0) {
return [{
what,
result: "refused",
problem: `${app.module} already has "${alike[0].name}", which is not the mesh's name ("${name}") and was left alone. To have the mesh manage it, set downloads.${kind.provision}.name to "${alike[0].name}"`,
}];
}
const outcomes: Outcome[] = [];
const others = entries.filter((e) => e.implementation === kind.implementation && e.name !== name);
if (others.length > 0) {
outcomes.push({
what,
result: "notice",
note: `also present and not the mesh's, left alone: ${others.map((o) => `"${o.name}"`).join(", ")}`,
});
}
if (exact.length === 0) {
outcomes.push(
await createEntry(http, app, "downloadclient", what, kind.implementation, name, {
...clientConnection(ep),
password: ep.credential,
}),
);
return outcomes;
}
outcomes.push(
await reconcileEntry(http, app, "downloadclient", what, exact[0], clientConnection(ep), clientDiffers(exact[0], ep), "password", ep.credential),
);
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Indexers
const FEED = /\/api\/v2\.0\/indexers\/([^/]+)\/results\/torznab\/?$/;
/** A Torznab entry reading a jackett feed: the host it points at and the jackett indexer's id. */
export function jackettFeed(e: Entry): { host: string; id: string } | undefined {
if (e.implementation !== "Torznab") return undefined;
const raw = String(field(e, "baseUrl") ?? "").trim();
if (!raw) return undefined;
let u: URL;
try {
u = new URL(raw);
} catch {
return undefined;
}
const path = `${u.pathname.replace(/\/+$/, "")}/${String(field(e, "apiPath") ?? "").replace(/^\/+/, "")}`;
const m = FEED.exec(path);
if (!m) return undefined;
return { host: u.hostname.replace(/^\[|\]$/g, "").toLowerCase(), id: decodeURIComponent(m[1]) };
}
/** The feed's two fields as the mesh says them. */
export function feedConnection(ep: Endpoint, id: string): Record<string, unknown> {
return { baseUrl: baseUrl(ep), apiPath: `/api/v2.0/indexers/${id}/results/torznab/` };
}
function sameUrl(a: string, b: string): boolean {
try {
const x = new URL(a);
const y = new URL(b);
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
return (
x.protocol === y.protocol &&
x.hostname.toLowerCase() === y.hostname.toLowerCase() &&
port(x) === port(y) &&
x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "")
);
} catch {
return false;
}
}
export function feedDiffers(e: Entry, ep: Endpoint, id: string): string[] {
const want = feedConnection(ep, id);
const out: string[] = [];
if (!sameUrl(String(field(e, "baseUrl") ?? ""), String(want.baseUrl))) out.push("baseUrl");
const path = (v: unknown) => `/${String(v ?? "").replace(/^\/+|\/+$/g, "")}`;
if (path(field(e, "apiPath")) !== path(want.apiPath)) out.push("apiPath");
return out;
}
export interface IndexerSettings {
/** Hosts whose jackett feeds the mesh takes over — a migration's old names. */
adoptHosts: string[];
/** jackett indexer ids the app should have. */
indexers: string[];
}
/**
* Bring the app's jackett feeds in line: every one the mesh manages is pointed at the bound jackett
* with its key, and every listed jackett indexer the app lacks is registered.
*/
export async function reconcileIndexers(
http: Http,
app: App,
ep: Endpoint,
configured: Map<string, string>,
settings: IndexerSettings,
remembered: string[],
entries: Entry[],
): Promise<Outcome[]> {
const ours = new Set([ep.host, ...settings.adoptHosts, ...remembered].map((h) => h.trim().toLowerCase()).filter(Boolean));
const outcomes: Outcome[] = [];
const managed = new Set<string>();
for (const e of entries) {
const feed = jackettFeed(e);
if (!feed || !ours.has(feed.host)) continue;
managed.add(feed.id);
const what = `jackett-api ("${e.name}", jackett indexer ${feed.id})`;
const known = feed.id === "all" || configured.has(feed.id);
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
outcomes.push(outcome);
if (!known && outcome.result !== "refused") {
outcomes.push({ what, result: "notice", note: `jackett has no indexer "${feed.id}" configured, so it was not tested; configure it in jackett or remove the entry in ${app.module}` });
}
}
for (const id of settings.indexers) {
if (managed.has(id)) continue;
const what = `jackett-api (jackett indexer ${id})`;
if (id !== "all" && !configured.has(id)) {
outcomes.push({ what, result: "refused", problem: `downloads.jackett-api.indexers lists "${id}", and jackett has no indexer by that id configured` });
continue;
}
const name = `Jackett - ${id === "all" ? "all" : configured.get(id)}`;
outcomes.push(await createEntry(http, app, "indexer", what, "Torznab", name, { ...feedConnection(ep, id), apiKey: ep.credential }));
}
if (outcomes.length === 0) {
outcomes.push({ what: "jackett-api", result: "notice", note: `${app.module} has no jackett feed the mesh manages, and downloads.jackett-api.indexers lists none to register` });
}
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Files
/** The module's settings for this step, from its merged `downloads.json`. */
export interface StepSettings {
node: string;
names: Record<string, string>;
indexers: IndexerSettings;
}
export function stepSettings(raw: unknown): StepSettings {
const doc = (raw ?? {}) as Record<string, unknown>;
const d = (doc.downloads ?? {}) as Record<string, Record<string, unknown> | undefined>;
const names: Record<string, string> = {};
for (const k of CLIENTS) {
const n = d[k.provision]?.name;
names[k.provision] = typeof n === "string" && n.trim() ? n.trim() : k.provision.replace(/-api$/, "");
}
const j = d[JACKETT] ?? {};
const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "").map((x) => x.trim()) : []);
return {
node: typeof doc.node === "string" ? doc.node : "",
names,
indexers: { adoptHosts: strings(j["adopt-hosts"]), indexers: strings(j.indexers) },
};
}
/** The app's own key and base path, from its config.xml. */
export function appConfig(xml: string): { apiKey: string; urlBase: string } {
const tag = (t: string) => new RegExp(`<${t}>([^<]*)</${t}>`).exec(xml)?.[1]?.trim() ?? "";
return { apiKey: tag("ApiKey"), urlBase: normBase(tag("UrlBase")) };
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A JSON file parsed, or undefined when absent or not JSON. */
export async function readJson(path: string | undefined): Promise<unknown> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
/** Wait for the app to answer, because the step runs right after its container starts. */
export async function appReady(http: Http, app: App, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const a = await appCall(http, app, "GET", "/system/status");
if (a.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
export async function listEntries(http: Http, app: App, resource: "downloadclient" | "indexer"): Promise<Entry[]> {
return ((await appGet(http, app, `/${resource}`)) as Entry[]) ?? [];
}
/** Anything printed goes through this: the credentials the step holds never reach a log. */
export function scrub(text: string, secrets: string[]): string {
let out = text;
for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("<redacted>");
return out;
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+7 -90
View File
@@ -1,19 +1,6 @@
{ {
"module": "sonarr", "module": "sonarr",
"version": "1", "version": "1",
"provides": [
{
"name": "sonarr-api",
"scope": "mesh"
}
],
"serves": {
"sonarr-api": {
"scheme": "http",
"port": 8989,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -31,7 +18,7 @@
"port": 8989, "port": 8989,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "managing series: its web UI, and the API other modules reach as sonarr-api" "why": "managing series"
} }
], ],
"accesses": [ "accesses": [
@@ -55,15 +42,10 @@
"path": "/var/lib/mesh/sonarr", "path": "/var/lib/mesh/sonarr",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/sonarr/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -71,7 +53,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "sonarr", "name": "sonarr",
"image": "lscr.io/linuxserver/sonarr@sha256:a5c1a5fecbef946927ab90ad68df319ac5fe644057e5fc18cd993f01ac07b2b2", "image": "lscr.io/linuxserver/sonarr@sha256:c19aa4ecdf03d73e1d5c901da33744cb7eb4d921f89bafed1ca264601d7fa224",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -81,7 +63,7 @@
"8989" "8989"
], ],
"volumes": [ "volumes": [
"${dir:config}:/config", "/services/sonarr/config:/config",
"/services/media/series:/series", "/services/media/series:/series",
"/services/media/anime:/anime", "/services/media/anime:/anime",
"/services/media/downloads:/downloads" "/services/media/downloads:/downloads"
@@ -94,74 +76,17 @@
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro", "/var/lib/mesh/sonarr/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/sonarr/config:ro" "/services/sonarr/config:/var/lib/sonarr/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}", "MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config" "MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
}, },
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "downloads-config",
"type": "file",
"path": "${dir:state}/downloads.json",
"mode": "0600",
"content": "{\n \"node\": \"${machine:name}\",\n \"downloads\": {\n \"nzbget-api\": {\n \"name\": \"nzbget\"\n },\n \"qbittorrent-api\": {\n \"name\": \"qbittorrent\"\n },\n \"jackett-api\": {\n \"adopt-hosts\": [],\n \"indexers\": []\n }\n }\n}\n",
"merge": "json"
},
{
"id": "downloads-memory",
"type": "directory",
"mode": "0700"
},
{
"id": "downloads",
"type": "container",
"name": "mesh-sonarr-downloads",
"network": "host",
"run-once": true,
"volumes": [
"${dir:state}/downloads.json:/run/downloads/downloads.json:ro",
"${dir:state}/nzbget-api.json:/run/downloads/nzbget-api.json:ro",
"${dir:state}/nzbget-api.secret:/run/downloads/nzbget-api.secret:ro",
"${dir:state}/qbittorrent-api.json:/run/downloads/qbittorrent-api.json:ro",
"${dir:state}/qbittorrent-api.secret:/run/downloads/qbittorrent-api.secret:ro",
"${dir:state}/jackett-api.json:/run/downloads/jackett-api.json:ro",
"${dir:state}/jackett-api.secret:/run/downloads/jackett-api.secret:ro",
"${dir:config}:/var/lib/sonarr/config:ro",
"${dir:downloads-memory}:/var/lib/downloads"
],
"env": {
"MESH_DOWNLOADS_APP": "sonarr",
"MESH_DOWNLOADS_API": "v3",
"MESH_DOWNLOADS_APP_URL": "http://127.0.0.1:${port:8989}",
"MESH_DOWNLOADS_APP_CONFIG": "/var/lib/sonarr/config/config.xml",
"MESH_DOWNLOADS_DIR": "/run/downloads",
"MESH_DOWNLOADS_SETTINGS": "/run/downloads/downloads.json",
"MESH_DOWNLOADS_MEMORY": "/var/lib/downloads/memory.json"
},
"args": [
"run",
"/app/modules/sonarr/dist/downloads/index.js"
],
"restart-on": [
"downloads-config",
"bound-nzbget-api",
"secret-nzbget-api",
"bound-qbittorrent-api",
"secret-qbittorrent-api",
"bound-jackett-api",
"secret-jackett-api"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"jackett-api",
"nzbget-api",
"qbittorrent-api",
"route" "route"
], ],
"contributes": { "contributes": {
@@ -171,15 +96,7 @@
} }
}, },
"binds": { "binds": {
"route": "${dir:state}/route.json", "route": "/var/lib/mesh/sonarr/route.json"
"nzbget-api": "${dir:state}/nzbget-api.json",
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
"jackett-api": "${dir:state}/jackett-api.json"
},
"secrets": {
"nzbget-api": "${dir:state}/nzbget-api.secret",
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
"jackett-api": "${dir:state}/jackett-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
-5
View File
@@ -4,11 +4,6 @@
"description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/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.0" "@novox/mesh-sdk": "^0.1.0"
}, },
-365
View File
@@ -1,365 +0,0 @@
// What holds the downloads step (downloads/settings.ts): the app's download clients and jackett
// feeds are made to say what the mesh bound — host, port, TLS, base path, user, credential — and
// nothing else they keep is touched; only the mesh's entries are touched, and nothing is ever
// deleted; nothing is written when nothing differs; a missing one is registered; and a credential
// the provider refuses (the mesh's own minted value, before the operator accepts the provider's)
// is never written, with the `secret accept` that fixes it named.
//
// The app and the providers are fakes: the routes the step touches, answering as the real ones do
// (checked against the catalogue's pinned sonarr, radarr, lidarr, bookshelf, nzbget, qBittorrent
// and jackett): the app masks a stored password as "********", tests an enabled entry before
// saving it, refuses a warning unless forceSave, and jackett answers a wrong key 200 with an error.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
CLIENTS,
acceptRemedy,
jackettFeed,
providerTakes,
reconcileClient,
reconcileIndexers,
stepSettings,
wanted,
type App,
type Binding,
type Endpoint,
type Entry,
type Http,
} from "../downloads/settings.ts";
const NZBGET = CLIENTS.find((c) => c.provision === "nzbget-api")!;
const QBIT = CLIENTS.find((c) => c.provision === "qbittorrent-api")!;
const APP: App = { module: "sonarr", url: "http://127.0.0.1:8989", apiKey: "app-key", api: "v3" };
const MASK = "********";
interface Provider {
host: string;
port: number;
user?: string;
secret: string;
configured?: Record<string, string>;
}
const nzbget: Provider = { host: "ace.internal", port: 20201, user: "luffy", secret: "nzb-real" };
const qbit: Provider = { host: "ace.internal", port: 8112, user: "luffy", secret: "qbt-real" };
const jackett: Provider = { host: "ace.internal", port: 20204, secret: "jackett-real", configured: { rutracker: "RuTracker", torrent9: "Torrent9" } };
function binding(p: Provider, provision: string, extra: Record<string, unknown> = {}): Binding {
return {
provision,
from: "ace",
at: p.host,
as: "mesh_ace_sonarr",
serves: { scheme: "http", port: p.port, "url-base": "", ...(p.user ? { username: p.user } : {}), ...extra },
};
}
function endpoint(p: Provider, provision: string, credential = p.secret): Endpoint {
const w = wanted(provision, binding(p, provision), credential, provision !== "jackett-api");
if (!w.ok) throw new Error(w.problem);
return w.endpoint;
}
const f = (name: string, value: unknown) => ({ name, value });
function client(id: number, name: string, implementation: string, host: string, port: number, password: string, extra: Record<string, unknown> = {}): Entry {
return {
id, name, implementation, enable: true, priority: 1, tags: [],
fields: [f("host", host), f("port", port), f("useSsl", false), f("urlBase", null), f("username", "luffy"), f("password", password), f("tvCategory", "Series"), ...Object.entries(extra).map(([k, v]) => f(k, v))],
};
}
function feed(id: number, name: string, baseUrl: string, jid: string, key: string, on = true): Entry {
return {
id, name, implementation: "Torznab", enableRss: on, enableAutomaticSearch: on, enableInteractiveSearch: on, priority: 25,
fields: [f("baseUrl", baseUrl), f("apiPath", `/api/v2.0/indexers/${jid}/results/torznab/`), f("apiKey", key), f("categories", [5000])],
};
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
indexer: structuredClone(start.indexers ?? []),
};
let next = 100;
const secretOf: Record<string, string> = { downloadclient: "password", indexer: "apiKey" };
const masked = (e: Entry): Entry => ({ ...e, fields: e.fields!.map((x) => (x.name === "password" || x.name === "apiKey") && x.value ? { ...x, value: MASK } : { ...x }) });
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
const p = e.implementation === "Nzbget" ? nzbget : qbit;
if (val(e, "host") !== p.host || val(e, "port") !== p.port) return [{ propertyName: "Host", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== p.secret || val(e, "username") !== p.user) return [{ propertyName: "Username", errorMessage: "Authentication Failure", isWarning: false }];
if (val(e, "tvCategory") === "") return [{ propertyName: "TvCategory", errorMessage: "A category is recommended", isWarning: true }];
if (e.implementation === "Nzbget" && val(e, "tvCategory") === "tv") return [{ propertyName: "TvCategory", errorMessage: "Category does not exist", isWarning: false }];
return [];
}
const u = new URL(String(val(e, "baseUrl")));
if (u.hostname !== jackett.host || Number(u.port) !== jackett.port) return [{ propertyName: "BaseUrl", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
return [];
};
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body && init.headers?.["Content-Type"] === "application/json" ? (JSON.parse(init.body) as Entry) : init?.body;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value)) });
const u = new URL(url);
// Providers.
if (u.pathname === "/jsonrpc/version") {
const want = "Basic " + Buffer.from(`${nzbget.user}:${nzbget.secret}`).toString("base64");
return init?.headers?.Authorization === want ? reply(200, { result: "26.0" }) : reply(401);
}
if (u.pathname === "/api/v2/auth/login") {
const form = new URLSearchParams(String(init?.body ?? ""));
return form.get("username") === qbit.user && form.get("password") === qbit.secret ? reply(204) : reply(401, "Unauthorized");
}
if (u.pathname.endsWith("/torznab/api")) {
if (u.searchParams.get("apikey") !== jackett.secret) return reply(200, '<?xml version="1.0"?><error code="100" description="Invalid API Key" />');
const items = Object.entries(jackett.configured!).map(([id, t]) => `<indexer id="${id}" configured="true"><title>${t}</title></indexer>`).join("");
return reply(200, `<?xml version="1.0"?><indexers>${items}</indexers>`);
}
// The app.
if (init?.headers?.["X-Api-Key"] !== APP.apiKey) return reply(401);
const m = /^\/api\/v3\/(downloadclient|indexer)(?:\/(schema|test|\d+))?$/.exec(u.pathname);
if (!m) return reply(404);
const [, kind, sub] = m;
const force = u.searchParams.get("forceSave") === "true";
if (method === "GET" && !sub) return reply(200, store[kind].map(masked));
if (method === "GET" && sub === "schema") {
return reply(200, kind === "downloadclient"
? [client(0, "", "Nzbget", "localhost", 6789, MASK, {}), client(0, "", "QBittorrent", "localhost", 8080, "", {})].map((e) => ({ ...e, fields: e.fields!.map((x) => x.name === "tvCategory" ? { ...x, value: e.implementation === "Nzbget" ? "tv" : "tv-sonarr" } : x.name === "username" ? { ...x, value: null } : x) }))
: [{ ...feed(0, "", "", "x", "", false), supportsRss: true, supportsSearch: true, fields: [f("baseUrl", null), f("apiPath", "/api"), f("apiKey", null), f("categories", [5030, 5040])] }]);
}
if (method === "GET") {
const e = store[kind].find((s) => s.id === Number(sub));
return e ? reply(200, masked(e)) : reply(404);
}
if (method === "POST" && sub === "test") {
const fails = appTest(kind, body as Entry);
return fails.length ? reply(400, fails) : reply(200);
}
// Save: tested when enabled; a mask keeps what is stored.
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
if (store[kind].some((s) => String(s.name).toLowerCase() === String(e.name).toLowerCase())) {
return reply(400, [{ propertyName: "Name", errorMessage: "Should be unique", isWarning: false }]);
}
const created = { ...e, id: next++ };
store[kind].push(created);
return reply(201, masked(created));
}
if (method === "PUT") {
const i = store[kind].findIndex((s) => s.id === Number(sub));
if (i < 0) return reply(404);
const was = store[kind][i];
store[kind][i] = { ...e, fields: e.fields!.map((x) => x.value === MASK ? { ...x, value: val(was, x.name) } : x) };
return reply(202, masked(store[kind][i]));
}
return reply(405);
},
};
const saves = () => calls.filter((c) => (c.method === "PUT" || (c.method === "POST" && !c.url.endsWith("/test"))) && c.url.includes("/api/v3/"));
return { http, calls, store, saves };
}
const aceClients = () => [
client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real"),
client(2, "qBitTorrent", "QBittorrent", "qbittorrent", 8112, "qbt-real"),
];
test("the migration's download client is repointed, its category and everything else kept", async () => {
const f = fakes({ clients: aceClients() });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient.map((e) => ({ ...e })));
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "written", fields: ["host", "port"] }]);
const saved = f.store.downloadclient.find((e) => e.id === 1)!;
const v = (n: string) => saved.fields!.find((x) => x.name === n)?.value;
assert.equal(v("host"), "ace.internal");
assert.equal(v("port"), 20201);
assert.equal(v("tvCategory"), "Series");
assert.equal(v("password"), "nzb-real", "the password it held works, so it was kept, not rewritten");
assert.equal(saved.priority, 1);
});
test("rerun: already as the mesh says, nothing saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "ace.internal", 20201, "nzb-real")] });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "unchanged" }]);
assert.equal(f.saves().length, 0);
});
test("a stale password is replaced by the delivered one, which the provider took first", async () => {
const f = fakes({ clients: [client(2, "qBitTorrent", "QBittorrent", "ace.internal", 8112, "old-pass")] });
const out = await reconcileClient(f.http, APP, QBIT, "qBitTorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'qbittorrent-api ("qBitTorrent")', result: "written", fields: ["password"] }]);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "password")?.value, "qbt-real");
});
test("a minted credential is refused by the provider: nothing written, the accept named", async () => {
const f = fakes({ clients: aceClients() });
const ep = endpoint(nzbget, "nzbget-api", "a-value-the-mesh-minted");
assert.deepEqual(await providerTakes(f.http, "nzbget-api", ep), { took: false });
const remedy = acceptRemedy(APP, "ace", "nzbget-api", "nzbget", "ControlPassword", ep.from);
assert.match(remedy, /`secret accept ace sonarr nzbget-api --provider ace --from <file holding nzbget's ControlPassword>`/);
assert.doesNotMatch(remedy, /minted/);
assert.equal(f.calls.some((c) => c.url.includes("/api/v3/")), false, "the app was not even asked");
const q = endpoint(qbit, "qbittorrent-api", "minted");
assert.deepEqual(await providerTakes(f.http, "qbittorrent-api", q), { took: false });
const j = endpoint(jackett, "jackett-api", "minted");
assert.deepEqual(await providerTakes(f.http, "jackett-api", j), { took: false });
assert.equal(f.calls.find((c) => c.url.includes("apikey="))?.url.includes("jackett-real"), false);
});
test("a fresh app gets the mesh's client registered, under the mesh's name", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), []);
assert.equal(out[0].result, "created");
const e = f.store.downloadclient[0];
assert.equal(e.name, "qbittorrent");
assert.equal(e.enable, true);
assert.equal(e.fields!.find((x) => x.name === "tvCategory")?.value, "tv-sonarr", "the app's own default category");
});
test("nzbget without the app's default category: registered with none, and said", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), []);
assert.equal(out[0].result, "notice");
assert.match((out[0] as { note: string }).note, /category left empty/);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "tvCategory")?.value, "");
});
test("somebody else's entries are never touched: another name of the same kind, and a clash of names", async () => {
const mine = client(7, "My seedbox", "QBittorrent", "seedbox.example", 443, "theirs");
const f = fakes({ clients: [mine] });
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.equal(out[0].result, "notice");
assert.deepEqual(f.store.downloadclient.find((e) => e.id === 7), mine);
// Same name in another case: refused, and the setting that adopts it named.
const g = fakes({ clients: aceClients() });
const clash = await reconcileClient(g.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), g.store.downloadclient);
assert.equal(clash[0].result, "refused");
assert.match((clash[0] as { problem: string }).problem, /downloads\.nzbget-api\.name to "NZBGet"/);
assert.equal(g.saves().length, 0);
});
test("jackett feeds: the migration's are repointed, a person's is left, a listed one is registered", async () => {
const personal = feed(9, "Seedbox jackett", "https://jackett.seedbox.example", "rutracker", "their-key");
const f = fakes({
indexers: [
feed(5, "Torznab - RuTracker", "https://indexers.zurag.be", "rutracker-ru", "jackett-real", false),
feed(6, "Torznab - Torrent9", "https://indexers.zurag.be", "torrent9", "jackett-real", false),
feed(4, "Jackett - RARBG", "http://jackett:9117", "therarbg", "old", false),
personal,
],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured,
{ adoptHosts: ["indexers.zurag.be", "jackett"], indexers: ["rutracker"] }, [], f.store.indexer,
);
const byWhat = Object.fromEntries(out.map((o) => [o.what + ":" + o.result, o]));
// torrent9: jackett has it; repointed, key already right, tested.
assert.ok(byWhat['jackett-api ("Torznab - Torrent9", jackett indexer torrent9):written']);
// rutracker-ru and therarbg: jackett has neither — repointed with the key, untested, and said.
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):written']);
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):notice']);
// rutracker is listed and nothing of the mesh's reads it: registered.
assert.ok(byWhat["jackett-api (jackett indexer rutracker):created"]);
assert.deepEqual(f.store.indexer.find((e) => e.id === 9), personal, "a person's jackett is not the mesh's");
const t9 = f.store.indexer.find((e) => e.id === 6)!;
assert.equal(t9.fields!.find((x) => x.name === "baseUrl")?.value, "http://ace.internal:20204");
assert.equal(t9.enableRss, false, "disabled stays disabled");
const created = f.store.indexer.find((e) => e.name === "Jackett - RuTracker")!;
assert.equal(created.enableRss, true);
assert.equal(f.calls.some((c) => c.method === "DELETE"), false, "nothing is ever deleted");
});
test("jackett feeds rerun: nothing saved, and a remembered host keeps a moved jackett's feeds", async () => {
const f = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["torrent9"] }, [], f.store.indexer);
assert.deepEqual(out.map((o) => o.result), ["unchanged"]);
assert.equal(f.saves().length, 0);
// jackett moved to novox: the feed on ace.internal is still the mesh's because it was remembered.
const moved = { ...jackett, host: "novox.internal" };
const g = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const saved = jackett.host;
jackett.host = moved.host;
try {
const again = await reconcileIndexers(g.http, APP, endpoint(moved, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, ["ace.internal"], g.store.indexer);
assert.equal(again[0].result, "written");
assert.equal(g.store.indexer[0].fields!.find((x) => x.name === "baseUrl")?.value, "http://novox.internal:20204");
} finally {
jackett.host = saved;
}
});
test("an enabled entry the app cannot test at the mesh's address is not saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real")] });
const elsewhere = { ...nzbget, port: 1 };
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(elsewhere, "nzbget-api"), f.store.downloadclient);
assert.equal(out[0].result, "refused");
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "host")?.value, "nzbget", "left as it was");
});
test("bindings: loopback, no user, no credential are refused; the base path is normalised", () => {
assert.equal(wanted("nzbget-api", { ...binding(nzbget, "nzbget-api"), at: "127.0.0.1" }, "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding({ ...nzbget, user: undefined }, "nzbget-api"), "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding(nzbget, "nzbget-api"), " \n", true).ok, false);
const w = wanted("jackett-api", binding(jackett, "jackett-api", { "url-base": "jackett/" }), "k", false);
assert.equal(w.ok && w.endpoint.urlBase, "/jackett");
});
test("a feed is read whole: base path in the base URL or in the API path", () => {
const e = feed(1, "x", "http://ace.internal:9117/jackett", "rutracker", "k");
assert.deepEqual(jackettFeed(e), { host: "ace.internal", id: "rutracker" });
assert.equal(jackettFeed({ ...e, implementation: "Newznab" }), undefined);
assert.equal(jackettFeed({ ...e, fields: [f("baseUrl", "https://api.nzbgeek.info"), f("apiPath", "/api")] }), undefined);
});
test("settings: names default to the provider's, adoption and registration read from the merged file", () => {
assert.deepEqual(stepSettings({ node: "ace" }), { node: "ace", names: { "nzbget-api": "nzbget", "qbittorrent-api": "qbittorrent" }, indexers: { adoptHosts: [], indexers: [] } });
const s = stepSettings({ node: "ace", downloads: { "nzbget-api": { name: "NZBGet" }, "jackett-api": { "adopt-hosts": ["jackett"], indexers: ["torrent9", 3] } } });
assert.equal(s.names["nzbget-api"], "NZBGet");
assert.deepEqual(s.indexers, { adoptHosts: ["jackett"], indexers: ["torrent9"] });
});
// The four copies are one step. Where the siblings are checked out beside this module, they must
// be byte-identical — a fix made in one and not the others is a bug in three apps.
test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
const here = dirname(dirname(fileURLToPath(import.meta.url)));
const modules = dirname(here);
for (const file of ["downloads/settings.ts", "downloads/index.ts", "test/downloads.test.ts"]) {
const mine = readFileSync(join(here, file), "utf8");
for (const sibling of ["sonarr", "radarr", "lidarr", "bookshelf"]) {
const theirs = join(modules, sibling, file);
if (existsSync(theirs)) assert.equal(readFileSync(theirs, "utf8"), mine, `${sibling}/${file} differs`);
}
}
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"] "include": ["client.ts", "index.ts", "tools/index.ts"]
} }
File diff suppressed because one or more lines are too long
+41 -21
View File
@@ -10,35 +10,35 @@
"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 and API, over its own self-signed tls; named through the proxy as an https route"
}, },
{ {
"name": "inform", "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, how APs and switches check in and are adopted; the controller tells devices this number, so the machine must publish it on the same one"
}, },
{ {
"name": "stun", "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 for managed devices; the controller tells devices this number, so the machine must publish it on the same one"
}, },
{ {
"name": "discovery", "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 broadcasts from unadopted devices and the UniFi apps"
}, },
{ {
"name": "discovery-l2", "name": "discovery-l2",
"port": 1902, "port": 1900,
"protocol": "udp", "protocol": "udp",
"from": "mesh", "from": "mesh",
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900" "why": "make-controller-discoverable-on-L2 (SSDP); the software listens on 1900, which machines commonly have taken by another SSDP speaker"
}, },
{ {
"name": "portal-tls", "name": "portal-tls",
@@ -76,10 +76,15 @@
"path": "/var/lib/mesh/unifi", "path": "/var/lib/mesh/unifi",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/unifi/data",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -87,17 +92,17 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "unifi-controller", "name": "unifi-controller",
"image": "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f", "image": "lscr.io/linuxserver/unifi-controller@sha256:0ae315a3a45635e443899e30e86bd507c2c48922cb27f4bc7241777885f4650e",
"ports": [ "ports": [
"8443:8443", "8443",
"8080:8080", "8080",
"3478:3478/udp", "3478/udp",
"10001:10001/udp", "10001/udp",
"1902:1900/udp", "1900/udp",
"8843:8843", "8843",
"8880:8880", "8880",
"6789:6789", "6789",
"5514:5514/udp" "5514/udp"
], ],
"env": { "env": {
"PUID": "1000", "PUID": "1000",
@@ -107,7 +112,7 @@
"MEM_STARTUP": "1024" "MEM_STARTUP": "1024"
}, },
"volumes": [ "volumes": [
"/services/unifi/data:/config" "${dir:data}:/config"
] ]
}, },
{ {
@@ -115,7 +120,7 @@
"type": "file", "type": "file",
"path": "/var/lib/mesh/unifi/config.json", "path": "/var/lib/mesh/unifi/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
"merge": "json" "merge": "json"
}, },
{ {
@@ -129,7 +134,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_UNIFI_URL": "https://127.0.0.1:8443", "MESH_UNIFI_URL": "https://127.0.0.1:${port:8443}",
"MESH_UNIFI_CONFIG_FILE": "/run/config/config.json" "MESH_UNIFI_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
@@ -138,8 +143,23 @@
"artifact": "runtime" "artifact": "runtime"
} }
], ],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "unifi",
"endpoint": "web",
"scheme": "https",
"insecure": true
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/unifi/broker" "broker": "/var/lib/mesh/unifi/broker",
"controller": "/var/lib/mesh/unifi/controller"
}, },
"build": { "build": {
"on": [ "on": [