package reconcile import ( "fmt" "net/netip" "slices" "strings" "testing" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) var ( keyA = wgtypes.Key{1} keyB = wgtypes.Key{2} ) func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) } func pfxs(list ...string) []netip.Prefix { var out []netip.Prefix for _, s := range list { out = append(out, pfx(s)) } return out } func set(list ...string) Set { s := make(Set) for _, p := range list { s[pfx(p)] = struct{}{} } return s } func sorted(s Set) []string { var out []string for p := range s { out = append(out, p.String()) } slices.Sort(out) return out } func route(dst, gw string, metric int) Route { return Route{Dst: pfx(dst), Gw: netip.MustParseAddr(gw), Metric: metric} } func peerA(allowed ...string) Peer { return Peer{Key: keyA, AllowedIPs: pfxs(allowed...)} } func peerB(allowed ...string) Peer { return Peer{Key: keyB, AllowedIPs: pfxs(allowed...)} } func peerName(key wgtypes.Key) string { return map[wgtypes.Key]string{keyA: "A", keyB: "B"}[key] } func render(plan Plan) []string { var out []string for _, c := range plan.Changes { var parts []string for _, p := range c.Add { parts = append(parts, "+"+p.String()) } for _, p := range c.Remove { parts = append(parts, "-"+p.String()) } out = append(out, fmt.Sprintf("%s %s", peerName(c.Peer), strings.Join(parts, " "))) } return out } func TestCompute(t *testing.T) { tests := []struct { name string in Input want []string // changes wantManaged []string wantUnresolved int wantConflicts []string }{ { name: "route is added to the peer containing the gateway", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")}, }, want: []string{"A +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "gateway matches the longest prefix", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/8"), peerB("10.1.0.0/16")}, }, want: []string{"B +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "ipv6 gateway", in: Input{ Routes: []Route{route("2001:db8::/32", "fd00::1", 1024)}, Peers: []Peer{peerA("10.0.0.0/24"), peerB("fd00::/64")}, }, want: []string{"B +2001:db8::/32"}, wantManaged: []string{"2001:db8::/32"}, }, { name: "default routes of both families", in: Input{ Routes: []Route{route("0.0.0.0/0", "10.0.0.1", 0), route("::/0", "fd00::1", 1024)}, Peers: []Peer{peerA("10.0.0.0/24"), peerB("fd00::/64")}, }, want: []string{"A +0.0.0.0/0", "B +::/0"}, wantManaged: []string{"0.0.0.0/0", "::/0"}, }, { name: "gateway in no peer", in: Input{ Routes: []Route{route("203.0.113.0/24", "192.168.9.9", 0)}, Peers: []Peer{peerA("10.0.0.0/24")}, }, wantUnresolved: 1, }, { name: "startup: prefix on the right peer is kept and adopted", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24")}, Startup: true, }, wantManaged: []string{"203.0.113.0/24"}, }, { name: "prefix on the wrong peer moves, without a removal", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")}, Managed: set("203.0.113.0/24"), }, want: []string{"B +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "lowest metric wins", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 200), route("203.0.113.0/24", "10.0.0.1", 100)}, Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")}, }, want: []string{"A +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "equal metric prefers the main table, then the lower table, then the lower gateway", in: Input{ Routes: []Route{ {Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100}, {Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254}, {Dst: pfx("198.51.100.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 200}, {Dst: pfx("198.51.100.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100}, {Dst: pfx("192.0.2.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 254}, {Dst: pfx("192.0.2.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254}, }, Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")}, }, want: []string{"A +192.0.2.0/24 +203.0.113.0/24", "B +198.51.100.0/24"}, wantManaged: []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"}, }, { name: "a lower metric wins over the main table", in: Input{ Routes: []Route{ {Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100, Metric: 10}, {Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254, Metric: 20}, }, Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")}, }, want: []string{"B +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "deleting the backup route keeps the primary peer's prefix", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 100)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")}, Managed: set("203.0.113.0/24"), }, wantManaged: []string{"203.0.113.0/24"}, }, { name: "deleting the primary route moves the prefix to the backup peer", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 200)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")}, Managed: set("203.0.113.0/24"), }, want: []string{"B +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, }, { name: "managed prefix without route is removed", in: Input{ Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24", "198.51.100.0/24")}, Managed: set("203.0.113.0/24"), }, want: []string{"A -203.0.113.0/24"}, }, { name: "managed prefix is removed from the peer it is on now", in: Input{ Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24", "203.0.113.0/24")}, Managed: set("203.0.113.0/24"), }, want: []string{"B -203.0.113.0/24"}, }, { name: "managed prefix that is gone already is forgotten", in: Input{ Peers: []Peer{peerA("10.0.0.0/24")}, Managed: set("203.0.113.0/24"), }, }, { name: "startup: present prefix with a route on another peer is moved", in: Input{ Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")}, Startup: true, }, want: []string{"B +192.0.2.0/24"}, wantManaged: []string{"192.0.2.0/24"}, }, { name: "startup: prefix without a route is manual and stays", in: Input{ Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24")}, Startup: true, }, }, { name: "after startup: present prefix with a route on the same peer stays manual", in: Input{ Routes: []Route{route("192.0.2.0/24", "10.0.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24")}, }, }, { name: "after startup: present prefix with a route on another peer is a conflict and stays", in: Input{ Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")}, }, wantConflicts: []string{"192.0.2.0/24 on A, route to B"}, }, { name: "after startup: managed prefix on another peer than its route is moved", in: Input{ Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")}, Managed: set("192.0.2.0/24"), }, want: []string{"B +192.0.2.0/24"}, wantManaged: []string{"192.0.2.0/24"}, }, { name: "after startup: manual gateway prefix with a learned route on another peer resolves gateways", in: Input{ Routes: []Route{ route("203.0.113.0/24", "10.250.0.2", 0), route("10.250.0.2/32", "10.1.0.1", 0), }, Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32"), peerB("10.1.0.0/24")}, }, want: []string{"A +203.0.113.0/24"}, wantManaged: []string{"203.0.113.0/24"}, wantConflicts: []string{"10.250.0.2/32 on A, route to B"}, }, { name: "startup: prefix of a learned route does not resolve gateways, protecting it does", in: Input{ Routes: []Route{ route("203.0.113.0/24", "10.250.0.2", 0), route("198.51.100.0/24", "10.251.0.2", 0), route("10.250.0.2/32", "10.1.0.1", 0), route("10.251.0.2/32", "10.1.0.1", 0), }, Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32", "10.251.0.2/32"), peerB("10.1.0.0/24")}, Protected: set("10.250.0.2/32"), Startup: true, }, want: []string{"A +203.0.113.0/24", "B +10.251.0.2/32"}, wantManaged: []string{"10.251.0.2/32", "203.0.113.0/24"}, wantUnresolved: 1, }, { name: "protected prefixes are not added, moved or removed", in: Input{ Routes: []Route{ route("192.0.2.0/24", "10.1.0.1", 0), route("198.51.100.0/24", "10.1.0.1", 0), }, Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")}, Managed: set("203.0.113.0/24"), Protected: set("192.0.2.0/24", "203.0.113.0/24"), }, want: []string{"B +198.51.100.0/24"}, wantManaged: []string{"198.51.100.0/24"}, }, { name: "route derived prefixes do not resolve gateways", in: Input{ Routes: []Route{ route("10.0.0.0/8", "192.168.1.1", 0), route("172.16.0.0/12", "10.5.5.5", 0), }, Peers: []Peer{peerA("192.168.1.0/24", "10.0.0.0/8")}, Managed: set("10.0.0.0/8"), }, wantManaged: []string{"10.0.0.0/8"}, wantUnresolved: 1, }, { name: "startup: prefix present that a route backs does not resolve gateways either", in: Input{ Routes: []Route{ route("10.0.0.0/8", "192.168.1.1", 0), route("172.16.0.0/12", "10.5.5.5", 0), }, Peers: []Peer{peerA("192.168.1.0/24", "10.0.0.0/8")}, Startup: true, }, wantManaged: []string{"10.0.0.0/8"}, wantUnresolved: 1, }, { name: "unresolved route removes its managed prefix", in: Input{ Routes: []Route{route("203.0.113.0/24", "10.9.9.9", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24")}, Managed: set("203.0.113.0/24"), }, want: []string{"A -203.0.113.0/24"}, wantUnresolved: 1, }, { name: "changes are ordered by peer and prefix", in: Input{ Routes: []Route{ route("203.0.113.0/24", "10.1.0.1", 0), route("198.51.100.0/24", "10.1.0.1", 0), route("192.0.2.0/24", "10.0.0.1", 0), }, Peers: []Peer{peerB("10.1.0.0/24"), peerA("10.0.0.0/24")}, }, want: []string{"A +192.0.2.0/24", "B +198.51.100.0/24 +203.0.113.0/24"}, wantManaged: []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { plan := Compute(tt.in) if got := render(plan); !slices.Equal(got, tt.want) { t.Errorf("changes:\n got %q\nwant %q", got, tt.want) } if got := sorted(plan.Managed); !slices.Equal(got, tt.wantManaged) { t.Errorf("managed:\n got %q\nwant %q", got, tt.wantManaged) } if got := len(plan.Unresolved); got != tt.wantUnresolved { t.Errorf("unresolved = %d, want %d", got, tt.wantUnresolved) } var conflicts []string for _, c := range plan.Conflicts { conflicts = append(conflicts, fmt.Sprintf("%s on %s, route to %s", c.Dst, peerName(c.Present), peerName(c.Wanted))) } if !slices.Equal(conflicts, tt.wantConflicts) { t.Errorf("conflicts:\n got %q\nwant %q", conflicts, tt.wantConflicts) } }) } } // without returns the list without the removed prefixes. func without(list, removed []netip.Prefix) []netip.Prefix { return slices.DeleteFunc(slices.Clone(list), func(p netip.Prefix) bool { return slices.Contains(removed, p) }) } // apply the plan like WireGuard does and carry over the managed set. func apply(in *Input, plan Plan) { for _, c := range plan.Changes { for i := range in.Peers { if in.Peers[i].Key == c.Peer { in.Peers[i].AllowedIPs = append(without(in.Peers[i].AllowedIPs, c.Remove), c.Add...) } else { in.Peers[i].AllowedIPs = without(in.Peers[i].AllowedIPs, c.Add) // kernel moves } } } in.Managed = plan.Managed } // Applying a plan and computing again must not change anything. func TestComputeConverges(t *testing.T) { for _, startup := range []bool{true, false} { in := Input{ Routes: []Route{ route("203.0.113.0/24", "10.1.0.1", 100), route("203.0.113.0/24", "10.0.0.1", 200), route("0.0.0.0/0", "10.0.0.1", 0), }, Peers: []Peer{ peerA("10.0.0.0/24", "203.0.113.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24"), }, Managed: set("203.0.113.0/24", "198.51.100.0/24"), Startup: startup, } apply(&in, Compute(in)) in.Startup = false if again := Compute(in); len(again.Changes) != 0 { t.Errorf("startup=%v: second pass has changes: %q", startup, render(again)) } } } // A manual prefix that is a gateway must survive a routing daemon learning it on another peer. func TestComputeKeepsManualGateway(t *testing.T) { in := Input{ Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32"), peerB("10.1.0.0/24")}, } step := func(name string, want ...string) { t.Helper() plan := Compute(in) if got := render(plan); !slices.Equal(got, want) { t.Errorf("%s:\n got %q\nwant %q", name, got, want) } apply(&in, plan) } in.Routes = []Route{route("203.0.113.0/24", "10.250.0.2", 0)} step("route via the manual prefix", "A +203.0.113.0/24") in.Routes = append(in.Routes, route("10.250.0.2/32", "10.1.0.1", 0)) step("gateway learned on B") step("again") in.Routes = in.Routes[:1] step("learned route gone") in.Routes = nil step("route gone", "A -203.0.113.0/24") if want := pfxs("10.0.0.0/24", "10.250.0.2/32"); !slices.Equal(in.Peers[0].AllowedIPs, want) { t.Errorf("A = %v, want %v", in.Peers[0].AllowedIPs, want) } } // What an earlier run left behind is taken over once, at startup. func TestComputeStartupTakesOverLeftovers(t *testing.T) { in := Input{ Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)}, Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")}, Startup: true, } apply(&in, Compute(in)) in.Startup = false in.Routes = nil plan := Compute(in) if got, want := render(plan), []string{"A -203.0.113.0/24"}; !slices.Equal(got, want) { t.Errorf("got %q, want %q", got, want) } }