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.
106 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|