From f506cc5e1f15b3232ae828f20307acc271d0e192 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 28 Jul 2026 22:52:46 +0200 Subject: [PATCH 1/3] feat: add TUN-less relay mode --- core/nylon_wireguard.go | 4 +-- core/sys_physical.go | 19 ++++++++--- core/sys_virtual.go | 13 ++++++-- docs/reference/config.mdx | 1 + example/sample-node.yaml | 1 + integration/routing_test.go | 56 +++++++++++++++++++++++++++++++ polyamide/tun/dummy.go | 66 +++++++++++++++++++++++++++++++++++++ polyamide/tun/dummy_test.go | 47 ++++++++++++++++++++++++++ state/config.go | 31 ++++++++--------- state/validation.go | 3 ++ state/validation_test.go | 13 ++++++++ 11 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 polyamide/tun/dummy.go create mode 100644 polyamide/tun/dummy_test.go diff --git a/core/nylon_wireguard.go b/core/nylon_wireguard.go index b80c1ddc..d83062fa 100644 --- a/core/nylon_wireguard.go +++ b/core/nylon_wireguard.go @@ -67,7 +67,7 @@ listen_port=%d } } - if !n.NoNetConfigure { + if !n.NoNetConfigure && !n.NoTun { for _, addr := range n.GetRouter(n.LocalCfg.Id).Addresses { err := ConfigureAlias(n.Log, itfName, addr) if err != nil { @@ -235,7 +235,7 @@ func (n *Nylon) syncWireGuardEndpoints() error { } func (n *Nylon) SyncSystemState() error { - if n.NoNetConfigure { + if n.NoNetConfigure || n.NoTun { return nil } return errors.Join(n.syncAliases(), n.syncSystemRoutes()) diff --git a/core/sys_physical.go b/core/sys_physical.go index f8b09f87..ffd43cfa 100644 --- a/core/sys_physical.go +++ b/core/sys_physical.go @@ -16,13 +16,18 @@ import ( func NewWireGuardDevice(n *Nylon) (dev *device.Device, tunDevice tun.Device, realItf string, err error) { itfName := n.InterfaceName // attempt to name the interface - if runtime.GOOS == "darwin" { + if runtime.GOOS == "darwin" && !n.NoTun { itfName = "utun" } - tdev, err := tun.CreateTUN(itfName, device.DefaultMTU) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to create TUN: %v. Check if an interface with the name nylon exists already", err) + var tdev tun.Device + if n.NoTun { + tdev = tun.NewDummyDevice(itfName) + } else { + tdev, err = tun.CreateTUN(itfName, device.DefaultMTU) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to create TUN: %v. Check if an interface with the name nylon exists already", err) + } } realInterfaceName, err := tdev.Name() if err == nil { @@ -65,7 +70,11 @@ func NewWireGuardDevice(n *Nylon) (dev *device.Device, tunDevice tun.Device, rea }() } - n.Log.Info("Created WireGuard interface", "name", itfName) + if n.NoTun { + n.Log.Info("Created userspace-only WireGuard device", "name", itfName) + } else { + n.Log.Info("Created WireGuard interface", "name", itfName) + } return dev, tdev, itfName, nil } diff --git a/core/sys_virtual.go b/core/sys_virtual.go index 0e5b6618..211a82d6 100644 --- a/core/sys_virtual.go +++ b/core/sys_virtual.go @@ -28,7 +28,12 @@ func NewWireGuardDevice(n *Nylon) (dev *device.Device, tunDevice tun.Device, rea itfName := "nylon-vn" bind := vn.Bind(n.LocalCfg.Id) - tdev := vn.Tun(n.LocalCfg.Id) + var tdev tun.Device + if n.NoTun { + tdev = tun.NewDummyDevice(itfName) + } else { + tdev = vn.Tun(n.LocalCfg.Id) + } wgLog := n.Log.With("module", log.ScopePolyamide) @@ -47,7 +52,11 @@ func NewWireGuardDevice(n *Nylon) (dev *device.Device, tunDevice tun.Device, rea }, }) - n.Log.Info("Created WireGuard interface", "name", itfName) + if n.NoTun { + n.Log.Info("Created userspace-only WireGuard device", "name", itfName) + } else { + n.Log.Info("Created WireGuard interface", "name", itfName) + } return dev, tdev, itfName, nil } diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 3b8ebe7f..59c4f608 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -17,6 +17,7 @@ port: 57175 # UDP port nylon listens on # --- Optional fields below --- use_system_routing: false # if true, all peer packets exit via the TUN interface +no_tun: false # if true, run as a userspace-only relay without creating a TUN interface (requires use_system_routing: false) no_net_configure: false # if true, nylon won't touch system routes or interfaces log_path: "" # write logs to this file (empty = stderr only) interface_name: "" # override the interface name (default: "nylon", or utunX on macOS) diff --git a/example/sample-node.yaml b/example/sample-node.yaml index 7af856e1..b73e711b 100644 --- a/example/sample-node.yaml +++ b/example/sample-node.yaml @@ -4,6 +4,7 @@ port: 57175 # Default: 57175 - UDP port that Nylon listens on # the following are optional use_system_routing: false # Default: false - all packets from peers will come out of the TUN interface +no_tun: false # Default: false - run as a userspace-only relay without creating a TUN interface no_net_configure: false # Default: false - do not configure system networking at all log_path: "" # Default: "" - If set, Nylon will log to this file interface_name: "" # Default: "" - If set, Nylon will use this interface name instead of the default "nylon" or utunx on macOS diff --git a/integration/routing_test.go b/integration/routing_test.go index f07085e0..8a63d078 100644 --- a/integration/routing_test.go +++ b/integration/routing_test.go @@ -69,6 +69,62 @@ func TestInProcessRouting(t *testing.T) { vh.Stop() } +func TestInProcessRoutingThroughTUNLessRelay(t *testing.T) { + defer goleak.VerifyNone(t) + vh := &VirtualHarness{} + vh.UntrackedRouting = true + a1 := "192.168.1.1:1234" + vh.NewNode("a", "10.0.0.1/32") + b1 := "192.168.1.2:1234" + vh.NewNode("b", "10.0.0.2/32") + vh.Local[1].NoTun = true + c1 := "192.168.1.3:1234" + vh.NewNode("c", "10.0.0.3/32") + vh.Central.Graph = []string{ + "a, b", + "b, c", + } + vh.Endpoints = map[string]state.NodeId{ + a1: "a", + b1: "b", + c1: "c", + } + vh.AddLink(a1, b1) + vh.AddLink(b1, a1) + vh.AddLink(b1, c1) + vh.AddLink(c1, b1) + + errs := vh.Start() + defer vh.Stop() + + received := make(chan struct{}, 1) + vh.Net.SelfHandler = func(node state.NodeId, src, dst netip.Addr, data []byte) bool { + if node == "c" && src.String() == "10.0.0.1" && dst.String() == "10.0.0.3" && data[0] == 222 { + received <- struct{}{} + } + return true + } + + go func() { + for { + select { + case <-vh.Context.Done(): + return + case <-time.After(100 * time.Millisecond): + vh.Net.Send("a", "10.0.0.1", "10.0.0.3", []byte{222}, 64) + } + } + }() + + select { + case <-received: + case <-time.After(10 * time.Second): + t.Error("timed out waiting for packet through TUN-less relay") + case err := <-errs: + t.Error(err) + } +} + func TestTTL(t *testing.T) { defer goleak.VerifyNone(t) vh := &VirtualHarness{} diff --git a/polyamide/tun/dummy.go b/polyamide/tun/dummy.go new file mode 100644 index 00000000..37047b16 --- /dev/null +++ b/polyamide/tun/dummy.go @@ -0,0 +1,66 @@ +package tun + +import ( + "os" + "sync" +) + +// DummyDevice is a userspace-only TUN device. It lets the WireGuard data plane +// operate without creating a host network interface. Reads block until the +// device is closed, while writes are discarded. +type DummyDevice struct { + name string + closed chan struct{} + events chan Event + closeOnce sync.Once +} + +func NewDummyDevice(name string) *DummyDevice { + return &DummyDevice{ + name: name, + closed: make(chan struct{}), + events: make(chan Event), + } +} + +func (d *DummyDevice) File() *os.File { + return nil +} + +func (d *DummyDevice) Read(_ [][]byte, _ []int, _ int) (int, error) { + <-d.closed + return 0, os.ErrClosed +} + +func (d *DummyDevice) Write(bufs [][]byte, _ int) (int, error) { + select { + case <-d.closed: + return 0, os.ErrClosed + default: + return len(bufs), nil + } +} + +func (d *DummyDevice) MTU() (int, error) { + return 1420, nil +} + +func (d *DummyDevice) Name() (string, error) { + return d.name, nil +} + +func (d *DummyDevice) Events() <-chan Event { + return d.events +} + +func (d *DummyDevice) Close() error { + d.closeOnce.Do(func() { + close(d.closed) + close(d.events) + }) + return nil +} + +func (d *DummyDevice) BatchSize() int { + return 1 +} diff --git a/polyamide/tun/dummy_test.go b/polyamide/tun/dummy_test.go new file mode 100644 index 00000000..90f2e896 --- /dev/null +++ b/polyamide/tun/dummy_test.go @@ -0,0 +1,47 @@ +package tun + +import ( + "errors" + "os" + "testing" +) + +func TestDummyDevice(t *testing.T) { + dev := NewDummyDevice("relay") + + name, err := dev.Name() + if err != nil { + t.Fatal(err) + } + if name != "relay" { + t.Fatalf("Name() = %q, want relay", name) + } + if mtu, err := dev.MTU(); err != nil || mtu != 1420 { + t.Fatalf("MTU() = %d, %v; want 1420, nil", mtu, err) + } + if n, err := dev.Write([][]byte{{1, 2, 3}}, 0); err != nil || n != 1 { + t.Fatalf("Write() = %d, %v; want 1, nil", n, err) + } + + readDone := make(chan error, 1) + go func() { + _, err := dev.Read(nil, nil, 0) + readDone <- err + }() + + if err := dev.Close(); err != nil { + t.Fatal(err) + } + if err := dev.Close(); err != nil { + t.Fatal(err) + } + if err := <-readDone; !errors.Is(err, os.ErrClosed) { + t.Fatalf("Read() error = %v, want os.ErrClosed", err) + } + if _, ok := <-dev.Events(); ok { + t.Fatal("Events channel remained open after Close") + } + if _, err := dev.Write([][]byte{{1}}, 0); !errors.Is(err, os.ErrClosed) { + t.Fatalf("Write() error = %v, want os.ErrClosed", err) + } +} diff --git a/state/config.go b/state/config.go index 7c963f69..2df117be 100644 --- a/state/config.go +++ b/state/config.go @@ -49,22 +49,23 @@ type CentralCfg struct { // LocalCfg represents local node-level configuration type LocalCfg struct { // Node Private Key - Key NyPrivateKey - Id NodeId // unique id for this node - Port uint16 // Address that the data plane can be accessed by - Dist *LocalDistributionCfg `yaml:",omitempty"` // distribution configuration - UseSystemRouting bool `yaml:"use_system_routing,omitempty"` // all packets from peers will come out of the TUN interface - NoNetConfigure bool `yaml:"no_net_configure,omitempty"` // do not configure system networking at all - DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // DNS resolvers used for endpoints and config repositories - 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 + Key NyPrivateKey + Id NodeId // unique id for this node + Port uint16 // Address that the data plane can be accessed by + Dist *LocalDistributionCfg `yaml:",omitempty"` // distribution configuration + UseSystemRouting bool `yaml:"use_system_routing,omitempty"` // all packets from peers will come out of the TUN interface + NoTun bool `yaml:"no_tun,omitempty"` // run without creating a TUN interface (relay-only mode) + NoNetConfigure bool `yaml:"no_net_configure,omitempty"` // do not configure system networking at all + DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // DNS resolvers used for endpoints and config repositories + 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 ObservabilityAddr string `yaml:"observability_addr,omitempty"` // HTTP address for metrics, health, readiness, and service discovery - 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 - PostUp []string `yaml:"post_up,omitempty"` // a list of commands executed in order after the nylon interface is brought up - PostDown []string `yaml:"post_down,omitempty"` // a list of commands executed in order after the nylon interface is brought down + 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 + PostUp []string `yaml:"post_up,omitempty"` // a list of commands executed in order after the nylon interface is brought up + PostDown []string `yaml:"post_down,omitempty"` // a list of commands executed in order after the nylon interface is brought down } func (c *CentralCfg) Clone() (error, *CentralCfg) { diff --git a/state/validation.go b/state/validation.go index 267c3bde..ef80b1ff 100644 --- a/state/validation.go +++ b/state/validation.go @@ -32,6 +32,9 @@ func NodeConfigValidator(central *CentralCfg, node *LocalCfg) error { if node.Key == [32]byte{} { return fmt.Errorf("private key must not be empty") } + if node.NoTun && node.UseSystemRouting { + return fmt.Errorf("no_tun cannot be used with use_system_routing") + } if node.InterfaceName != "" { err = NameValidator(node.InterfaceName) if err != nil { diff --git a/state/validation_test.go b/state/validation_test.go index 78b0f830..f0c39184 100644 --- a/state/validation_test.go +++ b/state/validation_test.go @@ -55,6 +55,19 @@ func TestNodeConfigValidator_DnsResolver(t *testing.T) { })) } +func TestNodeConfigValidator_TunlessMode(t *testing.T) { + base := LocalCfg{ + Id: "relay", + Port: 57175, + Key: [32]byte{1}, + NoTun: true, + } + assert.NoError(t, NodeConfigValidator(nil, &base)) + + base.UseSystemRouting = true + assert.ErrorContains(t, NodeConfigValidator(nil, &base), "no_tun cannot be used with use_system_routing") +} + func TestCentralConfigValidator_OverlappingPrefix(t *testing.T) { cfg := &CentralCfg{ Routers: []RouterCfg{ From eae329687236d15f50e55916b7acfdca6d9f82da Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Wed, 29 Jul 2026 08:57:44 +0200 Subject: [PATCH 2/3] test: select TUN-less relay by node ID --- integration/routing_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/routing_test.go b/integration/routing_test.go index 8a63d078..8f9e9d1a 100644 --- a/integration/routing_test.go +++ b/integration/routing_test.go @@ -77,7 +77,7 @@ func TestInProcessRoutingThroughTUNLessRelay(t *testing.T) { vh.NewNode("a", "10.0.0.1/32") b1 := "192.168.1.2:1234" vh.NewNode("b", "10.0.0.2/32") - vh.Local[1].NoTun = true + vh.Local[vh.IndexOf("b")].NoTun = true c1 := "192.168.1.3:1234" vh.NewNode("c", "10.0.0.3/32") vh.Central.Graph = []string{ From 70a90d7a6452ae8ec63685a9eeb03e6785ca0cb8 Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sat, 8 Aug 2026 23:10:30 +0000 Subject: [PATCH 3/3] feat(validation): tighten no-tun validation --- docs/reference/config.mdx | 2 +- e2e/tunless_test.go | 79 +++++++++++++++++++++++++++++++++++++++ example/sample-node.yaml | 2 +- state/config.go | 2 +- state/validation.go | 10 ++++- state/validation_test.go | 17 +++++++++ 6 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 e2e/tunless_test.go diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 59c4f608..afc1e4bb 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -17,7 +17,7 @@ port: 57175 # UDP port nylon listens on # --- Optional fields below --- use_system_routing: false # if true, all peer packets exit via the TUN interface -no_tun: false # if true, run as a userspace-only relay without creating a TUN interface (requires use_system_routing: false) +no_tun: false # if true, run as a userspace-only relay without a TUN (requires use_system_routing: false and no addresses/prefixes for this node in central.yaml) no_net_configure: false # if true, nylon won't touch system routes or interfaces log_path: "" # write logs to this file (empty = stderr only) interface_name: "" # override the interface name (default: "nylon", or utunX on macOS) diff --git a/e2e/tunless_test.go b/e2e/tunless_test.go new file mode 100644 index 00000000..05845664 --- /dev/null +++ b/e2e/tunless_test.go @@ -0,0 +1,79 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "testing" + "time" + + "github.com/encodeous/nylon/protocol" + "github.com/encodeous/nylon/state" +) + +func TestTUNLessRelay(t *testing.T) { + t.Parallel() + h := NewHarness(t) + + sourceKey := state.GenerateKey() + relayKey := state.GenerateKey() + destinationKey := state.GenerateKey() + + sourceIP := GetIP(h.Subnet, 10) + relayIP := GetIP(h.Subnet, 11) + destinationIP := GetIP(h.Subnet, 12) + + const ( + sourceNylonIP = "10.0.0.1" + destinationNylonIP = "10.0.0.3" + ) + + configDir := h.SetupTestDir() + central := state.CentralCfg{ + Routers: []state.RouterCfg{ + SimpleRouter("source", sourceKey.Pubkey(), sourceNylonIP, ""), + { + NodeCfg: state.NodeCfg{ + Id: "relay", + PubKey: relayKey.Pubkey(), + }, + Endpoints: []string{fmt.Sprintf("%s:57175", relayIP)}, + }, + SimpleRouter("destination", destinationKey.Pubkey(), destinationNylonIP, ""), + }, + Graph: []string{ + "source, relay", + "relay, destination", + }, + Timestamp: time.Now().UnixNano(), + } + centralPath := h.WriteConfig(configDir, "central.yaml", central) + + sourceCfg := SimpleLocal("source", sourceKey) + relayCfg := SimpleLocal("relay", relayKey) + relayCfg.NoTun = true + destinationCfg := SimpleLocal("destination", destinationKey) + + h.StartNodes( + NodeSpec{Name: "source", IP: sourceIP, CentralConfigPath: centralPath, NodeConfigPath: h.WriteConfig(configDir, "source.yaml", sourceCfg)}, + NodeSpec{Name: "relay", IP: relayIP, CentralConfigPath: centralPath, NodeConfigPath: h.WriteConfig(configDir, "relay.yaml", relayCfg)}, + NodeSpec{Name: "destination", IP: destinationIP, CentralConfigPath: centralPath, NodeConfigPath: h.WriteConfig(configDir, "destination.yaml", destinationCfg)}, + ) + + h.WaitForStatus(t, "source", func(status *protocol.StatusResponse) bool { + return HasSelectedRoute(status, destinationNylonIP+"/32", "relay", "destination") + }) + h.WaitForStatus(t, "destination", func(status *protocol.StatusResponse) bool { + return HasSelectedRoute(status, sourceNylonIP+"/32", "relay", "source") + }) + + h.StartTrace("relay") + ttlPing := h.ExecBackground("source", []string{"ping", "-c", "10", "-W", "1", "-t", "1", destinationNylonIP}) + h.WaitForTrace("relay", fmt.Sprintf("TTL Expired: %s -> %s", sourceNylonIP, destinationNylonIP)) + _, _, _ = ttlPing.Wait() + + stdout, stderr, err := h.Exec("source", []string{"ping", "-c", "3", "-W", "2", destinationNylonIP}) + if err != nil { + t.Fatalf("ping through TUN-less relay failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } +} diff --git a/example/sample-node.yaml b/example/sample-node.yaml index b73e711b..473f82e7 100644 --- a/example/sample-node.yaml +++ b/example/sample-node.yaml @@ -4,7 +4,7 @@ port: 57175 # Default: 57175 - UDP port that Nylon listens on # the following are optional use_system_routing: false # Default: false - all packets from peers will come out of the TUN interface -no_tun: false # Default: false - run as a userspace-only relay without creating a TUN interface +no_tun: false # Default: false - userspace-only relay; requires no addresses/prefixes for this node in central.yaml no_net_configure: false # Default: false - do not configure system networking at all log_path: "" # Default: "" - If set, Nylon will log to this file interface_name: "" # Default: "" - If set, Nylon will use this interface name instead of the default "nylon" or utunx on macOS diff --git a/state/config.go b/state/config.go index 2df117be..a1853782 100644 --- a/state/config.go +++ b/state/config.go @@ -54,7 +54,7 @@ type LocalCfg struct { Port uint16 // Address that the data plane can be accessed by Dist *LocalDistributionCfg `yaml:",omitempty"` // distribution configuration UseSystemRouting bool `yaml:"use_system_routing,omitempty"` // all packets from peers will come out of the TUN interface - NoTun bool `yaml:"no_tun,omitempty"` // run without creating a TUN interface (relay-only mode) + NoTun bool `yaml:"no_tun,omitempty"` // relay-only mode; requires no advertised addresses or prefixes NoNetConfigure bool `yaml:"no_net_configure,omitempty"` // do not configure system networking at all DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // DNS resolvers used for endpoints and config repositories InterfaceName string `yaml:"interface_name,omitempty"` // the name of the nylon interface diff --git a/state/validation.go b/state/validation.go index ef80b1ff..79e5bbb2 100644 --- a/state/validation.go +++ b/state/validation.go @@ -66,8 +66,14 @@ func NodeConfigValidator(central *CentralCfg, node *LocalCfg) error { } } // check that node is in central config - if central != nil && !central.IsNode(node.Id) { - return fmt.Errorf("node %s is not in central config", node.Id) + if central != nil { + centralNode := central.TryGetNode(node.Id) + if centralNode == nil { + return fmt.Errorf("node %s is not in central config", node.Id) + } + if node.NoTun && (len(centralNode.Addresses) != 0 || len(centralNode.Prefixes) != 0) { + return fmt.Errorf("no_tun node %s cannot advertise addresses or prefixes", node.Id) + } } return nil } diff --git a/state/validation_test.go b/state/validation_test.go index f0c39184..256d9eeb 100644 --- a/state/validation_test.go +++ b/state/validation_test.go @@ -66,6 +66,23 @@ func TestNodeConfigValidator_TunlessMode(t *testing.T) { base.UseSystemRouting = true assert.ErrorContains(t, NodeConfigValidator(nil, &base), "no_tun cannot be used with use_system_routing") + + base.UseSystemRouting = false + central := CentralCfg{ + Routers: []RouterCfg{{ + NodeCfg: NodeCfg{Id: base.Id}, + }}, + } + assert.NoError(t, NodeConfigValidator(¢ral, &base)) + + central.Routers[0].Addresses = []netip.Addr{netip.MustParseAddr("10.0.0.1")} + assert.ErrorContains(t, NodeConfigValidator(¢ral, &base), "cannot advertise addresses or prefixes") + + central.Routers[0].Addresses = nil + central.Routers[0].Prefixes = []PrefixHealthWrapper{{ + PrefixHealth: &StaticPrefixHealth{Prefix: netip.MustParsePrefix("192.0.2.0/24")}, + }} + assert.ErrorContains(t, NodeConfigValidator(¢ral, &base), "cannot advertise addresses or prefixes") } func TestCentralConfigValidator_OverlappingPrefix(t *testing.T) {