diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index d6b2580..7791246 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -620,7 +620,7 @@ func overlayPush(ctx context.Context, inv *inventory.Inventory) error { fmt.Printf("%s is not on the overlay yet — skipped\n", n.Name) continue } - declaration, err := overlay.Declaration(n, peers, "") + declaration, err := overlay.Declaration(n, peers, nodes, "") if err != nil { return err } diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go index eef7aec..c92112f 100644 --- a/internal/overlay/declaration.go +++ b/internal/overlay/declaration.go @@ -34,7 +34,7 @@ type Resource map[string]any // Three resources and nothing clever: the tools, the configuration, and the interface running. A // person can read it, which is the point — this is the first thing a node is ever told, and if it // is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { +func Declaration(node Node, peers []Peer, everyone []Node, keyPath string) ([]byte, error) { if node.Address == "" { return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", node.Name) @@ -78,6 +78,18 @@ func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { }, } + // And the names, which come from the same graph and arrive in the same declaration. Separate + // steps in the design and one delivery in practice: a node that had the peers and not the + // names, or the reverse, would be half on the network for as long as that lasted. + names, err := Hosts(everyone, node.Name) + if err != nil { + return nil, err + } + resources = append(resources, Resource{ + "id": "mesh-names", "type": "file", "path": HostsPath, + "mode": "0644", "content": names, + }) + return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) } diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go index 8e41884..61d437c 100644 --- a/internal/overlay/declaration_test.go +++ b/internal/overlay/declaration_test.go @@ -9,7 +9,7 @@ import ( func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { t.Helper() - raw, err := Declaration(node, peers, "") + raw, err := Declaration(node, peers, nil, "") if err != nil { t.Fatal(err) } @@ -85,11 +85,17 @@ func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { func TestTheConfigurationIsNotWorldReadable(t *testing.T) { // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a // private key is, and not something to leave readable on a machine somebody else also uses. + // The peer list specifically, not every file. `/etc/hosts` is in here too and must be + // world-readable, or nothing on the machine resolves anything — a check that swept all files + // would force it to 0600 and break the machine to protect a file that is not secret. _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) for _, r := range resources { - if r["type"] == "file" && r["mode"] != "0600" { + if r["id"] == "overlay-config" && r["mode"] != "0600" { t.Errorf("the peer list is mode %v", r["mode"]) } + if r["id"] == "mesh-names" && r["mode"] != "0644" { + t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) + } } } @@ -121,7 +127,7 @@ func TestTheFileSaysNotToEditIt(t *testing.T) { func TestANodeWithNoAddressIsRefused(t *testing.T) { // Rather than a configuration with a blank address, which wg-quick would reject on the // machine, at boot, where the failure is much harder to see. - if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { + if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, nil, ""); err == nil { t.Fatal("a node with no overlay address was given a configuration") } } diff --git a/internal/overlay/names.go b/internal/overlay/names.go new file mode 100644 index 0000000..a495f56 --- /dev/null +++ b/internal/overlay/names.go @@ -0,0 +1,101 @@ +package overlay + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// Names are how one node reaches another by name rather than by address. +// +// novox/hq 08-connectivity: what the host receives is the resolver's configuration, as files, +// listing every peer's internal name and overlay address. Computed centrally for the same reason +// the peer graph is — it needs every node at once. + +// SuffixVar lets a mesh choose what its internal names end in. +const SuffixVar = "MESH_INTERNAL_SUFFIX" + +// DefaultSuffix is `.internal`, which IANA reserved for exactly this in 2024. A name under it can +// never collide with a public one, so an internal name that leaks into a public resolver fails +// rather than reaching a stranger's machine. +const DefaultSuffix = "internal" + +// HostsPath is where the names go. +// +// This is not the `/etc/hosts` floor the design removes. That floor existed because a node had to +// reach the mesh's database before its own DNS worked — a fallback for a circularity, and the +// circularity is gone. This is the mechanism itself: the complete set of names in this mesh, +// generated whole and owned by the mesh (novox/hq ADR 0011), rather than a patch written +// underneath something else. +// +// A file rather than a resolver daemon, deliberately, for now: it works on every Linux, needs no +// package, and has no failure mode of its own. A daemon becomes necessary when names are wanted +// that are not one-per-node — service names, wildcards — and that is not yet true. +const HostsPath = "/etc/hosts" + +// Suffix is what internal names end in. +func Suffix() string { + if v := strings.TrimSpace(os.Getenv(SuffixVar)); v != "" { + return strings.TrimPrefix(v, ".") + } + return DefaultSuffix +} + +// nodeName is what a node may be called, so that it can also be a hostname. +var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// InternalName is a node's name inside the mesh. +func InternalName(node string) string { return node + "." + Suffix() } + +// Hosts writes the name file for one node. +// +// Every node in the mesh, including this one. Including itself because a machine referring to +// itself by its mesh name should get its overlay address rather than a loopback — otherwise a +// service that binds to the name it was given ends up unreachable from everywhere else. +// +// The machine's own loopback lines come first and are not the mesh's to have an opinion about, +// but they have to be here: this file is generated whole, so anything left out is removed. +func Hosts(nodes []Node, self string) (string, error) { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a node\n") + b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") + + // The floor every Linux expects, and which removing would break things that have nothing to + // do with the mesh. + b.WriteString("127.0.0.1\tlocalhost\n") + b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n") + if self != "" { + fmt.Fprintf(&b, "127.0.1.1\t%s\n", self) + } + + named := make([]Node, 0, len(nodes)) + for _, n := range nodes { + if n.Address == "" { + // A node with no address on the network has no name here. Writing one that resolves + // to nothing is worse than not writing it: a connection to an address that does not + // answer hangs, where a name that does not resolve fails at once and says so. + continue + } + if !nodeName.MatchString(n.Name) { + return "", fmt.Errorf( + "%q cannot be a mesh name: it becomes a hostname, so it is lower-case letters, "+ + "digits and dashes", n.Name) + } + named = append(named, n) + } + sort.Slice(named, func(i, j int) bool { return named[i].Name < named[j].Name }) + + if len(named) > 0 { + fmt.Fprintf(&b, "\n# the mesh, %d node(s)\n", len(named)) + } + for _, n := range named { + line := fmt.Sprintf("%s\t%s\t%s", n.Address, InternalName(n.Name), n.Name) + if n.Name == self { + line += "\t# this machine" + } + b.WriteString(line + "\n") + } + return b.String(), nil +} diff --git a/internal/overlay/names_test.go b/internal/overlay/names_test.go new file mode 100644 index 0000000..00103f7 --- /dev/null +++ b/internal/overlay/names_test.go @@ -0,0 +1,105 @@ +package overlay + +import ( + "strings" + "testing" +) + +func hostsFor(t *testing.T, self string, nodes ...Node) string { + t.Helper() + out, err := Hosts(nodes, self) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestEveryNodeWithAPlaceGetsAName(t *testing.T) { + got := hostsFor(t, "laptop", + Node{Name: "anchor", Address: "10.42.0.1"}, + Node{Name: "laptop", Address: "10.42.0.2"}) + + for _, want := range []string{"10.42.0.1\tanchor.internal\tanchor", "10.42.0.2\tlaptop.internal\tlaptop"} { + if !strings.Contains(got, want) { + t.Errorf("no entry for %q in:\n%s", want, got) + } + } +} + +func TestANodeSeesItselfAtItsOverlayAddress(t *testing.T) { + // Not at a loopback. A service that binds to the name the machine was given would otherwise + // listen somewhere nothing else can reach, and the failure appears on every other node rather + // than this one. + got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"}) + if !strings.Contains(got, "10.42.0.2\tlaptop.internal") { + t.Error("a node does not resolve its own mesh name to its overlay address") + } +} + +func TestTheMachinesOwnLoopbackSurvives(t *testing.T) { + // This file is generated whole, so anything left out is removed. Dropping localhost would + // break things that have nothing to do with the mesh, on a machine the mesh was asked to + // improve. + got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"}) + if !strings.Contains(got, "127.0.0.1\tlocalhost") { + t.Error("localhost is missing; this file replaces the machine's own") + } + if !strings.Contains(got, "::1") { + t.Error("the IPv6 loopback is missing") + } +} + +func TestANodeWithNoAddressGetsNoName(t *testing.T) { + // A name resolving to nothing is worse than no name: a connection to an address that does not + // answer hangs, where a name that does not resolve fails at once and says which name it was. + got := hostsFor(t, "laptop", + Node{Name: "laptop", Address: "10.42.0.2"}, + Node{Name: "newcomer", Address: ""}) + if strings.Contains(got, "newcomer") { + t.Error("a node with no address on the network was given a name") + } +} + +func TestANameThatCannotBeAHostnameIsRefused(t *testing.T) { + // Refused here, where a person is looking, rather than written into a file that every + // machine then reads and disagrees about. + for _, bad := range []string{"Anchor", "my node", "under_score", "-leading", "trailing-"} { + if _, err := Hosts([]Node{{Name: bad, Address: "10.42.0.1"}}, ""); err == nil { + t.Errorf("%q was accepted as a mesh name", bad) + } + } +} + +func TestTheOrderIsStable(t *testing.T) { + // The file is rewritten whenever anything changes, and a file whose lines move for no reason + // makes every reconcile look like a change — which means a service that reflects it restarts + // for ever. + a := hostsFor(t, "", Node{Name: "b", Address: "10.42.0.2"}, Node{Name: "a", Address: "10.42.0.1"}) + b := hostsFor(t, "", Node{Name: "a", Address: "10.42.0.1"}, Node{Name: "b", Address: "10.42.0.2"}) + if a != b { + t.Error("the same mesh produced two different files depending on the order it was read in") + } +} + +func TestTheSuffixIsReservedForThis(t *testing.T) { + // `.internal` was reserved by IANA in 2024 for exactly this. A name under it can never + // collide with a public one, so an internal name that leaks into a public resolver fails + // rather than reaching a stranger's machine. + if InternalName("anchor") != "anchor.internal" { + t.Errorf("internal names end in %q", Suffix()) + } +} + +func TestTheSuffixCanBeChosen(t *testing.T) { + t.Setenv(SuffixVar, ".mesh") + if InternalName("anchor") != "anchor.mesh" { + t.Errorf("got %q", InternalName("anchor")) + } +} + +func TestTheFileSaysItIsGenerated(t *testing.T) { + got := hostsFor(t, "laptop", Node{Name: "laptop", Address: "10.42.0.2"}) + if !strings.Contains(got, "Do not edit") { + t.Error("a generated file does not say so") + } +}