The token says what the mesh calls this machine

Found by raising a mesh end to end for the first time. Enrolment's own
help says the token "is the only thing it needs", and it also needed
--name, with no default. Without it the failure is:

  cannot reach the broker at 192.0.2.10:5671 as : username or password
  not allowed

An empty username, and nothing about the cause.

The node cannot work its own name out. The broker account it
authenticates as is named after it and exists before this machine has
been told anything, so the name has to arrive with the rest. It is not a
secret and the issuer already knows it.

--name stays, as an override for a token issued before the name
travelled in one, and says so when it is needed rather than failing at
the broker.

Also corrects the bundle example, which claimed to stop before the
control plane runs and has raised one for some time. A comment about what
something does not do is a comment nobody updates.
This commit is contained in:
2026-08-30 02:36:42 +02:00
parent ef0d96a1a8
commit bdc9c436b4
5 changed files with 57 additions and 13 deletions
+14 -1
View File
@@ -108,7 +108,7 @@ func parseArgs(args []string) (string, options, error) {
set.StringVar(&opts.state, "state", opts.state, "where this node keeps what it knows") set.StringVar(&opts.state, "state", opts.state, "where this node keeps what it knows")
set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing") set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing")
set.StringVar(&opts.token, "token", "", "enrol: the one-time token, carried here by a person") set.StringVar(&opts.token, "token", "", "enrol: the one-time token, carried here by a person")
set.StringVar(&opts.nodeName, "name", "", "enrol: what this machine is called in the mesh") set.StringVar(&opts.nodeName, "name", "", "enrol: override the name the token carries")
// Parsed in a loop, because the standard library stops at the FIRST non-flag argument. // Parsed in a loop, because the standard library stops at the FIRST non-flag argument.
// `mesh-host inventory --json` hit that once, and taking the subcommand off the front // `mesh-host inventory --json` hit that once, and taking the subcommand off the front
@@ -405,6 +405,19 @@ func enrol(ctx context.Context, opts options) error {
return err return err
} }
// The name comes from the token, because the node cannot work it out: the broker account it
// authenticates as is named after it, and that account exists before this machine has been
// told anything. --name remains for a token issued before the name travelled in one, and
// saying so beats a connection refused with an empty username — which is what this was.
if strings.TrimSpace(*name) == "" {
*name = token.Node
}
if strings.TrimSpace(*name) == "" {
return errors.New(
"this token does not say what the mesh calls this machine, and no --name was given. " +
"A token issued by a current control plane carries the name")
}
// Before anything else: an already-enrolled machine must not quietly acquire a second // Before anything else: an already-enrolled machine must not quietly acquire a second
// identity. The mesh believes the first one, and re-enrolling is a deliberate act that // identity. The mesh believes the first one, and re-enrolling is a deliberate act that
// starts with a person issuing a new token for that node record. // starts with a person issuing a new token for that node record.
+12 -8
View File
@@ -2,20 +2,24 @@
## `substrate-first-node.lock` ## `substrate-first-node.lock`
What a machine must be before a mesh exists — steps 0 to 4 of the bootstrap in What a machine must be before a mesh exists — the bootstrap in
[novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq): [novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq), whole:
``` ```
0 a container runtime 0 a container runtime
1 the store runs 1 the store runs
2 a database per context one today, `inventory` 2 a database per context `inventory` and `identity`
3 that context's schema mesh-control migrate 3 those contexts' schemas mesh-control migrate
4 the broker runs 4 the broker runs with a certificate it generated itself
5 the control plane runs mesh-control serve
``` ```
**It stops there, and the file says why.** Step 5 is a virtual host, a credential and a **A machine that applies this is a mesh** — one node, with nothing joined to it yet, which is
certificate; step 6 is the control plane running. Nothing consumes any of them yet, and a bundle exactly what the first node is (novox/hq ADR 0004). From here it hands out tokens and everything
whose last step cannot be checked is worse than a shorter one. else joins the ordinary way.
This file said it stopped at step 4 for longer than that was true, which is its own small lesson:
a comment about what something does not do is a comment nobody updates.
Build a host carrying it: Build a host carrying it:
+4 -3
View File
@@ -1,9 +1,10 @@
// substrate-first-node.lock — what a machine must be before a mesh exists. // substrate-first-node.lock — what a machine must be before a mesh exists.
// //
// Steps 0 to 5 of the bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-substrate.md): a container // The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-substrate.md): a container runtime, a
// runtime, a store, a database per context, that context's schema, and the broker. // store, a database per context, those contexts' schemas, the broker, and the control plane
// running on top of them.
// //
// It stops before step 6, where the control plane runs. // It stopped before the control plane once, and this comment said so for longer than it was true.
// //
// The broker generates its OWN certificate, in its own image, into a volume it then mounts read // The broker generates its OWN certificate, in its own image, into a volume it then mounts read
// only. Self-signed, because at this moment there is no mesh to issue one and no public name to // only. Self-signed, because at this moment there is no mesh to issue one and no public name to
+19
View File
@@ -377,3 +377,22 @@ func TestASealingKeyOnDiskSurvivesATrailingNewline(t *testing.T) {
t.Fatalf("a round trip through the disk changed the key") t.Fatalf("a round trip through the disk changed the key")
} }
} }
func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) {
// The node cannot work its own name out. The broker account it authenticates as is named
// after it and exists before this machine has been told anything — so without the name in the
// token, enrolment is a connection refused with an empty username, which names nothing about
// the cause. That is exactly how the first end-to-end raise went.
raw := base64.RawURLEncoding.EncodeToString([]byte(
`{"v":1,"node":"anchor","broker":"192.0.2.10:5671",` +
`"fingerprint":"sha256:` + strings.Repeat("ab", 32) + `",` +
`"signer":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `",` +
`"secret":"a-one-time-secret"}`))
token, err := ParseToken(raw)
if err != nil {
t.Fatal(err)
}
if token.Node != "anchor" {
t.Fatalf("the name did not survive the token: %q", token.Node)
}
}
+8 -1
View File
@@ -18,7 +18,14 @@ import (
// so they are held together by a test on each side asserting the exact field names rather than by // so they are held together by a test on each side asserting the exact field names rather than by
// a shared type. If a field is renamed here and not there, that test fails on both sides. // a shared type. If a field is renamed here and not there, that test fails on both sides.
type Token struct { type Token struct {
Version int `json:"v"` Version int `json:"v"`
// Node is what the mesh calls this machine, and it arrives here because the node cannot work
// it out. The broker account it must authenticate as is named after it, so it has to be known
// before the mesh can say anything — and without it enrolment is a connection refused with an
// empty username, which names nothing.
Node string `json:"node,omitempty"`
Broker string `json:"broker,omitempty"` Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"` Fingerprint string `json:"fingerprint,omitempty"`
Signer []byte `json:"signer,omitempty"` Signer []byte `json:"signer,omitempty"`