Reconcile AllowedIPs from the routes of the interface instead of replaying route events

This commit is contained in:
2026-10-20 23:28:49 +00:00
parent 925d266ab4
commit c7b16dd275
20 changed files with 2424 additions and 1191 deletions
+258
View File
@@ -0,0 +1,258 @@
// Package reconcile computes the AllowedIPs changes that make WireGuard peers
// follow a set of routes. It does no I/O.
package reconcile
import (
"bytes"
"cmp"
"net/netip"
"slices"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// Set of prefixes.
type Set = map[netip.Prefix]struct{}
// Peer with its current AllowedIPs, which must be canonical (see PrefixFromIPNet).
type Peer struct {
Key wgtypes.Key
AllowedIPs []netip.Prefix
}
// Route to a destination via a gateway, with canonical prefix and address.
type Route struct {
Dst netip.Prefix
Gw netip.Addr
Metric int
Table int
}
type Input struct {
Routes []Route
Peers []Peer
// Managed are the prefixes the program added or adopted because of a route.
// Only these are removed once no route backs them anymore.
Managed Set
// Protected prefixes are never added, moved or removed.
Protected Set
// Startup is set for the first reconcile after the start or after the interface
// was recreated. Only then a prefix that is present already and not managed is
// adopted or moved when a route backs it, as it can be left from an earlier run.
// Later it is manual, whatever the routes say.
Startup bool
}
// Change to the AllowedIPs of one peer.
type Change struct {
Peer wgtypes.Key
// Add prefixes. When a prefix is currently on another peer, adding it moves it.
Add []netip.Prefix
Remove []netip.Prefix
}
// Conflict is a route to a prefix that is on another peer and not managed.
// It is left as it is.
type Conflict struct {
Dst netip.Prefix
Present wgtypes.Key // peer having the prefix
Wanted wgtypes.Key // peer the route points to
}
type Plan struct {
Changes []Change
// Unresolved are the routes whose gateway belongs to no peer.
Unresolved []Route
// Conflicts are ordered by prefix.
Conflicts []Conflict
// Managed is the new managed set once all changes are applied.
// It is the set of prefixes backed by a route that the program added or adopted.
Managed Set
}
// Compute the changes that bring the peers in line with the routes.
//
// Every destination is assigned to the peer with the longest AllowedIPs prefix
// containing the gateway. Of several routes to a destination the lowest metric wins.
// A managed prefix on the wrong peer is moved, a managed prefix without a route is removed.
// Prefixes the program does not manage are left alone, except at startup (see Input.Startup).
func Compute(in Input) Plan {
present := make(map[netip.Prefix]wgtypes.Key)
for _, peer := range in.Peers {
for _, prefix := range peer.AllowedIPs {
present[prefix] = peer.Key
}
}
desired, unresolved := desiredState(in)
changes := make(map[wgtypes.Key]*Change)
change := func(key wgtypes.Key) *Change {
if changes[key] == nil {
changes[key] = &Change{Peer: key}
}
return changes[key]
}
plan := Plan{Unresolved: unresolved}
managed := make(Set, len(desired))
for dst, key := range desired {
current, isPresent := present[dst]
_, ours := in.Managed[dst]
if isPresent && !ours && !in.Startup {
// Somebody else set it up, so the route has no say about it
if current != key {
plan.Conflicts = append(plan.Conflicts, Conflict{Dst: dst, Present: current, Wanted: key})
}
continue
}
managed[dst] = struct{}{}
if !isPresent || current != key {
c := change(key)
c.Add = append(c.Add, dst)
}
}
for dst := range in.Managed {
_, wanted := desired[dst]
_, protected := in.Protected[dst]
if wanted || protected {
continue
}
if key, ok := present[dst]; ok {
c := change(key)
c.Remove = append(c.Remove, dst)
}
}
plan.Managed = managed
slices.SortFunc(plan.Conflicts, func(a, b Conflict) int { return comparePrefix(a.Dst, b.Dst) })
for _, c := range changes {
slices.SortFunc(c.Add, comparePrefix)
slices.SortFunc(c.Remove, comparePrefix)
plan.Changes = append(plan.Changes, *c)
}
slices.SortFunc(plan.Changes, func(a, b Change) int { return bytes.Compare(a.Peer[:], b.Peer[:]) })
return plan
}
type entry struct {
prefix netip.Prefix
key wgtypes.Key
}
// desiredState assigns the destination of every usable route to a peer.
func desiredState(in Input) (map[netip.Prefix]wgtypes.Key, []Route) {
// Destinations that may be adopted or moved at startup
dsts := make(Set, len(in.Routes))
if in.Startup {
for _, route := range in.Routes {
if _, protected := in.Protected[route.Dst]; !protected {
dsts[route.Dst] = struct{}{}
}
}
}
// Gateways are matched against the prefixes set up by hand only. Prefixes
// derived from routes would make the result depend on what was applied before.
// At startup the managed set is not known, so the prefixes a route backs are
// left out, as they are about to be taken over.
var static []entry
for _, peer := range in.Peers {
for _, prefix := range peer.AllowedIPs {
_, managed := in.Managed[prefix]
_, routed := dsts[prefix]
if !managed && !routed {
static = append(static, entry{prefix, peer.Key})
}
}
}
type owner struct {
key wgtypes.Key
ok bool
}
owners := make(map[netip.Addr]owner)
peerFor := func(gw netip.Addr) (wgtypes.Key, bool) {
if o, seen := owners[gw]; seen {
return o.key, o.ok
}
key, ok := longestMatch(static, gw)
owners[gw] = owner{key, ok}
return key, ok
}
best := make(map[netip.Prefix]Route)
desired := make(map[netip.Prefix]wgtypes.Key)
var unresolved []Route
for _, route := range in.Routes {
if !route.Dst.IsValid() || !route.Gw.IsValid() {
continue
}
if _, protected := in.Protected[route.Dst]; protected {
continue
}
key, ok := peerFor(route.Gw)
if !ok {
unresolved = append(unresolved, route)
continue
}
if current, ok := best[route.Dst]; !ok || compareRoutes(route, current) < 0 {
best[route.Dst] = route
desired[route.Dst] = key
}
}
return desired, unresolved
}
// longestMatch returns the peer owning the longest prefix that contains the address,
// like WireGuard picks the peer for a destination.
func longestMatch(entries []entry, addr netip.Addr) (wgtypes.Key, bool) {
var (
key wgtypes.Key
bits = -1
)
for _, e := range entries {
if e.prefix.Bits() > bits && e.prefix.Contains(addr) {
key, bits = e.key, e.prefix.Bits()
}
}
return key, bits >= 0
}
// tableMain is the main routing table.
const tableMain = 254
// compareRoutes orders routes by preference: metric, then table (main first, then the
// lower number) and gateway as stable tie-breakers.
func compareRoutes(a, b Route) int {
return cmp.Or(
cmp.Compare(a.Metric, b.Metric),
cmp.Compare(tableRank(a.Table), tableRank(b.Table)),
a.Gw.Compare(b.Gw),
)
}
func tableRank(table int) int {
if table == tableMain {
return -1
}
return table
}
func comparePrefix(a, b netip.Prefix) int {
return cmp.Or(
a.Addr().Compare(b.Addr()),
cmp.Compare(a.Bits(), b.Bits()),
)
}
+472
View File
@@ -0,0 +1,472 @@
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)
}
}
+66
View File
@@ -0,0 +1,66 @@
package reconcile
import (
"fmt"
"net"
"net/netip"
"strings"
)
// AddrFromIP converts an address to its canonical form, where IPv4-in-IPv6 is plain IPv4.
func AddrFromIP(ip net.IP) (netip.Addr, bool) {
addr, ok := netip.AddrFromSlice(ip)
return addr.Unmap(), ok
}
// PrefixFromIPNet converts to the canonical prefix: with the host bits cleared, and IPv4 as
// netip.Is4. This makes prefixes comparable, which net.IPNet is not (4 or 16 byte IPs and masks).
// The family follows the mask, so ::ffff:0:0/96 stays IPv6 while a 16 byte IP with a 32 bit mask is IPv4.
func PrefixFromIPNet(n net.IPNet) (netip.Prefix, bool) {
addr, ok := netip.AddrFromSlice(n.IP)
if !ok {
return netip.Prefix{}, false
}
ones, bits := n.Mask.Size()
switch {
case bits == 0: // non-canonical mask
return netip.Prefix{}, false
case bits == 32:
addr = addr.Unmap()
case addr.Is4() && bits == 128: // 4 byte IP with an IPv6 sized mask
ones -= 96
}
prefix := netip.PrefixFrom(addr, ones)
if !prefix.IsValid() {
return netip.Prefix{}, false
}
return prefix.Masked(), true
}
// ToIPNet converts a prefix, IPv4 gets 4 byte IP and mask.
func ToIPNet(p netip.Prefix) net.IPNet {
return net.IPNet{
IP: p.Addr().AsSlice(),
Mask: net.CIDRMask(p.Bits(), p.Addr().BitLen()),
}
}
// ParsePrefixes parses a comma-separated list of CIDRs.
func ParsePrefixes(list string) (map[netip.Prefix]struct{}, error) {
prefixes := make(map[netip.Prefix]struct{})
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("invalid prefix '%s': %w", entry, err)
}
prefixes[prefix.Masked()] = struct{}{}
}
return prefixes, nil
}
+83
View File
@@ -0,0 +1,83 @@
package reconcile
import (
"net"
"net/netip"
"slices"
"testing"
)
func TestPrefixFromIPNet(t *testing.T) {
tests := []struct {
name string
in net.IPNet
want string // empty: invalid
}{
{"v4 4-byte", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"v4 16-byte ip", net.IPNet{IP: net.ParseIP("10.1.2.0"), Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"v4 default as synthesized", net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, "0.0.0.0/0"},
{"v4 default as wgctrl", net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)}, "0.0.0.0/0"},
{"v4 4-byte ip with 128-bit mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(120, 128)}, "10.1.2.0/24"},
{"v4 4-byte ip with short 128-bit mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(64, 128)}, ""},
{"v4-mapped v6 stays v6", net.IPNet{IP: net.ParseIP("10.1.2.0"), Mask: net.CIDRMask(120, 128)}, "::ffff:10.1.2.0/120"},
{"v4-mapped v6 default range", net.IPNet{IP: net.ParseIP("::ffff:0:0"), Mask: net.CIDRMask(96, 128)}, "::ffff:0.0.0.0/96"},
{"v6", net.IPNet{IP: net.ParseIP("fd00::"), Mask: net.CIDRMask(64, 128)}, "fd00::/64"},
{"v6 default", net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, "::/0"},
{"host bits are cleared", net.IPNet{IP: net.IP{10, 1, 2, 77}, Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"non-canonical mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.IPMask{255, 0, 255, 0}}, ""},
{"no ip", net.IPNet{Mask: net.CIDRMask(24, 32)}, ""},
{"v4 with 32-bit mask is unmapped", net.IPNet{IP: net.ParseIP("::ffff:10.1.2.0"), Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := PrefixFromIPNet(tt.in)
if ok != (tt.want != "") {
t.Fatalf("ok = %v, want %v (got %s)", ok, tt.want != "", got)
}
if ok && got != netip.MustParsePrefix(tt.want) {
t.Errorf("got %s, want %s", got, tt.want)
}
})
}
}
func TestToIPNetRoundTrip(t *testing.T) {
for _, s := range []string{"10.1.2.0/24", "0.0.0.0/0", "fd00::/64", "::/0", "192.0.2.1/32", "::ffff:0.0.0.0/96"} {
want := netip.MustParsePrefix(s)
n := ToIPNet(want)
if want.Addr().Is4() && (len(n.IP) != 4 || len(n.Mask) != 4) {
t.Errorf("%s: IPv4 is not 4 bytes: %v", s, n)
}
got, ok := PrefixFromIPNet(n)
if !ok || got != want {
t.Errorf("%s: round trip gave %s", s, got)
}
}
}
func TestParsePrefixes(t *testing.T) {
tests := []struct {
name string
in string
want []string
wantErr bool
}{
{name: "empty", in: ""},
{name: "one", in: "10.0.0.0/8", want: []string{"10.0.0.0/8"}},
{name: "spaces and trailing comma", in: " 10.0.0.0/8 , fd00::/8,", want: []string{"10.0.0.0/8", "fd00::/8"}},
{name: "host bits are cleared", in: "10.1.2.3/8", want: []string{"10.0.0.0/8"}},
{name: "not a prefix", in: "10.0.0.0", wantErr: true},
{name: "one bad entry", in: "10.0.0.0/8,nope", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePrefixes(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if !slices.Equal(sorted(got), tt.want) {
t.Errorf("got %v, want %v", sorted(got), tt.want)
}
})
}
}
-25
View File
@@ -1,25 +0,0 @@
package netchecks
import (
"fmt"
"net"
"reflect"
)
func IPNetIndexByIP(list *[]net.IPNet, ip *net.IP) (int, error) {
for index, ipNetEntry := range *list {
if ipNetEntry.Contains(*ip) {
return index, nil
}
}
return -1, fmt.Errorf("ip not in ipNet-list")
}
func IPNetIndexByIPNet(list *[]net.IPNet, ipNet *net.IPNet) (int, error) {
for index, ipNetEntry := range *list {
if reflect.DeepEqual(ipNetEntry, *ipNet) {
return index, nil
}
}
return -1, fmt.Errorf("ipNet not in ipNet-list")
}
-42
View File
@@ -1,42 +0,0 @@
package wgChecks
import (
"fmt"
"net"
"git.ruekov.eu/ruakij/routingtabletowg/lib/wgchecks/netchecks"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func PeerIndexByIP(peers *[]wgtypes.Peer, ip *net.IP) (int, int, error) {
for index, peer := range *peers {
if ipIndex, err := netchecks.IPNetIndexByIP(&peer.AllowedIPs, ip); err == nil {
return index, ipIndex, nil
}
}
return -1, -1, fmt.Errorf("no peer by ip in list")
}
func PeerByIP(peers *[]wgtypes.Peer, ip *net.IP) (*wgtypes.Peer, error) {
index, _, err := PeerIndexByIP(peers, ip)
if err != nil {
return nil, err
}
return &(*peers)[index], nil
}
func PeerIndexByIPNet(peers *[]wgtypes.Peer, ipNet *net.IPNet) (int, int, error) {
for index, peer := range *peers {
if ipNetIndex, err := netchecks.IPNetIndexByIPNet(&peer.AllowedIPs, ipNet); err == nil {
return index, ipNetIndex, nil
}
}
return -1, -1, fmt.Errorf("no peer by ipNet in list")
}
func PeerByIPNet(peers *[]wgtypes.Peer, ipNet *net.IPNet) (*wgtypes.Peer, error) {
index, _, err := PeerIndexByIPNet(peers, ipNet)
if err != nil {
return nil, err
}
return &(*peers)[index], nil
}