Compare commits

..
Author SHA1 Message Date
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 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
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
53 changed files with 255 additions and 75 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 -8
View File
@@ -28,19 +28,12 @@
"path": "/etc/fail2ban/action.d",
"mode": "0755"
},
{
"id": "fail2ban-local",
"type": "file",
"path": "/etc/fail2ban/fail2ban.local",
"mode": "0644",
"content": "[Definition]\n\n# Where fail2ban writes its own log, declared rather than assumed. The recidive jail reads\n# this file to ban whoever keeps coming back, and the logrotate file this module ships\n# rotates it -- but nothing told fail2ban to write there. Where the package default stands,\n# fail2ban logs to the journal, the recidive jail finds no log file, and the whole service\n# refuses to start, taking the sshd jail with it.\n#\n# In .local, not in fail2ban.conf: that file belongs to the package.\nlogtarget = /var/log/fail2ban.log\n"
},
{
"id": "jail-local",
"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",
@@ -49,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",
+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",
+9 -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
// 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";
@@ -88,8 +89,13 @@ export class NodeRedClient {
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
const body = await this.req("/flows", {
method: "POST",
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
body: JSON.stringify(config),
// v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
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 };
}
+42 -8
View File
@@ -5,6 +5,8 @@
"flows.deployed"
],
"own-secrets": {
"admin": "/var/lib/mesh/nodered/admin",
"api-token": "/var/lib/mesh/nodered/api-token",
"broker": "/var/lib/mesh/nodered/broker"
},
"capabilities": [
@@ -12,6 +14,7 @@
],
"listens": [
{
"name": "web",
"port": 1880,
"protocol": "tcp",
"from": "mesh",
@@ -25,26 +28,58 @@
"path": "/var/lib/mesh/nodered",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "data",
"type": "directory",
"path": "/services/nodered/data",
"mode": "0700",
"owner": "1000:1000"
},
{
"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.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\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",
"type": "container",
"name": "nodered",
"image": "nodered/node-red@sha256:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff",
"image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace",
"env": {
"TZ": "Etc/UTC"
},
"ports": [
"1880"
],
"args": [
"--settings",
"/config/settings.js"
],
"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"
]
},
{
@@ -52,8 +87,7 @@
"type": "file",
"path": "/var/lib/mesh/nodered/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
},
{
"id": "runtime",
@@ -66,7 +100,7 @@
],
"env": {
"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"
},
"restart-on": [
@@ -81,11 +115,11 @@
"contributes": {
"route": {
"label": "nodered",
"port": 1880
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/nodered/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"on": [
+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",
+2 -1
View File
@@ -12,6 +12,7 @@
],
"listens": [
{
"name": "web",
"port": 8181,
"protocol": "tcp",
"from": "mesh",
@@ -85,7 +86,7 @@
"contributes": {
"route": {
"label": "tautulli",
"port": 8181
"endpoint": "web"
}
},
"binds": {
+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",