diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index da18fc1..345ab2c 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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.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.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. // `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 } + // 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 // 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. diff --git a/examples/README.md b/examples/README.md index d4dfb62..687430a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,20 +2,24 @@ ## `substrate-first-node.lock` -What a machine must be before a mesh exists — steps 0 to 4 of the bootstrap in -[novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq): +What a machine must be before a mesh exists — the bootstrap in +[novox/hq `07-the-substrate.md`](https://git.novox.be/novox/hq), whole: ``` 0 a container runtime 1 the store runs -2 a database per context one today, `inventory` -3 that context's schema mesh-control migrate -4 the broker runs +2 a database per context `inventory` and `identity` +3 those contexts' schemas mesh-control migrate +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 -certificate; step 6 is the control plane running. Nothing consumes any of them yet, and a bundle -whose last step cannot be checked is worse than a shorter one. +**A machine that applies this is a mesh** — one node, with nothing joined to it yet, which is +exactly what the first node is (novox/hq ADR 0004). From here it hands out tokens and everything +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: diff --git a/examples/substrate-first-node.lock b/examples/substrate-first-node.lock index 5fa6a86..1e0fb3d 100644 --- a/examples/substrate-first-node.lock +++ b/examples/substrate-first-node.lock @@ -1,9 +1,10 @@ // 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 -// runtime, a store, a database per context, that context's schema, and the broker. +// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-substrate.md): a container runtime, a +// 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 // only. Self-signed, because at this moment there is no mesh to issue one and no public name to diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index 762faca..2d0c4f9 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -377,3 +377,22 @@ func TestASealingKeyOnDiskSurvivesATrailingNewline(t *testing.T) { 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) + } +} diff --git a/internal/identity/token.go b/internal/identity/token.go index 7ea0dff..40fab81 100644 --- a/internal/identity/token.go +++ b/internal/identity/token.go @@ -18,7 +18,14 @@ import ( // 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. 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"` Fingerprint string `json:"fingerprint,omitempty"` Signer []byte `json:"signer,omitempty"`