package main import ( "context" "os" "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/overlay" ) // placementOf is what the mesh holds about where one node is. func placementOf(t *testing.T, ctx context.Context, inv *inventory.Inventory, name string) inventory.Overlay { t.Helper() placed, err := inv.Overlays(ctx) if err != nil { t.Fatal(err) } for _, one := range placed { if one.Name == name { return one } } t.Fatalf("%s is not placed at all", name) return inventory.Overlay{} } // The sibling of `node public-domain`, and the worse one: a placement is three facts declared // together, so an invocation that said none of them took all three away — the endpoint every other // machine dials, the site, and the hub. A mesh whose hub was placed that way has no paths left. func TestPlacingANodeWithNothingSaidDoesNotUnplaceIt(t *testing.T) { open := aMesh(t) ctx := t.Context() if err := overlayPlace(ctx, open.inventory, []string{"anchor", "--endpoint", "198.51.100.10:51820", "--site", "hosting", "--hub"}); err != nil { t.Fatal(err) } err := overlayPlace(ctx, open.inventory, []string{"anchor"}) if err == nil { t.Fatal("saying nothing unplaced the node instead of being refused") } if !strings.Contains(err.Error(), "--nothing") { t.Errorf("the refusal does not say how to mean it: %v", err) } held := placementOf(t, ctx, open.inventory, "anchor") if held.Endpoint != "198.51.100.10:51820" || held.Site != "hosting" || !held.Hub { t.Fatalf("the placement was taken away by an invocation that was refused: %+v", held) } } // Placing a machine with nothing set is a real thing to want — one that roams and opens every path // itself is exactly that — so it keeps a way to be said, by name. func TestPlacingANodeWithNothingIsAskedForByName(t *testing.T) { open := aMesh(t) ctx := t.Context() if err := overlayPlace(ctx, open.inventory, []string{"anchor", "--endpoint", "198.51.100.10:51820", "--site", "hosting"}); err != nil { t.Fatal(err) } if err := overlayPlace(ctx, open.inventory, []string{"anchor", "--nothing"}); err != nil { t.Fatal(err) } held := placementOf(t, ctx, open.inventory, "anchor") if held.Endpoint != "" || held.Site != "" || held.Hub { t.Fatalf("--nothing did not place it with nothing: %+v", held) } } // Both at once cannot be meant, so neither silently wins. func TestAPlacementAndNothingTogetherIsRefused(t *testing.T) { open := aMesh(t) ctx := t.Context() if err := overlayPlace(ctx, open.inventory, []string{"anchor", "--site", "hosting", "--nothing"}); err == nil { t.Fatal("a placement and --nothing together was accepted") } } // A machine is named for the others only while it is on the private network — placed AND running // what puts it there — the same set the resolver means by "on the private network". A machine // that has an address and no networking is not named: a name resolving to an address that does // not answer hangs where an unknown name fails at once (novox/hq issue 079). func TestOnlyAMachineOnThePrivateNetworkIsNamed(t *testing.T) { open := aMesh(t) // two placed machines, both assigned what puts them on the private network ctx := t.Context() shelf, err := open.inventory.Catalogue(ctx) if err != nil { t.Fatal(err) } names, err := namesInTheMesh(ctx, open.inventory, shelf) if err != nil { t.Fatal(err) } if names["anchor.internal"] != "10.77.0.1" || names["laptop.internal"] != "10.77.0.2" { t.Fatalf("two machines on the network are not both named: %v", names) } // The laptop keeps its place and its address, and stops running the network. if err := open.inventory.Unassign(ctx, "laptop", overlay.Name); err != nil { t.Fatal(err) } names, err = namesInTheMesh(ctx, open.inventory, shelf) if err != nil { t.Fatal(err) } if _, still := names["laptop.internal"]; still || names["anchor.internal"] != "10.77.0.1" { t.Fatalf("a machine that left the network is still named, or the one that stayed is not: %v", names) } } // novox/hq ADR 0105: the range every address is composed from is the adopted tunnel's, read from // the tunnel the hub holds — never stored anywhere else. func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { open := aMesh(t) ctx := t.Context() inv := open.inventory t.Setenv(OverlayCIDRVar, "10.99.0.0/16") before, err := overlayRange(ctx, inv) if err != nil || before != "10.99.0.0/16" { t.Fatalf("without an adopted tunnel the range is not what genesis said: %q %v", before, err) } // The hub becomes what genesis makes of a machine in use: adopted, enrolled with the found // tunnel's key, and presenting the tunnel. if err := inv.SetAdopted(ctx, "anchor", true); err != nil { t.Fatal(err) } hub, err := inv.NodeByName(ctx, "anchor") if err != nil { t.Fatal(err) } const key = "THE-TUNNELS-KEY=========================" if err := inv.RecordOverlayKey(ctx, hub.ID, key); err != nil { t.Fatal(err) } if err := inv.RecordTunnel(ctx, hub.ID, inventory.Tunnel{ Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key, Peers: []inventory.TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}, }); err != nil { t.Fatal(err) } after, err := overlayRange(ctx, inv) if err != nil || after != "192.0.2.0/24" { t.Fatalf("with an adopted tunnel the range is %q (%v), not the tunnel's", after, err) } // A placement whose endpoint is on another port than the tunnel's is refused: the peers dial // the tunnel's port. err = overlayPlace(ctx, inv, []string{"anchor", "--endpoint", "198.51.100.10:51820", "--site", "hosting", "--hub"}) if err == nil || !strings.Contains(err.Error(), "51900") { t.Fatalf("an endpoint off the tunnel's port was accepted: %v", err) } // On the tunnel's port, the hub is placed at the tunnel's address — whatever it had before. if err := inv.SetPlace(ctx, "anchor", "", "", false, ""); err != nil { t.Fatal(err) } if err := overlayPlace(ctx, inv, []string{"anchor", "--endpoint", "198.51.100.10:51900", "--site", "hosting", "--hub"}); err != nil { t.Fatal(err) } if placed := placementOf(t, ctx, inv, "anchor"); placed.Address != "192.0.2.1" { t.Fatalf("the hub was placed at %s, not the tunnel's own address", placed.Address) } // And the hub's declaration carries the peer and the takeover. nodes, computed, err := graph(ctx, open) if err != nil { t.Fatal(err) } var hubNode overlay.Node for _, n := range nodes { if n.Name == "anchor" { hubNode = n } } if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" { t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode) } carried := false for _, p := range computed["anchor"] { if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" { carried = true } } if !carried { t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"]) } } // A takeover is composed only for a hub whose placement agrees with the tunnel: an address or an // endpoint port that differs would have the host stop the found interface and raise the mesh's // where no peer is listening. func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { open := aMesh(t) ctx := t.Context() inv := open.inventory if err := inv.SetAdopted(ctx, "anchor", true); err != nil { t.Fatal(err) } hub, err := inv.NodeByName(ctx, "anchor") if err != nil { t.Fatal(err) } const key = "THE-TUNNELS-KEY=========================" if err := inv.RecordOverlayKey(ctx, hub.ID, key); err != nil { t.Fatal(err) } if err := inv.RecordTunnel(ctx, hub.ID, inventory.Tunnel{ Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key}); err != nil { t.Fatal(err) } // aMesh placed anchor at 10.77.0.1 on :51820 — the record of a hub placed before it took the // tunnel over. _, _, err = graph(ctx, open) if err == nil { t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's") } for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} { if !strings.Contains(err.Error(), want) { t.Errorf("the refusal does not say %q: %v", want, err) } } // Re-placed on the tunnel, it composes. if err := inv.SetPlace(ctx, "anchor", "", "", false, ""); err != nil { t.Fatal(err) } if err := overlayPlace(ctx, inv, []string{"anchor", "--endpoint", "198.51.100.10:51900", "--site", "here", "--hub"}); err != nil { t.Fatal(err) } if _, _, err := graph(ctx, open); err != nil { t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err) } } // theResolver is the catalogue's dnsmasq module as it is, or the test is skipped where the // catalogue is not beside this checkout. func theResolver(t *testing.T) catalogue.Manifest { t.Helper() raw, err := os.ReadFile("../../../mesh-catalog/modules/dnsmasq/module.json") if err != nil { t.Skipf("the catalogue is not beside this checkout: %v", err) } m, err := catalogue.ParseManifest(raw) if err != nil { t.Fatalf("dnsmasq does not parse:\n%v", err) } return m } // The resolver is handed every machine on the private network as a wildcard, the same set and the // same source as the hosts file, and is handed it again when a machine leaves — through the // module's own manifest asking for the fact, with no module of the mesh's own in between (hal // dnsmasq-app conversion, novox/hq 08-connectivity). The runtime on that machine is pointed at the // machine's own address, where the resolver answers for its containers. func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) { open := aMesh(t) ctx := t.Context() register(t, open, theResolver(t)) if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil { t.Fatal(err) } zones := func() string { t.Helper() for _, r := range composed(t, open, "anchor").Resources { if r["id"] == "dnsmasq.fact-node-zones" { if r["path"] != "/etc/mesh-resolver/nodes.conf" { t.Fatalf("the machines were written somewhere the resolver does not read: %v", r["path"]) } return r["content"].(string) } } t.Fatal("the resolver was not handed the machines") return "" } first := zones() for _, want := range []string{ "local=/internal/", "address=/anchor.internal/10.77.0.1\n", "address=/laptop.internal/10.77.0.2\n", } { if !strings.Contains(first, want) { t.Errorf("the resolver's machines lack %q:\n%s", want, first) } } for _, r := range composed(t, open, "anchor").Resources { if r["id"] == "dnsmasq.runtime-dns" { if !strings.Contains(r["content"].(string), `"10.77.0.1"`) || r["into"] != "json" { t.Errorf("the runtime is not pointed at this machine's own address, written into its file: %v", r) } } } // The laptop keeps its place and its address, and stops running the network. if err := open.inventory.Unassign(ctx, "laptop", overlay.Name); err != nil { t.Fatal(err) } after := zones() if strings.Contains(after, "laptop") || !strings.Contains(after, "address=/anchor.internal/10.77.0.1\n") { t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after) } }