Reconcile AllowedIPs from the routes of the interface instead of replaying route events
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// routeDst returns the destination of a route, the default route has none.
|
||||
func routeDst(route netlink.Route) (netip.Prefix, bool) {
|
||||
if route.Dst != nil {
|
||||
return reconcile.PrefixFromIPNet(*route.Dst)
|
||||
}
|
||||
|
||||
// The family comes from the route, as a default route may have no gateway to tell it
|
||||
switch route.Family {
|
||||
case unix.AF_INET:
|
||||
return netip.PrefixFrom(netip.IPv4Unspecified(), 0), true
|
||||
case unix.AF_INET6:
|
||||
return netip.PrefixFrom(netip.IPv6Unspecified(), 0), true
|
||||
}
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
|
||||
// toRoutes converts the routes that can be translated, which are the ones with a
|
||||
// gateway and a destination that can be written to WireGuard. The destinations of
|
||||
// the latter are returned as skipped.
|
||||
func toRoutes(list []netlink.Route) (routes []reconcile.Route, skipped []netip.Prefix) {
|
||||
routes = make([]reconcile.Route, 0, len(list))
|
||||
for _, route := range list {
|
||||
gw, ok := reconcile.AddrFromIP(route.Gw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dst, ok := routeDst(route)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !writable(reconcile.ToIPNet(dst)) {
|
||||
skipped = append(skipped, dst)
|
||||
continue
|
||||
}
|
||||
routes = append(routes, reconcile.Route{
|
||||
Dst: dst,
|
||||
Gw: gw,
|
||||
Metric: route.Priority,
|
||||
Table: route.Table,
|
||||
})
|
||||
}
|
||||
return routes, skipped
|
||||
}
|
||||
|
||||
func toPeers(device *wgtypes.Device) []reconcile.Peer {
|
||||
peers := make([]reconcile.Peer, 0, len(device.Peers))
|
||||
for _, peer := range device.Peers {
|
||||
allowed := make([]netip.Prefix, 0, len(peer.AllowedIPs))
|
||||
for _, ipNet := range peer.AllowedIPs {
|
||||
if prefix, ok := reconcile.PrefixFromIPNet(ipNet); ok {
|
||||
allowed = append(allowed, prefix)
|
||||
}
|
||||
}
|
||||
peers = append(peers, reconcile.Peer{Key: peer.PublicKey, AllowedIPs: allowed})
|
||||
}
|
||||
return peers
|
||||
}
|
||||
|
||||
func toIPNets(prefixes []netip.Prefix) []net.IPNet {
|
||||
ipNets := make([]net.IPNet, len(prefixes))
|
||||
for i, prefix := range prefixes {
|
||||
ipNets[i] = reconcile.ToIPNet(prefix)
|
||||
}
|
||||
return ipNets
|
||||
}
|
||||
|
||||
// withoutPrefixes returns the list without the entries equal to one of the prefixes.
|
||||
// The others are returned as they are, so nothing gets rewritten into another form.
|
||||
func withoutPrefixes(list []net.IPNet, drop []netip.Prefix) []net.IPNet {
|
||||
return slices.DeleteFunc(slices.Clone(list), func(n net.IPNet) bool {
|
||||
prefix, ok := reconcile.PrefixFromIPNet(n)
|
||||
return ok && slices.Contains(drop, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
// writable tells if wgctrl sends the entry to the kernel as it is. wgctrl takes
|
||||
// every IP with a 4 byte form for IPv4, so an IPv4-mapped IPv6 entry such as
|
||||
// ::ffff:10.0.0.0/104 goes out as IPv4 with a mask too long for it and is rejected.
|
||||
func writable(n net.IPNet) bool {
|
||||
_, bits := n.Mask.Size()
|
||||
return bits != 128 || n.IP.To4() == nil
|
||||
}
|
||||
|
||||
func prefixString(n net.IPNet) string {
|
||||
if prefix, ok := reconcile.PrefixFromIPNet(n); ok {
|
||||
return prefix.String()
|
||||
}
|
||||
return n.String()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
func TestToRoutes(t *testing.T) {
|
||||
_, v4Net, _ := net.ParseCIDR("203.0.113.0/24")
|
||||
_, v6Net, _ := net.ParseCIDR("2001:db8::/32")
|
||||
|
||||
list := []netlink.Route{
|
||||
{Dst: v4Net, Gw: net.ParseIP("10.0.0.1"), Priority: 5, Table: 254, Family: unix.AF_INET},
|
||||
{Dst: v6Net, Gw: net.ParseIP("fd00::1"), Priority: 1024, Family: unix.AF_INET6},
|
||||
// default routes have no Dst, the family comes from the route and not from the gateway
|
||||
{Gw: net.ParseIP("10.0.0.1"), Family: unix.AF_INET},
|
||||
{Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
|
||||
// gateway as 16 bytes
|
||||
{Gw: net.ParseIP("10.0.0.2").To16(), Family: unix.AF_INET},
|
||||
// no gateway
|
||||
{Dst: v4Net, Family: unix.AF_INET},
|
||||
{Family: unix.AF_INET},
|
||||
// no destination to tell
|
||||
{Gw: net.ParseIP("10.0.0.1")},
|
||||
}
|
||||
|
||||
got, skipped := toRoutes(list)
|
||||
if len(skipped) != 0 {
|
||||
t.Errorf("skipped %v", skipped)
|
||||
}
|
||||
want := []reconcile.Route{
|
||||
{Dst: netip.MustParsePrefix("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Metric: 5, Table: 254},
|
||||
{Dst: netip.MustParsePrefix("2001:db8::/32"), Gw: netip.MustParseAddr("fd00::1"), Metric: 1024},
|
||||
{Dst: netip.MustParsePrefix("0.0.0.0/0"), Gw: netip.MustParseAddr("10.0.0.1")},
|
||||
{Dst: netip.MustParsePrefix("::/0"), Gw: netip.MustParseAddr("fd00::1")},
|
||||
{Dst: netip.MustParsePrefix("0.0.0.0/0"), Gw: netip.MustParseAddr("10.0.0.2")},
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wgctrl would send an IPv4-mapped IPv6 destination as IPv4 with a mask too long for it,
|
||||
// which the kernel rejects for the whole call.
|
||||
func TestToRoutesSkipsUnwritableDestinations(t *testing.T) {
|
||||
_, mapped, _ := net.ParseCIDR("::ffff:10.0.0.0/104")
|
||||
_, mappedRange, _ := net.ParseCIDR("::ffff:0:0/96")
|
||||
_, short, _ := net.ParseCIDR("::ffff:0:0/80") // nothing of the mapped form is left in it
|
||||
_, plain, _ := net.ParseCIDR("2001:db8::/32")
|
||||
|
||||
list := []netlink.Route{
|
||||
{Dst: mapped, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
|
||||
{Dst: mappedRange, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
|
||||
{Dst: short, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
|
||||
{Dst: plain, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
|
||||
// without gateway it is not translated anyway, so there is nothing to report
|
||||
{Dst: mapped, Family: unix.AF_INET6},
|
||||
}
|
||||
|
||||
routes, skipped := toRoutes(list)
|
||||
|
||||
if len(routes) != 2 || routes[0].Dst != netip.MustParsePrefix("::/80") || routes[1].Dst != netip.MustParsePrefix("2001:db8::/32") {
|
||||
t.Errorf("routes = %v", routes)
|
||||
}
|
||||
wantSkipped := []netip.Prefix{netip.MustParsePrefix("::ffff:10.0.0.0/104"), netip.MustParsePrefix("::ffff:0.0.0.0/96")}
|
||||
if !slices.Equal(skipped, wantSkipped) {
|
||||
t.Errorf("skipped = %v, want %v", skipped, wantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// wgctrl reports IPv4 prefixes with 4 byte IPs and masks, so they must equal the synthesized default route.
|
||||
func TestToPeersMatchesDefaultRoute(t *testing.T) {
|
||||
device := &wgtypes.Device{Peers: []wgtypes.Peer{{
|
||||
PublicKey: wgtypes.Key{1},
|
||||
AllowedIPs: []net.IPNet{
|
||||
{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)},
|
||||
{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)},
|
||||
},
|
||||
}}}
|
||||
|
||||
peers := toPeers(device)
|
||||
want := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
|
||||
if len(peers) != 1 || !slices.Equal(peers[0].AllowedIPs, want) {
|
||||
t.Errorf("got %v, want %v", peers, want)
|
||||
}
|
||||
|
||||
routes, _ := toRoutes([]netlink.Route{{Gw: net.ParseIP("10.0.0.1"), Family: unix.AF_INET}})
|
||||
if len(routes) != 1 || !slices.Contains(peers[0].AllowedIPs, routes[0].Dst) {
|
||||
t.Errorf("default route %v not found in %v", routes, peers[0].AllowedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix that is IPv6 for WireGuard must not turn into an IPv4 one on the way back.
|
||||
func TestRemovalKeepsOtherEntriesAsTheyAre(t *testing.T) {
|
||||
mapped := net.IPNet{IP: net.ParseIP("::ffff:0:0"), Mask: net.CIDRMask(96, 128)}
|
||||
v4Default := net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)}
|
||||
other := net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(24, 32)}
|
||||
list := []net.IPNet{mapped, v4Default, other}
|
||||
|
||||
device := &wgtypes.Device{Peers: []wgtypes.Peer{{PublicKey: wgtypes.Key{1}, AllowedIPs: list}}}
|
||||
wantPrefixes := []netip.Prefix{netip.MustParsePrefix("::ffff:0.0.0.0/96"), netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("10.1.2.0/24")}
|
||||
if got := toPeers(device)[0].AllowedIPs; !slices.Equal(got, wantPrefixes) {
|
||||
t.Fatalf("got %v, want %v", got, wantPrefixes)
|
||||
}
|
||||
|
||||
got := withoutPrefixes(list, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("203.0.113.0/24")})
|
||||
want := []net.IPNet{mapped, other}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].IP.Equal(want[i].IP) || len(got[i].IP) != len(want[i].IP) || !slices.Equal(got[i].Mask, want[i].Mask) {
|
||||
t.Errorf("entry %d: got %v, want %v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Errorf("input was modified: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options FilterOptions
|
||||
wantTable int
|
||||
wantProto bool
|
||||
protoValue netlink.RouteProtocol
|
||||
}{
|
||||
{name: "all", options: FilterOptions{Table: -1, Protocol: -1}},
|
||||
{name: "table", options: FilterOptions{Table: 100, Protocol: -1}, wantTable: 100},
|
||||
{name: "protocol", options: FilterOptions{Table: -1, Protocol: 12}, wantProto: true, protoValue: 12},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, mask := tt.options.routeFilter(7)
|
||||
if mask&netlink.RT_FILTER_OIF == 0 || mask&netlink.RT_FILTER_TABLE == 0 || filter.LinkIndex != 7 {
|
||||
t.Errorf("link and table must always be filtered: %v %b", filter, mask)
|
||||
}
|
||||
if filter.Table != tt.wantTable {
|
||||
t.Errorf("table = %d, want %d", filter.Table, tt.wantTable)
|
||||
}
|
||||
if (mask&netlink.RT_FILTER_PROTOCOL != 0) != tt.wantProto || filter.Protocol != tt.protoValue {
|
||||
t.Errorf("protocol filter = %v/%d, want %v/%d", mask&netlink.RT_FILTER_PROTOCOL != 0, filter.Protocol, tt.wantProto, tt.protoValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -2,17 +2,27 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FilterOptions select the routes to translate, -1 selects all.
|
||||
type FilterOptions struct {
|
||||
Table int
|
||||
Protocol int
|
||||
}
|
||||
|
||||
func CheckFilter(options FilterOptions, route netlink.Route) bool {
|
||||
if (options.Table != -1 && options.Table != route.Table) ||
|
||||
(options.Protocol != -1 && options.Protocol != int(route.Protocol)) {
|
||||
return false
|
||||
// routeFilter returns the filter and mask to list the matching routes of a link.
|
||||
func (o FilterOptions) routeFilter(ifindex int) (*netlink.Route, uint64) {
|
||||
// RT_TABLE_UNSPEC selects all tables, otherwise only the main table is listed
|
||||
filter := &netlink.Route{LinkIndex: ifindex, Table: unix.RT_TABLE_UNSPEC}
|
||||
mask := netlink.RT_FILTER_OIF | netlink.RT_FILTER_TABLE
|
||||
|
||||
if o.Table > 0 {
|
||||
filter.Table = o.Table
|
||||
}
|
||||
return true
|
||||
if o.Protocol != -1 {
|
||||
filter.Protocol = netlink.RouteProtocol(o.Protocol)
|
||||
mask |= netlink.RT_FILTER_PROTOCOL
|
||||
}
|
||||
return filter, mask
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//go:build linux && integration
|
||||
|
||||
// Needs root, the wireguard kernel module, the wg tool (wireguard-tools-wg on Alpine) and ip
|
||||
// (BusyBox on Alpine is enough), run it in a privileged container:
|
||||
//
|
||||
// go test -tags integration ./cmd/app
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
wgName = "wgtest0"
|
||||
otherName = "dmytest0"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
client *wgctrl.Client
|
||||
priv wgtypes.Key
|
||||
keyA wgtypes.Key
|
||||
keyB wgtypes.Key
|
||||
}
|
||||
|
||||
func mustKey(t *testing.T) wgtypes.Key {
|
||||
t.Helper()
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func cidr(s string) net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return *n
|
||||
}
|
||||
|
||||
func cidrs(list ...string) []net.IPNet {
|
||||
var out []net.IPNet
|
||||
for _, s := range list {
|
||||
out = append(out, cidr(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func check(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// createWg creates the interface with two peers. A has 10.200.0.0/24 and fd00:200::/64 for its
|
||||
// gateways, B has 10.201.0.0/24 and 10.99.0.0/24. The extra prefixes are set before the syncer starts.
|
||||
func (e *env) createWg(extraA ...string) netlink.Link {
|
||||
t := e.t
|
||||
t.Helper()
|
||||
|
||||
check(t, netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: wgName}}))
|
||||
link, err := netlink.LinkByName(wgName)
|
||||
check(t, err)
|
||||
|
||||
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{
|
||||
PrivateKey: &e.priv,
|
||||
ReplacePeers: true,
|
||||
Peers: []wgtypes.PeerConfig{
|
||||
{PublicKey: e.keyA, AllowedIPs: cidrs(append([]string{"10.200.0.0/24", "fd00:200::/64"}, extraA...)...)},
|
||||
{PublicKey: e.keyB, AllowedIPs: cidrs("10.201.0.0/24", "10.99.0.0/24")},
|
||||
},
|
||||
}))
|
||||
|
||||
for _, addr := range []string{"10.200.0.254/24", "10.201.0.254/24", "fd00:200::254/64"} {
|
||||
a, err := netlink.ParseAddr(addr)
|
||||
check(t, err)
|
||||
a.Flags = unix.IFA_F_NODAD
|
||||
check(t, netlink.AddrAdd(link, a))
|
||||
}
|
||||
check(t, netlink.LinkSetUp(link))
|
||||
return link
|
||||
}
|
||||
|
||||
func (e *env) route(link netlink.Link, dst, gw string, metric int) *netlink.Route {
|
||||
r := &netlink.Route{LinkIndex: link.Attrs().Index, Gw: net.ParseIP(gw), Priority: metric}
|
||||
if dst != "" {
|
||||
n := cidr(dst)
|
||||
r.Dst = &n
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// allowed returns the AllowedIPs of the peers as sorted strings.
|
||||
func (e *env) allowed() map[wgtypes.Key][]string {
|
||||
device, err := e.client.Device(wgName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[wgtypes.Key][]string)
|
||||
for _, peer := range toPeers(device) {
|
||||
var list []string
|
||||
for _, p := range peer.AllowedIPs {
|
||||
list = append(list, p.String())
|
||||
}
|
||||
slices.Sort(list)
|
||||
out[peer.Key] = list
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCopy(list []string) []string {
|
||||
out := slices.Clone(list)
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// expect waits until the peers have exactly these AllowedIPs.
|
||||
func (e *env) expect(step string, a, b []string) {
|
||||
e.t.Helper()
|
||||
want := map[wgtypes.Key][]string{e.keyA: sortedCopy(a), e.keyB: sortedCopy(b)}
|
||||
|
||||
var got map[wgtypes.Key][]string
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
got = e.allowed()
|
||||
if slices.Equal(got[e.keyA], want[e.keyA]) && slices.Equal(got[e.keyB], want[e.keyB]) {
|
||||
e.t.Logf("%s: A=%v B=%v", step, got[e.keyA], got[e.keyB])
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
e.t.Fatalf("%s:\n got A=%v B=%v\nwant A=%v B=%v", step, got[e.keyA], got[e.keyB], want[e.keyA], want[e.keyB])
|
||||
}
|
||||
|
||||
// settle gives the syncer time to react to something that must not change anything.
|
||||
func settle() { time.Sleep(4 * debounce) }
|
||||
|
||||
func TestSyncIntegration(t *testing.T) {
|
||||
client, err := wgctrl.New()
|
||||
check(t, err)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
e := &env{t: t, client: client, priv: mustKey(t), keyA: mustKey(t).PublicKey(), keyB: mustKey(t).PublicKey()}
|
||||
|
||||
// Leftovers of a previous run
|
||||
for _, name := range []string{wgName, otherName} {
|
||||
if link, err := netlink.LinkByName(name); err == nil {
|
||||
check(t, netlink.LinkDel(link))
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, name := range []string{wgName, otherName} {
|
||||
if link, err := netlink.LinkByName(name); err == nil {
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A has a manual prefix, and one a route backs from before the start
|
||||
link := e.createWg("192.0.2.0/24", "198.51.100.0/24")
|
||||
check(t, netlink.RouteAdd(e.route(link, "198.51.100.0/24", "10.200.0.1", 0)))
|
||||
|
||||
// An interface of another kind with a route whose gateway is in a prefix of B
|
||||
check(t, netlink.LinkAdd(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: otherName}}))
|
||||
other, err := netlink.LinkByName(otherName)
|
||||
check(t, err)
|
||||
otherAddr, err := netlink.ParseAddr("10.99.0.1/24")
|
||||
check(t, err)
|
||||
check(t, netlink.AddrAdd(other, otherAddr))
|
||||
check(t, netlink.LinkSetUp(other))
|
||||
|
||||
// The subscription can be cut to see that it is restored
|
||||
var (
|
||||
subscriptions atomic.Int32
|
||||
cut = make(chan struct{}, 1)
|
||||
)
|
||||
s := newSyncer(wgName, FilterOptions{Table: -1, Protocol: -1}, make(reconcile.Set), client)
|
||||
s.subscribe = func() (<-chan netlink.RouteUpdate, chan struct{}, error) {
|
||||
updates, done, err := subscribeRoutes()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
subscriptions.Add(1)
|
||||
|
||||
out := make(chan netlink.RouteUpdate)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for {
|
||||
select {
|
||||
case u := <-updates:
|
||||
out <- u
|
||||
case <-cut:
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, done, nil
|
||||
}
|
||||
|
||||
// t.Context() ends before the cleanup functions are called
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
s.run(t.Context(), 0)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("run did not stop")
|
||||
}
|
||||
})
|
||||
|
||||
manual := "192.0.2.0/24"
|
||||
base := []string{"10.200.0.0/24", "fd00:200::/64", manual}
|
||||
baseB := []string{"10.201.0.0/24", "10.99.0.0/24"}
|
||||
|
||||
t.Run("start leaves everything as it is", func(t *testing.T) {
|
||||
e.t = t
|
||||
settle()
|
||||
e.expect("start", append([]string{"198.51.100.0/24"}, base...), baseB)
|
||||
})
|
||||
|
||||
t.Run("add route", func(t *testing.T) {
|
||||
e.t = t
|
||||
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
|
||||
e.expect("A", append([]string{"198.51.100.0/24", "203.0.113.0/24"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteAdd(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
|
||||
e.expect("B", append([]string{"198.51.100.0/24", "203.0.113.0/24"}, base...), append([]string{"198.18.0.0/24"}, baseB...))
|
||||
})
|
||||
|
||||
t.Run("replace route changes the peer", func(t *testing.T) {
|
||||
e.t = t
|
||||
check(t, netlink.RouteReplace(e.route(link, "203.0.113.0/24", "10.201.0.1", 0)))
|
||||
e.expect("moved", append([]string{"198.51.100.0/24"}, base...), append([]string{"198.18.0.0/24", "203.0.113.0/24"}, baseB...))
|
||||
})
|
||||
|
||||
t.Run("delete route of a previous run and of this one", func(t *testing.T) {
|
||||
e.t = t
|
||||
check(t, netlink.RouteDel(e.route(link, "198.51.100.0/24", "10.200.0.1", 0)))
|
||||
e.expect("adopted prefix removed, manual stays", base, append([]string{"198.18.0.0/24", "203.0.113.0/24"}, baseB...))
|
||||
|
||||
check(t, netlink.RouteDel(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
|
||||
check(t, netlink.RouteDel(e.route(link, "203.0.113.0/24", "10.201.0.1", 0)))
|
||||
e.expect("all removed", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("primary and backup route", func(t *testing.T) {
|
||||
e.t = t
|
||||
primary := e.route(link, "100.64.0.0/10", "10.200.0.1", 10)
|
||||
backup := e.route(link, "100.64.0.0/10", "10.201.0.1", 20)
|
||||
|
||||
check(t, netlink.RouteAdd(backup))
|
||||
check(t, netlink.RouteAdd(primary))
|
||||
e.expect("primary wins", append([]string{"100.64.0.0/10"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(backup))
|
||||
settle()
|
||||
e.expect("backup deleted, primary keeps it", append([]string{"100.64.0.0/10"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteAdd(backup))
|
||||
check(t, netlink.RouteDel(primary))
|
||||
e.expect("primary deleted, backup takes over", base, append([]string{"100.64.0.0/10"}, baseB...))
|
||||
|
||||
check(t, netlink.RouteDel(backup))
|
||||
e.expect("both deleted", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("default routes", func(t *testing.T) {
|
||||
e.t = t
|
||||
// The container has default routes of its own
|
||||
v4 := e.route(link, "", "10.200.0.1", 100)
|
||||
v6 := e.route(link, "", "fd00:200::1", 100)
|
||||
check(t, netlink.RouteAdd(v4))
|
||||
check(t, netlink.RouteAdd(v6))
|
||||
e.expect("added", append([]string{"0.0.0.0/0", "::/0"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(v4))
|
||||
check(t, netlink.RouteDel(v6))
|
||||
e.expect("deleted", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("routes of other interfaces are ignored", func(t *testing.T) {
|
||||
e.t = t
|
||||
sub := e.route(other, "172.16.0.0/16", "10.99.0.5", 0)
|
||||
def := e.route(other, "", "10.99.0.5", 200)
|
||||
check(t, netlink.RouteAdd(sub))
|
||||
check(t, netlink.RouteAdd(def))
|
||||
settle()
|
||||
e.expect("added", base, baseB)
|
||||
|
||||
// An existing route of ours stays when another interface drops a route
|
||||
ours := e.route(link, "203.0.113.0/24", "10.200.0.1", 0)
|
||||
check(t, netlink.RouteAdd(ours))
|
||||
e.expect("ours added", append([]string{"203.0.113.0/24"}, base...), baseB)
|
||||
check(t, netlink.RouteDel(def))
|
||||
check(t, netlink.RouteDel(sub))
|
||||
settle()
|
||||
e.expect("other deleted", append([]string{"203.0.113.0/24"}, base...), baseB)
|
||||
|
||||
// Replacing ours from another interface is no delete, but the prefix has to go
|
||||
check(t, netlink.RouteReplace(e.route(other, "203.0.113.0/24", "10.99.0.5", 0)))
|
||||
e.expect("replaced from other interface", base, baseB)
|
||||
check(t, netlink.RouteDel(e.route(other, "203.0.113.0/24", "10.99.0.5", 0)))
|
||||
})
|
||||
|
||||
t.Run("lost subscription is restored", func(t *testing.T) {
|
||||
e.t = t
|
||||
before := subscriptions.Load()
|
||||
cut <- struct{}{}
|
||||
|
||||
// Added while there is no subscription
|
||||
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
|
||||
e.expect("added in the gap", append([]string{"203.0.113.0/24"}, base...), baseB)
|
||||
if subscriptions.Load() <= before {
|
||||
t.Error("did not subscribe again")
|
||||
}
|
||||
|
||||
check(t, netlink.RouteDel(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
|
||||
e.expect("deleted again", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("manual gateway prefix is not taken over by a learned route", func(t *testing.T) {
|
||||
e.t = t
|
||||
const gateway = "10.250.0.2/32"
|
||||
|
||||
// Set by hand after the start, so it is never adopted or moved
|
||||
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{Peers: []wgtypes.PeerConfig{
|
||||
{PublicKey: e.keyA, UpdateOnly: true, AllowedIPs: cidrs(gateway)},
|
||||
}}))
|
||||
withGateway := append([]string{gateway}, base...)
|
||||
|
||||
via := e.route(link, "203.0.113.0/24", "10.250.0.2", 0)
|
||||
via.Flags = int(netlink.FLAG_ONLINK)
|
||||
check(t, netlink.RouteAdd(via))
|
||||
e.expect("route via the manual prefix", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
|
||||
|
||||
// A routing daemon learns the gateway itself on another peer
|
||||
learned := e.route(link, gateway, "10.201.0.1", 0)
|
||||
check(t, netlink.RouteAdd(learned))
|
||||
settle()
|
||||
e.expect("learned route to the gateway", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(learned))
|
||||
settle()
|
||||
e.expect("learned route gone", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(via))
|
||||
e.expect("route gone, manual stays", withGateway, baseB)
|
||||
|
||||
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{Peers: []wgtypes.PeerConfig{
|
||||
{PublicKey: e.keyA, UpdateOnly: true, ReplaceAllowedIPs: true, AllowedIPs: cidrs(base...)},
|
||||
}}))
|
||||
e.expect("restored", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("peer with an entry wgctrl cannot write is left alone", func(t *testing.T) {
|
||||
e.t = t
|
||||
// wgctrl sends IPv4-mapped IPv6 as IPv4 with a mask too long for it, and
|
||||
// the kernel then drops this entry and all after it when replacing the list
|
||||
const mapped = "::ffff:10.0.0.0/104"
|
||||
wgSet := func(list ...string) {
|
||||
t.Helper()
|
||||
out, err := exec.Command("wg", "set", wgName, "peer", e.keyA.String(), "allowed-ips", strings.Join(list, ",")).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("wg set: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
ours := "203.0.113.0/24"
|
||||
|
||||
route := e.route(link, ours, "10.200.0.1", 0)
|
||||
check(t, netlink.RouteAdd(route))
|
||||
e.expect("added", append([]string{ours}, base...), baseB)
|
||||
|
||||
wgSet(append([]string{ours, mapped}, base...)...)
|
||||
stuck := append([]string{ours, mapped}, base...)
|
||||
e.expect("entry set", stuck, baseB)
|
||||
|
||||
check(t, netlink.RouteDel(route))
|
||||
settle()
|
||||
e.expect("nothing lost", stuck, baseB)
|
||||
|
||||
// Once the entry is gone the removal goes through, also when nothing else triggers it
|
||||
wgSet(append([]string{ours}, base...)...)
|
||||
other := e.route(link, "198.18.0.0/24", "10.200.0.1", 0)
|
||||
check(t, netlink.RouteAdd(other))
|
||||
e.expect("removed once possible", append([]string{"198.18.0.0/24"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(other))
|
||||
e.expect("restored", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("route to an IPv4-mapped prefix is skipped", func(t *testing.T) {
|
||||
e.t = t
|
||||
// The netlink library cannot add this one, ip can
|
||||
const mapped = "::ffff:10.0.0.0/104"
|
||||
if out, err := exec.Command("ip", "-6", "route", "add", mapped, "via", "fd00:200::1", "dev", wgName).CombinedOutput(); err != nil {
|
||||
t.Fatalf("ip route add: %v: %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { _ = exec.Command("ip", "-6", "route", "del", mapped, "dev", wgName).Run() })
|
||||
|
||||
// wgctrl would reject the mapped prefix and with it the ones sorted after it
|
||||
after := e.route(link, "2001:db8::/32", "fd00:200::1", 0)
|
||||
check(t, netlink.RouteAdd(after))
|
||||
e.expect("others are added", append([]string{"2001:db8::/32"}, base...), baseB)
|
||||
|
||||
check(t, netlink.RouteDel(after))
|
||||
e.expect("others are removed", base, baseB)
|
||||
})
|
||||
|
||||
t.Run("recreated interface", func(t *testing.T) {
|
||||
e.t = t
|
||||
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
|
||||
e.expect("added", append([]string{"203.0.113.0/24"}, base...), baseB)
|
||||
|
||||
// The new interface has the prefix in the config of A. It is not one of ours anymore.
|
||||
check(t, netlink.LinkDel(link))
|
||||
link = e.createWg(manual, "203.0.113.0/24")
|
||||
check(t, netlink.RouteAdd(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
|
||||
e.expect("new interface follows its routes", append([]string{"203.0.113.0/24"}, base...), append([]string{"198.18.0.0/24"}, baseB...))
|
||||
})
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
const (
|
||||
// debounce coalesces bursts of route updates into one reconcile
|
||||
debounce = 250 * time.Millisecond
|
||||
|
||||
minBackoff = time.Second
|
||||
maxBackoff = 30 * time.Second
|
||||
|
||||
// Receive buffer of the subscription, so bursts of route updates (e.g. a
|
||||
// full table being loaded) do not overflow it while a reconcile is running.
|
||||
// An overflow closes the subscription.
|
||||
subscribeBufferSize = 4 << 20
|
||||
updatesQueueSize = 4096
|
||||
)
|
||||
|
||||
// backoff doubles the delay on every failure up to maxBackoff.
|
||||
type backoff time.Duration
|
||||
|
||||
func (b *backoff) next() time.Duration {
|
||||
d := min(max(time.Duration(*b)*2, minBackoff), maxBackoff)
|
||||
*b = backoff(d)
|
||||
return d
|
||||
}
|
||||
|
||||
func (b *backoff) reset() { *b = 0 }
|
||||
|
||||
// subscribeRoutes subscribes to route updates. Closing done ends the subscription.
|
||||
func subscribeRoutes() (<-chan netlink.RouteUpdate, chan struct{}, error) {
|
||||
updates := make(chan netlink.RouteUpdate, updatesQueueSize)
|
||||
done := make(chan struct{})
|
||||
|
||||
err := netlink.RouteSubscribeWithOptions(updates, done, netlink.RouteSubscribeOptions{
|
||||
ErrorCallback: func(err error) {
|
||||
select {
|
||||
case <-done: // closing the socket makes the receive fail
|
||||
default:
|
||||
logger.Warn.Printf("Route-subscription: %s", err)
|
||||
}
|
||||
},
|
||||
ReceiveBufferSize: subscribeBufferSize,
|
||||
ReceiveBufferForceSize: true,
|
||||
})
|
||||
if err != nil {
|
||||
close(done)
|
||||
return nil, nil, err
|
||||
}
|
||||
return updates, done, nil
|
||||
}
|
||||
|
||||
// run reconciles until the context ends. A reconcile happens
|
||||
// - after route updates of the interface, debounced,
|
||||
// - every periodic if it is set,
|
||||
// - after (re)subscribing, as updates may have been missed,
|
||||
// - after a failure, with backoff.
|
||||
func (s *syncer) run(ctx context.Context, periodic time.Duration) {
|
||||
var ticks <-chan time.Time
|
||||
if periodic > 0 {
|
||||
ticker := time.NewTicker(periodic)
|
||||
defer ticker.Stop()
|
||||
ticks = ticker.C
|
||||
}
|
||||
|
||||
var (
|
||||
updates <-chan netlink.RouteUpdate // nil without subscription
|
||||
done chan struct{}
|
||||
|
||||
// dirty: the state has to be reconciled
|
||||
// recheck: updates of other interfaces came in, one of them may be a recreated interface
|
||||
// armed: the work timer is running
|
||||
dirty, recheck, armed = true, false, true
|
||||
|
||||
reconcileBackoff, subscribeBackoff backoff
|
||||
)
|
||||
work := time.NewTimer(0)
|
||||
resubscribe := time.NewTimer(0)
|
||||
|
||||
arm := func() {
|
||||
if !armed {
|
||||
work.Reset(debounce)
|
||||
armed = true
|
||||
}
|
||||
}
|
||||
trigger := func() {
|
||||
dirty = true
|
||||
arm()
|
||||
}
|
||||
unsubscribe := func() {
|
||||
if done != nil {
|
||||
close(done)
|
||||
}
|
||||
updates, done = nil, nil
|
||||
}
|
||||
defer unsubscribe()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-resubscribe.C:
|
||||
var err error
|
||||
updates, done, err = s.subscribe()
|
||||
if err != nil {
|
||||
delay := subscribeBackoff.next()
|
||||
logger.Error.Printf("Couldn't subscribe to route-changes, retrying in %s: %s", delay, err)
|
||||
resubscribe.Reset(delay)
|
||||
continue
|
||||
}
|
||||
subscribeBackoff.reset()
|
||||
trigger()
|
||||
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
unsubscribe()
|
||||
delay := subscribeBackoff.next()
|
||||
logger.Warn.Printf("Route-subscription closed, resubscribing in %s", delay)
|
||||
resubscribe.Reset(delay)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.watches(update.Route) {
|
||||
trigger()
|
||||
} else {
|
||||
recheck = true
|
||||
arm()
|
||||
}
|
||||
|
||||
case <-ticks:
|
||||
logger.Info.Printf("Periodically syncing all routes")
|
||||
trigger()
|
||||
|
||||
case <-work.C:
|
||||
armed = false
|
||||
if recheck && s.linkChanged() {
|
||||
dirty = true
|
||||
}
|
||||
recheck = false
|
||||
if !dirty {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.reconcile(); err != nil {
|
||||
delay := reconcileBackoff.next()
|
||||
logger.Error.Printf("Sync failed, retrying in %s: %s", delay, err)
|
||||
work.Reset(delay)
|
||||
armed = true
|
||||
continue
|
||||
}
|
||||
dirty = false
|
||||
reconcileBackoff.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoff(t *testing.T) {
|
||||
var b backoff
|
||||
var got []time.Duration
|
||||
for range 8 {
|
||||
got = append(got, b.next())
|
||||
}
|
||||
want := []time.Duration{1, 2, 4, 8, 16, 30, 30, 30}
|
||||
for i := range want {
|
||||
if got[i] != want[i]*time.Second {
|
||||
t.Fatalf("step %d: got %s, want %s", i, got[i], want[i]*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
b.reset()
|
||||
if d := b.next(); d != minBackoff {
|
||||
t.Errorf("after reset: got %s, want %s", d, minBackoff)
|
||||
}
|
||||
}
|
||||
+15
-183
@@ -1,20 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
envChecks "git.ruekov.eu/ruakij/routingtabletowg/lib/environmentchecks"
|
||||
ip2Map "git.ruekov.eu/ruakij/routingtabletowg/lib/iproute2mapping"
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/wgchecks"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
var envRequired = []string{
|
||||
@@ -65,193 +63,27 @@ func main() {
|
||||
logger.Error.Fatalf("Couldn't read PERIODIC_SYNC '%s': %s", periodicSyncStr, err)
|
||||
}
|
||||
|
||||
protectedStr := os.Getenv("PROTECTED_PREFIXES")
|
||||
protected, err := reconcile.ParsePrefixes(protectedStr)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't read PROTECTED_PREFIXES '%s': %s", protectedStr, err)
|
||||
}
|
||||
|
||||
// Create filter
|
||||
filterOptions := FilterOptions{
|
||||
Table: filterTable,
|
||||
Protocol: filterProtocol,
|
||||
}
|
||||
|
||||
// Get Link-Device
|
||||
link, err := netlink.LinkByName(iface)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't get interface '%s': %s", iface, err)
|
||||
}
|
||||
|
||||
// Test getting wg-client
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't create wgctl-client: %s", err)
|
||||
}
|
||||
// Test getting wg-device
|
||||
_, err = client.Device(iface)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't get wg-interface '%s': %s", iface, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Subscribe to route-change events
|
||||
routeSubChan, routeSubDoneChan := make(chan netlink.RouteUpdate), make(chan struct{})
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
err = netlink.RouteSubscribe(routeSubChan, routeSubDoneChan)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't subscribe to route-changes: %s", err)
|
||||
}
|
||||
go handleRouteEvents(routeSubChan, filterOptions, iface)
|
||||
|
||||
//# Initial Route-setup
|
||||
// Get routing-table entries from device
|
||||
routeList, err := netlink.RouteList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't get route-entries: %s", err)
|
||||
}
|
||||
|
||||
logger.Info.Printf("Initially setting all current routes")
|
||||
syncCurrentRoutesToHandler(routeSubChan, routeList)
|
||||
|
||||
if periodicSync > 0 {
|
||||
go runPeriodicSync(periodicSync, link, routeSubChan)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
func runPeriodicSync(seconds int, link netlink.Link, routeSubChan chan netlink.RouteUpdate) {
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
|
||||
// Get routing-table entries from device
|
||||
routeList, err := netlink.RouteList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't get route-entries: %s", err)
|
||||
}
|
||||
|
||||
logger.Info.Printf("Periodically syncing all routes")
|
||||
syncCurrentRoutesToHandler(routeSubChan, routeList)
|
||||
}
|
||||
}
|
||||
|
||||
func syncCurrentRoutesToHandler(routeSubChan chan netlink.RouteUpdate, routeList []netlink.Route) {
|
||||
|
||||
for _, route := range routeList {
|
||||
// Ignore routes with empty gateway
|
||||
if route.Gw == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send current routes to handler
|
||||
routeSubChan <- netlink.RouteUpdate{
|
||||
Type: unix.RTM_NEWROUTE,
|
||||
Route: route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var routeUpdateTypeMapFromId = map[uint16]string{
|
||||
unix.RTM_NEWROUTE: "+",
|
||||
unix.RTM_DELROUTE: "-",
|
||||
}
|
||||
|
||||
// TODO: Add proxy to apply filter in channels rather than.. this mess
|
||||
func handleRouteEvents(routeSubChan <-chan netlink.RouteUpdate, filterOptions FilterOptions, iface string) {
|
||||
// Create wg-client
|
||||
client, err := wgctrl.New()
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't create wgctl-client: %s", err)
|
||||
}
|
||||
|
||||
for {
|
||||
// Receive Route-Updates
|
||||
routeUpdate := <-routeSubChan
|
||||
route := routeUpdate.Route
|
||||
|
||||
// Check filter
|
||||
if !CheckFilter(filterOptions, routeUpdate.Route) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Special case for default-route
|
||||
if route.Dst == nil {
|
||||
if route.Gw.To4() != nil { // IPv4
|
||||
route.Dst = &net.IPNet{
|
||||
IP: net.IPv4zero,
|
||||
Mask: net.CIDRMask(0, 32),
|
||||
}
|
||||
} else { // IPv6
|
||||
route.Dst = &net.IPNet{
|
||||
IP: net.IPv6zero,
|
||||
Mask: net.CIDRMask(0, 128),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info.Printf("Route-Update: [%s] %s via %s", routeUpdateTypeMapFromId[routeUpdate.Type], route.Dst, route.Gw)
|
||||
|
||||
// Get wgDevice
|
||||
wgDevice, err := client.Device(iface)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Couldn't get wg-interface '%s' while running: %s", iface, err)
|
||||
}
|
||||
|
||||
// Empty config for filling in switch
|
||||
var wgConfig wgtypes.Config
|
||||
|
||||
switch routeUpdate.Type {
|
||||
case unix.RTM_NEWROUTE:
|
||||
// Check if gateway is set
|
||||
if route.Gw == nil {
|
||||
logger.Warn.Printf("Gateway unset, ignoring")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if other peer already has exact same dst
|
||||
if peer, err := wgChecks.PeerByIPNet(&wgDevice.Peers, route.Dst); err == nil {
|
||||
logger.Warn.Printf("dst-IPNet already set for Peer '%s', ignoring", peer.PublicKey)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get peer containing gateway-addr
|
||||
peer, err := wgChecks.PeerByIP(&wgDevice.Peers, &route.Gw)
|
||||
if err != nil {
|
||||
logger.Warn.Printf("No peer found containing gw-IP '%s', ignoring", route.Gw)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set peerConfig, this will override set values for that peer
|
||||
wgConfig.Peers = []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: peer.PublicKey,
|
||||
AllowedIPs: append(peer.AllowedIPs, *route.Dst),
|
||||
},
|
||||
}
|
||||
|
||||
case unix.RTM_DELROUTE:
|
||||
// Get peer containing dst-NetIP
|
||||
peerIndex, ipNetIndex, err := wgChecks.PeerIndexByIPNet(&wgDevice.Peers, route.Dst)
|
||||
if err != nil {
|
||||
logger.Warn.Printf("No peer found having dst-IPNet '%s', ignoring", route.Dst)
|
||||
continue
|
||||
}
|
||||
peer := wgDevice.Peers[peerIndex]
|
||||
|
||||
// Delete dstNet from allowedIPs
|
||||
peer.AllowedIPs[ipNetIndex] = peer.AllowedIPs[len(peer.AllowedIPs)-1]
|
||||
peer.AllowedIPs = peer.AllowedIPs[:len(peer.AllowedIPs)-1]
|
||||
|
||||
// Set peerConfig, this will override set values for that peer
|
||||
wgConfig.Peers = []wgtypes.PeerConfig{
|
||||
{
|
||||
PublicKey: peer.PublicKey,
|
||||
UpdateOnly: true,
|
||||
|
||||
ReplaceAllowedIPs: true,
|
||||
AllowedIPs: peer.AllowedIPs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = client.ConfigureDevice(iface, wgConfig)
|
||||
if err != nil {
|
||||
logger.Error.Fatalf("Error configuring wg-device '%s': %s", iface, err)
|
||||
}
|
||||
}
|
||||
newSyncer(iface, filterOptions, protected, client).run(ctx, time.Duration(periodicSync)*time.Second)
|
||||
logger.Info.Printf("Shutting down")
|
||||
}
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// wireguard is the part of wgctrl.Client the syncer uses, so tests can replace it.
|
||||
type wireguard interface {
|
||||
Device(name string) (*wgtypes.Device, error)
|
||||
ConfigureDevice(name string, cfg wgtypes.Config) error
|
||||
}
|
||||
|
||||
// syncer makes the AllowedIPs of the peers follow the routes of the interface.
|
||||
//
|
||||
// Routes only tell when to look. Every reconcile lists the routes and the
|
||||
// peers anew, so lost or reordered route updates cannot leave a stale state.
|
||||
// It is not safe for concurrent use, the run loop is the only caller.
|
||||
type syncer struct {
|
||||
iface string
|
||||
filter FilterOptions
|
||||
protected reconcile.Set
|
||||
wg wireguard
|
||||
|
||||
// subscribe is replaced in tests
|
||||
subscribe func() (<-chan netlink.RouteUpdate, chan struct{}, error)
|
||||
|
||||
// ifindex of the interface at the last reconcile, 0 if it did not exist
|
||||
ifindex int
|
||||
|
||||
// known is the last index the interface had. Another one means it was recreated.
|
||||
known int
|
||||
|
||||
// managed are the prefixes added or adopted because of a route. Others are
|
||||
// never removed. It is kept in memory only.
|
||||
managed reconcile.Set
|
||||
|
||||
// started is set once a reconcile of the interface got as far as planning, also
|
||||
// if writing failed. Until then the prefixes present already are taken over if a route backs them.
|
||||
started bool
|
||||
|
||||
// warned are the warnings logged for the current state, so they are not repeated
|
||||
warned map[string]struct{}
|
||||
}
|
||||
|
||||
func newSyncer(iface string, filter FilterOptions, protected reconcile.Set, client wireguard) *syncer {
|
||||
return &syncer{
|
||||
iface: iface,
|
||||
filter: filter,
|
||||
protected: protected,
|
||||
wg: client,
|
||||
subscribe: subscribeRoutes,
|
||||
managed: make(reconcile.Set),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *syncer) reconcile() error {
|
||||
// The interface can be recreated, so it is resolved again every time
|
||||
link, err := netlink.LinkByName(s.iface)
|
||||
if err != nil {
|
||||
s.ifindex = 0
|
||||
return fmt.Errorf("couldn't get interface '%s': %w", s.iface, err)
|
||||
}
|
||||
s.observe(link.Attrs().Index)
|
||||
|
||||
filter, mask := s.filter.routeFilter(s.ifindex)
|
||||
routeList, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, mask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't get route-entries: %w", err)
|
||||
}
|
||||
|
||||
device, err := s.wg.Device(s.iface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
|
||||
}
|
||||
|
||||
routes, skipped := toRoutes(routeList)
|
||||
plan := reconcile.Compute(reconcile.Input{
|
||||
Routes: routes,
|
||||
Peers: toPeers(device),
|
||||
Managed: s.managed,
|
||||
Protected: s.protected,
|
||||
Startup: !s.started,
|
||||
})
|
||||
return s.commit(plan, skippedWarnings(skipped))
|
||||
}
|
||||
|
||||
// commit applies the plan and takes over the resulting state. The notes are
|
||||
// warnings about what was left out before the plan.
|
||||
func (s *syncer) commit(plan reconcile.Plan, notes []string) error {
|
||||
// From here on prefixes that are present are not taken over anymore, also when
|
||||
// applying fails: the managed set below covers what the plan took over.
|
||||
s.started = true
|
||||
|
||||
result, err := s.apply(plan)
|
||||
s.warn(slices.Concat(notes, warnings(plan), result.warnings))
|
||||
if err != nil {
|
||||
// It is unknown how far the plan got, so keep what may belong to us
|
||||
maps.Copy(s.managed, plan.Managed)
|
||||
maps.Copy(s.managed, result.kept)
|
||||
return err
|
||||
}
|
||||
|
||||
s.managed = plan.Managed
|
||||
maps.Copy(s.managed, result.kept)
|
||||
return nil
|
||||
}
|
||||
|
||||
// observe records the index of the interface. Another index than before means the
|
||||
// interface was recreated, so what was learned about the old one is dropped. The
|
||||
// prefixes of the new one are not ours, except the ones a route backs, like at the start.
|
||||
func (s *syncer) observe(index int) {
|
||||
s.ifindex = index
|
||||
if s.known != 0 && s.known != index {
|
||||
logger.Info.Printf("Interface '%s' was recreated, starting over", s.iface)
|
||||
s.managed = make(reconcile.Set)
|
||||
s.started = false
|
||||
s.warned = nil
|
||||
}
|
||||
s.known = index
|
||||
}
|
||||
|
||||
// outcome of applying a plan, besides the error
|
||||
type outcome struct {
|
||||
// warnings about changes that were not made
|
||||
warnings []string
|
||||
|
||||
// kept are the prefixes that were to be removed and are still there. They stay managed.
|
||||
kept reconcile.Set
|
||||
}
|
||||
|
||||
// apply writes the changes of the plan to the wg-interface.
|
||||
func (s *syncer) apply(plan reconcile.Plan) (outcome, error) {
|
||||
// Adding a prefix to a peer takes it from the one having it. Only the new
|
||||
// prefixes are sent, so nothing else on the peer is touched.
|
||||
var add wgtypes.Config
|
||||
for _, change := range plan.Changes {
|
||||
if len(change.Add) == 0 {
|
||||
continue
|
||||
}
|
||||
add.Peers = append(add.Peers, wgtypes.PeerConfig{
|
||||
PublicKey: change.Peer,
|
||||
UpdateOnly: true, // never recreate a peer removed in the meantime
|
||||
AllowedIPs: toIPNets(change.Add),
|
||||
})
|
||||
}
|
||||
if len(add.Peers) > 0 {
|
||||
if err := s.wg.ConfigureDevice(s.iface, add); err != nil {
|
||||
return outcome{}, fmt.Errorf("error adding to wg-device '%s': %w", s.iface, err)
|
||||
}
|
||||
for _, change := range plan.Changes {
|
||||
for _, prefix := range change.Add {
|
||||
logger.Info.Printf("[+] %s to peer %s", prefix, change.Peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.remove(plan.Changes)
|
||||
}
|
||||
|
||||
// remove deletes prefixes from their peers. WireGuard can only replace the
|
||||
// whole list, so it is read right before to keep the window for changes made
|
||||
// by others small.
|
||||
//
|
||||
// The kernel drops the list first and then inserts the new entries one by one,
|
||||
// until one is rejected. A list that cannot be written back completely would
|
||||
// lose the entries from the rejected one on, so such a peer is left alone.
|
||||
// Every peer is written on its own, so a failing one does not stop the others.
|
||||
func (s *syncer) remove(changes []reconcile.Change) (outcome, error) {
|
||||
result := outcome{kept: make(reconcile.Set)}
|
||||
if !slices.ContainsFunc(changes, func(c reconcile.Change) bool { return len(c.Remove) > 0 }) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
device, err := s.wg.Device(s.iface)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
|
||||
}
|
||||
current := make(map[wgtypes.Key][]net.IPNet)
|
||||
for _, peer := range device.Peers {
|
||||
current[peer.PublicKey] = peer.AllowedIPs
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, change := range changes {
|
||||
allowed := current[change.Peer]
|
||||
left := withoutPrefixes(allowed, change.Remove)
|
||||
if len(left) == len(allowed) { // gone already, or the peer is
|
||||
continue
|
||||
}
|
||||
|
||||
if i := slices.IndexFunc(left, func(n net.IPNet) bool { return !writable(n) }); i >= 0 {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf(
|
||||
"Peer %s has the AllowedIP %s, which cannot be written back to Wireguard, so %v stay on the peer",
|
||||
change.Peer, prefixString(left[i]), change.Remove))
|
||||
for _, prefix := range change.Remove {
|
||||
result.kept[prefix] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
err := s.wg.ConfigureDevice(s.iface, wgtypes.Config{Peers: []wgtypes.PeerConfig{{
|
||||
PublicKey: change.Peer,
|
||||
UpdateOnly: true,
|
||||
ReplaceAllowedIPs: true,
|
||||
AllowedIPs: left,
|
||||
}}})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("error removing from peer %s of wg-device '%s': %w", change.Peer, s.iface, err))
|
||||
continue
|
||||
}
|
||||
for _, prefix := range change.Remove {
|
||||
logger.Info.Printf("[-] %s from peer %s", prefix, change.Peer)
|
||||
}
|
||||
}
|
||||
return result, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// linkChanged tells if the interface is another one than at the last reconcile.
|
||||
func (s *syncer) linkChanged() bool {
|
||||
link, err := netlink.LinkByName(s.iface)
|
||||
if err != nil {
|
||||
return s.ifindex != 0
|
||||
}
|
||||
return link.Attrs().Index != s.ifindex
|
||||
}
|
||||
|
||||
// watches tells if a route update may change what the last reconcile did.
|
||||
func (s *syncer) watches(route netlink.Route) bool {
|
||||
if s.ifindex != 0 && route.LinkIndex == s.ifindex {
|
||||
return true
|
||||
}
|
||||
|
||||
// A route of another interface can replace one of ours without a delete
|
||||
dst, ok := routeDst(route)
|
||||
_, managed := s.managed[dst]
|
||||
return ok && managed
|
||||
}
|
||||
|
||||
// warnings describes what a plan leaves out, in a stable order.
|
||||
func warnings(plan reconcile.Plan) []string {
|
||||
counts := make(map[netip.Addr]int)
|
||||
for _, route := range plan.Unresolved {
|
||||
counts[route.Gw]++
|
||||
}
|
||||
|
||||
var messages []string
|
||||
for gw, count := range counts {
|
||||
messages = append(messages, fmt.Sprintf("No peer found containing gw-IP '%s', ignoring %d route(s)", gw, count))
|
||||
}
|
||||
for _, c := range plan.Conflicts {
|
||||
messages = append(messages, fmt.Sprintf(
|
||||
"Route to %s points to peer %s, but the prefix is on peer %s and not managed by this program, leaving it",
|
||||
c.Dst, c.Wanted, c.Present))
|
||||
}
|
||||
slices.Sort(messages)
|
||||
return messages
|
||||
}
|
||||
|
||||
// skippedWarnings describes the routes that were left out because their destination
|
||||
// cannot be written to WireGuard, once per destination.
|
||||
func skippedWarnings(skipped []netip.Prefix) []string {
|
||||
messages := make([]string, 0, len(skipped))
|
||||
for _, prefix := range skipped {
|
||||
messages = append(messages, fmt.Sprintf("Ignoring route to %s, WireGuard cannot take an IPv4-mapped IPv6 prefix as AllowedIP", prefix))
|
||||
}
|
||||
slices.Sort(messages)
|
||||
return slices.Compact(messages)
|
||||
}
|
||||
|
||||
// warn logs the messages that were not part of the last call. A condition that
|
||||
// lasts is logged when it appears, not at every reconcile.
|
||||
func (s *syncer) warn(messages []string) {
|
||||
for _, message := range s.fresh(messages) {
|
||||
logger.Warn.Print(message)
|
||||
}
|
||||
}
|
||||
|
||||
// fresh returns the messages that were not part of the last call and remembers the current ones.
|
||||
func (s *syncer) fresh(messages []string) []string {
|
||||
var fresh []string
|
||||
current := make(map[string]struct{}, len(messages))
|
||||
for _, message := range messages {
|
||||
current[message] = struct{}{}
|
||||
if _, seen := s.warned[message]; !seen {
|
||||
fresh = append(fresh, message)
|
||||
}
|
||||
}
|
||||
s.warned = current
|
||||
return fresh
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
func TestWarnings(t *testing.T) {
|
||||
plan := reconcile.Plan{
|
||||
Unresolved: []reconcile.Route{
|
||||
{Dst: netip.MustParsePrefix("203.0.113.0/24"), Gw: netip.MustParseAddr("10.9.9.9")},
|
||||
{Dst: netip.MustParsePrefix("198.51.100.0/24"), Gw: netip.MustParseAddr("10.9.9.9")},
|
||||
},
|
||||
Conflicts: []reconcile.Conflict{
|
||||
{Dst: netip.MustParsePrefix("192.0.2.0/24"), Present: wgtypes.Key{1}, Wanted: wgtypes.Key{2}},
|
||||
},
|
||||
}
|
||||
|
||||
got := warnings(plan)
|
||||
if len(got) != 2 || got[0] != "No peer found containing gw-IP '10.9.9.9', ignoring 2 route(s)" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if want := warnings(plan); !slices.Equal(got, want) {
|
||||
t.Errorf("order is not stable: %q vs %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshReportsChangesOnly(t *testing.T) {
|
||||
s := &syncer{}
|
||||
steps := []struct {
|
||||
in, want []string
|
||||
}{
|
||||
{[]string{"a", "b"}, []string{"a", "b"}},
|
||||
{[]string{"a", "b"}, nil},
|
||||
{[]string{"b", "c"}, []string{"c"}},
|
||||
{nil, nil},
|
||||
{[]string{"a"}, []string{"a"}}, // gone and back is a change
|
||||
}
|
||||
for i, step := range steps {
|
||||
if got := s.fresh(step.in); !slices.Equal(got, step.want) {
|
||||
t.Errorf("step %d: got %q, want %q", i, got, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveStartsOverForAnotherInterface(t *testing.T) {
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, nil)
|
||||
s.observe(7)
|
||||
s.managed[netip.MustParsePrefix("203.0.113.0/24")] = struct{}{}
|
||||
s.started = true
|
||||
s.warned = map[string]struct{}{"x": {}}
|
||||
|
||||
s.observe(7)
|
||||
if len(s.managed) != 1 || !s.started || len(s.warned) != 1 {
|
||||
t.Errorf("same interface must keep its state: %v %v %v", s.managed, s.started, s.warned)
|
||||
}
|
||||
|
||||
// Gone for a moment, back as the same one
|
||||
s.ifindex = 0
|
||||
s.observe(7)
|
||||
if len(s.managed) != 1 || !s.started {
|
||||
t.Errorf("same interface must keep its state: %v %v", s.managed, s.started)
|
||||
}
|
||||
|
||||
s.observe(9)
|
||||
if len(s.managed) != 0 || s.started || s.warned != nil || s.ifindex != 9 || s.known != 9 {
|
||||
t.Errorf("recreated interface must start over: %v %v %v %d %d", s.managed, s.started, s.warned, s.ifindex, s.known)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
keyA = wgtypes.Key{1}
|
||||
keyB = wgtypes.Key{2}
|
||||
)
|
||||
|
||||
func ipNet(t *testing.T, cidr string) net.IPNet {
|
||||
t.Helper()
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return *n
|
||||
}
|
||||
|
||||
func ipNets(t *testing.T, list ...string) []net.IPNet {
|
||||
t.Helper()
|
||||
var out []net.IPNet
|
||||
for _, s := range list {
|
||||
out = append(out, ipNet(t, s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeWg records the writes. Writes touching a peer in fail return an error.
|
||||
type fakeWg struct {
|
||||
device *wgtypes.Device
|
||||
configs []wgtypes.Config
|
||||
fail map[wgtypes.Key]bool
|
||||
}
|
||||
|
||||
func (f *fakeWg) Device(string) (*wgtypes.Device, error) { return f.device, nil }
|
||||
|
||||
func (f *fakeWg) ConfigureDevice(_ string, cfg wgtypes.Config) error {
|
||||
f.configs = append(f.configs, cfg)
|
||||
for _, peer := range cfg.Peers {
|
||||
if f.fail[peer.PublicKey] {
|
||||
return errors.New("rejected")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWritable(t *testing.T) {
|
||||
tests := []struct {
|
||||
cidr string
|
||||
want bool
|
||||
}{
|
||||
{"10.200.0.0/24", true},
|
||||
{"0.0.0.0/0", true},
|
||||
{"fd00:200::/64", true},
|
||||
{"::/0", true},
|
||||
{"::ffff:0:0/80", true}, // the host bits are cleared, nothing of the mapped form is left
|
||||
{"::ffff:10.0.0.0/104", false},
|
||||
{"::ffff:0:0/96", false},
|
||||
{"::ffff:10.1.2.3/128", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := writable(ipNet(t, tt.cidr)); got != tt.want {
|
||||
t.Errorf("writable(%s) = %v, want %v", tt.cidr, got, tt.want)
|
||||
}
|
||||
}
|
||||
if !writable(net.IPNet{IP: net.IP{10, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}) {
|
||||
t.Error("4 byte IPv4 must be writable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveLeavesPeerWithUnwritableEntryAlone(t *testing.T) {
|
||||
fake := &fakeWg{device: &wgtypes.Device{Peers: []wgtypes.Peer{
|
||||
{PublicKey: keyA, AllowedIPs: ipNets(t, "10.200.0.0/24", "::ffff:10.0.0.0/104", "fd00:200::/64", "203.0.113.0/24")},
|
||||
{PublicKey: keyB, AllowedIPs: ipNets(t, "10.201.0.0/24", "198.51.100.0/24")},
|
||||
}}}
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, fake)
|
||||
|
||||
result, err := s.remove([]reconcile.Change{
|
||||
{Peer: keyA, Remove: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}},
|
||||
{Peer: keyB, Remove: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(fake.configs) != 1 || len(fake.configs[0].Peers) != 1 || fake.configs[0].Peers[0].PublicKey != keyB {
|
||||
t.Fatalf("only peer B may be written: %+v", fake.configs)
|
||||
}
|
||||
peer := fake.configs[0].Peers[0]
|
||||
if !peer.ReplaceAllowedIPs || !peer.UpdateOnly || len(peer.AllowedIPs) != 1 || peer.AllowedIPs[0].String() != "10.201.0.0/24" {
|
||||
t.Errorf("wrong write for peer B: %+v", peer)
|
||||
}
|
||||
|
||||
if _, kept := result.kept[netip.MustParsePrefix("203.0.113.0/24")]; !kept || len(result.kept) != 1 {
|
||||
t.Errorf("kept = %v", result.kept)
|
||||
}
|
||||
if len(result.warnings) != 1 || !strings.Contains(result.warnings[0], "::ffff:10.0.0.0/104") ||
|
||||
!strings.Contains(result.warnings[0], "203.0.113.0/24") {
|
||||
t.Errorf("warnings = %q", result.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWritesEachPeerOnItsOwn(t *testing.T) {
|
||||
fake := &fakeWg{
|
||||
device: &wgtypes.Device{Peers: []wgtypes.Peer{
|
||||
{PublicKey: keyA, AllowedIPs: ipNets(t, "10.200.0.0/24", "203.0.113.0/24")},
|
||||
{PublicKey: keyB, AllowedIPs: ipNets(t, "10.201.0.0/24", "198.51.100.0/24")},
|
||||
}},
|
||||
fail: map[wgtypes.Key]bool{keyA: true},
|
||||
}
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, fake)
|
||||
|
||||
_, err := s.remove([]reconcile.Change{
|
||||
{Peer: keyA, Remove: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}},
|
||||
{Peer: keyB, Remove: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), keyA.String()) {
|
||||
t.Errorf("error = %v, want one naming peer A", err)
|
||||
}
|
||||
if len(fake.configs) != 2 {
|
||||
t.Fatalf("want a write per peer, got %d", len(fake.configs))
|
||||
}
|
||||
for _, cfg := range fake.configs {
|
||||
if len(cfg.Peers) != 1 {
|
||||
t.Errorf("a write must touch one peer: %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitStartsEvenIfApplyFails(t *testing.T) {
|
||||
fake := &fakeWg{fail: map[wgtypes.Key]bool{keyA: true}}
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, fake)
|
||||
prefix := netip.MustParsePrefix("203.0.113.0/24")
|
||||
|
||||
err := s.commit(reconcile.Plan{
|
||||
Changes: []reconcile.Change{{Peer: keyA, Add: []netip.Prefix{prefix}}},
|
||||
Managed: reconcile.Set{prefix: {}},
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want the error of the write")
|
||||
}
|
||||
if !s.started {
|
||||
t.Error("startup must end with the first plan, or it lasts over the retries")
|
||||
}
|
||||
if _, ok := s.managed[prefix]; !ok {
|
||||
t.Errorf("what the plan took over must stay managed: %v", s.managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitKeepsPrefixManagedThatCannotBeRemoved(t *testing.T) {
|
||||
fake := &fakeWg{device: &wgtypes.Device{Peers: []wgtypes.Peer{
|
||||
{PublicKey: keyA, AllowedIPs: ipNets(t, "::ffff:10.0.0.0/104", "203.0.113.0/24")},
|
||||
}}}
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, fake)
|
||||
prefix := netip.MustParsePrefix("203.0.113.0/24")
|
||||
s.managed[prefix] = struct{}{}
|
||||
|
||||
err := s.commit(reconcile.Plan{
|
||||
Changes: []reconcile.Change{{Peer: keyA, Remove: []netip.Prefix{prefix}}},
|
||||
Managed: make(reconcile.Set),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.managed[prefix]; !ok {
|
||||
t.Errorf("the removal has to be tried again later: %v", s.managed)
|
||||
}
|
||||
if len(fake.configs) != 0 {
|
||||
t.Errorf("nothing may be written: %+v", fake.configs)
|
||||
}
|
||||
if len(s.warned) != 1 {
|
||||
t.Errorf("want one warning, got %v", s.warned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedWarnings(t *testing.T) {
|
||||
mapped := netip.MustParsePrefix("::ffff:10.0.0.0/104")
|
||||
other := netip.MustParsePrefix("::ffff:0:0/96")
|
||||
|
||||
got := skippedWarnings([]netip.Prefix{mapped, other, mapped})
|
||||
if len(got) != 2 || !strings.Contains(got[0], "::ffff:0.0.0.0/96") || !strings.Contains(got[1], "::ffff:10.0.0.0/104") {
|
||||
t.Errorf("want one message per prefix, got %q", got)
|
||||
}
|
||||
if got := skippedWarnings(nil); len(got) != 0 {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What a reconcile leaves out is reported when it appears, not at every reconcile.
|
||||
func TestCommitReportsNotesOnce(t *testing.T) {
|
||||
s := newSyncer("wg0", FilterOptions{}, nil, &fakeWg{})
|
||||
notes := skippedWarnings([]netip.Prefix{netip.MustParsePrefix("::ffff:10.0.0.0/104")})
|
||||
|
||||
for range 2 {
|
||||
if err := s.commit(reconcile.Plan{Managed: make(reconcile.Set)}, notes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, remembered := s.warned[notes[0]]; !remembered {
|
||||
t.Fatalf("note was not remembered: %v", s.warned)
|
||||
}
|
||||
}
|
||||
if fresh := s.fresh(notes); len(fresh) != 0 {
|
||||
t.Errorf("a repeated note must not be logged again: %q", fresh)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user