From a431746c0e8c21700c2d8bc235c2aa3a410c515f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 18:17:59 +0100 Subject: [PATCH] What a person decides of a node lives in its placement file and what the machine observes in its record; two nodes on one address or two hubs are refused (issue 1126, K2) --- go/compose/frombus/frombus.go | 26 +++++++++++--------- go/compose/frombus/frombus_test.go | 16 ++++++------ go/delivery/noderecord.go | 36 ++++++++------------------- go/delivery/noderecord_test.go | 18 ++++++-------- go/delivery/placement.go | 39 +++++++++++++++++++++++++++++- go/delivery/placement_test.go | 7 ++++-- 6 files changed, 84 insertions(+), 58 deletions(-) diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 2e96c5b..c698795 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -9,8 +9,10 @@ // **What it reads, and from whom.** // - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the // indexer's state `placement`, one key per node (ADR 0549 §2); -// - what each node knows of itself (its place on the private network, capabilities, accounts, keys, networks, -// tunnel, and on the hub the adopted tunnel and its carried peers): the node-engine's state `records`; +// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and +// accounts): the same placement key, Placement.Decided; +// - what each node observes of itself (its tunnel key, capabilities, keys, networks, the tunnel it found, and on the +// hub the adopted tunnel and its carried peers): the node-engine's state `records`; // - every module's manifest: the indexer's `modules.` and its manifests by digest, or the digest the // composing node's want names where it runs another build (Scope.Kept); // - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the @@ -170,12 +172,12 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er if in.Tunnels == nil { in.Tunnels = map[string]compose.FoundTunnel{} } - in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*rec.Tunnel), NodeAdopted: rec.Adopted} - if rec.Place.Hub { + in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*rec.Tunnel), NodeAdopted: p.Decided.Adopted} + if p.Decided.Hub { in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*rec.Tunnel), Hub: node, Adopted: rec.HubAdopted} } } - if rec.Place.Hub { + if p.Decided.Hub { for _, c := range rec.Carried { in.CarriedPeers = append(in.CarriedPeers, compose.CarriedPeer{PublicKey: c.PublicKey, Address: c.Address, EnrolledAs: c.EnrolledAs, Named: c.Named}) @@ -204,15 +206,17 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s return compose.Compositions(in, s.Node, fps, compose.Reading) } -// nodeOf is the composition's node from its record and its placement. +// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person +// decided of it, and where its modules run). func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { + d := p.Decided n := compose.Node{ Name: rec.Node, - Place: compose.Place{Key: rec.Place.Key, Endpoint: rec.Place.Endpoint, Site: rec.Place.Site, Hub: rec.Place.Hub, Address: rec.Place.Address}, - Assigned: p.Assigned(), PublicDomain: rec.PublicDomain, - Account: rec.Account, AccountHome: rec.AccountHome, AgentAccount: rec.AgentAccount, AgentAccountHome: rec.AgentHome, - Adopted: rec.Adopted, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey, - Taken: rec.Taken, OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, + Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}, + Assigned: p.Assigned(), PublicDomain: d.PublicDomain, + Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome, + Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey, + OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, } if len(rec.Capabilities) > 0 { diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 93d8f1e..8246eb5 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -71,17 +71,17 @@ func TestInput(t *testing.T) { mk := func() *bus { return &bus{ placements: map[string]delivery.Placement{ - "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: overlay.Name}}, - Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}}, - "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: overlay.Name}, - {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}}, + "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}}, + Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}}, + "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}, + {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}}, }, records: map[string]delivery.NodeRecord{ - "anchor": {Node: "anchor", Place: delivery.NodePlace{Key: keys["anchor"], Endpoint: "anchor.example:51820", - Site: "here", Hub: true, Address: "10.77.0.1"}, Capabilities: caps, Tunnel: tunnel, Contract: 2, + "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Tunnel: tunnel, Contract: 2, Carried: []delivery.RecordCarriedPeer{{PublicKey: "peer", Address: "10.77.0.9", EnrolledAs: "phone"}}}, - "laptop": {Node: "laptop", Place: delivery.NodePlace{Key: keys["laptop"], Endpoint: "laptop.example:51820", - Site: "here", Address: "10.77.0.2"}, Capabilities: caps}, + "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps}, }, modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "unread": {}}, manifests: map[string][]byte{digest: xterm}, diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go index 72c8ac0..b159d42 100644 --- a/go/delivery/noderecord.go +++ b/go/delivery/noderecord.go @@ -45,16 +45,6 @@ func ParseNodeRecordKey(key string) (node string, err error) { return node, nil } -// NodePlace is where the node is on the private network: its tunnel key (public), endpoint, site, whether it is the -// hub, and its address (compose.Place). -type NodePlace struct { - Key string `json:"key,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Site string `json:"site,omitempty"` - Hub bool `json:"hub,omitempty"` - Address string `json:"address,omitempty"` -} - // RecordTunnel is a tunnel found on the node, as compose.Tunnel holds it: public keys and addresses only. type RecordTunnel struct { Interface string `json:"interface"` @@ -82,21 +72,17 @@ type RecordCarriedPeer struct { Named string `json:"named,omitempty"` } -// NodeRecord is one node's record. Tunnel is the tunnel found on it whose key is its private-network key; on the hub -// alone, Adopted says whether the hub took its found tunnel over and Carried is every peer of it the hub carries, so -// the tunnels and the carried peers are each one node's own and need no other home. +// NodeRecord is one node's record: what the machine observes of itself (issue 1126's K2: what a person decides lives +// in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the public +// key of its private-network tunnel; Tunnel the tunnel found on it whose key that is; on the hub alone, HubAdopted says +// whether the hub took its found tunnel over and Carried is every peer of it the hub carries, so the tunnels and the +// carried peers are each one node's own and need no other home. Certificate is the one the mesh issued for its +// internal name, public, as the node holds it. type NodeRecord struct { V int `json:"v"` Node string `json:"node"` - Place NodePlace `json:"place"` + TunnelKey string `json:"tunnel-key,omitempty"` Capabilities []string `json:"capabilities,omitempty"` - PublicDomain string `json:"public-domain,omitempty"` - Account string `json:"account,omitempty"` - AccountHome string `json:"account-home,omitempty"` - AgentAccount string `json:"agent-account,omitempty"` - AgentHome string `json:"agent-account-home,omitempty"` - Adopted bool `json:"adopted,omitempty"` - Taken []string `json:"taken,omitempty"` SealingKey string `json:"sealing-key,omitempty"` ServingKey string `json:"serving-key,omitempty"` Certificate string `json:"certificate,omitempty"` @@ -113,9 +99,6 @@ func (r NodeRecord) check() error { if err := checkName("node", r.Node); err != nil { return err } - if !r.Place.Hub && (r.HubAdopted || len(r.Carried) > 0) { - return fmt.Errorf("delivery: the record of %s carries peers or an adopted tunnel, which only the hub's does", r.Node) - } if r.HubAdopted && r.Tunnel == nil { return fmt.Errorf("delivery: the record of %s says the hub adopted its tunnel, and names none", r.Node) } @@ -136,8 +119,9 @@ func EncodeNodeRecord(r NodeRecord) ([]byte, error) { return json.Marshal(r) } -// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, -// carried peers or an adopted tunnel on a node that is not the hub, and an empty capability. +// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, an +// adopted tunnel with no tunnel, and an empty capability. Whether the node is the hub is the catalogue's (Placement.Decided): +// a reader takes carried peers and an adopted tunnel only from the hub's record. func DecodeNodeRecord(data []byte) (NodeRecord, error) { var r NodeRecord if err := decode(data, &r); err != nil { diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index e882f76..c816e71 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -13,8 +13,8 @@ import ( // check (ADR 0201 §13) refuses such a field in a state every node reads. var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) -// Issue 1126 (L1 as narrowed by ADR 0549): a node's record keys, reads back, refuses what only the hub may carry, and -// names no field like a secret. +// Issue 1126 (K2): a node's record keys, reads back, refuses an adopted tunnel it does not name, and neither it nor the +// placement file names a field like a secret. func TestNodeRecord(t *testing.T) { key, err := NodeRecordKey("ace") if err != nil || key != "record.ace" || NodeRecordsBucket != "node-engine_records" { @@ -29,11 +29,9 @@ func TestNodeRecord(t *testing.T) { } } - hub := NodeRecord{Node: "novox", Place: NodePlace{Key: "k", Endpoint: "192.0.2.1:51820", Hub: true, Address: "10.0.0.1"}, - Capabilities: []string{"docker"}, PublicDomain: "example.org", Account: "op", AccountHome: "/home/op", - AgentAccount: "agent", AgentHome: "/var/lib/agent", SealingKey: "age1x", ServingKey: "pub", Certificate: "cert", - OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, - NetworksSaid: true, Contract: 3, Taken: []string{"x"}, Adopted: true, + hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", + Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, + NetworksSaid: true, Contract: 3, Tunnel: &RecordTunnel{Interface: "wg0", PublicKey: "k", Peers: []RecordTunnelPeer{{PublicKey: "p"}}}, HubAdopted: true, Carried: []RecordCarriedPeer{{PublicKey: "p", EnrolledAs: "phone"}}} raw, err := EncodeNodeRecord(hub) @@ -46,9 +44,7 @@ func TestNodeRecord(t *testing.T) { } for name, r := range map[string]NodeRecord{ - "carried off the hub": {Node: "ace", Carried: []RecordCarriedPeer{{PublicKey: "p"}}}, - "adopted off the hub": {Node: "ace", HubAdopted: true}, - "adopted with no tunnel": {Node: "novox", Place: NodePlace{Hub: true}, HubAdopted: true}, + "adopted with no tunnel": {Node: "novox", HubAdopted: true}, "empty capability": {Node: "ace", Capabilities: []string{""}}, "node that is not a name": {Node: "a.b"}, } { @@ -76,4 +72,6 @@ func TestNodeRecord(t *testing.T) { } } walk(reflect.TypeOf(NodeRecord{}), "NodeRecord") + // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. + walk(reflect.TypeOf(Placement{}), "Placement") } diff --git a/go/delivery/placement.go b/go/delivery/placement.go index 482ff7d..4f48a1b 100644 --- a/go/delivery/placement.go +++ b/go/delivery/placement.go @@ -109,11 +109,33 @@ type Placement struct { V int `json:"v"` Node string `json:"node"` Revision string `json:"revision,omitempty"` + Decided NodeDecided `json:"decided"` Modules []PlacedModule `json:"modules"` Pins map[string]Provider `json:"pins,omitempty"` Holds []Holding `json:"holds,omitempty"` } +// NodeDecided is what a person decides of the node, kept in its placement file (issue 1126's K2: what a person decides +// lives in the catalogue, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and +// its site, whether it is the private network's hub, its address on the private network, the domain its routed names +// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than +// converged, and the modules taken on an adopted node. The address is a decision written once, at enrolment, by a +// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address. +// No secret and no key material: accounts are names, never credentials. +type NodeDecided struct { + Endpoint string `json:"endpoint,omitempty"` + Site string `json:"site,omitempty"` + Hub bool `json:"hub,omitempty"` + Address string `json:"address,omitempty"` + PublicDomain string `json:"public-domain,omitempty"` + Account string `json:"account,omitempty"` + AccountHome string `json:"account-home,omitempty"` + AgentAccount string `json:"agent-account,omitempty"` + AgentHome string `json:"agent-account-home,omitempty"` + Adopted bool `json:"adopted,omitempty"` + Taken []string `json:"taken,omitempty"` +} + func (p Placement) check() error { if err := checkName("node", p.Node); err != nil { return err @@ -326,7 +348,8 @@ func (p Placement) Held() []catalogue.Held { // CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the // modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a // placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host -// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice. +// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice; +// two nodes with one address on the private network; more than one hub. // A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its // ForeseenPorts and Unbound), which the catalogue's composition check reads. func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string { @@ -349,11 +372,22 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b return false } meshSeats := map[string]string{} + addresses := map[string]string{} + var hubs []string for _, node := range nodes { p := placements[node] if p.Node != node { refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node)) } + if a := strings.TrimSpace(p.Decided.Address); a != "" { + if other, twice := addresses[a]; twice { + refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a)) + } + addresses[a] = node + } + if p.Decided.Hub { + hubs = append(hubs, node) + } if !enrolled(node) { refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node)) } @@ -391,6 +425,9 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b meshSeats[seat] = node } } + if len(hubs) > 1 { + refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", "))) + } return refusals } diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 40c6eb3..3993556 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -40,6 +40,7 @@ func TestPlacement(t *testing.T) { }) g14 := Placement{Node: "g14", Revision: "abc", + Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}}, Modules: []PlacedModule{ {Module: "zsh"}, {Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}}, @@ -47,7 +48,7 @@ func TestPlacement(t *testing.T) { Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}}, Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, } - novox := Placement{Node: "novox", Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}}, + novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}}, Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} t.Run("value", func(t *testing.T) { @@ -153,10 +154,12 @@ func TestPlacement(t *testing.T) { clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}} twice := novox twice.Node = "stranger" + twice.Decided.Address = "10.77.0.2" r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled) for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080", "bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node", - "mesh-store, held once for the mesh, is held on novox and stranger"} { + "mesh-store, held once for the mesh, is held on novox and stranger", + "g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} { found := false for _, line := range r { found = found || strings.Contains(line, want)