Files
mesh-controller/internal/overlay/names_test.go
T
jschoubben fc1417be72 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.
2026-08-29 19:58:01 +02:00

106 lines
3.8 KiB
Go

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")
}
}