diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 792b520..4add78a 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -816,6 +816,13 @@ func enrol(ctx context.Context, opts options) error { Fingerprint: firstNonEmpty(reply.Fingerprint, token.Fingerprint), Signer: firstNonEmpty2(reply.Signer, token.Signer), Password: reply.Password, + // **Which bus this membership is for, said rather than left empty** (novox/hq + // 04-ISSUES/146). The link refuses a membership that names another bus, and an empty name + // is not this one's — so a node enrolled without it came up and reconnected for ever + // against its own record: "this membership is for \"\", and the mesh's bus is nats". The + // reply does not carry it because there is one bus and the host knows which (ADR 0131); + // what was missing was writing that down where the link reads it. + Transport: link.OnNATS, } if mine.Membership.Password == "" { // The mesh did not replace the token's secret, so it is still this node's broker diff --git a/internal/link/asking_nats.go b/internal/link/asking_nats.go index b8df9e6..ed8bebd 100644 --- a/internal/link/asking_nats.go +++ b/internal/link/asking_nats.go @@ -3,6 +3,7 @@ package link import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -63,8 +64,15 @@ func presentNats(_ context.Context, to Approach, node, secret string, // subscribe its own inbox and nothing else (design 25 §6). The secret is its password, the same // string the request claims, so the server proves somebody holds the token and the request // proves the same thing to the controller without it having to ask the server who connected. + // **Its own inbox space, because that is the only one it may listen in** (novox/hq + // 04-ISSUES/146). A JetStream publish waits for the stream's acknowledgement on an inbox the + // client picks, and the client's default is `_INBOX.` — which this user may not + // subscribe to, so the enrolment failed with a permissions violation on a subject nobody had + // chosen. The permission is `_INBOX.enrol..>` (design 25 §6), so the client is told to + // pick its inboxes there; the reply address below is in the same space for the same reason. conn, err := nats.Connect(natsURL(to.Address), nats.Secure(config), + nats.CustomInboxPrefix("_INBOX.enrol."+node), nats.UserInfo("enrol."+node, secret), nats.Name("mesh-host/enrol/"+node), nats.Timeout(timeout), @@ -124,7 +132,19 @@ func (a *natsAsking) Ask(ctx context.Context, request []byte, wait time.Duration defer cancel() // Into the stream and awaited: an enrolment the bus never accepted must fail here rather than be // assumed, because the node has nothing else to go on. - if _, err := a.js.Publish(EnrolSubject, addressed, nats.Context(publish)); err != nil { + // + // **Once, however many times it is sent** (novox/hq 04-ISSUES/146). The client re-publishes when + // an acknowledgement is slow, and the mesh enrolled the machine on each copy — minting a second + // credential, which replaced the first, which is the one the node had already been given. The + // machine then reconnected for ever as a user whose password the mesh had rotated out from under + // it, and the controller's log said "enrolled anchor" twice in the same second. + // + // The id is the message: the same bytes carry the same id, so the stream discards the client's + // own retry, and a genuine second attempt — which carries a new reply address — is a different + // message and is let through. + sum := sha256.Sum256(addressed) + if _, err := a.js.Publish(EnrolSubject, addressed, + nats.MsgId(hex.EncodeToString(sum[:])), nats.Context(publish)); err != nil { return nil, fmt.Errorf("cannot ask the mesh to enrol this node: %w", err) }