Names, from the same graph as the network
Step 5 of the connectivity order. Every node's internal name resolves to its overlay address, on every node, computed centrally because it needs every node at once. Under `.internal`, which IANA reserved for exactly this in 2024 -- a name there 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. The suffix is settable for a mesh that wants its own. Delivered in the same declaration as the peer list rather than a second one. A node holding the peers and not the names, or the reverse, is half on the network for as long as that lasts. 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 that is now gone. This is the mechanism: the complete set of names, generated whole and owned by the mesh, rather than a patch written underneath something else. A resolver daemon becomes necessary when names are wanted that are not one-per-node, and that is not yet true. A node resolves its own name to its overlay address rather than a loopback, because a service binding to the name it was given would otherwise listen somewhere nothing else can reach -- and the failure would appear on every other machine rather than that one. A node with no address gets no name. A name resolving to nothing is worse than no name: connecting to an address that does not answer hangs, where a name that does not resolve fails at once and says which name it was. Found while writing it: a test asserting every file in the declaration is mode 0600 would have forced /etc/hosts to 0600 and broken every lookup on the machine, to protect a file that is not secret. Verified in the lab: three machines, nine name lookups, each resolving to the right overlay address and reaching it.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user