Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1080f45012 | ||
|
|
a32394ec22 | ||
|
|
8064e5da8f | ||
|
|
63a255c5cb | ||
|
|
7ad1fbd5c6 | ||
|
|
67f5f4cffd | ||
|
|
8797335fbc | ||
|
|
53dc108603 | ||
|
|
ebf5ba2d4c | ||
|
|
bbac08a7d2 | ||
|
|
784a5a6514 | ||
|
|
0c31499fb0 | ||
|
|
f118344246 | ||
|
|
822df220ab | ||
|
|
9eb1265bc8 | ||
|
|
41cfc70b53 | ||
|
|
acedc5d9d9 | ||
|
|
521a8dd1e2 | ||
|
|
e145e2236c | ||
|
|
4d7e37e319 | ||
|
|
026421fd6e | ||
|
|
af89bb11ff |
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "registry",
|
||||
"port": 5000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 8000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 8086,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8283,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 59125,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 27017,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/mosquitto
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
+38
-24
@@ -20,6 +20,8 @@ import { readFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface MqttConn {
|
||||
@@ -141,14 +143,19 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The
|
||||
* client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and
|
||||
* receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped
|
||||
* ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs.
|
||||
* Create (or reset to a known state) a client granted exactly these topic filters, idempotently.
|
||||
* The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and
|
||||
* nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a
|
||||
* consumer's `topics` narrows what it may do. By default the filters are the consumer's own
|
||||
* subtree, `<as>/#` (see topics.ts). Called again for an existing client, it resets the password
|
||||
* and re-asserts the ACLs.
|
||||
*
|
||||
* Only the role named for this client is ever changed. A client or role the mesh did not make —
|
||||
* a device carried from the predecessor's password file, its `legacy-full-access` role — is never
|
||||
* read, changed or removed here.
|
||||
*/
|
||||
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> {
|
||||
async createScopedClient(username: string, password: string, filters: readonly string[]): Promise<void> {
|
||||
const role = username; // one role per client, named for it
|
||||
const pattern = `${topicPrefix}/#`;
|
||||
|
||||
if (await this.clientExists(username)) {
|
||||
await this.ctl("setClientPassword", username, password);
|
||||
@@ -161,17 +168,18 @@ export class MosquittoClient {
|
||||
await this.ctl("createClient", username, "-p", password);
|
||||
}
|
||||
|
||||
// A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are
|
||||
// all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it
|
||||
// created on a previous reconcile. That rejection is the intended terminal state — the ACL is
|
||||
// deterministic (`<prefix>/#`, allow), so re-adding the identical entry is a no-op — so it is
|
||||
// swallowed. (Until the exit code was fixed this was invisible: the tool returned 0 and the
|
||||
// rejection was lost; now it surfaces, and each of these adds must tolerate its own idempotent
|
||||
// re-run explicitly.)
|
||||
// createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run
|
||||
// against a role/ACL it created on a previous reconcile. That rejection is the intended terminal
|
||||
// state, so it is swallowed.
|
||||
await ignoreExisting(this.ctl("createRole", role));
|
||||
for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) {
|
||||
// allow (1) this client to send to, receive on, and subscribe under its own subtree.
|
||||
await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow"));
|
||||
const wanted = wantedAcls(filters);
|
||||
const current = parseRoleAcls(await this.ctl("getRole", role));
|
||||
for (const acl of missingAcls(current, wanted)) {
|
||||
await ignoreExisting(this.ctl("addRoleACL", role, acl.type, acl.topic, "allow"));
|
||||
}
|
||||
// What the consumer no longer asks for — added before it narrowed its topics — is taken away.
|
||||
for (const acl of staleAcls(current, wanted)) {
|
||||
await ignoreMissing(this.ctl("removeRoleACL", role, acl.type, acl.topic));
|
||||
}
|
||||
// Bind the role only when it is not already bound — addClientRole is the one call whose
|
||||
// idempotent re-run cannot be recognised by message (see clientHasRole).
|
||||
@@ -181,25 +189,31 @@ export class MosquittoClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's client accepts exactly this password and still carries its own role.
|
||||
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
|
||||
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
|
||||
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
|
||||
* Whether a consumer's client accepts exactly this password, still carries its own role, and that
|
||||
* role grants exactly these filters. Read-only. The password is checked the way the consumer is
|
||||
* checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted,
|
||||
* 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects
|
||||
* (novox/hq issue 120).
|
||||
*/
|
||||
async holdsClient(username: string, password: string): Promise<boolean> {
|
||||
async holdsClient(username: string, password: string, filters: readonly string[]): Promise<boolean> {
|
||||
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
|
||||
if (code === 4 || code === 5) return false;
|
||||
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
|
||||
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
|
||||
// unlike clientHasRole, which reads every failure as "no role".
|
||||
let out: string;
|
||||
let client: string;
|
||||
let role: string;
|
||||
try {
|
||||
out = await this.ctl("getClient", username);
|
||||
client = await this.ctl("getClient", username);
|
||||
role = await this.ctl("getRole", username);
|
||||
} catch (err) {
|
||||
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
|
||||
throw err;
|
||||
}
|
||||
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
|
||||
if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false;
|
||||
const current = parseRoleAcls(role);
|
||||
const wanted = wantedAcls(filters);
|
||||
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
|
||||
}
|
||||
|
||||
/** Remove a client and the per-client role created for it, idempotently. */
|
||||
|
||||
@@ -20,26 +20,31 @@
|
||||
"mosquitto.topic.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"mqtt-topic": {}
|
||||
"mqtt-topic": {
|
||||
"scheme": "mqtt",
|
||||
"port": 1883
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json"
|
||||
"mqtt-topic": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mqtt-topic": "/var/lib/mosquitto-module/grants"
|
||||
"mqtt-topic": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/mosquitto-module/admin.secret",
|
||||
"admin": "/var/lib/mesh/mosquitto/admin",
|
||||
"broker": "/var/lib/mesh/mosquitto/broker"
|
||||
},
|
||||
"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",
|
||||
@@ -56,26 +61,24 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mosquitto-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants-dir",
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mosquitto-module/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mosquitto/data",
|
||||
"mode": "0700",
|
||||
"owner": "1883:1883"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mosquitto-module/mosquitto.conf",
|
||||
"path": "${dir:state}/mosquitto.conf",
|
||||
"mode": "0600",
|
||||
"owner": "1883:1883",
|
||||
"content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n"
|
||||
@@ -91,8 +94,8 @@
|
||||
"name": "mosquitto-bootstrap",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MQTT": "mosquitto:1883",
|
||||
@@ -110,15 +113,15 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mosquitto",
|
||||
"image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797",
|
||||
"image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408",
|
||||
"network": "mosquitto",
|
||||
"ports": [
|
||||
"1883",
|
||||
"8081"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mosquitto/data:/mosquitto/data",
|
||||
"/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
"${dir:data}:/mosquitto/data",
|
||||
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -128,8 +131,8 @@
|
||||
"network": "mosquitto",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro",
|
||||
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:grants}:/var/lib/mosquitto-module/grants:ro",
|
||||
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-mosquitto",
|
||||
"version": "0.1.0",
|
||||
"description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"description": "mosquitto \u2014 provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
//
|
||||
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and
|
||||
// publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security
|
||||
// role scoped to exactly that subtree.
|
||||
// role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its
|
||||
// work needs (a home-automation hub needs the devices' topics); then the role grants exactly those
|
||||
// (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created
|
||||
// or changed until it is fixed.
|
||||
//
|
||||
// What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port
|
||||
// of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is
|
||||
// the pair credential the mesh delivers to it.
|
||||
//
|
||||
// **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the
|
||||
// login and hands it to both ends so they agree, and mints the password and delivers a copy to each.
|
||||
@@ -15,6 +22,7 @@
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MosquittoClient } from "../client.js";
|
||||
import { topicFilters } from "../topics.js";
|
||||
|
||||
const mosquitto = MosquittoClient.fromEnv();
|
||||
|
||||
@@ -29,13 +37,20 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
|
||||
|
||||
runProvisioner("mqtt-topic", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// The topic subtree is scoped to the consumer's own login, so one cannot read another's topics.
|
||||
const topicPrefix = p.as;
|
||||
await mosquitto.createScopedClient(p.as, p.password, topicPrefix);
|
||||
// By default the consumer's own subtree, so one cannot read another's topics; what it
|
||||
// contributed as `topics` otherwise.
|
||||
const granted = topicFilters(p.values, p.as);
|
||||
if ("problem" in granted) {
|
||||
// Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until
|
||||
// its contribution is valid, rather than being given a grant it did not ask for.
|
||||
throw new Error(`${p.as}: ${granted.problem}`);
|
||||
}
|
||||
await mosquitto.createScopedClient(p.as, p.password, granted.filters);
|
||||
await announce("topic.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
username: p.as,
|
||||
topicPrefix,
|
||||
topicPrefix: granted.own ? p.as : "",
|
||||
topics: granted.filters.join(" "),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -46,6 +61,9 @@ runProvisioner("mqtt-topic", {
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return mosquitto.holdsClient(p.as, p.password);
|
||||
const granted = topicFilters(p.values, p.as);
|
||||
// An invalid list was never applied; create refuses it again, loudly, on every pass.
|
||||
if ("problem" in granted) return false;
|
||||
return mosquitto.holdsClient(p.as, p.password, granted.filters);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// What a consumer of mqtt-topic is granted (topics.ts): its own subtree unless it contributed
|
||||
// `topics`; a contributed list is granted exactly, refused whole when it is not topic filters; and
|
||||
// the role is brought to exactly the wanted ACLs — missing ones added, stale ones removed — read from
|
||||
// `mosquitto_ctrl dynsec getRole` as eclipse-mosquitto 2.1.2 prints it.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterProblem, missingAcls, parseRoleAcls, staleAcls, topicFilters, wantedAcls } from "../topics.ts";
|
||||
|
||||
test("a consumer that contributed nothing gets its own subtree", () => {
|
||||
assert.deepEqual(topicFilters({}, "mesh_ace_hass"), { ok: true, filters: ["mesh_ace_hass/#"], own: true });
|
||||
assert.deepEqual(topicFilters(undefined, "x"), { ok: true, filters: ["x/#"], own: true });
|
||||
// Settings merge into every contribution: keys that are not `topics` change nothing.
|
||||
assert.deepEqual(topicFilters({ endpoints: { web: {} } }, "x"), { ok: true, filters: ["x/#"], own: true });
|
||||
});
|
||||
|
||||
test("a contributed list is granted exactly, duplicates once", () => {
|
||||
assert.deepEqual(topicFilters({ topics: ["#"] }, "x"), { ok: true, filters: ["#"], own: false });
|
||||
assert.deepEqual(topicFilters({ topics: ["stat/+/POWER", "tele/#", "tele/#", "/octoprint/x"] }, "x"), {
|
||||
ok: true,
|
||||
filters: ["stat/+/POWER", "tele/#", "/octoprint/x"],
|
||||
own: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a list that is not topic filters is refused whole", () => {
|
||||
for (const topics of [[], "#", [""], ["a/#/b"], ["a#"], ["a/b+"], [42], ["a\u0000b"], {}]) {
|
||||
const out = topicFilters({ topics } as Record<string, unknown>, "x");
|
||||
assert.equal(out.ok, false, JSON.stringify(topics));
|
||||
}
|
||||
assert.equal(filterProblem("+/+/#"), undefined);
|
||||
assert.equal(filterProblem("#"), undefined);
|
||||
});
|
||||
|
||||
const GET_ROLE = `Warning: You are running mosquitto_ctrl without encryption.
|
||||
This means all of the configuration changes you are making are visible on the network, including passwords.
|
||||
|
||||
Rolename: u1
|
||||
ACLs: publishClientSend : allow : # (priority: 0)
|
||||
subscribePattern : allow : u1/# (priority: 0)
|
||||
publishClientReceive : deny : secret topic/with space (priority: -1)
|
||||
`;
|
||||
|
||||
test("getRole's ACL lines are read, the warning and headings are not", () => {
|
||||
assert.deepEqual(parseRoleAcls(GET_ROLE), [
|
||||
{ type: "publishClientSend", allow: true, topic: "#" },
|
||||
{ type: "subscribePattern", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
|
||||
]);
|
||||
assert.deepEqual(parseRoleAcls("Rolename: empty\nACLs:\n"), []);
|
||||
});
|
||||
|
||||
test("the role is brought to exactly the wanted ACLs", () => {
|
||||
const current = parseRoleAcls(GET_ROLE);
|
||||
const wanted = wantedAcls(["u1/#"]);
|
||||
assert.deepEqual(wanted, [
|
||||
{ type: "publishClientSend", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
|
||||
{ type: "subscribePattern", allow: true, topic: "u1/#" },
|
||||
]);
|
||||
assert.deepEqual(missingAcls(current, wanted), [
|
||||
{ type: "publishClientSend", allow: true, topic: "u1/#" },
|
||||
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
|
||||
]);
|
||||
assert.deepEqual(staleAcls(current, wanted), [
|
||||
{ type: "publishClientSend", allow: true, topic: "#" },
|
||||
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
|
||||
]);
|
||||
assert.deepEqual(staleAcls(wanted, wanted), []);
|
||||
assert.deepEqual(missingAcls(wanted, wanted), []);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// Which topics a consumer of `mqtt-topic` may use — the one choice a consumer makes about its grant.
|
||||
//
|
||||
// **By default, its own subtree and nothing else.** A consumer connects as the login the mesh derived
|
||||
// (`as`) and may publish, receive and subscribe under `<as>/#` — isolated from every other consumer,
|
||||
// which is the point of a per-consumer client (novox/hq ADR 0039/0048).
|
||||
//
|
||||
// **A consumer whose work IS the shared topic space says so.** Home Assistant discovers devices
|
||||
// under `homeassistant/#` and `tasmota/discovery/#` and follows whatever state topics they announce;
|
||||
// Node-RED's flows subscribe to the topics devices publish on (`stat/<device>/POWER`, …). Confined
|
||||
// to `<as>/#` neither could do its job. So a consumer contributes `topics` to its `mqtt-topic`
|
||||
// requirement — a list of MQTT topic filters — and the provisioner grants exactly those, both ways.
|
||||
// Because assignment settings merge into every contribution, an operator narrows (or widens) the
|
||||
// list per machine with the same key, without editing a manifest.
|
||||
//
|
||||
// Pure, so it is tested without a broker (test/topics.test.ts).
|
||||
|
||||
/** The dynsec ACL types a granted filter carries: send to it, receive from it, subscribe to it. */
|
||||
export const GRANTED_ACL_TYPES = ["publishClientSend", "publishClientReceive", "subscribePattern"] as const;
|
||||
|
||||
/** One ACL on a role, as `mosquitto_ctrl dynsec getRole` reports it. */
|
||||
export interface Acl {
|
||||
type: string;
|
||||
allow: boolean;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export type Filters = { ok: true; filters: string[]; own: boolean } | { ok: false; problem: string };
|
||||
|
||||
/**
|
||||
* The topic filters a consumer is granted: what it contributed as `topics`, or its own subtree when
|
||||
* it contributed nothing. Refused — never silently narrowed or widened — when the list is not a
|
||||
* list of valid MQTT topic filters: a grant that quietly differs from what was asked is a consumer
|
||||
* that fails somewhere far from the cause.
|
||||
*/
|
||||
export function topicFilters(values: Readonly<Record<string, unknown>> | undefined, as: string): Filters {
|
||||
const given = values?.topics;
|
||||
if (given === undefined || given === null) {
|
||||
return { ok: true, filters: [`${as}/#`], own: true };
|
||||
}
|
||||
if (!Array.isArray(given) || given.length === 0) {
|
||||
return { ok: false, problem: `topics must be a non-empty list of MQTT topic filters, not ${JSON.stringify(given)}` };
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const f of given) {
|
||||
if (typeof f !== "string") {
|
||||
return { ok: false, problem: `topics holds ${JSON.stringify(f)}, which is not a topic filter` };
|
||||
}
|
||||
const problem = filterProblem(f);
|
||||
if (problem) return { ok: false, problem: `topic filter ${JSON.stringify(f)}: ${problem}` };
|
||||
if (!out.includes(f)) out.push(f);
|
||||
}
|
||||
return { ok: true, filters: out, own: out.length === 1 && out[0] === `${as}/#` };
|
||||
}
|
||||
|
||||
/** Why a string is not a valid MQTT topic filter (MQTT 3.1.1 §4.7), or undefined when it is one. */
|
||||
export function filterProblem(filter: string): string | undefined {
|
||||
if (filter.length === 0) return "it is empty";
|
||||
if (Buffer.byteLength(filter, "utf8") > 65535) return "it is longer than MQTT allows";
|
||||
if (filter.includes("\u0000")) return "it contains a NUL character";
|
||||
const levels = filter.split("/");
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
if (level.includes("#") && (level !== "#" || i !== levels.length - 1)) {
|
||||
return "'#' must be a whole level, and the last one";
|
||||
}
|
||||
if (level.includes("+") && level !== "+") return "'+' must be a whole level";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The ACLs a role must carry to grant these filters: every granted type, allowed, on every filter. */
|
||||
export function wantedAcls(filters: readonly string[]): Acl[] {
|
||||
const out: Acl[] = [];
|
||||
for (const topic of filters) {
|
||||
for (const type of GRANTED_ACL_TYPES) out.push({ type, allow: true, topic });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACLs `mosquitto_ctrl dynsec getRole` lists, one per line under its "ACLs:" heading:
|
||||
* `ACLs: publishClientSend : allow : # (priority: 0)`
|
||||
* ` subscribePattern : allow : u1/# (priority: 0)`
|
||||
*/
|
||||
export function parseRoleAcls(output: string): Acl[] {
|
||||
const out: Acl[] = [];
|
||||
const line = /^(?:ACLs:)?\s*([A-Za-z]+)\s*:\s*(allow|deny)\s*:\s*(.*?)\s+\(priority:\s*-?\d+\)\s*$/;
|
||||
for (const raw of output.split(/\r?\n/)) {
|
||||
const m = raw.match(line);
|
||||
if (m) out.push({ type: m[1], allow: m[2] === "allow", topic: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const key = (a: Acl): string => `${a.type}\u0000${a.allow ? "allow" : "deny"}\u0000${a.topic}`;
|
||||
|
||||
/** ACLs a role carries that it should not: in `current` and not in `wanted`. */
|
||||
export function staleAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
|
||||
const want = new Set(wanted.map(key));
|
||||
return current.filter((a) => !want.has(key(a)));
|
||||
}
|
||||
|
||||
/** ACLs a role should carry and does not. */
|
||||
export function missingAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
|
||||
const have = new Set(current.map(key));
|
||||
return wanted.filter((a) => !have.has(key(a)));
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
"include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 4848,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
{
|
||||
"name": "bus",
|
||||
"port": 4222,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 6789,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 11434,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 32400,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 5432,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "cache",
|
||||
"port": 6379,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "acme",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user