Compare commits

..
Author SHA1 Message Date
jschoubben 991e33f749 tautulli: reach plex through the mesh, written before Tautulli starts
Tautulli reached plex at 172.18.0.1, the gateway of a HAL network that goes
away with HAL, and the plan was to retype it by hand in the window. Tautulli
now requires plex-api, and where plex is comes from the binding.

Tautulli keeps the connection only in config.ini, reads it at start and
writes its whole config back on every shutdown; its API cannot set it and
its settings form needs an admin login. So a step after start would be
overwritten the moment the container is recreated. The write is made where
nothing can overwrite it: the linuxserver image's custom-init runs
plex/mesh-plex.py as root before Tautulli starts, and the server restarts on
its binding and credential, so a moved plex or an accepted token lands.

It writes only [PMS] keys, only when they differ, every other line byte for
byte: pms_ip, pms_port, pms_ssl and pms_url from the binding; pms_identifier
from plex's /identity; pms_token only when plex takes it. A minted value -
before the operator accepts the server's X-Plex-Token for this pair - is
never written, while the address still is, so Tautulli's own working token
keeps working at plex's new address.

A failure in custom-init is a log line nobody reads, so a run-once `plex`
step, declared last so it gates nothing (ADR 0136), checks what the mesh can
report: plex takes the credential (else it names the secret accept), Tautulli
holds the bound URL, and Tautulli says it is connected. It writes nothing.

The script is kept as plex/mesh-plex.py and plex/50-mesh-plex; module.json
carries copies, and a test fails when they differ. Tests run the script with
python3 against a fake plex (skipped where there is none) and the step
against fakes; `npm test` builds first.
2026-09-30 13:09:43 +02:00
jschoubben 3e378170df tautulli: its config is placed, it runs the build in use, and its runtime finds its own key
The module stated /services/tautulli/config and /var/lib/mesh/tautulli/route.json,
novox's layout, which no definition may carry (ADR 0112). Tautulli's config dir is
now a placed directory (${dir:config}) and the route binds into the placed state
(${dir:state}), as searxng and mosquitto do.

The image was pinned to v2.18.1-ls242; ace runs ls244 (2026-09-11), and Tautulli
migrates its own database schema, so the pin moves to the digest ace runs.

The runtime called http://127.0.0.1:8181, which is the software's port, not the
machine port the mesh assigns; it now asks with ${port:8181}, as gitea does.

The runtime mounted Tautulli's config dir read-only but never read it: its API key
could only come from MESH_TAUTULLI_APIKEY or the settings-merged config.json, i.e.
a secret in settings. Tautulli mints and owns that key in its config.ini, so the
runtime now reads it from there. Nothing for the mesh to mint or accept.

Verified: catalogue tests with MESH_CATALOGUE pointing here (not skipped); the
pinned image started in a throwaway container on a dir owned 1001:2000 with
PUID/PGID 1000 answers /status 200 and re-owns /config to 1000:1000 on start;
client.ts, run under node, read the key from that instance's config.ini and got
success from get_activity and get_history; without a config.ini it throws, which
the tools and events entrypoints already treat as "not configured".
2026-09-29 23:41:47 +02:00
mesh-admin 8064e5da8f Merge pull request 'searxng: its settings are a file the mesh writes, not the image's defaults' (#143) from feat/searxng-settings-as-a-file into main 2026-09-29 20:45:13 +00:00
jschoubben 63a255c5cb searxng: bind its route where its state now lives
The route binding still named /var/lib/searxng-module, the directory the
previous commit placed elsewhere — the host would have written it into a
directory nothing declares. Same shape as gitea and nextcloud.
2026-09-29 22:41:56 +02:00
jschoubben 7ad1fbd5c6 searxng: its settings are a file the mesh writes, not the image's defaults
The module ran searxng on the image's built-in settings, which serve html
only — so the module's own search tool (format=json) was refused by the
software it fronts. And there was no way to configure it per machine: the
only file settings reach was the sidecar's.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The reason it was at start — that a step blocking the apply would block the very apply bringing the
overlay up — stopped being true when a step's failure became its module's business rather than the
machine's (ADR 0136).
2026-09-28 15:40:28 +02:00
mesh-admin 4d9b4fdfa6 Merge pull request 'The catalogue declares the event it emits on starting' (#129) from fix/the-catalogue-declares-the-event-it-emits into main 2026-09-28 07:49:06 +00:00
jschoubben eff11b1d4d The catalogue declares the event it emits on starting
Its runtime announces that it has just started and may have missed builds — the event the control
plane follows to replay them — and its manifest did not declare it. A module's authority on the bus
is derived from what it declares, so the publish was refused and the runtime died on start, in a
loop, with the mesh's graph never catching up.
2026-09-28 09:49:03 +02:00
66 changed files with 1121 additions and 81 deletions
+2 -1
View File
@@ -14,7 +14,7 @@
},
"route": {
"label": "baserow",
"port": 80
"endpoint": "web"
}
},
"binds": {
@@ -30,6 +30,7 @@
},
"listens": [
{
"name": "web",
"port": 80,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -13,6 +13,7 @@
},
"listens": [
{
"name": "web",
"port": 6767,
"protocol": "tcp",
"from": "mesh",
@@ -110,7 +111,7 @@
"contributes": {
"route": {
"label": "subs",
"port": 6767
"endpoint": "web"
}
},
"binds": {
+2 -1
View File
@@ -15,6 +15,7 @@
},
"listens": [
{
"name": "web",
"port": 8787,
"protocol": "tcp",
"from": "mesh",
@@ -87,7 +88,7 @@
"contributes": {
"route": {
"label": "books",
"port": 8787
"endpoint": "web"
}
},
"binds": {
+56
View File
@@ -0,0 +1,56 @@
{
"module": "ca-trust",
"version": "1",
"slug": "catrust",
"capabilities": [
"service-manager"
],
"requires": [
"internal-acme-ca"
],
"seats": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"claims": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "anchor",
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
},
{
"id": "trust",
"type": "service",
"unit": "mesh-ca-trust.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"anchor",
"unit"
]
}
]
}
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": {
"route": {
"label": "de-spiegel",
"port": 35621
"endpoint": "web"
}
},
"binds": {
@@ -23,6 +23,7 @@
},
"listens": [
{
"name": "web",
"port": 35621,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -29,6 +29,7 @@
},
"listens": [
{
"name": "registry",
"port": 5000,
"protocol": "tcp",
"from": "mesh",
+9
View File
@@ -22,11 +22,20 @@
],
"listens": [
{
"name": "dns-udp",
"port": 53,
"protocol": "udp",
"from": "mesh",
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
"fixed": true
},
{
"name": "dns-tcp",
"port": 53,
"protocol": "tcp",
"from": "mesh",
"why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success",
"fixed": true
}
],
"resources": [
+9 -1
View File
@@ -33,7 +33,7 @@
"type": "file",
"path": "/etc/fail2ban/jail.local",
"mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
},
{
"id": "jail-sshd",
@@ -42,6 +42,14 @@
"mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
},
{
"id": "log",
"type": "file",
"path": "/var/log/fail2ban.log",
"mode": "0640",
"create-once": true,
"content": ""
},
{
"id": "jail-recidive",
"type": "file",
+3 -1
View File
@@ -13,7 +13,7 @@
"route": {
"web": {
"label": "git",
"port": 3000
"endpoint": "web"
},
"internal-api-refused": {
"label": "git",
@@ -44,12 +44,14 @@
],
"listens": [
{
"name": "web",
"port": 3000,
"protocol": "tcp",
"from": "mesh",
"why": "the forge, over http"
},
{
"name": "ssh",
"port": 22,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -13,6 +13,7 @@
],
"listens": [
{
"name": "web",
"port": 3000,
"protocol": "tcp",
"from": "mesh",
@@ -96,7 +97,7 @@
"contributes": {
"route": {
"label": "grafana",
"port": 3000
"endpoint": "web"
}
},
"binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": {
"route": {
"label": "hello",
"port": 8080
"endpoint": "web"
}
},
"binds": {
@@ -19,6 +19,7 @@
},
"listens": [
{
"name": "web",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
},
"listens": [
{
"name": "web",
"port": 8123,
"protocol": "tcp",
"from": "mesh",
@@ -85,7 +86,7 @@
"contributes": {
"route": {
"label": "home-assistant",
"port": 8123
"endpoint": "web"
}
},
"binds": {
+1
View File
@@ -13,6 +13,7 @@
},
"listens": [
{
"name": "stream",
"port": 8000,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -9,6 +9,7 @@
},
"listens": [
{
"name": "api",
"port": 8086,
"protocol": "tcp",
"from": "mesh",
+4 -2
View File
@@ -17,11 +17,11 @@
"route": {
"site": {
"label": "invoicing",
"port": 80
"endpoint": "web"
},
"api": {
"label": "invoicing-api",
"port": 9000
"endpoint": "api"
}
}
},
@@ -36,12 +36,14 @@
},
"listens": [
{
"name": "web",
"port": 80,
"protocol": "tcp",
"from": "mesh",
"why": "the invoicing web frontend; a public name is a route grant later"
},
{
"name": "api",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -6,6 +6,7 @@
],
"listens": [
{
"name": "web",
"port": 9117,
"protocol": "tcp",
"from": "mesh",
@@ -82,7 +83,7 @@
"contributes": {
"route": {
"label": "indexers",
"port": 9117
"endpoint": "web"
}
},
"binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
},
"route": {
"label": "keycloak",
"port": 8080
"endpoint": "web"
}
},
"binds": {
@@ -34,6 +34,7 @@
],
"listens": [
{
"name": "web",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -24,6 +24,7 @@
},
"listens": [
{
"name": "web",
"port": 8283,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
},
"listens": [
{
"name": "web",
"port": 8686,
"protocol": "tcp",
"from": "mesh",
@@ -86,7 +87,7 @@
"contributes": {
"route": {
"label": "lidarr",
"port": 8686
"endpoint": "web"
}
},
"binds": {
+15 -5
View File
@@ -16,27 +16,27 @@
"route": {
"web": {
"label": "mail",
"port": 7443,
"endpoint": "web-tls",
"scheme": "https",
"insecure": true
},
"acme": {
"label": "mail",
"path": "/.well-known/acme-challenge",
"port": 7080,
"endpoint": "web",
"priority": 100
},
"autoconfig": {
"label": "autoconfig",
"port": 4243
"endpoint": "autoconfig"
},
"autodiscover": {
"label": "autodiscover",
"port": 4243
"endpoint": "autoconfig"
},
"automx": {
"label": "automx",
"port": 4243
"endpoint": "autoconfig"
}
}
},
@@ -60,6 +60,7 @@
],
"listens": [
{
"name": "smtp",
"port": 25,
"protocol": "tcp",
"from": "anywhere",
@@ -67,6 +68,7 @@
"fixed": true
},
{
"name": "pop3",
"port": 110,
"protocol": "tcp",
"from": "anywhere",
@@ -74,6 +76,7 @@
"fixed": true
},
{
"name": "imap",
"port": 143,
"protocol": "tcp",
"from": "anywhere",
@@ -81,6 +84,7 @@
"fixed": true
},
{
"name": "smtps",
"port": 465,
"protocol": "tcp",
"from": "anywhere",
@@ -88,6 +92,7 @@
"fixed": true
},
{
"name": "submission",
"port": 587,
"protocol": "tcp",
"from": "anywhere",
@@ -95,6 +100,7 @@
"fixed": true
},
{
"name": "imaps",
"port": 993,
"protocol": "tcp",
"from": "anywhere",
@@ -102,6 +108,7 @@
"fixed": true
},
{
"name": "pop3s",
"port": 995,
"protocol": "tcp",
"from": "anywhere",
@@ -109,18 +116,21 @@
"fixed": true
},
{
"name": "web",
"port": 7080,
"protocol": "tcp",
"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"
},
{
"name": "web-tls",
"port": 7443,
"protocol": "tcp",
"from": "mesh",
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
},
{
"name": "autoconfig",
"port": 4243,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -6,6 +6,7 @@
],
"listens": [
{
"name": "api",
"port": 59125,
"protocol": "tcp",
"from": "mesh",
+6 -1
View File
@@ -22,7 +22,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+20 -6
View File
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv();
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the
// overlay would block the very apply that brings the overlay up.
await graph.migrate();
// The schema is not brought up here. The mesh prepares this module's state before it starts this
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */
interface Built {
@@ -47,7 +49,15 @@ interface Built {
replay?: boolean;
}
await on("mesh-build-machine.built", async (event) => {
/**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = event.body as Built;
if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
because: next.because,
});
}
});
};
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
//
+5 -2
View File
@@ -29,13 +29,16 @@
"broker": "/var/lib/mesh/mesh-catalog/broker"
},
"consumes": [
"mesh-build-machine.built"
"mesh-build-machine.built",
"mesh-controller.built-before"
],
"emits": [
"registered",
"upgraded",
"rebuild-needed"
"rebuild-needed",
"catching-up"
],
"prepares": true,
"resources": [
{
"id": "mesh-state",
+17
View File
@@ -0,0 +1,17 @@
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
//
// **The mesh runs this before the version that needs it, and does not start that version if it
// fails** — and the refusal reaches this module and nothing else on the machine
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
// nothing anywhere said so.
//
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// process exiting non-zero is how the host knows not to start the runtime.
import { Graph } from "../store.js";
const graph = Graph.fromEnv();
await graph.migrate();
console.log("[mesh-catalog] the module graph's schema is what this version needs");
await graph.close();
+2 -1
View File
@@ -12,6 +12,7 @@
"pg.d.ts",
"store.ts",
"index.ts",
"tools/index.ts"
"tools/index.ts",
"prepare/index.ts"
]
}
+4 -2
View File
@@ -14,11 +14,11 @@
"route": {
"api": {
"label": "files-api",
"port": 9000
"endpoint": "s3"
},
"console": {
"label": "files",
"port": 9001
"endpoint": "console"
}
}
},
@@ -31,12 +31,14 @@
],
"listens": [
{
"name": "s3",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
"why": "the S3 endpoint"
},
{
"name": "console",
"port": 9001,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -20,6 +20,7 @@
],
"listens": [
{
"name": "database",
"port": 27017,
"protocol": "tcp",
"from": "mesh",
+2
View File
@@ -34,12 +34,14 @@
},
"listens": [
{
"name": "mqtt",
"port": 1883,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a topic namespace"
},
{
"name": "mqtt-websockets",
"port": 8081,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -20,6 +20,7 @@
],
"listens": [
{
"name": "database",
"port": 4848,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -14,7 +14,7 @@
},
"route": {
"label": "n8n",
"port": 5682
"endpoint": "web"
}
},
"binds": {
@@ -29,6 +29,7 @@
},
"listens": [
{
"name": "web",
"port": 5682,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -21,6 +21,7 @@
"consumes": [],
"listens": [
{
"name": "bus",
"port": 4222,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -13,7 +13,7 @@
},
"route": {
"label": "drive",
"port": 80
"endpoint": "web"
}
},
"binds": {
@@ -38,6 +38,7 @@
],
"listens": [
{
"name": "web",
"port": 80,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -12,6 +12,7 @@
],
"listens": [
{
"name": "web",
"port": 1880,
"protocol": "tcp",
"from": "mesh",
@@ -81,7 +82,7 @@
"contributes": {
"route": {
"label": "nodered",
"port": 1880
"endpoint": "web"
}
},
"binds": {
+2 -1
View File
@@ -10,7 +10,7 @@
"contributes": {
"route": {
"label": "@",
"port": 4000
"endpoint": "web"
}
},
"binds": {
@@ -18,6 +18,7 @@
},
"listens": [
{
"name": "web",
"port": 4000,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -15,6 +15,7 @@
},
"listens": [
{
"name": "web",
"port": 6789,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -12,6 +12,7 @@
],
"listens": [
{
"name": "api",
"port": 11434,
"protocol": "tcp",
"from": "machine",
+2 -1
View File
@@ -14,6 +14,7 @@
},
"listens": [
{
"name": "web",
"port": 3579,
"protocol": "tcp",
"from": "mesh",
@@ -89,7 +90,7 @@
"contributes": {
"route": {
"label": "ombi",
"port": 3579
"endpoint": "web"
}
},
"binds": {
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": {
"route": {
"label": "office",
"port": 9070
"endpoint": "web"
}
},
"binds": {
@@ -22,6 +22,7 @@
},
"listens": [
{
"name": "web",
"port": 9070,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": {
"route": {
"label": "eef",
"port": 4012
"endpoint": "web"
}
},
"binds": {
@@ -19,6 +19,7 @@
},
"listens": [
{
"name": "web",
"port": 4012,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -11,7 +11,7 @@
"contributes": {
"route": {
"label": "filip",
"port": 4013
"endpoint": "web"
}
},
"binds": {
@@ -19,6 +19,7 @@
},
"listens": [
{
"name": "web",
"port": 4013,
"protocol": "tcp",
"from": "mesh",
+3 -1
View File
@@ -18,7 +18,7 @@
},
"route": {
"label": "photos",
"port": 4001
"endpoint": "web"
}
},
"binds": {
@@ -32,12 +32,14 @@
},
"listens": [
{
"name": "api",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
"why": "the photos backend API; the client sites on the module network call it"
},
{
"name": "web",
"port": 4001,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -18,6 +18,7 @@
},
"listens": [
{
"name": "stream",
"port": 32400,
"protocol": "tcp",
"from": "mesh",
+3 -1
View File
@@ -7,12 +7,14 @@
],
"listens": [
{
"name": "web",
"port": 9090,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
},
{
"name": "web-tls",
"port": 9443,
"protocol": "tcp",
"from": "mesh",
@@ -103,7 +105,7 @@
"contributes": {
"route": {
"label": "portainer",
"port": 9090
"endpoint": "web"
}
},
"binds": {
+1
View File
@@ -26,6 +26,7 @@
],
"listens": [
{
"name": "database",
"port": 5432,
"protocol": "tcp",
"from": "mesh",
+1
View File
@@ -16,6 +16,7 @@
},
"listens": [
{
"name": "web",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
},
"listens": [
{
"name": "web",
"port": 7878,
"protocol": "tcp",
"from": "mesh",
@@ -86,7 +87,7 @@
"contributes": {
"route": {
"label": "movies",
"port": 7878
"endpoint": "web"
}
},
"binds": {
+1
View File
@@ -40,6 +40,7 @@
},
"listens": [
{
"name": "cache",
"port": 6379,
"protocol": "tcp",
"from": "mesh",
+2
View File
@@ -27,12 +27,14 @@
},
"listens": [
{
"name": "http",
"port": 80,
"protocol": "tcp",
"from": "anywhere",
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
},
{
"name": "https",
"port": 443,
"protocol": "tcp",
"from": "anywhere",
+23 -21
View File
@@ -5,11 +5,12 @@
"container-runtime"
],
"own-secrets": {
"secret": "/var/lib/searxng-module/secret.secret",
"secret": "/var/lib/mesh/searxng/secret",
"broker": "/var/lib/mesh/searxng/broker"
},
"listens": [
{
"name": "web",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
@@ -26,22 +27,14 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/searxng-module",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "valkey-data",
"type": "directory",
"path": "/var/lib/searxng-module/valkey-data",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/searxng-module/server.env",
"mode": "0600",
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
},
{
"id": "net",
"type": "network",
@@ -62,30 +55,39 @@
"warning"
],
"volumes": [
"/var/lib/searxng-module/valkey-data:/data"
"${dir:valkey-data}:/data"
]
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.yml",
"mode": "0600",
"merge": "json",
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
},
{
"id": "server",
"type": "container",
"name": "searxng",
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371",
"image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
"network": "searxng",
"env-file": [
"/var/lib/searxng-module/server.env"
],
"ports": [
"8080"
],
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done"
"volumes": [
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
],
"restart-on": [
"settings"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/searxng/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"content": "{}\n"
},
{
"id": "runtime",
@@ -113,11 +115,11 @@
"contributes": {
"route": {
"label": "searxng",
"port": 8080
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/searxng-module/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"on": [
+1
View File
@@ -43,6 +43,7 @@
],
"listens": [
{
"name": "web",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
+2 -1
View File
@@ -14,6 +14,7 @@
},
"listens": [
{
"name": "web",
"port": 8989,
"protocol": "tcp",
"from": "mesh",
@@ -91,7 +92,7 @@
"contributes": {
"route": {
"label": "series",
"port": 8989
"endpoint": "web"
}
},
"binds": {
+2
View File
@@ -7,6 +7,7 @@
],
"listens": [
{
"name": "ssh",
"port": 22,
"protocol": "tcp",
"from": "anywhere",
@@ -31,6 +32,7 @@
"type": "service",
"unit": "sshd.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"config"
]
+1
View File
@@ -26,6 +26,7 @@
},
"listens": [
{
"name": "acme",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/tautulli
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts plex/check.ts plex/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/tautulli/dist /app/modules/tautulli/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/tautulli/dist/index.js,/app/modules/tautulli/dist/tools/index.js
# NOT dist/plex/index.js: that is a step the host runs to completion, named by the `plex`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+31 -4
View File
@@ -41,6 +41,32 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/**
* The API key Tautulli minted for itself, read from its own config.ini (mounted read-only).
*
* Tautulli owns this key: it writes it on first run and every client of its API — this runtime
* included — must present the same one. So the mesh does not mint or hold it; the one place it
* lives is the file Tautulli keeps, and a key regenerated in Tautulli's settings is simply read
* again on the next start. Undefined when there is no file or no key yet (a fresh install whose
* setup wizard has not run).
*/
export function keyOfTautulli(dir?: string): string | undefined {
if (!dir) return undefined;
let ini: string;
try { ini = readFileSync(`${dir.replace(/\/$/, "")}/config.ini`, "utf8"); }
catch { return undefined; }
let section = "";
for (const raw of ini.split(/\r?\n/)) {
const line = raw.trim();
const header = /^\[(.+)\]$/.exec(line);
if (header) { section = header[1]; continue; }
if (section !== "General") continue;
const kv = /^api_key\s*=\s*"?([^"]*)"?$/.exec(line);
if (kv && kv[1]) return kv[1];
}
return undefined;
}
export class TautulliClient {
readonly baseUrl: string;
@@ -52,15 +78,16 @@ export class TautulliClient {
}
/**
* Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY
* (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container.
* Build from the module's resolved environment. The API key is the one Tautulli minted for
* itself (Settings → Web Interface), read from its config.ini under MESH_TAUTULLI_CONFIG_DIR;
* MESH_TAUTULLI_APIKEY still wins where it is set. The base URL defaults to the local container.
* Throws when no key is configured — the module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient {
const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`);
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY;
if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY");
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY ?? keyOfTautulli(env.MESH_TAUTULLI_CONFIG_DIR);
if (!apiKey) throw new Error("no Tautulli API key — Tautulli's config.ini has none yet (finish its setup and enable the API)");
return new TautulliClient(url, apiKey);
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -4,6 +4,11 @@
"description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts plex/check.ts plex/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": {
"@novox/mesh-sdk": "^0.1.0"
},
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Run by the linuxserver image's custom-init each time Tautulli's container starts, as root, before
# Tautulli: puts where the mesh says plex is into Tautulli's config.ini (mesh-plex.py says why).
#
# Written by the mesh from the tautulli module's manifest. Editing it here lasts until the next apply.
PY=/lsiopy/bin/python3
[ -x "$PY" ] || PY=python3
exec "$PY" /run/mesh/mesh-plex.py
+175
View File
@@ -0,0 +1,175 @@
// Whether Tautulli reaches Plex as the mesh says — the half of tautulli's plex-api consumer the
// mesh can see fail.
//
// **The write is not here.** Tautulli keeps its Plex connection only in config.ini, rewrites that
// file from memory on every shutdown, and has no API command that sets it; so the write happens in
// the server container itself, before Tautulli starts (plex/mesh-plex.py, run by the image's
// custom-init). A failure there is a line in Tautulli's log that nobody reads. This step runs after
// the server, as a run-once container declared last, and fails the node's report when:
//
// - the pair credential is one plex refuses — the mesh's own minted value, before the operator
// accepts the server's X-Plex-Token for this pair — naming the `secret accept` that fixes it;
// - Tautulli is not pointed where the binding says (the start-time write did not land);
// - Tautulli is pointed there and still not connected to plex (its own connection state).
//
// It writes nothing, to Tautulli or to plex. Pure logic and a small HTTP seam, tested against fakes
// (test/plex.test.ts).
/** What the mesh wrote at `binds.plex-api`: the binding document (controller's boundFile). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export const PROVISION = "plex-api";
export interface Wanted {
url: string;
token: string;
from: string;
}
/** The HTTP the step needs, so a test can stand fakes in for Tautulli and plex. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string> }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export type Outcome = { result: "connected"; url: string } | { result: "refused"; problem: string };
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** The URL Tautulli should hold as pms_url — the same one mesh-plex.py writes. */
export function wanted(binding: Binding | undefined, credential: string | undefined): Wanted | { problem: string } {
if (!binding) return { problem: `no binding for ${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);
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (!at) return { problem: `the ${PROVISION} binding names no host (at)` };
if (isLoopback(at)) {
return {
problem:
`the ${PROVISION} binding says plex is at ${at}, which from Tautulli's container is Tautulli itself; ` +
`the mesh hands loopback to a machine that is not on the private network`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { problem: `the ${PROVISION} binding serves no usable port (${String(serves.port)})` };
}
if (scheme !== "http" && scheme !== "https") return { problem: `the ${PROVISION} binding serves scheme ${scheme}, which Tautulli cannot dial` };
const token = (credential ?? "").trim();
if (!token) return { problem: `the ${PROVISION} credential is empty or was not delivered` };
const host = at.includes(":") && !at.startsWith("[") ? `[${at}]` : at;
return { url: `${scheme}://${host}:${port}`, token, from: typeof binding.from === "string" ? binding.from : "" };
}
/** The remedy for a refused token, in the controller's own words (ADR 0092). */
export function acceptRemedy(from: string): string {
return (
`plex refuses the ${PROVISION} credential the mesh delivered, so Tautulli was not given it. plex's ` +
`token is issued by plex.tv and the mesh cannot make it: accept the server's own token for this pair — ` +
`\`secret accept <this node> tautulli ${PROVISION} --provider ${from || "<its node>"} ` +
`--from <file holding the server's X-Plex-Token>\``
);
}
/** Does plex take the token? 401/403, or 400 on a network plex trusts, is a refusal. */
export async function plexTakes(http: Http, want: Wanted): Promise<boolean> {
const res = await http.fetch(`${want.url}/`, { method: "GET", headers: { "X-Plex-Token": want.token, Accept: "application/json" } });
if (res.status === 400 || res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`plex answered ${res.status} at /`);
}
export interface Tautulli {
url: string;
apiKey: string;
}
/** One Tautulli API command's `data`, or a thrown error. The error never carries the key. */
export async function tautulliCmd(http: Http, t: Tautulli, cmd: string): Promise<any> {
const q = new URLSearchParams({ apikey: t.apiKey, cmd });
const res = await http.fetch(`${t.url.replace(/\/$/, "")}/api/v2?${q.toString()}`, { method: "GET" });
const text = await res.text();
if (res.status !== 200) throw new Error(`Tautulli ${cmd} answered ${res.status}`);
const body = (JSON.parse(text) as { response?: { result?: string; message?: string; data?: unknown } }).response ?? {};
if (body.result !== "success") throw new Error(`Tautulli ${cmd}: ${body.message ?? "error"}`);
return body.data;
}
/** Wait for Tautulli to answer, because the step runs right after its container starts. */
export async function tautulliReady(http: Http, t: Tautulli, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${t.url.replace(/\/$/, "")}/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));
}
}
/**
* Check Tautulli against the mesh: the token plex takes, the URL Tautulli holds, and Tautulli's own
* connection state, polled for `connectMs` because Tautulli connects to plex a moment after start.
* Never throws.
*/
export async function check(
http: Http,
t: Tautulli,
binding: Binding | undefined,
credential: string | undefined,
connectMs = 60_000,
pauseMs = 3000,
): Promise<Outcome> {
const w = wanted(binding, credential);
if ("problem" in w) return { result: "refused", problem: w.problem };
try {
if (!(await plexTakes(http, w))) return { result: "refused", problem: acceptRemedy(w.from) };
} catch (err) {
return { result: "refused", problem: `plex could not be asked whether it takes the token at ${w.url}: ${message(err)}` };
}
try {
const info = (await tautulliCmd(http, t, "get_server_info")) as { pms_url?: unknown } | undefined;
const holds = typeof info?.pms_url === "string" ? info.pms_url : "";
if (holds.replace(/\/$/, "") !== w.url) {
return {
result: "refused",
problem:
`Tautulli reaches plex at ${holds || "nothing"}, not ${w.url} as the mesh says. Its container writes ` +
`this into config.ini as it starts (custom-init 50-mesh-plex); its log says why it did not`,
};
}
const until = Date.now() + connectMs;
for (;;) {
// server_status answers {connected}, not wrapped in `data` on every version: accept both.
const status = (await tautulliCmd(http, t, "server_status")) as { connected?: unknown } | undefined;
if (status?.connected === true) return { result: "connected", url: w.url };
if (Date.now() >= until) {
return {
result: "refused",
problem: `Tautulli holds ${w.url} and is not connected to plex there — its log says why (a token plex no longer takes, or plex down)`,
};
}
await new Promise((r) => setTimeout(r, pauseMs));
}
} catch (err) {
return { result: "refused", problem: message(err) };
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+47
View File
@@ -0,0 +1,47 @@
// tautulli's plex step — run once by the host after Tautulli's server container, and again whenever
// its binding, its pair credential or the server changed (the container's `restart-on`, novox/hq
// ADR 0099). It checks; it writes nothing (plex/check.ts says why, and where the write is).
//
// Exits non-zero when Tautulli does not reach plex as the mesh says, so the node reports the step
// failed. Declared last in the manifest, so its failing gates nothing else of tautulli's (novox/hq
// ADR 0136). Never prints a key or a token.
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { keyOfTautulli } from "../client.js";
import { check, tautulliReady, PROVISION, type Binding, type Http } from "./check.js";
const dir = process.env.MESH_PLEX_DIR ?? "/run/plex";
const url = process.env.MESH_TAUTULLI_URL ?? "http://127.0.0.1:8181";
const waitSeconds = Number(process.env.MESH_TAUTULLI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
const read = (path: string) => readFile(path, "utf8").catch(() => undefined);
const apiKey = keyOfTautulli(process.env.MESH_TAUTULLI_CONFIG_DIR);
if (!apiKey) {
console.error("[tautulli-plex] Tautulli's config.ini holds no API key yet — it writes one on its first start");
process.exit(1);
}
const tautulli = { url, apiKey };
if (!(await tautulliReady(http, tautulli, waitSeconds * 1000))) {
console.error(`[tautulli-plex] Tautulli did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let binding: Binding | undefined;
try {
const raw = await read(join(dir, `${PROVISION}.json`));
binding = raw === undefined ? undefined : (JSON.parse(raw) as Binding);
} catch {
binding = undefined;
}
const outcome = await check(http, tautulli, binding, await read(join(dir, `${PROVISION}.secret`)));
if (outcome.result === "connected") {
console.log(`[tautulli-plex] Tautulli reaches plex at ${outcome.url} as the mesh says; connected`);
} else {
console.error(`[tautulli-plex] ${outcome.problem}`);
process.exitCode = 1;
}
+260
View File
@@ -0,0 +1,260 @@
# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before
# Tautulli starts.
#
# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's
# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli.
# Editing it here lasts until the next apply.
#
# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini
# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown.
# A step editing the file while Tautulli runs is overwritten the moment the container is recreated;
# its API has no command that sets the connection, and its settings form needs an admin login. The
# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old
# container stopped (and wrote), before the new one reads. The container restarts on its binding
# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here.
#
# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says:
# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable
# pms_identifier - plex's own machineIdentifier, when plex answers /identity
# pms_token - the pair credential, ONLY when plex takes it
# Every other key and section, comment and ordering stays as it was, byte for byte.
#
# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for
# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it
# trusts). Writing it would replace a working token with a dead one. The address is still written:
# it is right whatever the token, and Tautulli's existing token keeps working at the new address.
# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`.
#
# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli
# starting on what it had is better than not starting. The step after the server is what fails.
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
BINDING = os.environ.get("MESH_PLEX_BINDING", "/run/mesh/plex-api.json")
SECRET = os.environ.get("MESH_PLEX_SECRET", "/run/mesh/plex-api.secret")
CONFIG = os.environ.get("MESH_TAUTULLI_CONFIG", "/config/config.ini")
WAIT = float(os.environ.get("MESH_PLEX_WAIT_SECONDS", "60"))
PROVISION = "plex-api"
def say(message):
print("[mesh-plex] " + message, flush=True)
def is_loopback(host):
h = host.lower()
return h in ("localhost", "::1", "[::1]") or h.startswith("127.")
def wanted_address(binding):
"""The [PMS] address keys the binding says, or a reason it cannot say them."""
if not isinstance(binding, dict):
return None, "no binding for %s was delivered" % PROVISION
at = binding.get("at")
at = at.strip() if isinstance(at, str) else ""
serves = binding.get("serves") if isinstance(binding.get("serves"), dict) else {}
try:
port = int(serves.get("port"))
except (TypeError, ValueError):
port = 0
scheme = serves.get("scheme") or "http"
if not at:
return None, "the %s binding names no host (at)" % PROVISION
if is_loopback(at):
return None, (
"the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; "
"the mesh hands loopback to a machine that is not on the private network" % (PROVISION, at))
if not 0 < port < 65536:
return None, "the %s binding serves no usable port (%r)" % (PROVISION, serves.get("port"))
if scheme not in ("http", "https"):
return None, "the %s binding serves scheme %s, which Tautulli cannot dial" % (PROVISION, scheme)
host = "[%s]" % at if ":" in at and not at.startswith("[") else at
url = "%s://%s:%d" % (scheme, host, port)
return {"pms_ip": at, "pms_port": str(port), "pms_ssl": "1" if scheme == "https" else "0", "pms_url": url}, None
def plex_get(url, path, token=None):
"""(status, parsed JSON or None); raises OSError when plex cannot be asked."""
headers = {"Accept": "application/json"}
if token is not None:
headers["X-Plex-Token"] = token
request = urllib.request.Request(url + path, headers=headers)
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read()
try:
return response.status, json.loads(body)
except ValueError:
return response.status, None
except urllib.error.HTTPError as err:
return err.code, None
def ask_plex(url, token):
"""(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time."""
deadline = time.monotonic() + WAIT
while True:
try:
status, _ = plex_get(url, "/", token)
if status in (400, 401, 403):
takes = False
elif 200 <= status < 300:
takes = True
else:
raise OSError("plex answered %d at /" % status)
identifier = None
status, body = plex_get(url, "/identity")
if status == 200 and isinstance(body, dict):
value = (body.get("MediaContainer") or {}).get("machineIdentifier")
identifier = value if isinstance(value, str) and value else None
return takes, identifier
except OSError as err:
if time.monotonic() >= deadline:
say("plex could not be asked at %s: %s" % (url, err))
return None, None
time.sleep(3)
SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$")
KEY = re.compile(r"^(\s*)([A-Za-z0-9_]+)(\s*=\s*)(.*?)\s*$")
def unquoted(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
return value
def plain(value):
"""ConfigObj reads a value unquoted unless it holds one of these; none of ours should."""
return not re.search(r"[#,\"'\r\n]", value) and value == value.strip()
def laid_over(text, wanted):
"""config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed."""
lines = text.splitlines(True)
if lines and not lines[-1].endswith("\n"):
lines[-1] += "\n"
changed = []
start = end = None
for i, line in enumerate(lines):
m = SECTION.match(line)
if m:
if start is not None:
end = i
break
if m.group(1).strip() == "PMS":
start = i
if start is None:
if lines and lines[-1].strip():
lines.append("\n")
lines.append("[PMS]\n")
start, end = len(lines) - 1, len(lines)
elif end is None:
end = len(lines)
seen = set()
for i in range(start + 1, end):
m = KEY.match(lines[i])
if not m or m.group(2) not in wanted:
continue
key = m.group(2)
seen.add(key)
if unquoted(m.group(4)) != wanted[key]:
lines[i] = "%s%s%s%s\n" % (m.group(1), key, m.group(3), wanted[key])
changed.append(key)
missing = [k for k in wanted if k not in seen]
# Insert after the section's last key, not after the blank lines that separate it from the next.
at = end
while at > start + 1 and not lines[at - 1].strip():
at -= 1
for key in missing:
lines.insert(at, "%s = %s\n" % (key, wanted[key]))
at += 1
changed.append(key)
return "".join(lines), changed
def write_config(text):
"""Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner."""
directory = os.path.dirname(CONFIG) or "."
try:
st = os.stat(CONFIG)
uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777
except FileNotFoundError:
st = os.stat(directory)
uid, gid, mode = st.st_uid, st.st_gid, 0o644
fd, tmp = tempfile.mkstemp(prefix=".config.ini.", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, mode)
try:
os.chown(tmp, uid, gid)
except PermissionError:
pass
os.replace(tmp, CONFIG)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def read(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
def main():
raw = read(BINDING)
try:
binding = json.loads(raw) if raw is not None else None
except ValueError:
binding = None
address, problem = wanted_address(binding)
if problem:
say("left Tautulli's Plex connection as it was: " + problem)
return 0
wanted = dict(address)
token = (read(SECRET) or "").strip()
takes, identifier = ask_plex(address["pms_url"], token) if token else (False, None)
if identifier:
wanted["pms_identifier"] = identifier
frm = binding.get("from") or "<its node>"
if not token:
say("no %s credential was delivered; only the address was written" % PROVISION)
elif takes and plain(token):
wanted["pms_token"] = token
elif takes is False:
say("plex refuses the %s credential the mesh delivered, so it was not written; the address was. "
"plex's token is issued by plex.tv and the mesh cannot make it: accept the server's own token "
"for this pair - `secret accept <this node> tautulli %s --provider %s --from <file holding the "
"server's X-Plex-Token>`" % (PROVISION, PROVISION, frm))
elif takes:
say("the %s credential holds characters config.ini cannot carry unquoted; it was not written" % PROVISION)
else:
say("plex could not be asked whether it takes the %s credential; only the address was written" % PROVISION)
if not all(plain(v) for v in wanted.values()):
say("the %s binding holds characters config.ini cannot carry unquoted; nothing was written" % PROVISION)
return 0
before = read(CONFIG)
after, changed = laid_over(before or "", wanted)
if not changed:
say("Tautulli's Plex connection is already as the mesh says (%s)" % address["pms_url"])
return 0
write_config(after)
say("wrote %s into Tautulli's [PMS] (%s)" % (", ".join(changed), address["pms_url"]))
return 0
if __name__ == "__main__":
sys.exit(main())
+266
View File
@@ -0,0 +1,266 @@
// What holds tautulli's plex-api consumer — both halves.
//
// The write (plex/mesh-plex.py, run in the server container before Tautulli starts): [PMS] is made
// to say what the mesh bound and every other line of config.ini stays byte for byte; nothing is
// written when nothing differs; a token plex refuses is never written, while the address still is;
// a config.ini that does not exist yet is started with [PMS] alone. Run with the machine's python3
// against a fake plex; skipped where there is no python3.
//
// The check (plex/check.ts, the step declared last): a refused token fails naming the `secret
// accept`; a Tautulli pointed elsewhere, or not connected, fails; one pointed where the binding says
// and connected passes.
//
// And the manifest carries exactly the files in plex/ — they are the source, module.json the copy.
//
// Fakes answer as the real ones do (checked against lscr.io/linuxserver/tautulli 2.18.1-ls244 and
// plexinc/pms-docker 1.43.4: plex answers 401 to an unknown token from another network, 400 on one
// it trusts). Imports the compiled step, as keycloak's tests do.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile, spawnSync } from "node:child_process";
import { createServer, type Server } from "node:http";
import { mkdtempSync, readFileSync, statSync, writeFileSync, existsSync } from "node:fs";
import { networkInterfaces, tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { check, type Binding, type Http } from "../dist/plex/check.js";
const here = fileURLToPath(new URL("..", import.meta.url));
const TOKEN = "the-servers-own-token";
const MACHINE = "5c47d9a165d10b622995d55b3ae1f168242f33bd";
// ---- the manifest carries the files ------------------------------------------------------------
test("module.json carries plex/mesh-plex.py and plex/50-mesh-plex exactly", () => {
const m = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { resources: { id: string; content?: string }[] };
const byId = (id: string) => m.resources.find((r) => r.id === id)?.content;
assert.equal(byId("plex-init-code"), readFileSync(join(here, "plex/mesh-plex.py"), "utf8"));
assert.equal(byId("plex-init"), readFileSync(join(here, "plex/50-mesh-plex"), "utf8"));
// Nothing in them the mesh would read as a placeholder.
assert.doesNotMatch(byId("plex-init-code") ?? "", /\$\{/);
assert.doesNotMatch(byId("plex-init") ?? "", /\$\{/);
});
// ---- the write: mesh-plex.py -------------------------------------------------------------------
const python = spawnSync("python3", ["--version"]).status === 0 ? "python3" : undefined;
/** An address of this machine that is not loopback, which the script refuses as plex's. */
function outwardAddress(): string | undefined {
for (const list of Object.values(networkInterfaces())) {
for (const a of list ?? []) if (a.family === "IPv4" && !a.internal) return a.address;
}
return undefined;
}
const outward = outwardAddress();
function fakePlex(opts: { trusted?: boolean } = {}): Promise<{ server: Server; port: number }> {
const server = createServer((req, res) => {
if (req.url === "/identity") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ MediaContainer: { machineIdentifier: MACHINE } }));
return;
}
if (req.headers["x-plex-token"] !== TOKEN) {
res.writeHead(opts.trusted ? 400 : 401);
res.end();
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ MediaContainer: { friendlyName: "ace" } }));
});
return new Promise((resolve) => server.listen(0, "0.0.0.0", () => resolve({ server, port: (server.address() as { port: number }).port })));
}
// An operator's config.ini, shaped as Tautulli writes it: plex at HAL's network gateway.
const OPERATOR_INI = [
"[General]",
"first_run_complete = 1",
"api_key = 0123456789abcdef0123456789abcdef",
"",
"[PMS]",
"pms_identifier = " + MACHINE,
"pms_ip = 172.18.0.1",
"pms_is_remote = 0",
"pms_name = ace",
"pms_port = 32400",
'pms_token = "' + TOKEN + '"',
"pms_ssl = 0",
"pms_url = http://172.18.0.1:32400",
"pms_url_manual = 0",
"",
"[Monitoring]",
"monitor_pms_updates = 0",
"",
].join("\n");
function runScript(dir: string, at: string, port: number, credential: string, wait = "5") {
writeFileSync(join(dir, "plex-api.json"), JSON.stringify({ binding: 1, provision: "plex-api", from: "ace", at, serves: { scheme: "http", port } }));
writeFileSync(join(dir, "plex-api.secret"), credential + "\n");
// Asynchronously: the fake plex answers from this same process, so a blocking spawn would starve it.
return new Promise<{ status: number; out: string }>((resolve) => {
execFile(python as string, [join(here, "plex/mesh-plex.py")], {
env: {
...process.env,
MESH_PLEX_BINDING: join(dir, "plex-api.json"),
MESH_PLEX_SECRET: join(dir, "plex-api.secret"),
MESH_TAUTULLI_CONFIG: join(dir, "config.ini"),
MESH_PLEX_WAIT_SECONDS: wait,
},
encoding: "utf8",
}, (err, stdout, stderr) => resolve({ status: err ? Number((err as { code?: unknown }).code ?? 1) : 0, out: `${stdout}${stderr}` }));
});
}
const skip = !python ? "no python3 here" : !outward ? "no non-loopback address to serve a fake plex on" : false;
test("the write: [PMS] says what the mesh bound, and every other line stays", { skip }, async () => {
const { server, port } = await fakePlex();
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, TOKEN);
assert.equal(r.status, 0);
// The token was already the server's (quoted, as ConfigObj may write it): not rewritten.
assert.match(r.out, /wrote pms_ip, pms_port, pms_url into Tautulli's \[PMS\]/);
const url = `http://${outward}:${port}`;
const expected = OPERATOR_INI
.replace("pms_ip = 172.18.0.1", `pms_ip = ${outward}`)
.replace("pms_port = 32400", `pms_port = ${port}`)
.replace("pms_url = http://172.18.0.1:32400", `pms_url = ${url}`);
assert.equal(readFileSync(join(dir, "config.ini"), "utf8"), expected);
assert.doesNotMatch(r.out, new RegExp(TOKEN));
// Again: nothing differs, nothing is written.
const before = statSync(join(dir, "config.ini")).mtimeMs;
const again = await runScript(dir, outward as string, port, TOKEN);
assert.match(again.out, /already as the mesh says/);
assert.equal(statSync(join(dir, "config.ini")).mtimeMs, before);
} finally {
server.close();
}
});
test("the write: a token plex refuses is never written; the address still is", { skip }, async () => {
for (const trusted of [false, true]) {
const { server, port } = await fakePlex({ trusted });
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, "a-value-the-mesh-minted");
assert.equal(r.status, 0, "custom-init ignores the code; Tautulli starts on what it had");
assert.match(r.out, /secret accept <this node> tautulli plex-api --provider ace/);
assert.doesNotMatch(r.out, /a-value-the-mesh-minted/);
const ini = readFileSync(join(dir, "config.ini"), "utf8");
assert.match(ini, new RegExp(`pms_token = "${TOKEN}"`), "the working token stays");
assert.match(ini, new RegExp(`pms_ip = ${outward!.replace(/\./g, "\\.")}\n`));
} finally {
server.close();
}
}
});
test("the write: a Tautulli with no config.ini yet is started with [PMS] alone", { skip }, async () => {
const { server, port } = await fakePlex();
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
await runScript(dir, outward as string, port, TOKEN);
assert.equal(
readFileSync(join(dir, "config.ini"), "utf8"),
`[PMS]\npms_ip = ${outward}\npms_port = ${port}\npms_ssl = 0\npms_url = http://${outward}:${port}\n` +
`pms_identifier = ${MACHINE}\npms_token = ${TOKEN}\n`,
);
} finally {
server.close();
}
});
test("the write: a plex that cannot be asked gets its address written and no token", { skip }, async () => {
const { server, port } = await fakePlex();
await new Promise((r) => server.close(r)); // nothing listens there now
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, TOKEN, "0");
assert.match(r.out, /could not be asked/);
const ini = readFileSync(join(dir, "config.ini"), "utf8");
assert.match(ini, new RegExp(`pms_url = http://${outward!.replace(/\./g, "\\.")}:${port}\n`));
assert.match(ini, new RegExp(`pms_token = "${TOKEN}"`), "left exactly as it was");
});
test("the write: a loopback binding writes nothing", { skip: !python ? "no python3 here" : false }, async () => {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, "127.0.0.1", 32400, TOKEN, "0");
assert.match(r.out, /private network/);
assert.equal(readFileSync(join(dir, "config.ini"), "utf8"), OPERATOR_INI);
assert.equal(existsSync(join(dir, "config.ini")), true);
});
// ---- the check: plex/check.ts ------------------------------------------------------------------
function binding(at = "ace.internal", port = 32400): Binding {
return { provision: "plex-api", from: "ace", at, serves: { scheme: "http", port } };
}
function fakes(opts: { holds?: string; connected?: boolean; trusted?: boolean } = {}) {
const calls: string[] = [];
const http: Http = {
async fetch(url, init) {
calls.push(url);
const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : JSON.stringify(value)) });
const u = new URL(url);
if (u.hostname === "ace.internal") {
return init?.headers?.["X-Plex-Token"] === TOKEN ? reply(200, {}) : reply(opts.trusted ? 400 : 401);
}
if (u.searchParams.get("apikey") !== "tautulli-key") return reply(401);
const cmd = u.searchParams.get("cmd");
if (cmd === "get_server_info") {
return reply(200, { response: { result: "success", data: { pms_url: opts.holds ?? "http://ace.internal:32400", pms_ip: "ace.internal" } } });
}
if (cmd === "server_status") {
return reply(200, { response: { result: "success", data: { result: "success", connected: opts.connected ?? true } } });
}
return reply(404);
},
};
return { http, calls };
}
const TAUTULLI = { url: "http://127.0.0.1:8181", apiKey: "tautulli-key" };
test("the check: pointed where the binding says and connected passes", async () => {
const f = fakes();
assert.deepEqual(await check(f.http, TAUTULLI, binding(), TOKEN, 0, 0), { result: "connected", url: "http://ace.internal:32400" });
});
test("the check: a token plex refuses fails naming the accept, and never prints it", async () => {
for (const trusted of [false, true]) {
const f = fakes({ trusted });
const out = await check(f.http, TAUTULLI, binding(), "a-value-the-mesh-minted", 0, 0);
assert.equal(out.result, "refused");
const problem = (out as { problem: string }).problem;
assert.match(problem, /secret accept <this node> tautulli plex-api --provider ace/);
assert.doesNotMatch(problem, /a-value-the-mesh-minted/);
assert.equal(f.calls.some((c) => c.includes("/api/v2")), false, "Tautulli was not even asked");
}
});
test("the check: a Tautulli pointed elsewhere fails, naming where it points", async () => {
const out = await check(fakes({ holds: "http://172.18.0.1:32400" }).http, TAUTULLI, binding(), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /at http:\/\/172\.18\.0\.1:32400, not http:\/\/ace\.internal:32400/);
});
test("the check: pointed right but not connected fails", async () => {
const out = await check(fakes({ connected: false }).http, TAUTULLI, binding(), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /not connected/);
});
test("the check: a loopback binding is refused", async () => {
const out = await check(fakes().http, TAUTULLI, binding("127.0.0.1"), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /private network/);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts", "plex/check.ts", "plex/index.ts"]
}
+4 -3
View File
@@ -15,7 +15,7 @@
},
"route": {
"label": "umami",
"port": 3000
"endpoint": "web"
}
},
"binds": {
@@ -49,10 +49,11 @@
},
"listens": [
{
"name": "web",
"port": 3000,
"protocol": "tcp",
"from": "anywhere",
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere"
"from": "mesh",
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
}
],
"resources": [
+9
View File
@@ -6,54 +6,63 @@
],
"listens": [
{
"name": "web",
"port": 8443,
"protocol": "tcp",
"from": "mesh",
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
},
{
"name": "inform",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
"why": "device inform \u2014 how APs and switches check in and are adopted"
},
{
"name": "stun",
"port": 3478,
"protocol": "udp",
"from": "mesh",
"why": "STUN, so managed devices can find the controller through NAT"
},
{
"name": "discovery",
"port": 10001,
"protocol": "udp",
"from": "mesh",
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
},
{
"name": "discovery-l2",
"port": 1902,
"protocol": "udp",
"from": "mesh",
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
},
{
"name": "portal-tls",
"port": 8843,
"protocol": "tcp",
"from": "mesh",
"why": "the guest captive portal over https"
},
{
"name": "portal",
"port": 8880,
"protocol": "tcp",
"from": "mesh",
"why": "the guest captive portal over http"
},
{
"name": "speedtest",
"port": 6789,
"protocol": "tcp",
"from": "mesh",
"why": "mobile-app speed-test throughput measurement"
},
{
"name": "syslog",
"port": 5514,
"protocol": "udp",
"from": "mesh",