From 6805f7a8fde1ee2e38c0fb0a91be2731675b8715 Mon Sep 17 00:00:00 2001 From: encodeous Date: Thu, 8 Jan 2026 23:11:36 -0500 Subject: [PATCH 1/2] feat: add prefix health checks --- cmd/bundle.go | 2 +- core/entrypoint.go | 2 +- core/ipc.go | 4 +- core/nylon.go | 23 +++- core/nylon_distribution.go | 2 +- core/nylon_passive.go | 4 +- core/nylon_wireguard.go | 79 ++++++----- core/router.go | 39 +++++- core/router_algo.go | 8 +- core/sys_darwin.go | 10 ++ core/sys_linux.go | 4 + core/sys_windows.go | 32 ++++- example/sample-central.yaml | 22 +++- example/sample-node.yaml | 2 +- go.mod | 7 +- go.sum | 12 +- integration/harness.go | 17 ++- state/config.go | 22 +++- state/distribution.go | 5 +- state/distribution_test.go | 40 ++++-- state/endpoint.go | 16 ++- state/prefix_health.go | 253 ++++++++++++++++++++++++++++++++++++ state/prefix_health_test.go | 112 ++++++++++++++++ state/routing.go | 2 + state/serialize_test.go | 7 +- state/utils_test.go | 26 +++- state/validation.go | 51 +++++++- state/validation_test.go | 64 +++++++-- 28 files changed, 745 insertions(+), 122 deletions(-) create mode 100644 state/prefix_health.go create mode 100644 state/prefix_health_test.go diff --git a/cmd/bundle.go b/cmd/bundle.go index 65d8388a..65600b68 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -6,8 +6,8 @@ import ( "os" "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var sealCmd = &cobra.Command{ diff --git a/core/entrypoint.go b/core/entrypoint.go index d4c99ca5..707b95ca 100644 --- a/core/entrypoint.go +++ b/core/entrypoint.go @@ -19,8 +19,8 @@ import ( "github.com/encodeous/nylon/perf" "github.com/encodeous/nylon/state" "github.com/encodeous/tint" + "github.com/goccy/go-yaml" slogmulti "github.com/samber/slog-multi" - "gopkg.in/yaml.v3" ) func setupDebugging() { diff --git a/core/ipc.go b/core/ipc.go index 5dc08aad..2791cf76 100644 --- a/core/ipc.go +++ b/core/ipc.go @@ -84,9 +84,9 @@ func HandleNylonIPCGet(s *state.State, rw *bufio.ReadWriter) error { for prefix, adv := range s.Advertised { timeRem := adv.Expiry.Sub(time.Now()) if timeRem > time.Hour*24 { - rt = append(rt, fmt.Sprintf(" - %s expires never nh %s", prefix, adv.NodeId)) + rt = append(rt, fmt.Sprintf(" - %s expires never nh %s metric %d", prefix, adv.NodeId, adv.MetricFn())) } else { - rt = append(rt, fmt.Sprintf(" - %s expires %.2fs nh %s", prefix, timeRem.Seconds(), adv.NodeId)) + rt = append(rt, fmt.Sprintf(" - %s expires %.2fs nh %s metric %d", prefix, timeRem.Seconds(), adv.NodeId, adv.MetricFn())) } } slices.Sort(rt) diff --git a/core/nylon.go b/core/nylon.go index b8371b6f..11d218fd 100644 --- a/core/nylon.go +++ b/core/nylon.go @@ -3,6 +3,7 @@ package core import ( "context" "net" + "net/netip" "time" "github.com/encodeous/nylon/polyamide/device" @@ -13,12 +14,13 @@ import ( // Nylon struct must be thread safe, since it can receive packets through PolyReceiver type Nylon struct { - PingBuf *ttlcache.Cache[uint64, EpPing] - Device *device.Device - Tun tun.Device - wgUapi net.Listener - env *state.Env - itfName string + PingBuf *ttlcache.Cache[uint64, EpPing] + Device *device.Device + Tun tun.Device + wgUapi net.Listener + env *state.Env + itfName string + prevInstalledRoutes []netip.Prefix } func (n *Nylon) Init(s *state.State) error { @@ -90,6 +92,12 @@ func (n *Nylon) Init(s *state.State) error { return n.probeNew(s) }, state.ProbeDiscoveryDelay) + // prefix healthcheck + for _, ph := range s.GetNode(s.Id).Prefixes { + s.Log.Info("starting prefix healthcheck", "prefix", ph.GetPrefix()) + ph.Start(s.Log) + } + err = n.initPassiveClient(s) if err != nil { return err @@ -107,6 +115,9 @@ func (n *Nylon) Init(s *state.State) error { func (n *Nylon) Cleanup(s *state.State) error { n.PingBuf.Stop() + for _, ph := range s.GetNode(s.Id).Prefixes { + ph.Stop() + } return n.cleanupWireGuard(s) } diff --git a/core/nylon_distribution.go b/core/nylon_distribution.go index 57b30b12..e657cb58 100644 --- a/core/nylon_distribution.go +++ b/core/nylon_distribution.go @@ -9,7 +9,7 @@ import ( "os" "github.com/encodeous/nylon/state" - "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) // fetches and unbundles central config from url diff --git a/core/nylon_passive.go b/core/nylon_passive.go index fc7d1022..b7bb18e1 100644 --- a/core/nylon_passive.go +++ b/core/nylon_passive.go @@ -28,7 +28,7 @@ func scanPassivePeers(s *state.State) error { for _, prefix := range ncfg.Prefixes { for _, neigh := range s.Neighbours { for _, route := range neigh.Routes { - if route.Prefix == prefix && route.NodeId != s.Id && route.FD.Metric != state.INF { + if route.Prefix == prefix.GetPrefix() && route.NodeId != s.Id && route.FD.Metric != state.INF { hasOtherAdvertisers = true goto foundAdvertiser } @@ -43,7 +43,7 @@ func scanPassivePeers(s *state.State) error { if s.IsClient(*nid) { // we have a passive client for _, newPrefix := range ncfg.Prefixes { - recentlyAdvertised := r.hasRecentlyAdvertised(newPrefix) + recentlyAdvertised := r.hasRecentlyAdvertised(newPrefix.GetPrefix()) if recentlyUpdated || !hasOtherAdvertisers && recentlyAdvertised { r.updatePassiveClient(s, newPrefix, *nid, !recentlyUpdated) } diff --git a/core/nylon_wireguard.go b/core/nylon_wireguard.go index fb98cf10..b5c81837 100644 --- a/core/nylon_wireguard.go +++ b/core/nylon_wireguard.go @@ -82,15 +82,15 @@ listen_port=%d // configure system networking - if !s.NoNetConfigure { - // run pre-up commands - for _, cmd := range s.PreUp { - err = ExecSplit(s.Log, cmd) - if err != nil { - s.Log.Error("failed to run pre-up command", "err", err) - } + // run pre-up commands + for _, cmd := range s.PreUp { + err = ExecSplit(s.Log, cmd) + if err != nil { + s.Log.Error("failed to run pre-up command", "err", err) } + } + if !s.NoNetConfigure { for _, addr := range s.GetRouter(s.Id).Addresses { err := ConfigureAlias(s.Log, itfName, addr) if err != nil { @@ -99,44 +99,33 @@ listen_port=%d } err = InitInterface(s.Log, itfName) - if err != nil { return err } + } - // configure prefixes - exclude := append(state.SubtractPrefix(s.CentralCfg.ExcludeIPs, s.IncludeIPs), s.LocalCfg.ExcludeIPs...) - for _, excl := range exclude { - s.Log.Debug("Computed Exclude Prefix", "prefix", excl.String()) - } - computed := state.SubtractPrefix(s.GetPrefixes(), exclude) - for _, pre := range computed { - s.Log.Debug("Computed Prefix", "prefix", pre.String()) - } - for _, prefix := range computed { - err := ConfigureRoute(s.Log, n.Tun, itfName, prefix) - if err != nil { - s.Log.Error("failed to configure route", "err", err) - } - } - - // run post-up commands - for _, cmd := range s.PostUp { - err = ExecSplit(s.Log, cmd) - if err != nil { - s.Log.Error("failed to run post-up command", "err", err) - } + // run post-up commands + for _, cmd := range s.PostUp { + err = ExecSplit(s.Log, cmd) + if err != nil { + s.Log.Error("failed to run post-up command", "err", err) } } // init wireguard related tasks - s.RepeatTask(UpdateWireGuard, state.ProbeDelay) return nil } func (n *Nylon) cleanupWireGuard(s *state.State) error { + // remove routes + for _, route := range n.prevInstalledRoutes { + err := RemoveRoute(s.Log, n.Tun, n.itfName, route) + if err != nil { + s.Log.Error("failed to remove route", "err", err) + } + } // run pre-down commands for _, cmd := range s.PreUp { err := ExecSplit(s.Log, cmd) @@ -197,5 +186,33 @@ func UpdateWireGuard(s *state.State) error { wgPeer := dev.LookupPeer(device.NoisePublicKey(pcfg.PubKey)) wgPeer.SetEndpoints(eps) } + + // configure changed route table entries + if !s.NoNetConfigure { + router := Get[*NylonRouter](s) + newEntries := router.ComputeSysRouteTable() + oldEntries := n.prevInstalledRoutes + for _, oldEntry := range oldEntries { + if !slices.Contains(newEntries, oldEntry) { + // uninstall route + s.Log.Debug("removing old route", "prefix", oldEntry.String()) + err := RemoveRoute(s.Log, n.Tun, n.itfName, oldEntry) + if err != nil { + s.Log.Error("failed to remove route", "err", err) + } + } + } + for _, newEntry := range newEntries { + if !slices.Contains(oldEntries, newEntry) { + // install route + s.Log.Debug("installing new route", "prefix", newEntry.String()) + err := ConfigureRoute(s.Log, n.Tun, n.itfName, newEntry) + if err != nil { + s.Log.Error("failed to configure route", "err", err) + } + } + } + n.prevInstalledRoutes = newEntries + } return nil } diff --git a/core/router.go b/core/router.go index 8b6a6868..024919c5 100644 --- a/core/router.go +++ b/core/router.go @@ -170,7 +170,12 @@ func (r *NylonRouter) Init(s *state.State) error { } maxTime := time.Unix(1<<63-62135596801, 999999999) for _, prefix := range s.Env.GetRouter(s.Id).Prefixes { - s.RouterState.Advertised[prefix] = state.Advertisement{NodeId: s.Id, Expiry: maxTime, IsPassiveHold: false} + s.RouterState.Advertised[prefix.GetPrefix()] = state.Advertisement{ + NodeId: s.Id, + Expiry: maxTime, + IsPassiveHold: false, + MetricFn: prefix.GetMetric, + } } s.Log.Debug("schedule router tasks") @@ -188,11 +193,30 @@ func (r *NylonRouter) Init(s *state.State) error { return nil } -func (r *NylonRouter) updatePassiveClient(s *state.State, prefix netip.Prefix, node state.NodeId, passiveHold bool) { +// ComputeSysRouteTable computes: computed = prefixes - (((r.CentralCfg.ExcludeIPs U selected self prefixes) - r.LocalCfg.UnexcludeIPs) U r.LocalCfg.ExcludeIPs) +func (r *NylonRouter) ComputeSysRouteTable() []netip.Prefix { + prefixes := make([]netip.Prefix, 0) + selectedSelf := make(map[netip.Prefix]struct{}) + for entry, v := range r.ForwardTable.All() { + prefixes = append(prefixes, entry) + if v.Nh == r.Id { + selectedSelf[entry] = struct{}{} + } + } + + defaultExcludes := r.CentralCfg.ExcludeIPs + for p := range selectedSelf { + defaultExcludes = append(defaultExcludes, p) + } + exclude := append(state.SubtractPrefix(defaultExcludes, r.LocalCfg.UnexcludeIPs), r.LocalCfg.ExcludeIPs...) + return state.SubtractPrefix(prefixes, exclude) +} + +func (r *NylonRouter) updatePassiveClient(s *state.State, prefix state.PrefixHealthWrapper, node state.NodeId, passiveHold bool) { // inserts an artificial route into the table hasPassiveHold := false - old, ok := s.RouterState.Advertised[prefix] + old, ok := s.RouterState.Advertised[prefix.GetPrefix()] if ok && old.NodeId == node { hasPassiveHold = old.IsPassiveHold } @@ -200,13 +224,18 @@ func (r *NylonRouter) updatePassiveClient(s *state.State, prefix netip.Prefix, n if passiveHold && !hasPassiveHold { // the first time we enter passive hold, we should increment the seqno to prevent other nodes from switching away from the route // this reduces a lot of route flapping when the client wakes up, sends some traffic and then goes back to sleep - r.SetSeqno(prefix, s.RouterState.GetSeqno(prefix)+1) + r.SetSeqno(prefix.GetPrefix(), s.RouterState.GetSeqno(prefix.GetPrefix())+1) } - s.Advertised[prefix] = state.Advertisement{ + prefix.Start(s.Log) + s.Advertised[prefix.GetPrefix()] = state.Advertisement{ NodeId: node, Expiry: time.Now().Add(state.ClientKeepaliveInterval), IsPassiveHold: passiveHold, + MetricFn: prefix.GetMetric, + ExpiryFn: func() { + prefix.Stop() + }, } } diff --git a/core/router_algo.go b/core/router_algo.go index 8101ffad..afe9e66b 100644 --- a/core/router_algo.go +++ b/core/router_algo.go @@ -143,6 +143,9 @@ func RunGC(s *state.RouterState, r Router) { if now.After(exp.Expiry) { // advertised route expired, remove it delete(s.Advertised, svc) + if exp.ExpiryFn != nil { + exp.ExpiryFn() + } } } @@ -437,7 +440,7 @@ func ComputeRoutes(s *state.RouterState, r Router) { // add our own routes to the route table, so that we can advertise them for prefix, adv := range s.Advertised { - advMetric := uint32(0) + advMetric := adv.MetricFn() if adv.IsPassiveHold { // The metric should be high enough so that if the passive client connects to any other node, our route will be immediately unselected advMetric = state.INFM / 2 @@ -498,9 +501,6 @@ func ComputeRoutes(s *state.RouterState, r Router) { // enumerate through neighbour advertisements for S, adv := range neigh.Routes { - if _, ok := s.Advertised[S.Prefix]; ok { - continue // skip self routes - } prefix := S.Prefix // Cost(A, B) + Cost(S, B) diff --git a/core/sys_darwin.go b/core/sys_darwin.go index d5bd9c8f..2e13e471 100644 --- a/core/sys_darwin.go +++ b/core/sys_darwin.go @@ -62,3 +62,13 @@ func ConfigureRoute(logger *slog.Logger, dev tun.Device, itfName string, route n return Exec(logger, "/sbin/route", "-n", "add", "-net", addr.String(), "-netmask", netmask, "-interface", itfName) } } + +func RemoveRoute(logger *slog.Logger, dev tun.Device, itfName string, route netip.Prefix) error { + if route.Addr().Is6() { + return Exec(logger, "/sbin/route", "-n", "delete", "-inet6", route.String(), "-interface", itfName) + } else { + addr := route.Addr() + netmask := PrefixToMaskString(route) + return Exec(logger, "/sbin/route", "-n", "delete", "-net", addr.String(), "-netmask", netmask, "-interface", itfName) + } +} diff --git a/core/sys_linux.go b/core/sys_linux.go index f7f4e0ec..0d505cba 100644 --- a/core/sys_linux.go +++ b/core/sys_linux.go @@ -35,3 +35,7 @@ func ConfigureAlias(logger *slog.Logger, ifName string, addr netip.Addr) error { func ConfigureRoute(logger *slog.Logger, dev tun.Device, itfName string, route netip.Prefix) error { return Exec(logger, "ip", "route", "add", route.String(), "dev", itfName) } + +func RemoveRoute(logger *slog.Logger, dev tun.Device, itfName string, route netip.Prefix) error { + return Exec(logger, "ip", "route", "del", route.String(), "dev", itfName) +} diff --git a/core/sys_windows.go b/core/sys_windows.go index 48287c05..a1a3e915 100644 --- a/core/sys_windows.go +++ b/core/sys_windows.go @@ -34,13 +34,37 @@ func ConfigureAlias(logger *slog.Logger, ifName string, addr netip.Addr) error { } func ConfigureRoute(logger *slog.Logger, dev tun.Device, itfName string, route netip.Prefix) error { - addr := route.Addr() - _, mask, _ := net.ParseCIDR(route.String()) - maskStr := net.IP(mask.Mask).String() ifId := wintypes.LUID((dev.(*tun.NativeTun)).LUID()) itf, err := ifId.Interface() if err != nil { return err } - return Exec(logger, "route", "add", addr.String(), "mask", maskStr, "0.0.0.0", "IF", strconv.FormatUint(uint64(itf.InterfaceIndex), 10)) + ifIndex := strconv.FormatUint(uint64(itf.InterfaceIndex), 10) + + if route.Addr().Is6() { + return Exec(logger, "route", "add", route.String(), "::", "IF", ifIndex) + } else { + addr := route.Addr() + _, mask, _ := net.ParseCIDR(route.String()) + maskStr := net.IP(mask.Mask).String() + return Exec(logger, "route", "add", addr.String(), "mask", maskStr, "0.0.0.0", "IF", ifIndex) + } +} + +func RemoveRoute(logger *slog.Logger, dev tun.Device, itfName string, route netip.Prefix) error { + ifId := wintypes.LUID((dev.(*tun.NativeTun)).LUID()) + itf, err := ifId.Interface() + if err != nil { + return err + } + ifIndex := strconv.FormatUint(uint64(itf.InterfaceIndex), 10) + + if route.Addr().Is6() { + return Exec(logger, "route", "delete", route.String(), "::", "IF", ifIndex) + } else { + addr := route.Addr() + _, mask, _ := net.ParseCIDR(route.String()) + maskStr := net.IP(mask.Mask).String() + return Exec(logger, "route", "delete", addr.String(), "mask", maskStr, "0.0.0.0", "IF", ifIndex) + } } diff --git a/example/sample-central.yaml b/example/sample-central.yaml index 4bdb7204..b473b721 100644 --- a/example/sample-central.yaml +++ b/example/sample-central.yaml @@ -10,8 +10,21 @@ routers: pubkey: xmfAovAKN4AY5ocK5s+/VsG9I27KrQ13Vzb0HOsLKAs== addresses: [10.0.0.1] # this is the primary address of the nylon interface, it will be used when sending out packets from the interface prefixes: - - 192.168.0.0/24 # you can advertise a prefix as well. make sure to exclude this on nodes where it could overlap! - - 10.1.0.0/24 + - type: static # here, we define a static prefix advertisement + prefix: 192.168.0.0/24 # you can advertise a prefix as well. make sure to exclude this on nodes where it could overlap! + metric: 0 # optional advertisement metric, default is 0 + - type: ping # the ping prefix healthcheck will periodically ping the specified address, and advertise the prefix with metric according to the RTT of the ping. If the ping fails more than max_failures times, the prefix will not be advertised. + prefix: 10.1.0.0/24 + addr: 10.1.0.8 # address to ping + delay: 10s + max_failures: 3 + bind_if: en10 # optional bind, if empty, we bind to all interfaces + # metric: 0 # optional metric, if set, this will be used instead of RTT + - type: http # similar to the http prefix, but uses an HTTP GET request to determine prefix health. the duration of the GET request is used as the metric. + prefix: 10.2.0.0/24 + url: http://example.com/healthz # will expect a success response + delay: 15s + metric: 5 # optional static metric, overrides the GET duration - id: bob pubkey: 4GfHHSyVpXc+wkbjyIIONERa6Xf5EafB0nVGZLf2r2o= addresses: [10.0.0.2, 10.1.0.2] # you can advertise multiple addresses @@ -33,8 +46,9 @@ clients: # external "vanilla" WireGuard clients must be defined separately - id: client1 pubkey: SBI+yvF30Ba4xo0GKTtKHSSfbXAnRNFTBwydJyJp6Rk= addresses: [10.0.0.7] - prefixes: - - 192.168.1.0/24 # clients may also advertise prefixes, just make sure it's also added in the allowed ips in the WireGuard config + prefixes: # these prefixes will be advertised by the gateway which this client connects to + - type: static + prefix: 192.168.1.0/24 # clients may also advertise prefixes, just make sure it's also added in the allowed ips in the WireGuard config exclude_ips: # split tunnel, default excluded ip ranges for the whole network, if empty, all advertised prefixes will be included - 192.168.0.0/24 graph: # The graph determines which nodes will attempt/can peer with each other. Nodes will only connect to each other once they have been connected directly in the graph. diff --git a/example/sample-node.yaml b/example/sample-node.yaml index 2db25531..e487fe4a 100644 --- a/example/sample-node.yaml +++ b/example/sample-node.yaml @@ -11,7 +11,7 @@ dns_resolvers: [] # Default: [] - If set (e.g ["1.1.1.1:53"]), nylon will use th dist: # Optional: If set, Nylon will bootstrap central.yaml from this URL if it does not exist already url: https://static.example.com/network1.nybundle key: 7PaN6DmAayz4KnDnsXSXJH+Oy0TFGeoM4FEbQfLriVY= -include_ips: [] # split tunnel, subtracts from centrally excluded ip ranges +unexclude_ips: [] # split tunnel, subtracts from centrally excluded ip ranges exclude_ips: # split tunnel, adds to the centrally excluded ip ranges - 192.168.0.0/24 # e.g here, we exclude the local ip range diff --git a/go.mod b/go.mod index da3c0a80..13a4a3f7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module github.com/encodeous/nylon go 1.25.4 require ( + github.com/cilium/cilium v1.18.4 + github.com/digineo/go-ping v1.2.0 github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd github.com/encodeous/tint v1.2.0 github.com/gaissmai/bart v0.25.0 + github.com/goccy/go-yaml v1.19.1 github.com/google/go-cmp v0.7.0 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kmahyyg/go-network-compo v0.2.10 @@ -19,14 +22,13 @@ require ( golang.org/x/sys v0.38.0 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 google.golang.org/protobuf v1.36.10 - gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/cilium/cilium v1.18.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0 // indirect github.com/google/btree v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -42,4 +44,5 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index be94f222..ec57bb27 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,20 @@ github.com/cilium/cilium v1.18.4 h1:/HrbeMmk46UDkJs4uJemIqxB7wuZ+QRMNVscNYua5C8= github.com/cilium/cilium v1.18.4/go.mod h1:PPAuhDhMHOLaAiraQKDxEHUTmE68RrDlZj3988R+Lco= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0 h1:OT/LKmj81wMymnWXaKaKBR9n1vPlu+GC0VVKaZP6kzs= +github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0/go.mod h1:DmqdumeAKGQNU5E8MN0ruT5ZGx8l/WbAsMbXCXcSEts= +github.com/digineo/go-ping v1.2.0 h1:/9vEsoCRtQvol5vRMA2pE8guuhUVDSJB2ok2nKuJJbA= +github.com/digineo/go-ping v1.2.0/go.mod h1:cXJTVTs7mthQ41c/nWykPYuhlDQwCN0ba5LyOnyYv8Y= github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd h1:B32Ob80QTv5MomcVt709TsiWyD0QrpUYtnwW1jQFNlE= github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd/go.mod h1:DiXCPJtfZYioejF9zv9wfs3TXqWWglKGQ20DsBNVWVw= github.com/encodeous/tint v1.2.0 h1:1Y+32Iu+C8MXBoNjsM4YDf6iAkcks7csAI9f7b4fr8k= github.com/encodeous/tint v1.2.0/go.mod h1:pyfyH+fKmtmIuWVWeMcSJjJKVJSb9sVlIfVsW3jkP+Q= github.com/gaissmai/bart v0.25.0 h1:eqiokVPqM3F94vJ0bTHXHtH91S8zkKL+bKh+BsGOsJM= github.com/gaissmai/bart v0.25.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= +github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -34,8 +36,6 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= diff --git a/integration/harness.go b/integration/harness.go index 13ecec87..394a65f6 100644 --- a/integration/harness.go +++ b/integration/harness.go @@ -129,9 +129,16 @@ func (v *VirtualHarness) NewNode(id state.NodeId, virtPrefix string) { } ncfg := state.RouterCfg{ NodeCfg: state.NodeCfg{ - Id: id, - PubKey: privKey.Pubkey(), - Prefixes: []netip.Prefix{netip.MustParsePrefix(virtPrefix)}, + Id: id, + PubKey: privKey.Pubkey(), + Prefixes: []state.PrefixHealthWrapper{ + { + &state.StaticPrefixHealth{ + Prefix: netip.MustParsePrefix(virtPrefix), + Metric: 0, + }, + }, + }, }, } v.Central.Routers = append(v.Central.Routers, ncfg) @@ -256,7 +263,7 @@ func (i *InMemoryNetwork) virtualRouteTable(node state.NodeId, src, dst netip.Ad } } for _, prefix := range curCfg.Prefixes { - if prefix.Contains(dst) { + if prefix.GetPrefix().Contains(dst) { if i.SelfHandler.TryApply(node, src, dst, data) { return true } @@ -271,7 +278,7 @@ func (i *InMemoryNetwork) virtualRouteTable(node state.NodeId, src, dst netip.Ad continue } for _, prefix := range n.Prefixes { - if prefix.Contains(dst) { + if prefix.GetPrefix().Contains(dst) { // route to this node select { case i.virtTun[curIdx].Outbound <- pkt: // send back into our tun to get routed by WireGuard/Polyamide diff --git a/state/config.go b/state/config.go index a5c034f4..a17fc3a5 100644 --- a/state/config.go +++ b/state/config.go @@ -14,8 +14,8 @@ import ( type NodeCfg struct { Id NodeId PubKey NyPublicKey - Addresses []netip.Addr `yaml:",omitempty"` - Prefixes []netip.Prefix `yaml:",omitempty"` + Addresses []netip.Addr `yaml:",omitempty"` + Prefixes []PrefixHealthWrapper `yaml:",omitempty"` } // RouterCfg represents a central representation of a node that can route @@ -58,7 +58,7 @@ type LocalCfg struct { DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // dns resolvers used by nylon, currently only for config repo InterfaceName string `yaml:"interface_name,omitempty"` // the name of the nylon interface LogPath string `yaml:"log_path,omitempty"` // if not empty, nylon will write to this file - IncludeIPs []netip.Prefix `yaml:"include_ips,omitempty"` // split tunnel, subtracts from centrally excluded ip ranges + UnexcludeIPs []netip.Prefix `yaml:"unexclude_ips,omitempty"` // split tunnel, subtracts from centrally excluded ip ranges ExcludeIPs []netip.Prefix `yaml:"exclude_ips,omitempty"` // split tunnel, adds to the centrally excluded ip ranges PreUp []string `yaml:"pre_up,omitempty"` // a list of commands executed in order before the nylon interface is brought up PreDown []string `yaml:"pre_down,omitempty"` // a list of commands executed in order before the nylon interface is brought down @@ -73,14 +73,14 @@ func (c *CentralCfg) GetPrefixes() []netip.Prefix { // Collect from routers for _, router := range c.Routers { for _, prefix := range router.Prefixes { - prefixMap[prefix] = true + prefixMap[prefix.GetPrefix()] = true } } // Collect from clients for _, client := range c.Clients { for _, prefix := range client.Prefixes { - prefixMap[prefix] = true + prefixMap[prefix.GetPrefix()] = true } } @@ -388,13 +388,21 @@ func ExpandCentralConfig(cfg *CentralCfg) { // compatibility & convenience: advertise address as a host address (/32 or /128) for idx, node := range cfg.Routers { for _, addr := range node.Addresses { - node.Prefixes = append([]netip.Prefix{AddrToPrefix(addr)}, node.Prefixes...) + advAddress := StaticPrefixHealth{ + Prefix: AddrToPrefix(addr), + Metric: 0, + } + node.Prefixes = append([]PrefixHealthWrapper{{&advAddress}}, node.Prefixes...) } cfg.Routers[idx] = node } for idx, node := range cfg.Clients { for _, addr := range node.Addresses { - node.Prefixes = append([]netip.Prefix{AddrToPrefix(addr)}, node.Prefixes...) + advAddress := StaticPrefixHealth{ + Prefix: AddrToPrefix(addr), + Metric: 0, + } + node.Prefixes = append([]PrefixHealthWrapper{{&advAddress}}, node.Prefixes...) } cfg.Clients[idx] = node } diff --git a/state/distribution.go b/state/distribution.go index b9104b80..9b56d20f 100644 --- a/state/distribution.go +++ b/state/distribution.go @@ -5,10 +5,11 @@ import ( "crypto/rand" "encoding/base64" "errors" + "time" + + "github.com/goccy/go-yaml" "go.step.sm/crypto/x25519" "golang.org/x/crypto/chacha20poly1305" - "gopkg.in/yaml.v3" - "time" ) func SignBundle(data []byte, key NyPrivateKey) ([]byte, error) { diff --git a/state/distribution_test.go b/state/distribution_test.go index 89435fe6..0dcef8e6 100644 --- a/state/distribution_test.go +++ b/state/distribution_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" + "github.com/goccy/go-yaml" "github.com/stretchr/testify/assert" "go.step.sm/crypto/x25519" "golang.org/x/crypto/chacha20poly1305" - "gopkg.in/yaml.v3" ) func TestBundleUnbundle(t *testing.T) { @@ -21,9 +21,16 @@ func TestBundleUnbundle(t *testing.T) { Routers: make([]RouterCfg, 0), Clients: []ClientCfg{ {NodeCfg{ - Id: "blah", - PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.1/32")}, + Id: "blah", + PubKey: NyPublicKey{}, + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Metric: 0, + }, + }, + }, }}, }, Graph: []string{ @@ -54,10 +61,25 @@ func TestBundleTamper(t *testing.T) { {NodeCfg{ Id: "blah", PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.1/32"), - netip.MustParsePrefix("10.0.0.2/32"), - netip.MustParsePrefix("10.0.0.3/8"), + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.0.2/32"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.0.3/8"), + Metric: 0, + }, + }, }, }}, }, @@ -145,5 +167,5 @@ func TestBundleInvalidData2(t *testing.T) { assert.NoError(t, err) _, err = UnbundleConfig(base64.StdEncoding.EncodeToString(bundle), root.Pubkey()) - assert.ErrorContains(t, err, "yaml: unmarshal errors") + assert.Error(t, err) } diff --git a/state/endpoint.go b/state/endpoint.go index 8c72a3aa..73cea12a 100644 --- a/state/endpoint.go +++ b/state/endpoint.go @@ -154,9 +154,23 @@ func (u *NylonEndpoint) Metric() uint32 { if !u.IsActive() { return INF } - return uint32(min(u.StabilizedPing().Microseconds(), int64(INF)-1)) + return DurationToMetric(u.StabilizedPing()) } func (u *NylonEndpoint) IsRemote() bool { return u.remoteInit } + +func DurationToMetric(d time.Duration) uint32 { + if d == time.Duration(math.MaxInt64) { + return INF + } + return uint32(min(d.Microseconds(), int64(INF)-1)) +} + +func MetricToDuration(m uint32) time.Duration { + if m >= INF { + return time.Duration(math.MaxInt64) + } + return time.Duration(m) * time.Microsecond +} diff --git a/state/prefix_health.go b/state/prefix_health.go new file mode 100644 index 00000000..62763109 --- /dev/null +++ b/state/prefix_health.go @@ -0,0 +1,253 @@ +package state + +import ( + "fmt" + "log/slog" + "net" + "net/http" + "net/netip" + "sync/atomic" + "time" + + "github.com/digineo/go-ping" +) + +type PrefixHealth interface { + GetMetric() uint32 // Metric does not block, and returns the advertised metric for this prefix + GetPrefix() netip.Prefix + Start(log *slog.Logger) // Start begins any background monitoring required for this prefix + Stop() +} + +// StaticPrefixHealth represents a static prefix configuration, always advertised with the same metric +type StaticPrefixHealth struct { + Prefix netip.Prefix `yaml:"prefix"` + Metric uint32 `yaml:"metric,omitempty"` // the metric to advertise for this prefix +} + +func (s *StaticPrefixHealth) Stop() { + // do nothing +} + +func (s *StaticPrefixHealth) GetMetric() uint32 { + return s.Metric +} +func (s *StaticPrefixHealth) GetPrefix() netip.Prefix { + return s.Prefix +} +func (s *StaticPrefixHealth) Start(log *slog.Logger) { + // do nothing +} + +type PingPrefixHealth struct { + Prefix netip.Prefix `yaml:"prefix"` + Addr netip.Addr `yaml:"addr"` // the address to ping + MaxFailures int `yaml:"max_failures,omitempty"` // number of failures before returning infinite metric + Delay time.Duration `yaml:"delay,omitempty"` // delay between pings + BindIf string `yaml:"bind_if,omitempty"` // local interface to bind to + Metric *uint32 `yaml:"metric,omitempty"` // metric override + lastMetric uint32 + running atomic.Bool +} + +func (p *PingPrefixHealth) Stop() { + p.running.Swap(false) +} + +func GetIfIP(itf string, is6 bool) (string, error) { + ifp, err := net.InterfaceByName(itf) + if err != nil { + return "", err + } + + addrs, err := ifp.Addrs() + if err != nil { + return "", err + } + + for _, address := range addrs { + addr := netip.MustParsePrefix(address.String()).Addr() + if addr.Is6() && is6 { + return addr.String(), nil + } + if addr.Is4() && !is6 { + return addr.String(), nil + } + } + return "", fmt.Errorf("no address found for interface %s", itf) +} + +func (p *PingPrefixHealth) GetMetric() uint32 { + if p.Metric != nil { + return *p.Metric + } + return p.lastMetric +} +func (p *PingPrefixHealth) GetPrefix() netip.Prefix { + return p.Prefix +} +func (p *PingPrefixHealth) Start(log *slog.Logger) { + p.running.Swap(true) + go func() { + ticker := time.NewTicker(p.Delay) + for p.running.Load() { + time.Sleep(p.Delay) + p.lastMetric = INF + bind4 := "" + bind6 := "" + var err error + if p.Addr.Is6() { + if p.BindIf != "" { + bind6, err = GetIfIP(p.BindIf, true) + } else { + bind6 = "::" + } + } else { + if p.BindIf != "" { + bind4, err = GetIfIP(p.BindIf, false) + } else { + bind4 = "0.0.0.0" + } + } + if err != nil { + log.Error("failed to get bind address", "error", err) + continue + } + pinger, err := ping.New(bind4, bind6) + if err != nil { + log.Error("failed to start pinger", "error", err) + continue + } + for p.running.Load() { // TODO: add a way to interrupt this sleep, if ticker has a high delay + <-ticker.C + // ICMP ping + addr := &net.IPAddr{IP: net.IP(p.Addr.AsSlice())} + rtt, err := pinger.PingAttempts(addr, time.Duration(int64(p.Delay)/int64(p.MaxFailures)), p.MaxFailures) + if err != nil { + // failed + p.lastMetric = INF + log.Debug("prefix healthcheck failed", "prefix", p.Prefix.String(), "addr", p.Addr.String(), "error", err) + pinger.Close() + break // break to outer loop to recreate pinger + } else { + // success + p.lastMetric = DurationToMetric(rtt) + } + } + } + }() +} + +type HTTPPrefixHealth struct { + Prefix netip.Prefix `yaml:"prefix"` + URL string `yaml:"url"` // the URL to check + Delay time.Duration `yaml:"delay,omitempty"` // delay between probes + Metric *uint32 `yaml:"metric,omitempty"` // metric override + lastMetric uint32 + running atomic.Bool +} + +func (h *HTTPPrefixHealth) Stop() { + h.running.Swap(false) +} + +func (h *HTTPPrefixHealth) GetMetric() uint32 { + if h.Metric != nil { + return *h.Metric + } + return h.lastMetric +} +func (h *HTTPPrefixHealth) GetPrefix() netip.Prefix { + return h.Prefix +} +func (h *HTTPPrefixHealth) Start(log *slog.Logger) { + h.lastMetric = INF + h.running.Swap(true) + go func() { + ticker := time.NewTicker(h.Delay) + defer ticker.Stop() + for h.running.Load() { // TODO: add a way to interrupt this sleep, if ticker has a high delay + <-ticker.C + // HTTP probe logic would go here + startTime := time.Now() + resp, err := http.Get(h.URL) + if err != nil || resp.StatusCode != http.StatusOK { + // failed + h.lastMetric = INF + log.Debug("prefix healthcheck failed", "prefix", h.Prefix.String(), "url", h.URL, "error", err) + } else { + // success + rtt := time.Since(startTime) + h.lastMetric = DurationToMetric(rtt) + } + } + }() +} + +type PrefixHealthWrapper struct { + PrefixHealth +} + +func (p PrefixHealthWrapper) MarshalYAML() (interface{}, error) { + switch v := p.PrefixHealth.(type) { + case *StaticPrefixHealth: + return struct { + Type string `yaml:"type"` + *StaticPrefixHealth `yaml:",inline"` + }{ + Type: "static", + StaticPrefixHealth: v, + }, nil + case *PingPrefixHealth: + return struct { + Type string `yaml:"type"` + *PingPrefixHealth `yaml:",inline"` + }{ + Type: "ping", + PingPrefixHealth: v, + }, nil + case *HTTPPrefixHealth: + return struct { + Type string `yaml:"type"` + *HTTPPrefixHealth `yaml:",inline"` + }{ + Type: "http", + HTTPPrefixHealth: v, + }, nil + default: + return nil, nil + } +} + +func (p *PrefixHealthWrapper) UnmarshalYAML(unmarshal func(interface{}) error) error { + var raw struct { + Type string `yaml:"type"` + } + if err := unmarshal(&raw); err != nil { + return err + } + + switch raw.Type { + case "static": + var sp StaticPrefixHealth + if err := unmarshal(&sp); err != nil { + return err + } + p.PrefixHealth = &sp + case "ping": + var pp PingPrefixHealth + if err := unmarshal(&pp); err != nil { + return err + } + p.PrefixHealth = &pp + case "http": + var hp HTTPPrefixHealth + if err := unmarshal(&hp); err != nil { + return err + } + p.PrefixHealth = &hp + default: + return nil + } + return nil +} diff --git a/state/prefix_health_test.go b/state/prefix_health_test.go new file mode 100644 index 00000000..fd2deb3b --- /dev/null +++ b/state/prefix_health_test.go @@ -0,0 +1,112 @@ +package state + +import ( + "net/netip" + "testing" + "time" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" +) + +func TestPrefixHealthSerialization(t *testing.T) { + tests := []struct { + name string + wrapper PrefixHealthWrapper + yamlStr string + }{ + { + name: "StaticPrefixHealth", + wrapper: PrefixHealthWrapper{ + PrefixHealth: &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.0.0/24"), + Metric: 100, + }, + }, + yamlStr: `type: static +prefix: 10.0.0.0/24 +metric: 100 +`, + }, + { + name: "PingPrefixHealth", + wrapper: PrefixHealthWrapper{ + PrefixHealth: &PingPrefixHealth{ + Prefix: netip.MustParsePrefix("192.168.1.0/24"), + Addr: netip.MustParseAddr("8.8.8.8"), + MaxFailures: 3, + Delay: 10 * time.Second, + }, + }, + yamlStr: `type: ping +prefix: 192.168.1.0/24 +addr: 8.8.8.8 +max_failures: 3 +delay: 10s +`, + }, + { + name: "HTTPPrefixHealth", + wrapper: PrefixHealthWrapper{ + PrefixHealth: &HTTPPrefixHealth{ + Prefix: netip.MustParsePrefix("172.16.0.0/16"), + URL: "http://example.com/health", + Delay: 5 * time.Second, + }, + }, + yamlStr: `type: http +prefix: 172.16.0.0/16 +url: http://example.com/health +delay: 5s +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" Marshal", func(t *testing.T) { + data, err := yaml.Marshal(&tt.wrapper) + assert.NoError(t, err) + assert.YAMLEq(t, tt.yamlStr, string(data)) + }) + + t.Run(tt.name+" Unmarshal", func(t *testing.T) { + var wrapper PrefixHealthWrapper + err := yaml.Unmarshal([]byte(tt.yamlStr), &wrapper) + assert.NoError(t, err) + assert.NotNil(t, wrapper.PrefixHealth) + assert.Equal(t, tt.wrapper.GetPrefix(), wrapper.GetPrefix()) + }) + + t.Run(tt.name+" RoundTrip", func(t *testing.T) { + // Marshal + data, err := yaml.Marshal(&tt.wrapper) + assert.NoError(t, err) + + // Unmarshal + var wrapper PrefixHealthWrapper + err = yaml.Unmarshal(data, &wrapper) + assert.NoError(t, err) + + // Verify + assert.Equal(t, tt.wrapper.GetPrefix(), wrapper.GetPrefix()) + + switch orig := tt.wrapper.PrefixHealth.(type) { + case *StaticPrefixHealth: + result, ok := wrapper.PrefixHealth.(*StaticPrefixHealth) + assert.True(t, ok) + assert.Equal(t, orig.Metric, result.Metric) + case *PingPrefixHealth: + result, ok := wrapper.PrefixHealth.(*PingPrefixHealth) + assert.True(t, ok) + assert.Equal(t, orig.Addr, result.Addr) + assert.Equal(t, orig.MaxFailures, result.MaxFailures) + assert.Equal(t, orig.Delay, result.Delay) + case *HTTPPrefixHealth: + result, ok := wrapper.PrefixHealth.(*HTTPPrefixHealth) + assert.True(t, ok) + assert.Equal(t, orig.URL, result.URL) + assert.Equal(t, orig.Delay, result.Delay) + } + }) + } +} diff --git a/state/routing.go b/state/routing.go index 58e74d77..01c0c7fb 100644 --- a/state/routing.go +++ b/state/routing.go @@ -24,6 +24,8 @@ type Advertisement struct { NodeId Expiry time.Time IsPassiveHold bool + MetricFn func() uint32 + ExpiryFn func() } type RouterState struct { Id NodeId diff --git a/state/serialize_test.go b/state/serialize_test.go index c028ee20..9a1fa158 100644 --- a/state/serialize_test.go +++ b/state/serialize_test.go @@ -1,9 +1,10 @@ package state import ( - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" ) func TestSerialize(t *testing.T) { @@ -35,5 +36,5 @@ port: abcd ` y1 := LocalCfg{} err := yaml.Unmarshal([]byte(x1), &y1) - assert.ErrorContains(t, err, "line 3: cannot unmarshal !!str `abcd` into uint16") + assert.ErrorContains(t, err, "cannot unmarshal string") } diff --git a/state/utils_test.go b/state/utils_test.go index a3b46a0b..bf4869b5 100644 --- a/state/utils_test.go +++ b/state/utils_test.go @@ -30,9 +30,16 @@ func SampleNetwork(t *testing.T, numClients, numRouters int, fullyConnected bool clients[idx] = client keyStore[client] = GenerateKey() cfg.Clients = append(cfg.Clients, ClientCfg{NodeCfg{ - Id: NodeId(client), - PubKey: keyStore[client].Pubkey(), - Prefixes: []netip.Prefix{netip.MustParsePrefix(fmt.Sprintf("10.1.0.%d/32", idx))}, + Id: NodeId(client), + PubKey: keyStore[client].Pubkey(), + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix(fmt.Sprintf("10.1.0.%d/32", idx)), + Metric: 0, + }, + }, + }, }}) } @@ -44,9 +51,16 @@ func SampleNetwork(t *testing.T, numClients, numRouters int, fullyConnected bool keyStore[router] = GenerateKey() cfg.Routers = append(cfg.Routers, RouterCfg{ NodeCfg: NodeCfg{ - Id: NodeId(router), - PubKey: keyStore[router].Pubkey(), - Prefixes: []netip.Prefix{netip.MustParsePrefix(fmt.Sprintf("10.1.0.%d/32", idx))}, + Id: NodeId(router), + PubKey: keyStore[router].Pubkey(), + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix(fmt.Sprintf("10.1.0.%d/32", idx)), + Metric: 0, + }, + }, + }, }, Endpoints: []netip.AddrPort{ netip.MustParseAddrPort(fmt.Sprintf("192.168.0.%d:25565", idx)), diff --git a/state/validation.go b/state/validation.go index 2a2d2379..1f36a59c 100644 --- a/state/validation.go +++ b/state/validation.go @@ -69,7 +69,7 @@ func NodeConfigValidator(node *LocalCfg) error { } } // validate prefixes - for _, p := range append(node.IncludeIPs, node.ExcludeIPs...) { + for _, p := range append(node.UnexcludeIPs, node.ExcludeIPs...) { if !p.IsValid() { return fmt.Errorf("invalid prefix %s", p) } @@ -119,19 +119,19 @@ func CentralConfigValidator(cfg *CentralCfg) error { for _, router := range cfg.Routers { routerPrefixes := make(map[netip.Prefix]struct{}) for _, p := range router.Prefixes { - if _, ok := routerPrefixes[p]; ok { + if _, ok := routerPrefixes[p.GetPrefix()]; ok { return fmt.Errorf("router %s has duplicate prefix %s", router.Id, p) } - routerPrefixes[p] = struct{}{} + routerPrefixes[p.GetPrefix()] = struct{}{} } for _, peer := range cfg.GetPeers(router.Id) { if cfg.IsClient(peer) { client := cfg.GetClient(peer) for _, cp := range client.Prefixes { - if _, ok := routerPrefixes[cp]; ok { + if _, ok := routerPrefixes[cp.GetPrefix()]; ok { return fmt.Errorf("router %s has duplicate prefix %s (provided by client %s)", router.Id, cp, client.Id) } - routerPrefixes[cp] = struct{}{} + routerPrefixes[cp.GetPrefix()] = struct{}{} } } } @@ -146,11 +146,48 @@ func CentralConfigValidator(cfg *CentralCfg) error { } } } - // validate prefixes - for _, p := range append(cfg.GetPrefixes(), cfg.ExcludeIPs...) { + // validate excludes + for _, p := range cfg.ExcludeIPs { if !p.IsValid() { return fmt.Errorf("invalid prefix %s", p) } } + // validate prefixes + phs := make([]PrefixHealthWrapper, 0) + for _, c := range cfg.Clients { + phs = append(phs, c.Prefixes...) + } + for _, c := range cfg.Routers { + phs = append(phs, c.Prefixes...) + } + for _, p := range phs { + if !p.GetPrefix().IsValid() { + return fmt.Errorf("invalid prefix %s", p.GetPrefix()) + } + switch v := p.PrefixHealth.(type) { + case *StaticPrefixHealth: + // ok + case *PingPrefixHealth: + if !v.Addr.IsValid() { + return fmt.Errorf("invalid ping address %s for prefix %s", v.Addr, p.GetPrefix()) + } + if v.Delay <= 0 { + return fmt.Errorf("ping delay must be greater than 0 for prefix %s", p.GetPrefix()) + } + if v.MaxFailures <= 0 { + return fmt.Errorf("ping max_failures must be greater than 0 for prefix %s", p.GetPrefix()) + } + case *HTTPPrefixHealth: + _, err := url.Parse(v.URL) + if err != nil { + return fmt.Errorf("invalid HTTP URL %s for prefix %s: %v", v.URL, p.GetPrefix(), err) + } + if v.Delay <= 0 { + return fmt.Errorf("HTTP delay must be greater than 0 for prefix %s", p.GetPrefix()) + } + default: + return fmt.Errorf("unknown prefix health type for prefix %s", p.GetPrefix()) + } + } return nil } diff --git a/state/validation_test.go b/state/validation_test.go index 8704df88..b8e6ad13 100644 --- a/state/validation_test.go +++ b/state/validation_test.go @@ -62,10 +62,25 @@ func TestCentralConfigValidator_OverlappingPrefix(t *testing.T) { NodeCfg: NodeCfg{ Id: "node1", PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{ - netip.MustParsePrefix("10.5.0.1/32"), - netip.MustParsePrefix("10.5.0.0/24"), - netip.MustParsePrefix("10.5.0.1/8"), + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/32"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.0/24"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/8"), + Metric: 0, + }, + }, }, }, }, @@ -81,10 +96,25 @@ func TestCentralConfigValidator_DuplicatePrefix(t *testing.T) { NodeCfg: NodeCfg{ Id: "node1", PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{ - netip.MustParsePrefix("10.5.0.1/32"), - netip.MustParsePrefix("10.5.0.1/24"), - netip.MustParsePrefix("10.5.0.1/32"), // duplicate within same node + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/32"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/24"), + Metric: 0, + }, + }, + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/32"), + Metric: 0, + }, + }, }, }, }, @@ -101,8 +131,13 @@ func TestCentralConfigValidator_AnycastPrefix(t *testing.T) { NodeCfg: NodeCfg{ Id: "node1", PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{ - netip.MustParsePrefix("10.5.0.1/32"), + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/32"), + Metric: 0, + }, + }, }, }, }, @@ -110,8 +145,13 @@ func TestCentralConfigValidator_AnycastPrefix(t *testing.T) { NodeCfg: NodeCfg{ Id: "node2", PubKey: NyPublicKey{}, - Prefixes: []netip.Prefix{ - netip.MustParsePrefix("10.5.0.1/32"), // same prefix as node1 - this is valid for anycast + Prefixes: []PrefixHealthWrapper{ + { + &StaticPrefixHealth{ + Prefix: netip.MustParsePrefix("10.5.0.1/32"), // same prefix as node1 - this is valid for anycast + Metric: 0, + }, + }, }, }, }, From 0d8a75beed1aa4a5c107a8379fab31792024f945 Mon Sep 17 00:00:00 2001 From: encodeous Date: Thu, 8 Jan 2026 23:24:19 -0500 Subject: [PATCH 2/2] fix tests --- core/router_algo.go | 4 +++- core/router_test.go | 18 +++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/core/router_algo.go b/core/router_algo.go index afe9e66b..6ce3c3af 100644 --- a/core/router_algo.go +++ b/core/router_algo.go @@ -440,10 +440,12 @@ func ComputeRoutes(s *state.RouterState, r Router) { // add our own routes to the route table, so that we can advertise them for prefix, adv := range s.Advertised { - advMetric := adv.MetricFn() + advMetric := uint32(0) if adv.IsPassiveHold { // The metric should be high enough so that if the passive client connects to any other node, our route will be immediately unselected advMetric = state.INFM / 2 + } else if adv.MetricFn != nil { + advMetric = adv.MetricFn() } newTable[prefix] = state.SelRoute{ PubRoute: state.PubRoute{ diff --git a/core/router_test.go b/core/router_test.go index 2dd14399..541dd690 100644 --- a/core/router_test.go +++ b/core/router_test.go @@ -36,7 +36,7 @@ func TestRouterBasicComputeRoutes(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("b", "c", "d"), - Advertised: map[netip.Prefix]state.Advertisement{aPrefix: {state.NodeId("a"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{aPrefix: {NodeId: state.NodeId("a"), Expiry: maxTime}}, } ComputeRoutes(&rs, h) // we should have only routes to ourselves @@ -70,7 +70,7 @@ func TestRouterNet1A_BasicRetraction(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("S", "B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{aPrefix: {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{aPrefix: {NodeId: state.NodeId("A"), Expiry: maxTime}}, } sr := AddLink(rs, NewMockEndpoint("S", 1)) @@ -167,7 +167,7 @@ func TestRouterNet2S_SolveStarvation(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("A", "B"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("S"): {state.NodeId("S"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("S"): {NodeId: state.NodeId("S"), Expiry: maxTime}}, } AS := AddLink(rs, NewMockEndpoint("A", 1)) @@ -269,7 +269,7 @@ func TestRouterNet3A_HandleRetraction(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } _ = AddLink(rs, NewMockEndpoint("B", 1)) @@ -345,7 +345,7 @@ func TestRouterNet4A_OverlappingServiceHoldLoop(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("S", "B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } SA := AddLink(rs, NewMockEndpoint("S", 1)) @@ -423,7 +423,7 @@ func TestRouterNet4A_OverlappingServiceMetricIncrease(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("S", "B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } SA := AddLink(rs, NewMockEndpoint("S", 1)) @@ -519,7 +519,7 @@ func TestRouterNet5A_SelectedUnfeasibleUpdate(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } _ = AddLink(rs, NewMockEndpoint("B", 1)) @@ -598,7 +598,7 @@ func TestRouter5A_GCRoutes(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } _ = AddLink(rs, NewMockEndpoint("B", 1)) @@ -661,7 +661,7 @@ func TestRouterNet6A_ConvergeOptimal(t *testing.T) { Routes: make(map[netip.Prefix]state.SelRoute), Sources: make(map[state.Source]state.FD), Neighbours: MakeNeighbours("B", "C"), - Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {state.NodeId("A"), maxTime, false}}, + Advertised: map[netip.Prefix]state.Advertisement{nodeToPrefix("A"): {NodeId: state.NodeId("A"), Expiry: maxTime}}, } AB := AddLink(rs, NewMockEndpoint("B", 1))