What remains of names.go is the naming
The hosts-file writing moved to the node-names fact; what stays is the suffix (configurable, MESH_INTERNAL_SUFFIX, defaulting to .internal which IANA reserved for exactly this), a node's internal name, and the rule for what a node may be called. Several things compose an internal name, and one of them writing the suffix differently would be a name nothing answers to. First attempt at this rewrote the file from memory and silently dropped the configurable suffix. Restored from the original instead — deleting most of a file is git surgery, not paraphrase. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -29,19 +29,14 @@ import (
|
|||||||
// how a mesh ends up unable to have a second one.
|
// how a mesh ends up unable to have a second one.
|
||||||
const Requirement = "private-network"
|
const Requirement = "private-network"
|
||||||
|
|
||||||
// Name is the module that answers it with WireGuard, and Names is the one that gives the machines
|
// Name is the module that answers it with WireGuard.
|
||||||
// names. Two modules rather than one, because they are two different things: names would be the
|
//
|
||||||
// same over any private network, and they are only bundled here by an accident of both being
|
// **The names went with it.** A mesh-names module used to sit beside this — it wrote /etc/hosts
|
||||||
// computed.
|
// and ran nothing, which is not a module. Being on the private network is what gives a machine a
|
||||||
const (
|
// name, so this module asks for the `node-names` fact and the mesh writes the file. The
|
||||||
Name = "mesh-wireguard"
|
// name-resolution provision went the same way: names are facts the mesh computes, not something a
|
||||||
Names = "mesh-names"
|
// module that runs nowhere can provide.
|
||||||
)
|
const Name = "mesh-wireguard"
|
||||||
|
|
||||||
// Resolution is what a module asks for when it needs to reach other machines by name. Separate
|
|
||||||
// from Requirement because they are separate jobs: one is whether packets arrive, the other is
|
|
||||||
// whether a name means anything. A machine can want the first without the second.
|
|
||||||
const Resolution = "name-resolution"
|
|
||||||
|
|
||||||
// Addressing is the mesh handing out addresses on the private network itself.
|
// Addressing is the mesh handing out addresses on the private network itself.
|
||||||
//
|
//
|
||||||
@@ -131,13 +126,6 @@ func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
|
|||||||
return parsed.Resources, true, nil
|
return parsed.Resources, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NameGenerator answers what one node's hosts file is.
|
|
||||||
//
|
|
||||||
// Separate from the interface and the peers because it is a separate concern. A machine's names
|
|
||||||
// come from the mesh knowing every machine, not from how the packets travel — over a different
|
|
||||||
// private network the peers would be written by something else and this would be unchanged.
|
|
||||||
type NameGenerator struct{ nodes []Node }
|
|
||||||
|
|
||||||
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
|
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
|
||||||
func (g *Generator) Nodes() []Node { return g.nodes }
|
func (g *Generator) Nodes() []Node { return g.nodes }
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package overlay
|
package overlay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,53 +47,7 @@ var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
|||||||
// InternalName is a node's name inside the mesh.
|
// InternalName is a node's name inside the mesh.
|
||||||
func InternalName(node string) string { return node + "." + Suffix() }
|
func InternalName(node string) string { return node + "." + Suffix() }
|
||||||
|
|
||||||
// Hosts writes the name file for one node.
|
// **What remains of a larger file.** The rest wrote /etc/hosts — that is the `node-names` fact now
|
||||||
//
|
// (catalogue.FactsInto), computed where the graph lives instead of by a module that ran nothing.
|
||||||
// Every node in the mesh, including this one. Including itself because a machine referring to
|
// The naming stays here, because several things compose a node's internal name and one of them
|
||||||
// itself by its mesh name should get its overlay address rather than a loopback — otherwise a
|
// writing the suffix differently would be a name nothing answers to.
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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