Three machines across two sites, two of them behind no reachable address, all nine paths open. The mesh computes the graph, delivers it as a declaration, and the nodes bring it up. Every fault below looked like success from inside the mesh: the graph was right, the files were right, the services were up, every node reported it had applied. None was reachable by reasoning. A running interface does not re-read its configuration. A node joins, every existing node's peer list changes, the file is replaced -- and the service is already running, so nothing reloads it. Fixed as declared state rather than a command: the service must reflect the file. A command to restart would be an action, and the link may not carry one. The host refused exactly that, which is how this shape was arrived at. A hub sharing a site with a spoke appeared twice in that spoke's peer list -- once as a direct peer, once as the route of last resort. WireGuard takes one entry per key and refuses the file. The ordinary shape of a small mesh, and in none of the tests written before it ran. Two nodes at one site that neither can be dialled were peered directly. Nobody opens the path, and the direct route is more specific than the hub's, so it wins and blackholes -- this design's own warning arriving in its implementation. They now route through the hub unless one end can be dialled. And Docker sets the FORWARD policy to DROP, so a hub with ip_forward enabled carried nothing between its spokes. The substrate at tier 1 silently breaks the network at tier 2, and nothing in either tier's state says so. The hub inserts its own rule above those chains and removes it on the way down. Two weak tests found by injection along the way: one asserted the keepalive rule only against the hub, whose peer entries happen not to set that field at all, so it tested an absence; the other checked the firewall rules by looking for FORWARD anywhere, which the PostDown line satisfies on its own.
166 lines
6.2 KiB
Go
166 lines
6.2 KiB
Go
// Package overlay computes the private network every node runs on.
|
|
//
|
|
// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from
|
|
// every node at once, and no node has that. A node computes nothing about the mesh — it generates
|
|
// a keypair, publishes the public half, and receives the rest.
|
|
//
|
|
// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and
|
|
// the reason is a property of WireGuard rather than a preference: there is no failover. A more
|
|
// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a
|
|
// node gets exactly one path to any peer, because two would mean one of them silently swallowing
|
|
// traffic.
|
|
package overlay
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Node is one machine's place on the network, as the mesh holds it.
|
|
type Node struct {
|
|
Name string
|
|
Key string
|
|
Endpoint string
|
|
Site string
|
|
Hub bool
|
|
Address string
|
|
}
|
|
|
|
// Reachable reports whether other nodes can dial this one. Declared, never inferred.
|
|
func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" }
|
|
|
|
// Peer is one entry in a node's peer list.
|
|
type Peer struct {
|
|
Name string
|
|
Key string
|
|
// Endpoint is empty when this peer cannot be dialled — it must dial us instead.
|
|
Endpoint string
|
|
// Allowed is what traffic goes down this tunnel. A single address for a direct peer; the
|
|
// whole overlay for the hub, which is what makes it the route of last resort.
|
|
Allowed string
|
|
// Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep
|
|
// the path open from its end, or the peer's first packet arrives at a mapping that has
|
|
// already expired.
|
|
Keepalive bool
|
|
// Why this peer is in the list, for a person reading a generated file and wondering.
|
|
Why string
|
|
}
|
|
|
|
// Graph is every node's peer list.
|
|
type Graph map[string][]Peer
|
|
|
|
// ErrNoHub means nobody has said which node is the hub.
|
|
//
|
|
// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and
|
|
// answering with "no peers" would look like a working mesh where nothing can reach anything.
|
|
var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites")
|
|
|
|
// Compute derives every node's peer list.
|
|
//
|
|
// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and
|
|
// not yet been given a place on the network is an ordinary in-between state, and failing the whole
|
|
// graph because one node is half-configured would mean no node gets a network.
|
|
func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
|
|
var hub *Node
|
|
usable := make([]Node, 0, len(nodes))
|
|
for i := range nodes {
|
|
n := nodes[i]
|
|
if n.Key == "" || n.Address == "" {
|
|
continue
|
|
}
|
|
usable = append(usable, n)
|
|
if n.Hub {
|
|
hub = &usable[len(usable)-1]
|
|
}
|
|
}
|
|
if len(usable) == 0 {
|
|
return Graph{}, nil
|
|
}
|
|
if hub == nil {
|
|
return nil, ErrNoHub
|
|
}
|
|
if !hub.Reachable() {
|
|
return nil, fmt.Errorf(
|
|
"%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+
|
|
"that must be reachable from wherever the others are", hub.Name)
|
|
}
|
|
|
|
graph := Graph{}
|
|
for _, self := range usable {
|
|
var peers []Peer
|
|
|
|
// A node may share a site with the hub, and then the hub is one peer rather than two.
|
|
// Written before the loop because it changes what that loop may emit: WireGuard takes one
|
|
// entry per public key, so a hub appearing twice is a configuration it refuses — and the
|
|
// mesh would have produced it silently. The lab found this on the first two machines that
|
|
// shared a site with their hub.
|
|
hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site
|
|
|
|
for _, other := range usable {
|
|
if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) {
|
|
continue
|
|
}
|
|
// Two nodes at the same site peer directly — but only if one of them can be
|
|
// dialled. If neither can, nobody opens the path, and the direct route is more
|
|
// specific than the hub's, so it wins and blackholes. That is this design's own
|
|
// stated hazard arriving in it: *a more specific route to a dead endpoint
|
|
// blackholes; it does not fall back to the general one.*
|
|
//
|
|
// Found in the lab with two machines at one site behind no reachable address, which
|
|
// is the ordinary shape of a home: they were given each other as peers, neither
|
|
// could start, and they could not reach each other at all while both reached the hub
|
|
// perfectly.
|
|
if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) {
|
|
peers = append(peers, Peer{
|
|
Name: other.Name, Key: other.Key,
|
|
Endpoint: other.Endpoint,
|
|
Allowed: other.Address + "/32",
|
|
Keepalive: !self.Reachable(),
|
|
Why: "at the same site",
|
|
})
|
|
}
|
|
}
|
|
|
|
if !self.Hub {
|
|
// Everything else goes through the hub, including a node that roams. AllowedIPs is
|
|
// the whole overlay, so this is the route of last resort — and because direct peers
|
|
// above are single addresses, they win on specificity without either being ambiguous.
|
|
why := "the hub — everything not at this site"
|
|
if hubIsHere {
|
|
// One entry doing both jobs: the direct path to a machine that happens to be
|
|
// here, and the route to everywhere else. Splitting them would need two entries
|
|
// for one key, which is the thing being avoided.
|
|
why = "the hub, which is also at this site — everything goes here"
|
|
}
|
|
peers = append(peers, Peer{
|
|
Name: hub.Name, Key: hub.Key,
|
|
Endpoint: hub.Endpoint,
|
|
Allowed: overlayCIDR,
|
|
Keepalive: !self.Reachable(),
|
|
Why: why,
|
|
})
|
|
} else {
|
|
// The hub holds every node that does not share a site with it, because those nodes
|
|
// route through it and it must know where to send the replies. Ones it cannot dial
|
|
// will dial it.
|
|
for _, other := range usable {
|
|
if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) {
|
|
continue
|
|
}
|
|
peers = append(peers, Peer{
|
|
Name: other.Name, Key: other.Key,
|
|
Endpoint: other.Endpoint,
|
|
Allowed: other.Address + "/32",
|
|
Why: "routes through this hub",
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
|
|
graph[self.Name] = peers
|
|
}
|
|
return graph, nil
|
|
}
|