Commit Graph
14 Commits
Author SHA1 Message Date
jschoubben 3c7aafdc21 nodered: its MQTT broker comes from the mesh
Node-RED's one broker node pointed at zurag.be:1884, where nothing listens. nodered now requires
mqtt-topic (asking for every topic: flows follow the devices' own) and a run-once `mqtt` step —
declared last, restarted when the binding, credential or settings change — points the mesh's broker
nodes at the bound broker through Node-RED's admin API with the module's api-token: the node the
step makes itself when none is named, or the ones an assignment names in `mqtt.brokers`. Only host,
port, TLS and the login change; the broker is asked first whether it takes the login; the deploy is
against the revision read ("nodes", so only that node restarts) and a digest makes a rerun a no-op.
A broker node nobody named is never touched. settings.js keeps `mqtt` and `topics` out of Node-RED.
2026-09-30 13:01:14 +02:00
jschoubben 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
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
jschoubben 7b06a7a408 Event names are local now, in the manifests and in the code
Every module named its events the way the old bus spelled a routing key —
`module.<module>.<verb>`. Design 29 says a module names an event locally and the
mesh works out where it lands, so all 37 were stale against a rule already
decided. On the new bus that derives into a namespace belonging to a module
called "module", so no cross-module subscription in the mesh matched anything:
nothing failed, nothing reacted (novox/hq 04-ISSUES/127).

36 manifests converted, and 43 files of module code with them. The code mattered
as much as the manifests: the runtime builds the subject from what `emit()` is
handed, so a converted manifest with unconverted code would have had the
permission and the subject disagree.

Three things the new check found on the way:

- `photos` emitted an event its manifest never declared, which the new bus refuses
  outright. Declared.
- `showcase` waited for an event nothing emits, so its demo could never be
  triggered — only `showcase` may publish under its own name. It emits both halves
  now.
- `distribution` declared an event named after a different module. It emits
  `image.pushed` under its own name. An event about a *role* belongs on the seat,
  where the name outlives whoever holds it, but the sdk has no way to publish on a
  seat yet, so that stays recorded rather than declared.

The audit logger's "everything" pattern is `**` rather than the old bus's `#`.
2026-09-27 14:42:28 +02:00
jschoubben e362fb951c The rest of the catalogue becomes mesh-buildable (issue 060, batch 2)
The 21 media/home modules, the SaaS tool modules (cloudflare-dns,
confluence, gitlab, jira), model-usage, and the model-access trio get
the same Dockerfile + build section as batch 1. Scheduled-only modules
(anthropic-consumer, anthropic-manager, openai-consumer) deliberately
declare no MESH_TOOL_MODULES — every container of theirs names its
command. mosquitto's run-once bootstrap container builds from the same
artifact. Modules with third-party deps (model-usage: pg;
anthropic-manager: tweetnacl) install them beside their compiled code.

Also fixes cloudflare-dns's package.json, unparseable since its
description lost a closing quote.

Deliberately still without build sections: builder and mesh-controller
(the foundation builds them by its own path), distribution (provides
the artifact store — building it through itself is refused by design),
route-proxy (cross-repo build context, deferred), and the
upstream-image-only modules, which have no code to build.
2026-09-18 02:14:09 +02:00
jschoubben f0665ba956 ADR 0056: selectable ACME issuer, step-ca init from operator root, route labels
Piece A + B of ADR 0056, completing the internal-CA work in ff01ada.

Selectable issuer. acme-ca is now a role two providers can satisfy: step-ca
(internal CA) or the new public-acme (a fact-only module, no container/listen)
that serves Let's Encrypt production. A mesh assigns one or the other to satisfy
route-proxy's `requires: acme-ca`.

One directory shape for both. A provider serves the ACME directory's parts the
way the mesh already models any reachable service -- an address (`at`), a `port`
and a `path` -- and route-proxy composes `https://<at>:<port><path>`. step-ca
lets the mesh fill `at` (its node) and `port` (its single listen) and serves only
`path`; public-acme, not being a mesh service, serves all three (overriding `at`
with the public host). Same composition either way.

Empty root means the system trust store. Both providers serve `root`: step-ca
the operator root PEM (settled per mesh), public-acme an empty string. route-proxy
writes it to the CA bundle file unconditionally; the binary now reads an empty
bundle as "the root is already trusted by the OS" and falls back to system roots
(examples/route-proxy/main.go, committed on the mesh-control ADR-0056 branch).

step-ca inits from the operator's root. The operator's root cert, root key and
root-key password are mounted at the smallstep entrypoint's default init paths
(/run/secrets/root_ca.crt, root_ca_key, root_ca_key_password) with the matching
DOCKER_STEPCA_INIT_*_FILE vars, so `step ca init` adopts the operator's root
instead of self-generating one -- the CA that signs is the CA route-proxy trusts.

Route names are labels, not FQDNs. Every routed module now contributes a `label`
(the leftmost subdomain) instead of a full public hostname; the node's public
domain composes the name. Apex (novox.be) is left as a full name -- composeName
has no empty-label/apex convention yet (mesh-control follow-up).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 00:21:42 +02:00
jschoubben e26ca38eaa novox conversions: give the web apps public names through route-proxy
Add a `route` contribution (requires/contributes/binds) to every web app so
each gets a Host-routed public name via route-proxy, mirroring the
de-spiegel/only-office pattern:

- novox: gitea, keycloak, nextcloud, umami, invoicing, verdaccio, registry,
  and mailu (single mail.novox.be -> 7080; admin/webmail/api ride that port).
- ace: grafana, sonarr, radarr, lidarr, bazarr, ombi, tautulli, jackett,
  nodered, searxng, home-assistant, bookshelf, baserow.

Split photos so its three sites each get a name: photos keeps server +
admin-client (photos.novox.be), and new photos-eef (eef.novox.be) and
photos-filip (filip.novox.be) modules carry the client sites.

The production FQDN stays the literal default; a per-node .incus name is a
settings override applied where the mesh runs, not a manifest hardcoding.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-09 12:51:15 +02:00
jschoubben 6e8c18afff Resync hq ADR references 0044-0054 -> 0039-0049 after the hq record reconciliation 2026-09-05 12:49:16 +02:00
jschoubben 550393b847 Runtime config: dedicated mergeable file + restart-on so settings take effect (issue 009)
Give each settings-config runtime a mergeable config file with its own id
(runtime-config) — the previous "config" collided with modules that already own a
config directory, so ten runtimes mounted a config file no resource declared. Point
each runtime's restart-on at it, so a settings change recreates the runtime and it
re-reads the new value (needs the mesh-host container restart-on fix on
issue/009-container-restart-on). Proven: runtime-restart-on-config e2e green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:40:07 +02:00
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00
jschoubben 67d8d0dcbd catalogue: one mesh-state namespace — /var/lib/mesh/<module>/ (consistency)
The review found broker own-secret paths drifting: mostly /var/lib/<module>/
broker, but grafana/redis/icecast/nextcloud carried a '-module' suffix to dodge
a collision with the service's own /var/lib/<name> data, and photos sat under
/etc. Normalized to one collision-free namespace a service never owns:
/var/lib/mesh/<module>/broker, with a mesh-state directory resource for the
parent, across 24 modules. audit-logger is grandfathered (lab-proven, referenced
by the assigned test, and it has no service to collide with). All 33 manifests
parse; no client hardcoded a path, so nothing in code moved.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 21:28:55 +02:00
jschoubben eccfc70991 grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)
grafana: status/datasources/dashboards/alerts tools, emits alert.firing.
tautulli: activity/history/stats tools, emits watch.recorded. nextcloud:
users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits
user.created/share.created. nodered: flows/nodes/deploy tools, emits
flows.deployed inline from the deploy tool. All typecheck; manifests parse.
2026-09-04 02:43:20 +02:00
jschoubben c5efbd53ca A module is a package, not a bare manifest
Each module becomes modules/<name>/ holding module.json, with room for
the rest of what a module is — its tools, health checks, provisioning,
lifecycle — which the conversion from hal still has to bring across.
The flat <name>.json was only the resource-declaration half.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 00:47:14 +02:00