338 lines
12 KiB
Go
338 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"reflect"
|
|
"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)
|
|
}
|
|
// Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203):
|
|
// no bus is known to this test, so it is minted here, or composing refuses the placeholder.
|
|
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
|
|
Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "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)
|
|
}
|
|
}
|
|
|
|
// The roster is the machines and nothing else (novox/hq ADR 0191): each node's internal domain covers
|
|
// every route on it, and a node's public domains are public DNS's. A routed name in `.Names` was a
|
|
// private answer for a public name, handed by a resolver serving a LAN to a phone that could not use it.
|
|
func TestTheRosterNamesOnlyTheMachines(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
gens, err := generators(ctx, open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, node := range []string{"anchor", "laptop"} {
|
|
plan, settings, err := planFor(ctx, open, node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(with.Names, with.Machines) {
|
|
t.Fatalf("%s's roster names more than the machines:\n names %v\n machines %v", node, with.Names, with.Machines)
|
|
}
|
|
}
|
|
}
|