Commit Graph
179 Commits
Author SHA1 Message Date
jschoubben 3e0e6e6b7e Delete a forwarded opening the way ufw accepts it, and read a fresh machine's resolver as not in use — both measured on a lab machine (hq ADR 0100) 2026-09-22 17:37:01 +02:00
jschoubben 5e3dd3f59c Raise a machine in use adopted: keep its firewall, load no dropping table, guard the mesh's own ports, and take only the mesh's own modules (hq ADR 0100) 2026-09-22 17:32:44 +02:00
jschoubben 4811f176fd Refuse a converged genesis on a machine in use, naming every container and listener counted (hq ADR 0100) 2026-09-22 17:29:32 +02:00
jschoubben 3964d9da0a Take the foundation's ports as genesis inputs, check them free, and hand them to the controller as the node's settings (hq ADR 0100) 2026-09-22 17:28:36 +02:00
jschoubben 770f589401 Report what an adopted node holds, its firewall and what is reachable, and speak unasked when that changes (hq ADR 0100) 2026-09-22 17:22:31 +02:00
jschoubben 3c90d155b3 Converge openings through the firewall an adopted node was found with, and retire it only when the node converges (hq ADR 0100) 2026-09-22 17:19:49 +02:00
jschoubben 3a613113be Keep what an adopted node was found holding until its module is taken, and report it held (hq ADR 0100) 2026-09-22 17:14:04 +02:00
jschoubben fcc447c216 Read a node's adoption from every declaration, so the host knows which modules are untaken (hq ADR 0100) 2026-09-22 17:11:26 +02:00
jschoubben 4fc0330937 Merge pull request 'An enrolling node asks again while the mesh cannot answer, and proves it holds its key (issue 083)' (#19) from multiple-fixes into main 2026-09-22 14:42:56 +02:00
jschoubben 406a5559b0 An enrolling node signs its request with the identity it just generated, so the mesh can tell it from anyone who knows its public key (novox/hq issue 083) 2026-09-22 14:33:31 +02:00
jschoubben eaebae7b36 Review of 083: the give-up message says to wait out the mesh's hold before asking again; the installer no longer says the token is spent when it may not be 2026-09-22 14:23:20 +02:00
jschoubben c8bbdb2da5 An enrolling node asks again, with the same request, while the mesh says it cannot answer — for as long as the mesh holds the token for it (novox/hq issue 083) 2026-09-22 14:07:25 +02:00
jschoubben 0ed8914c07 Merge pull request 'A run-once step names what it reads and runs again when it changed (ADR 0099)' (#18) from multiple-fixes into main 2026-09-21 23:58:46 +02:00
jschoubben 8da78de210 A run-once step names what it reads and runs again when it changed (novox/hq ADR 0099, issue 077) 2026-09-21 23:25:06 +02:00
jschoubben e8b6d9ac31 Merge pull request 'Genesis registers the control plane with the manifest its build produced (hq issue 072)' (#17) from feat/one-controller-manifest into main 2026-09-21 19:23:17 +02:00
jschoubben ffe7dbd348 Step 9 without a build is refused, and a test says so 2026-09-21 19:21:04 +02:00
jschoubben 8afbe57814 The pinning line shows the built image's id, not one digit of it 2026-09-21 15:27:13 +02:00
jschoubben 5223169226 Genesis registers the control plane with the manifest its build produced
The control plane's manifest existed twice: at the root of its repository, read
whenever the mesh rebuilds it from source, and as a copy in the catalogue, read by
genesis. Nothing kept them equal, and the first rebuild replaced the mesh's record
with the repository's shape while every later push was refused (novox/hq
04-ISSUES/072). The builder's one-shot result already carries the manifest it built,
artifact resolved to the image; step 3 keeps it and step 9 registers it, re-pinning
the built image's bare id to the reference the registry assigned. The catalogue is
still read for the registry's and the builder's manifests and for phase two.
2026-09-21 15:17:47 +02:00
jschoubben 4661025eb7 Merge pull request 'The host applies the newest declaration, a file may be created once, the foundation filters first (issues 031, 035, 054)' (#16) from feat/migration-blockers into main 2026-09-21 13:48:51 +02:00
jschoubben c32eada62b The base filter opens the bus and the registry in the input chain too
A container on the machine dialling a port the machine publishes reaches it
through the runtime's proxy — input, not forward — and the builder could not
reach the broker. The derived ruleset opens the mesh's own ports in both
chains; the base one now does the same.
2026-09-21 12:28:57 +02:00
jschoubben b72b71a989 The host applies the newest declaration, a file may be created once, the foundation filters first
031: a window of unacknowledged declarations is drained to the newest; the
rest are set aside and reported as superseded. 035: a file resource may say
create-once — written when absent, kept untouched when present (ADR 0087).
054: the bundle installs nftables and loads a base ruleset before the store
and broker, in the table the filter module later replaces (ADR 0088).
2026-09-21 12:11:52 +02:00
jschoubben d7eea1ab66 Merge pull request 'The installer delivers any MESH_…_FILE own secret, not only the store's' (#15) from feat/secret-not-in-environment into main 2026-09-21 11:59:15 +02:00
jschoubben c5c42376c1 The installer delivers any MESH_…_FILE own secret, not only the store's (ADR 0086) 2026-09-21 10:10:33 +02:00
jschoubben e30a6b08ea Merge pull request 'Genesis makes the root secrets and the operator key, and installs the vault' (#14) from feat/secrets-vault into main 2026-09-21 10:03:17 +02:00
jschoubben d756effc33 The broker-admin marker ends in a newline, and the transcript never says a credential
The verify reads the marker with the shell's read, which fails at end of file
without a line ending; the action ran and its verify said no. And the applier
reports each action with its command line, two of which now carry the real
store and broker passwords — the installer masks the values it made in
everything it says.
2026-09-21 01:32:51 +02:00
jschoubben 70d0f36896 Installer review: secrets are staged privately, and a bundle is 0600 whether or not it existed
From review: the store and broker passwords genesis makes were carried into
the controller through a world-readable file in /tmp, a bundle left at 0644 by
an earlier installer kept that mode while now holding them, a mesh raised by
the old installer would have been handed new passwords its servers do not have,
and the broker-admin action's marker did not depend on the value. Secrets now
stage in a 0700 directory owned by the controller's account; the bundle is
chmod'd; an existing store or broker volume with no credential file is refused
by name; the marker holds the password's fingerprint. Also: one install path
for the store, broker and vault, no error-string matching for the operator
key, and no unreachable fallback for the superuser.
2026-09-21 01:26:35 +02:00
jschoubben 036af3cfdc The export is its own installer step, and the result names the operator files 2026-09-21 01:11:52 +02:00
jschoubben ee0c8b856e Genesis makes the root secrets, the operator key, and installs the vault
The template raises the store with the password 'bootstrap' and the broker
with its image's default administrator, and the installer carried both into
the mesh as accepted secrets — permanent, and not secret (novox/hq issue 071).

Now the installer makes both credentials, once, at the paths the postgres and
lavinmq modules declare as their own secrets, rewrites the produced bundle to
use them (the store reads its password from a file; the broker's default
account is given the new password by an action before anything dials it), and
writes the bundle at 0600 since it now carries them.

Before the first secret is accepted it makes the operator's sealing key beside
the bundle and gives the mesh the public half, so everything minted from there
is sealed to it too (ADR 0085, amended). Phase three adopts the broker as the
lavinmq module beside the store and installs mesh-vault as a foundation module;
the run ends by writing the operator-sealed export beside the key.
2026-09-21 00:12:55 +02:00
jschoubben 1f483fcacd Merge pull request 'Self-upgrade installer: genesis pivot, rename, adopt store+broker' (#13) from feat/a-bed-that-hands-over-nothing into main 2026-09-16 23:22:06 +02:00
jschoubben 56124c38b7 Phase 3.2: the foundation broker binds amqp (5672) mesh-wide for consumers
Like the store, the amqp provision is plaintext 5672 (vhost-per-login), so a
consumer must reach it — bind 0.0.0.0 (firewall-gated to `mesh`, WireGuard-
encrypted on the wire) instead of loopback. amqps (5671) was already mesh-wide;
management (15672) stays loopback for the host-networked provisioner.

Issue 051 (WBS 3.2).

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 21:24:13 +02:00
jschoubben 1232031fb9 Correct store phrasing — a module gets a database only if it asks
Not "every module's database"; a module requests one via requires
postgres-database. The one server holds the controller's contexts and the
database of each module that asks for one.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 21:20:00 +02:00
jschoubben 9109a8c178 Phase 3.1: adopt the foundation store as the postgres module
InstallStore turns the mesh-store the foundation raised at genesis into the
postgres module, adopted in place: it verifies the module's server names the
same container and the same image the foundation is running (fail-fast on a
drift, rather than tearing down the mesh's store), then registers, builds the
provisioner, and carries the superuser in via secret accept — the mesh cannot
invent a credential that already made the databases (mirroring the control
plane's store-connection delivery, control.go). pinImage generalised to any
module for reuse.

Issue 051 (WBS 3.1). One server holds the controller's contexts and every
module's database.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 21:04:06 +02:00
jschoubben 121367319d Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo
becomes mesh-controller, the seat the-controller, and the store+broker pair the
foundation (embedded base bundles, default template and example lock renamed with
their go:embed directives). No behaviour change — a pure vocabulary rename.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 18:40:40 +02:00
jschoubben 01c7730fb3 Genesis raises gitea correctly: host network, honest SQL, matched ROOT_URL
Fixes found raising the package registry end-to-end in the lab: seed gitea's DB
with plain psql statements (no \gexec, no $$ DO-blocks that clash with the
shell); run gitea on the host network so it reaches the substrate store and
answers where the builder looks; set gitea ROOT_URL to the machine's loopback so
npm's stored credential matches the tarball host; keep the pivot's passwords so a
re-run is the same run; create the admin without re-enabling must-change-password.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 14:11:36 +02:00
jschoubben 1a7c9fdac3 gitea runs on the host network at genesis
So it reaches the substrate store's loopback-published postgres and answers where
mesh-bootstrap and the builder look for it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 10:33:07 +02:00
jschoubben 79863068fc Genesis raises the package registry before it builds the base
The base (mesh-tools) resolves the SDK by version from the mesh's package
registry rather than cloning it from a git URL (hq ADR 0076, issue 053), so the
registry has to answer and the SDK has to be in it before the base build runs.

New steps, before base: seed gitea's database in the substrate store, raise
gitea's server on it, create the admin/org/team and the builder's account, seal
the builder its registry credential, and publish the SDK on a public base. gitea
is adopted as an ordinary module after the base, so its provisioner image can be
built. A minimal Go gitea admin client stands in until that module exists.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 10:27:11 +02:00
jschoubben 21474b0144 The installer goes as far as it can, and asks where a human must choose
Twelve steps made a mesh that RUNS and then said "what remains is somebody
else's". The seven things that turn it into a mesh that WORKS — the shared base,
a database provider, the catalogue, the private network, the packet filter — were
typed afterwards, which is how they went missing for weeks without anything
complaining.

Six more steps now: base, store, catalogue, network, filter, extras. Everything
in them is module add, build, assign and push — the same verbs a person types,
through the same commands, so the installer and an operator remain one act.

Where a human must choose, the installer asks. A choice resolves in the order a
person expects: the flag wins; a lone option answers itself ALOUD, because "it
chose for me" and "there was nothing to choose" read identically afterwards
unless one speaks; a terminal is asked; a default fills in; and a required
choice nothing answered refuses naming its flag — a guessed packet filter is a
machine somebody else configured. The filter is required, so the question is
which, not whether. A run without a terminal (the lab, --json) is never left
waiting on a prompt nobody will answer.

Placement is part of the network step, not a separate act — a lesson paid for:
the module installed, the names file was written with no names in it, and
everything reported success because nobody had said where the machine IS. The
hub endpoint derives from the broker address when unsaid: the host other
machines dial is one fact, not two that drift.

Extras fail the run rather than soft-fail: somebody asked for them by name, and
a mesh reporting success minus one thing is reporting the wrong thing.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 21:56:23 +02:00
jschoubben 7a223464e5 Genesis installs distribution, which is what the software is called
The module that provides artifact-store runs Distribution, the OCI reference
implementation. It was called registry, which named neither the software nor the
provision.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 20:51:00 +02:00
jschoubben de5160de4a A unit file reinterprets an environment value; a container does not
Found by being asked whether processes and containers handle environment the
same way. They do not, and the difference is not cosmetic.

Docker passes --env through literally. A unit file reads three things out of a
value that nothing else does, and a module's environment routinely contains all
three because a generated password is arbitrary bytes:

  - % begins a specifier. %H is the hostname. A password containing one is
    silently replaced, and it fails later as an authentication error nobody can
    explain by reading the declaration.
  - whitespace separates assignments. Unquoted, K=a b sets K to "a" and reads
    "b" as another assignment.
  - a newline ends the line, and what follows is read as a unit DIRECTIVE.

The first two are escaped: quoted, with quotes and backslashes escaped and
percent doubled. The third cannot be — a unit's environment has no way to carry
a line break — so it is refused in validation, near whoever wrote it. Without
that, an environment value could write ExecStart= and have the machine run
something nobody declared.

Ordinary awkward values stay accepted, because refusing those too would leave a
module unable to hold a generated password.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 12:40:36 +02:00
jschoubben f5cf9510c1 One kind for the module's own code, with three modes
The first cut of this added a `daemon` for the long-running case alone. That
would have meant a new vocabulary entry for each of the others — a scheduled
task, a run-once migration, a health check — when they are one thing run at
different cadences. That is a field, not four entries in a vocabulary where every
entry widens what a compromised control plane can express.

So it mirrors a container exactly, because it IS a container's twin: the same
intent, hosted by the machine's own supervisor instead of a runtime. Stays up,
runs once, or runs on a schedule.

Tools, hooks and event consumers are not further modes. They are loaded by a tool
host, which is itself a process that stays up — so the generic case already
covers them, which is the test of whether it is generic.

A scheduled process gets a timer and a unit that finishes; a long-running one
gets a unit that is restarted when it exits. Getting that wrong either way is a
second copy running continuously between fires, or a schedule that never fires.
The modes are exclusive and validation says so near the author: something that
runs once does not run on a schedule, and something not running between fires
cannot be restarted when a file changes.

A missed fire happens when the machine comes back rather than being skipped,
which is the difference between a machine that was down and a schedule that
quietly stopped.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 10:26:31 +02:00
jschoubben 1f0fb85128 A daemon says what to run, not how it is hosted
The mechanism was leaking into every module. Code of one's own meant a container
and therefore an image; a script meant a service and a unit somebody else had to
install. One intent — run this and keep it running — expressed two unrelated
ways, with the hosting chosen before anything could be declared.

A daemon names a bundle and a command. The host fetches it, refuses it unless it
hashes to what was declared, unpacks it where the mesh keeps such things, writes
the unit and puts it in the state asked for. The unit is the mesh's, generated
whole and saying so, because an edit that survives until the next declaration and
then vanishes is worse than one that is refused.

Its identity is the bytes AND how it is run: two daemons from one bundle
differing only in their command are different daemons, and tracking the digest
alone would call the second unchanged and leave the first running. The unit is
rendered deterministically for the same reason — environment from a map would be
written in Go's iteration order, so every apply would see a different unit and
restart an unchanged daemon for ever.

restart-on is honoured as a service's is: a running process does not re-read its
configuration, so replacing a file and finding the daemon already up leaves the
machine behaving as before while every check passes.

A full-host shape, not a portable one: it needs a process supervisor to install
into. It does NOT need a container runtime, which is the point.

Two guards caught this properly and both were updated deliberately rather than
silenced: the vocabulary count, which exists because every addition widens what a
compromised control plane can express, and the shape test that catches a kind the
language has and a host cannot apply — added after `network` did exactly that.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 02:29:33 +02:00
jschoubben 200c6c127f Merge pull request 'A one-shot service that finished is not stopped, and a container is what it reads' (#12) from fix/a-oneshot-that-finished-is-not-stopped into main 2026-09-14 17:16:14 +02:00
jschoubben e7f94e0402 A one-shot service that finished is not stopped, and a container is what it reads
Two faults that both reported success while being wrong, found while proving
the firewall module actually delivers.

A unit whose job is to apply something and exit — load a rule set, set a
sysctl — is inactive the instant it succeeds. Reading that as stopped made it
permanently unsatisfiable: the host started it, it worked, the host read back
stopped and reported the machine as not doing what it was told, on every apply,
for ever, with the rules correctly in place the whole time. That is what the
firewall has been doing on every machine it was assigned to, and why the
four-machine bed was red.

And a container took its identity from its own fields, not from the files it
reads. A file written in an earlier apply — or before the container declared it
as a dependency — left a process holding a credential the mesh had already
replaced, with everything reporting success (novox/hq 04-ISSUES/045). What a
container reads is now part of what it is, so the comparison is a standing one
rather than a tripwire that fires during one apply and never again.
2026-09-14 16:51:57 +02:00
jschoubben 0f143d16ce Merge pull request 'The installer names the unit this project packages' (#11) from fix/the-installer-names-the-unit-we-package into main 2026-09-14 14:11:42 +02:00
jschoubben cc9b3c1a8e The installer names the unit this project packages
It defaulted to mesh-host.service. What packaging/ ships is nox-mesh-host.service,
so on any machine with the packaged unit installed the installer looked for
something absent and told the operator a real machine needs it installed — when
it was installed, under the name the installer was not using.

Only the lab missed it, because the lab passes --host-in-background and never
names a unit at all.
2026-09-14 14:11:27 +02:00
jschoubben 6205a93bfe Merge pull request 'Installation sets up the builder, so a raised mesh can produce' (#10) from feat/installation-sets-up-the-builder into main 2026-09-14 12:32:04 +02:00
jschoubben 8eeb28f00b Installation sets up the builder, so a raised mesh can produce
Genesis ended with a mesh that runs and cannot make anything: every module in
the catalogue names artifacts and nothing had built them, so the first thing
anybody had to do was install a builder by hand.

The installer already carries one — it is what built the control plane — so
this is the same two acts the control plane goes through, in the same order:
publish it, so the mesh names it by a digest its own registry assigned rather
than a local identity nothing else can fetch, then install it as an ordinary
module pinned to that. And then the part only it needs, a broker account, issued
before the push so it arrives with the declaration rather than after it.

Verified on a bare machine: the install ends with a builder running, and that
mesh then built the shared base images and a module on top of them with nobody
helping it.
2026-09-14 12:31:43 +02:00
jschoubben 3dfe574e46 Merge pull request 'The installer carries a builder and builds the control plane it raises' (#9) from feat/a-module-is-a-repository-and-a-path into main 2026-09-13 11:16:50 +02:00
jschoubben 163a44a49a Preflight names the carried image for what it is
It said 'control plane' beside a builder's tag, which is the sort of line that
teaches a reader the wrong thing about what the installer carries.
2026-09-13 04:38:44 +02:00
jschoubben 432e3edcfd Publish the image this mesh built, not the one the installer carried
The carried image is the builder now. The publish step still pushed it, so the
registry got a builder under the control plane's name and the mesh installed it
as the control plane — which presented as a control plane that started, printed
a builder's usage, exited cleanly, and did it again. Caught by the lab on the
first genesis run, at the step that waits for it to answer.
2026-09-13 04:24:18 +02:00