hal dnsmasq-app conversion, hq 08-connectivity. Converting the resolver from the module it
replaces made it forward what it cannot answer, which is what the predecessor's does, and
that found two things the controller did not say.
A resolver that forwards must not send a mesh name it does not know upstream: the
`node-zones` fact now carries `local=/<suffix>/` beside the wildcards, written here rather
than in the daemon's configuration because the suffix is the mesh's choice and this file is
the one place the mesh writes what it chose. The default lives in one helper now instead of
being spelled in two functions.
The predecessor points the container runtime's `dns` at the machine's own tunnel address —
a container cannot reach the machine's loopback. A module writing that key needs the
address, and `${machine:at}` is the machine's name; a runtime's resolver list cannot be a
name it would need that resolver to look up. So a module may say `${machine:address}`: what
`at` resolves to, read from the same names the hosts file and the wildcards are written
from, absent — and refused — off the network like `at` is.
The `mesh-resolver` and `resolver-data` constants go: nothing provided or consumed either,
the fact and `mesh-addressing` are the mechanism, and a requirement nothing provides is
refused at resolution.
Tests: the catalogue's dnsmasq, resolv-conf and resolved-split-dns manifests are parsed
and composed as a machine would receive them — fixed upstreams, no-resolv, 127.0.0.1, the
machines file, the runtime's key, the pair that decides what a machine asks refused on one
node; and on a real mesh the resolver's machines file is composed with a wildcard per
machine on the network and composed again without one that left, mirroring the hosts fact.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
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)
|
|
}
|
|
}
|