A carried peer is nameable, and the mesh answers for it (hq 112) #73

Merged
jschoubben merged 1 commits from feat/112-a-carried-peer-is-nameable into main 2026-09-26 18:10:00 +00:00
5 changed files with 195 additions and 4 deletions
+37 -2
View File
@@ -48,7 +48,7 @@ func overlayRange(ctx context.Context, inv *inventory.Inventory) (string, error)
func overlayCommand(ctx context.Context, args []string) error { func overlayCommand(ctx context.Context, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New("overlay place <node> [flags], or overlay show") return errors.New("overlay place <node> [flags], overlay name <address> <name>, or overlay show")
} }
// Answered before anything is opened. A message about which command to use should not need a // 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. // 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:]) return overlayPlace(ctx, inv, args[1:])
case "show": case "show":
return overlayShow(ctx, open) return overlayShow(ctx, open)
case "name":
return overlayName(ctx, inv, args[1:])
default: 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 <carried-address> <node-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.<suffix> 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 { func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New( return errors.New(
@@ -588,6 +605,24 @@ func namesInTheMesh(ctx context.Context, inv *inventory.Inventory,
for _, p := range places { for _, p := range places {
out[overlay.InternalName(p.Name)] = p.Address 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 return out, nil
} }
+11
View File
@@ -69,6 +69,17 @@ func (i *Inventory) AssignAddress(ctx context.Context, node, cidr string) (strin
if key != nil && p.PublicKey == *key { if key != nil && p.PublicKey == *key {
// The tunnel already routes to this key: the node keeps that address, and the peer // The tunnel already routes to this key: the node keeps that address, and the peer
// notices nothing when its machine enrols. // 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) return i.place(ctx, node, p.Address)
} }
taken[p.Address] = true taken[p.Address] = true
@@ -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;
+92
View File
@@ -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)
}
}
+45 -2
View File
@@ -85,6 +85,9 @@ type CarriedPeer struct {
Address string Address string
// EnrolledAs names the node that enrolled with this key, or is empty while none has. // EnrolledAs names the node that enrolled with this key, or is empty while none has.
EnrolledAs string 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. // 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. // adopted no tunnel.
func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
rows, err := i.store.Pool().Query(ctx, 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 from tunnel_peer p
join node hub on hub.id = p.node and hub.is_hub 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' 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 var out []CarriedPeer
for rows.Next() { for rows.Next() {
var p CarriedPeer 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 return nil, err
} }
out = append(out, p) out = append(out, p)
@@ -268,6 +271,46 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
return out, rows.Err() 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 // 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. // declared to an adopted node only, since only there is a found unit kept to be stopped.
type FoundTunnel struct { type FoundTunnel struct {