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 566822ad..a24952f6 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 e487fe4a..ff6627ed 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 @@ -20,4 +21,4 @@ pre_up: [] pre_down: [] post_up: - iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -d 192.168.0.0/24 -j MASQUERADE # can be used in conjunction with an advertised prefix -post_down: [] \ No newline at end of file +post_down: [] diff --git a/integration/routing_test.go b/integration/routing_test.go index f07085e0..8f9e9d1a 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[vh.IndexOf("b")].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 309b4d4c..8917fb73 100644 --- a/state/config.go +++ b/state/config.go @@ -54,6 +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) 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 6685a32c..e46e8b0b 100644 --- a/state/validation.go +++ b/state/validation.go @@ -31,6 +31,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{