From 952092ccb3449652b1ea00d6ab205318dc67155f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:09:42 +0200 Subject: [PATCH] A carried peer is nameable, and the mesh answers for it (hq 112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel the hub took over routes to machines the predecessor knows by name and the mesh knew only by address — taking the resolver in that state silences three machines at once. Now the operator states which machine a carried address is (overlay name
), the statement rides tunnel_peer.named, and namesInTheMesh answers for named not-yet-enrolled peers — one reading, so the hosts fact, a container's hosts and the resolver cannot disagree. Enrolment verifies the word: a machine enrolling under a named peer's key with a different name is refused where the operator can read it, the stated name keeps the carried address, and an enrolled peer's name is the node's — naming it again refuses. The issue's rule holds: a name the predecessor answers for keeps resolving until the machine behind it is a node. --- cmd/mesh-controller/network.go | 39 +++++++- internal/inventory/addresses.go | 11 +++ .../0033-a-carried-peer-is-nameable.sql | 10 ++ internal/inventory/named_peer_test.go | 92 +++++++++++++++++++ internal/inventory/tunnel.go | 47 +++++++++- 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql create mode 100644 internal/inventory/named_peer_test.go diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 724eaf7..5bdf704 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -48,7 +48,7 @@ func overlayRange(ctx context.Context, inv *inventory.Inventory) (string, error) func overlayCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("overlay place [flags], or overlay show") + return errors.New("overlay place [flags], overlay name
, or overlay show") } // Answered before anything is opened. A message about which command to use should not need a // database to say so, and needing one turns a redirect into a connection error. @@ -69,12 +69,29 @@ func overlayCommand(ctx context.Context, args []string) error { return overlayPlace(ctx, inv, args[1:]) case "show": return overlayShow(ctx, open) + case "name": + return overlayName(ctx, inv, args[1:]) default: - return fmt.Errorf("overlay has no %q; it has place and show", args[0]) + return fmt.Errorf("overlay has no %q; it has place, name and show", args[0]) } } +// overlayName is the operator saying which machine a carried address is (novox/hq issue 112), +// so the mesh answers for its name until the machine enrols and verifies it. +func overlayName(ctx context.Context, inv *inventory.Inventory, args []string) error { + if len(args) != 2 { + return errors.New("overlay name ") + } + address, name := args[0], args[1] + if err := inv.NamePeer(ctx, address, name); err != nil { + return err + } + fmt.Printf("the peer at %s is %s until it enrols — the mesh answers for %s. from the "+ + "operator's word, and enrolment under this key must use this name\n", address, name, name) + return nil +} + func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string) error { if len(args) == 0 { return errors.New( @@ -588,6 +605,24 @@ func namesInTheMesh(ctx context.Context, inv *inventory.Inventory, for _, p := range places { out[overlay.InternalName(p.Name)] = p.Address } + // And the carried peers the operator has named (novox/hq issue 112): machines the + // predecessor's resolver answers for and the mesh routes to, known by name on the operator's + // word until they enrol — at which point enrolment verifies the name and the node's own + // entry takes over above. A name the predecessor answers for must keep resolving until the + // machine behind it is a node; without these, taking the resolver silences three machines. + carried, err := inv.CarriedPeers(ctx) + if err != nil { + return nil, err + } + for _, p := range carried { + if p.Named == "" || p.EnrolledAs != "" { + continue + } + if _, taken := out[overlay.InternalName(p.Named)]; taken { + continue // a node of the mesh owns the name; the stale statement loses + } + out[overlay.InternalName(p.Named)] = p.Address + } return out, nil } diff --git a/internal/inventory/addresses.go b/internal/inventory/addresses.go index 44214df..813f179 100644 --- a/internal/inventory/addresses.go +++ b/internal/inventory/addresses.go @@ -69,6 +69,17 @@ func (i *Inventory) AssignAddress(ctx context.Context, node, cidr string) (strin if key != nil && p.PublicKey == *key { // The tunnel already routes to this key: the node keeps that address, and the peer // notices nothing when its machine enrols. + // + // **Unless the operator named it something else** (novox/hq issue 112). The name is + // what the mesh has been answering for this address in the meantime; a machine + // enrolling under a different one would silently split the two — the name resolving + // here, the node known as that — so it is refused where the operator can read it. + if p.Named != "" && p.Named != name { + return "", fmt.Errorf( + "the carried peer at %s was named %q, and %q is enrolling under its key — "+ + "enrol it as %q, or rename the peer first (`overlay name`)", + p.Address, p.Named, name, p.Named) + } return i.place(ctx, node, p.Address) } taken[p.Address] = true diff --git a/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql b/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql new file mode 100644 index 0000000..0834ee8 --- /dev/null +++ b/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql @@ -0,0 +1,10 @@ +-- A carried peer may be named before it enrols (novox/hq issue 112). +-- +-- The tunnel the hub took over routes to machines the predecessor knows by name and the mesh +-- knows only by address. A name the predecessor answers for must keep resolving until the +-- machine behind it is a node — so the operator may state which machine a carried address is, +-- and everything derived from "the machines the mesh knows" (a container's hosts, the hosts +-- fact, the resolver) answers for it in the meantime. The mesh records the statement as the +-- operator's, unverified: enrolment is what verifies it, and enrolling under a different name +-- than the one stated is refused rather than silently renamed. +alter table tunnel_peer add column named text; diff --git a/internal/inventory/named_peer_test.go b/internal/inventory/named_peer_test.go new file mode 100644 index 0000000..52c669e --- /dev/null +++ b/internal/inventory/named_peer_test.go @@ -0,0 +1,92 @@ +package inventory + +// A carried peer is nameable (novox/hq issue 112): the operator states which machine a carried +// address is, the mesh answers for the name until the machine enrols, and enrolment verifies the +// statement rather than silently renaming it. + +import ( + "strings" + "testing" +) + +func TestACarriedPeerIsNamedAndTheRegistrySaysSo(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + + if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil { + t.Fatal(err) + } + carried, err := inv.CarriedPeers(t.Context()) + if err != nil { + t.Fatal(err) + } + byKey := map[string]CarriedPeer{} + for _, c := range carried { + byKey[c.PublicKey] = c + } + if byKey[peerTwo].Named != "home-server" || byKey[peerThree].Named != "" { + t.Fatalf("the statement was not recorded where it was made: %+v", carried) + } +} + +func TestNamingRefusesWhatWouldCollide(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + + if err := inv.NamePeer(t.Context(), "192.0.2.9", "ghost"); err == nil || + !strings.Contains(err.Error(), "no carried peer") { + t.Fatalf("naming an address nothing carries must refuse; got %v", err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.2", "anchor"); err == nil || + !strings.Contains(err.Error(), "node of this mesh") { + t.Fatalf("naming a peer after a node must refuse; got %v", err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil { + t.Fatal(err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err == nil || + !strings.Contains(err.Error(), "already named") { + t.Fatalf("one machine per name; got %v", err) + } +} + +func TestEnrolmentUnderANamedKeyMustUseTheName(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err != nil { + t.Fatal(err) + } + + // The wrong name is refused where the operator can read it… + imposter, err := inv.AddNode(t.Context(), "some-other-name") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordOverlayKey(t.Context(), imposter.ID, peerThree); err != nil { + t.Fatal(err) + } + if _, err := inv.AssignAddress(t.Context(), imposter.ID, "192.0.2.0/24"); err == nil || + !strings.Contains(err.Error(), `named "home-server"`) { + t.Fatalf("enrolling a named peer under another name must refuse; got %v", err) + } + + // …and the stated name enrols cleanly, keeping the carried address. + named, err := inv.AddNode(t.Context(), "home-server") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordOverlayKey(t.Context(), named.ID, peerThree); err != nil { + t.Fatal(err) + } + address, err := inv.AssignAddress(t.Context(), named.ID, "192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + if address != "192.0.2.3" { + t.Fatalf("the named peer keeps its carried address; got %s", address) + } + if err := inv.NamePeer(t.Context(), "192.0.2.3", "renamed"); err == nil || + !strings.Contains(err.Error(), "enrolled as") { + t.Fatalf("an enrolled peer's name is the node's; got %v", err) + } +} diff --git a/internal/inventory/tunnel.go b/internal/inventory/tunnel.go index 39ceeb7..18cb2f0 100644 --- a/internal/inventory/tunnel.go +++ b/internal/inventory/tunnel.go @@ -85,6 +85,9 @@ type CarriedPeer struct { Address string // EnrolledAs names the node that enrolled with this key, or is empty while none has. EnrolledAs string + // Named is what the operator said this peer is, before it enrolled (novox/hq issue 112) — + // a statement the mesh records and cannot verify, which is why enrolment checks it. + Named string } // ErrNoTunnel is asking about a tunnel on a node that presented none. @@ -247,7 +250,7 @@ func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, er // adopted no tunnel. func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { rows, err := i.store.Pool().Query(ctx, - `select p.public_key, host(p.address), coalesce(n.name, '') + `select p.public_key, host(p.address), coalesce(n.name, ''), coalesce(p.named, '') from tunnel_peer p join node hub on hub.id = p.node and hub.is_hub and hub.tunnel is not null and hub.overlay_key = hub.tunnel->>'public_key' @@ -260,7 +263,7 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { var out []CarriedPeer for rows.Next() { var p CarriedPeer - if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs); err != nil { + if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs, &p.Named); err != nil { return nil, err } out = append(out, p) @@ -268,6 +271,46 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { return out, rows.Err() } +// NamePeer records the operator's statement that a carried address is a particular machine +// (novox/hq issue 112). Refused when nothing carried has that address, when a node of the mesh +// already has the name — the statement would collide with something verified — and when another +// peer was already named it. Naming an enrolled peer is refused too: its name is the node's now. +func (i *Inventory) NamePeer(ctx context.Context, address, name string) error { + peers, err := i.CarriedPeers(ctx) + if err != nil { + return err + } + var at *CarriedPeer + for idx := range peers { + if peers[idx].Address == address { + at = &peers[idx] + continue + } + if peers[idx].Named == name { + return fmt.Errorf("the carried peer at %s is already named %q — one machine per name", + peers[idx].Address, name) + } + } + if at == nil { + return fmt.Errorf("no carried peer has the address %s — `overlay show` lists them", address) + } + if at.EnrolledAs != "" { + return fmt.Errorf("the peer at %s enrolled as %q — its name is the node's now", address, at.EnrolledAs) + } + if _, err := i.NodeByName(ctx, name); err == nil { + return fmt.Errorf("%q is a node of this mesh — a carried peer cannot be named after one", name) + } + tag, err := i.store.Pool().Exec(ctx, + `update tunnel_peer set named = $2 where host(address) = $1`, address, name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no carried peer has the address %s", address) + } + return nil +} + // FoundTunnel is a node's found tunnel with the node's mode, for composing: the takeover is // declared to an adopted node only, since only there is a found unit kept to be stopped. type FoundTunnel struct {