[GH-ISSUE #5902] Lneto-Netbird integration #11272

Open
opened 2026-08-05 01:29:08 -04:00 by saavagebueno · 1 comment
Owner

Originally created by @soypat on GitHub (Apr 16, 2026).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/5902

Context

Today Netbird uses gVisor for TCP/IP among other protocols like DNS. gVisor is a tool designed to give containers isolation from the OS and in doing so has a heavy footprint in terms of binary size, RAM(~15MB x86) and CPU 1 .

Lneto is a lean userspace networking stack that avoids allocations at runtime by defining memory used up-front. Lneto's stack has also been designed to share memory locality: the xnet.StackAsync not including buffers is 2.3kB in size. Lneto single thread performance with xnet.StackAsync on 12th Gen Intel i5-12400F below:

  • ARP exchange in ~177 ns (≈6.1e6 ops/s) 0 alloc/op
  • TCP 3-way handshake ~1.2 µs (≈9.87e5 ops/s) 0 alloc/op

Lneto has been shown to work on embedded systems- that said it is also in early development:

  • ICMPv4: Only Echo (ping) supported, no IGMP.
  • IPv6 support untested.
  • No routing logic beyond what is needed for basic internet access.

gVisor in Netbird

NetBird use gVisor netstack for userspace TCP/IP either as a direct consumer as is the case of the Forwarder or via wireguard's netstack. Below is an AI assisted dependency graph for relevant consumers in Netbird.

graph TD
    subgraph "Browser (GOOS=js)"
        WASM["wasm/cmd/main.go<br/><i>*embed.Client</i>"]
        SSH["wasm/internal/ssh/<br/><i>ssh.NewClientConn(net.Conn)</i>"]
        RDP["wasm/internal/rdp/<br/><i>embed.Client.Dial() → net.Conn</i>"]
        HTTP["wasm/internal/http/<br/><i>embed.Client.NewHTTPClient()</i>"]
    end

    subgraph "Embed SDK"
        EMBED["embed/embed.go<br/><i>Dial() → net.Conn</i><br/><i>ListenTCP() → net.Listener</i><br/><i>ListenUDP() → net.PacketConn</i>"]
    end

    subgraph "Engine"
        ENGINE["internal/engine.go<br/><i>GetNet() → *netstack.Net</i>"]
    end

    subgraph "Interface Layer"
        WGIFACE["iface/iface.go<br/><i>WGIface interface</i><br/><i>GetNet() → *netstack.Net</i>"]
        DEVICE_IF["iface/device.go<br/><i>WGTunDevice interface</i><br/><i>GetNet() → *netstack.Net</i>"]
    end

    subgraph "Device Implementations"
        NS_DEV["iface/device/device_netstack.go<br/><i>TunNetstackDevice</i><br/><i>field: net *netstack.Net</i>"]
        KERNEL["iface/device/device_kernel_linux.go<br/><i>TunKernelDevice</i><br/><i>GetNet() → nil</i>"]
        USP["iface/device/device_usp_unix.go<br/><i>TunUSPDevice</i><br/><i>GetNet() → nil</i>"]
    end

    subgraph "Netstack TUN"
        NS_TUN["iface/netstack/tun.go<br/><i>NetStackTun.Create()</i><br/><i>→ (tun.Device, *netstack.Net)</i>"]
        DIALER["iface/netstack/dialer.go<br/><i>NSDialer</i><br/><i>field: net *netstack.Net</i>"]
        PROXY["iface/netstack/proxy.go<br/><i>Socks5 proxy</i><br/><i>uses Dialer interface</i>"]
    end

    subgraph "gVisor via WireGuard"
        WG_NS["wireguard/tun/netstack<br/><i>CreateNetTUN()</i><br/><i>→ tun.Device + *netstack.Net</i>"]
        GVISOR["gvisor.dev/gvisor/pkg/tcpip<br/><i>stack.Stack, gonet, header</i>"]
    end

    subgraph "USP Forwarder — INDEPENDENT"
        FWD["forwarder/forwarder.go<br/><i>Forwarder.stack *stack.Stack</i><br/><i>Forwarder.endpoint *endpoint</i>"]
        FWD_EP["forwarder/endpoint.go<br/><i>impl stack.LinkEndpoint</i><br/><i>bridges → *wgdevice.Device</i>"]
        FWD_TCP["forwarder/tcp.go<br/><i>*tcp.ForwarderRequest</i><br/><i>gonet.NewTCPConn → net.Conn</i>"]
        FWD_UDP["forwarder/udp.go<br/><i>*udp.ForwarderRequest</i><br/><i>gonet.NewUDPConn → net.PacketConn</i>"]
        FWD_ICMP["forwarder/icmp.go<br/><i>header.ICMPv4, header.IPv4</i>"]
    end

    subgraph "DNS"
        DNSFWD["internal/dnsfwd/forwarder.go<br/><i>*netstack.Net</i><br/><i>.ListenTCPAddrPort()</i><br/><i>.ListenUDPAddrPort()</i>"]
    end

    WASM --> SSH & RDP & HTTP
    SSH -->|"net.Conn from Dial()"| EMBED
    RDP -->|"net.Conn from Dial()"| EMBED
    HTTP -->|"*http.Client"| EMBED
    EMBED -->|"GetNet()"| ENGINE
    ENGINE -->|"*netstack.Net"| WGIFACE
    WGIFACE --> DEVICE_IF
    DEVICE_IF --> NS_DEV & KERNEL & USP
    NS_DEV -->|"*netstack.Net"| NS_TUN
    NS_TUN -->|"CreateNetTUN()"| WG_NS
    WG_NS --> GVISOR
    NS_TUN -->|"*netstack.Net"| DIALER
    DIALER -->|"Dialer interface"| PROXY
    DNSFWD -->|"*netstack.Net"| ENGINE

    FWD --> FWD_EP & FWD_TCP & FWD_UDP & FWD_ICMP
    FWD -->|"stack.New()"| GVISOR
    FWD_EP -->|"stack.LinkEndpoint"| GVISOR
    FWD_TCP -->|"tcp.ForwarderRequest, gonet"| GVISOR
    FWD_UDP -->|"udp.ForwarderRequest, gonet"| GVISOR

    style WG_NS fill:#f66,stroke:#333,color:#fff
    style GVISOR fill:#f66,stroke:#333,color:#fff
    style FWD fill:#f96,stroke:#333
    style FWD_EP fill:#f96,stroke:#333
    style FWD_TCP fill:#f96,stroke:#333
    style FWD_UDP fill:#f96,stroke:#333
    style FWD_ICMP fill:#f96,stroke:#333
    style NS_TUN fill:#fd6,stroke:#333
    style NS_DEV fill:#fd6,stroke:#333

    subgraph "Legend"
        L1["gVisor code"]
        L2["Direct gVisor consumer"]
        L3["Thin wrapper over gVisor"]
        style L1 fill:#f66,stroke:#333,color:#fff
        style L2 fill:#f96,stroke:#333
        style L3 fill:#fd6,stroke:#333
    end

Proposed phases to integrate Lneto

Phase 0 - netstack.Net drop-in replacement

Phase 0: Create drop-in replacement for netstack.CreateNetTUN returned netstack.Net type and have NewNetStack use it via a build tag "netbirdlneto". This means integrating lneto with wireguard. Netbird would be unaffected since it just means implementing the API below with lneto on top of a wireguard connection:

type Net tunNet
func (tnet *Net) Dial(network string, address string) (net.Conn, error)
func (tnet *Net) DialContext(ctx context.Context, network string, address string) (net.Conn, error)
func (net *Net) DialContextTCP(ctx context.Context, addr *net.TCPAddr) (*gonet.TCPConn, error)
func (net *Net) DialContextTCPAddrPort(ctx context.Context, addr netip.AddrPort) (*gonet.TCPConn, error)
func (net *Net) DialPing(laddr *PingAddr, raddr *PingAddr) (*PingConn, error)
func (net *Net) DialPingAddr(laddr netip.Addr, raddr netip.Addr) (*PingConn, error)
func (net *Net) DialTCP(addr *net.TCPAddr) (*gonet.TCPConn, error)
func (net *Net) DialTCPAddrPort(addr netip.AddrPort) (*gonet.TCPConn, error)
func (net *Net) DialUDP(laddr *net.UDPAddr, raddr *net.UDPAddr) (*gonet.UDPConn, error)
func (net *Net) DialUDPAddrPort(laddr netip.AddrPort, raddr netip.AddrPort) (*gonet.UDPConn, error)
func (net *Net) ListenPing(laddr *PingAddr) (*PingConn, error)
func (net *Net) ListenPingAddr(laddr netip.Addr) (*PingConn, error)
func (net *Net) ListenTCP(addr *net.TCPAddr) (*gonet.TCPListener, error)
func (net *Net) ListenTCPAddrPort(addr netip.AddrPort) (*gonet.TCPListener, error)
func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error)
func (net *Net) ListenUDPAddrPort(laddr netip.AddrPort) (*gonet.UDPConn, error)
func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, error)
func (net *Net) LookupHost(host string) (addrs []string, err error)

Once this is implemented we can build NetBird locally. Test ping/tcp via Lneto stack on host. Impact: CPU, Mem, latency, throughput. At this point we evaluate if Lneto as-is is adequate fit for Netbird or if changes need to happen.

Phase 0.1 - gonet.UDPConn

Need to implement the gonet.UDPConn functionality on Lneto's side. Specificially the address specific ReadFrom, WriteTo functionality within udp.Conn. Need to add a UDP listener abstraction

Phase 0.2 - gonet.TCPConn

Need to implement half-duplex close methods CloseRead,CloseWrite on Lneto's side.

Phase 0.3 - netstack.PingConn

Need to implement ICMPv6 Echo protocol.

Phase 0.4 - IPv6 support and testing

Lneto "technically" supports IPv6. It has been thus far designed to receive IPv6 addresses at the API level but at times returns unsupported errors at certain levels like DHCP and ICMP Echo(Ping) since we only support IPv4 for these protocols. Lneto will need to have modernization effort to fully support IPv6.

Phase 0.6 - DNS over TCP

Lneto currently only has a UDP based DNS client. We'll likely need to add a stream based client so it can be used over TCP.

Phase 0.7 - Rebuild wireguard-go/tun/netstack/Net type

With all these things in place we are ready to build the wireguard netstack with Lneto and put it to the test.

Phase 2 - Testbuild: WASM Build and Browser tests

Put Lneto on the browser for the ssh client, enable only for testers or have a way to test locally. Test connectivity via SSH. Stress tests. Compare with gVisor.
Feedback loop with Lneto fixes.

Phase 3 - Gradual Deployment

Release Lneto behind opt-in feature. Measure Lneto's performance. Latency, bandwidth, CPU, error rate. Enable packet capture printing via pcap to be able to debug degenerate or buggy modes of operation

Phase 4 - Full Rollout

If Lneto is a clear win over gVisor in the browser then enable Lneto by default in WASM client and offer a fallback to gVisor webassembly binary. Monitor long-term: stability, performance. Impact: Broad use. Evaluate user experience (latency, throughput).

Phase 5 - Evaluate more Lneto

More Lneto? If you got up to this point maybe we want to replace gVisor in Netbird everywhere. Think on it.

Originally created by @soypat on GitHub (Apr 16, 2026). Original GitHub issue: https://github.com/netbirdio/netbird/issues/5902 ### Context Today Netbird uses gVisor for TCP/IP among other protocols like DNS. gVisor is a tool designed to give containers isolation from the OS and in doing so has a heavy footprint in terms of binary size, RAM(~15MB x86) and CPU [^1]. **Lneto** is a lean userspace networking stack that avoids allocations at runtime by defining memory used up-front. Lneto's stack has also been designed to share memory locality: the [xnet.StackAsync](https://github.com/soypat/lneto/blob/16c2c3de36a2afdcd201a0727ef7825f3becb02e/x/xnet/stack-async.go#L28) not including buffers is 2.3kB in size. Lneto single thread performance with xnet.StackAsync on 12th Gen Intel i5-12400F below: - ARP exchange in ~177 ns (≈6.1e6 ops/s) 0 alloc/op - TCP 3-way handshake ~1.2 µs (≈9.87e5 ops/s) 0 alloc/op Lneto has been shown to work on embedded systems- that said it is also in early development: - ICMPv4: Only Echo (ping) supported, no IGMP. - IPv6 support untested. - No routing logic beyond what is needed for basic internet access. ### gVisor in Netbird NetBird use gVisor netstack for userspace TCP/IP either as a direct consumer as is the case of the Forwarder or via wireguard's netstack. Below is an AI assisted dependency graph for relevant consumers in Netbird. ```mermaid graph TD subgraph "Browser (GOOS=js)" WASM["wasm/cmd/main.go<br/><i>*embed.Client</i>"] SSH["wasm/internal/ssh/<br/><i>ssh.NewClientConn(net.Conn)</i>"] RDP["wasm/internal/rdp/<br/><i>embed.Client.Dial() → net.Conn</i>"] HTTP["wasm/internal/http/<br/><i>embed.Client.NewHTTPClient()</i>"] end subgraph "Embed SDK" EMBED["embed/embed.go<br/><i>Dial() → net.Conn</i><br/><i>ListenTCP() → net.Listener</i><br/><i>ListenUDP() → net.PacketConn</i>"] end subgraph "Engine" ENGINE["internal/engine.go<br/><i>GetNet() → *netstack.Net</i>"] end subgraph "Interface Layer" WGIFACE["iface/iface.go<br/><i>WGIface interface</i><br/><i>GetNet() → *netstack.Net</i>"] DEVICE_IF["iface/device.go<br/><i>WGTunDevice interface</i><br/><i>GetNet() → *netstack.Net</i>"] end subgraph "Device Implementations" NS_DEV["iface/device/device_netstack.go<br/><i>TunNetstackDevice</i><br/><i>field: net *netstack.Net</i>"] KERNEL["iface/device/device_kernel_linux.go<br/><i>TunKernelDevice</i><br/><i>GetNet() → nil</i>"] USP["iface/device/device_usp_unix.go<br/><i>TunUSPDevice</i><br/><i>GetNet() → nil</i>"] end subgraph "Netstack TUN" NS_TUN["iface/netstack/tun.go<br/><i>NetStackTun.Create()</i><br/><i>→ (tun.Device, *netstack.Net)</i>"] DIALER["iface/netstack/dialer.go<br/><i>NSDialer</i><br/><i>field: net *netstack.Net</i>"] PROXY["iface/netstack/proxy.go<br/><i>Socks5 proxy</i><br/><i>uses Dialer interface</i>"] end subgraph "gVisor via WireGuard" WG_NS["wireguard/tun/netstack<br/><i>CreateNetTUN()</i><br/><i>→ tun.Device + *netstack.Net</i>"] GVISOR["gvisor.dev/gvisor/pkg/tcpip<br/><i>stack.Stack, gonet, header</i>"] end subgraph "USP Forwarder — INDEPENDENT" FWD["forwarder/forwarder.go<br/><i>Forwarder.stack *stack.Stack</i><br/><i>Forwarder.endpoint *endpoint</i>"] FWD_EP["forwarder/endpoint.go<br/><i>impl stack.LinkEndpoint</i><br/><i>bridges → *wgdevice.Device</i>"] FWD_TCP["forwarder/tcp.go<br/><i>*tcp.ForwarderRequest</i><br/><i>gonet.NewTCPConn → net.Conn</i>"] FWD_UDP["forwarder/udp.go<br/><i>*udp.ForwarderRequest</i><br/><i>gonet.NewUDPConn → net.PacketConn</i>"] FWD_ICMP["forwarder/icmp.go<br/><i>header.ICMPv4, header.IPv4</i>"] end subgraph "DNS" DNSFWD["internal/dnsfwd/forwarder.go<br/><i>*netstack.Net</i><br/><i>.ListenTCPAddrPort()</i><br/><i>.ListenUDPAddrPort()</i>"] end WASM --> SSH & RDP & HTTP SSH -->|"net.Conn from Dial()"| EMBED RDP -->|"net.Conn from Dial()"| EMBED HTTP -->|"*http.Client"| EMBED EMBED -->|"GetNet()"| ENGINE ENGINE -->|"*netstack.Net"| WGIFACE WGIFACE --> DEVICE_IF DEVICE_IF --> NS_DEV & KERNEL & USP NS_DEV -->|"*netstack.Net"| NS_TUN NS_TUN -->|"CreateNetTUN()"| WG_NS WG_NS --> GVISOR NS_TUN -->|"*netstack.Net"| DIALER DIALER -->|"Dialer interface"| PROXY DNSFWD -->|"*netstack.Net"| ENGINE FWD --> FWD_EP & FWD_TCP & FWD_UDP & FWD_ICMP FWD -->|"stack.New()"| GVISOR FWD_EP -->|"stack.LinkEndpoint"| GVISOR FWD_TCP -->|"tcp.ForwarderRequest, gonet"| GVISOR FWD_UDP -->|"udp.ForwarderRequest, gonet"| GVISOR style WG_NS fill:#f66,stroke:#333,color:#fff style GVISOR fill:#f66,stroke:#333,color:#fff style FWD fill:#f96,stroke:#333 style FWD_EP fill:#f96,stroke:#333 style FWD_TCP fill:#f96,stroke:#333 style FWD_UDP fill:#f96,stroke:#333 style FWD_ICMP fill:#f96,stroke:#333 style NS_TUN fill:#fd6,stroke:#333 style NS_DEV fill:#fd6,stroke:#333 subgraph "Legend" L1["gVisor code"] L2["Direct gVisor consumer"] L3["Thin wrapper over gVisor"] style L1 fill:#f66,stroke:#333,color:#fff style L2 fill:#f96,stroke:#333 style L3 fill:#fd6,stroke:#333 end ``` ## Proposed phases to integrate Lneto ### Phase 0 - netstack.Net drop-in replacement Phase 0: Create drop-in replacement for [netstack.CreateNetTUN returned netstack.Net type](https://github.com/netbirdio/netbird/blob/08f624507da775b066ca34dd30c0304da7bf7bf5/client/iface/netstack/tun.go#L35) and have NewNetStack use it via a build tag "netbirdlneto". This means integrating lneto with wireguard. Netbird would be unaffected since it just means implementing the API below with lneto on top of a wireguard connection: ```go type Net tunNet func (tnet *Net) Dial(network string, address string) (net.Conn, error) func (tnet *Net) DialContext(ctx context.Context, network string, address string) (net.Conn, error) func (net *Net) DialContextTCP(ctx context.Context, addr *net.TCPAddr) (*gonet.TCPConn, error) func (net *Net) DialContextTCPAddrPort(ctx context.Context, addr netip.AddrPort) (*gonet.TCPConn, error) func (net *Net) DialPing(laddr *PingAddr, raddr *PingAddr) (*PingConn, error) func (net *Net) DialPingAddr(laddr netip.Addr, raddr netip.Addr) (*PingConn, error) func (net *Net) DialTCP(addr *net.TCPAddr) (*gonet.TCPConn, error) func (net *Net) DialTCPAddrPort(addr netip.AddrPort) (*gonet.TCPConn, error) func (net *Net) DialUDP(laddr *net.UDPAddr, raddr *net.UDPAddr) (*gonet.UDPConn, error) func (net *Net) DialUDPAddrPort(laddr netip.AddrPort, raddr netip.AddrPort) (*gonet.UDPConn, error) func (net *Net) ListenPing(laddr *PingAddr) (*PingConn, error) func (net *Net) ListenPingAddr(laddr netip.Addr) (*PingConn, error) func (net *Net) ListenTCP(addr *net.TCPAddr) (*gonet.TCPListener, error) func (net *Net) ListenTCPAddrPort(addr netip.AddrPort) (*gonet.TCPListener, error) func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) func (net *Net) ListenUDPAddrPort(laddr netip.AddrPort) (*gonet.UDPConn, error) func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, error) func (net *Net) LookupHost(host string) (addrs []string, err error) ``` Once this is implemented we can build NetBird locally. Test ping/tcp via Lneto stack on host. Impact: CPU, Mem, latency, throughput. At this point we evaluate if Lneto as-is is adequate fit for Netbird or if changes need to happen. #### Phase 0.1 - gonet.UDPConn Need to implement the gonet.UDPConn functionality on Lneto's side. Specificially the address specific ReadFrom, WriteTo functionality within udp.Conn. Need to add a UDP listener abstraction #### Phase 0.2 - gonet.TCPConn Need to implement half-duplex close methods CloseRead,CloseWrite on Lneto's side. #### Phase 0.3 - netstack.PingConn Need to implement ICMPv6 Echo protocol. #### Phase 0.4 - IPv6 support and testing Lneto "technically" supports IPv6. It has been thus far designed to receive IPv6 addresses at the API level but at times returns unsupported errors at certain levels like DHCP and ICMP Echo(Ping) since we only support IPv4 for these protocols. Lneto will need to have modernization effort to fully support IPv6. #### Phase 0.6 - DNS over TCP Lneto currently only has a UDP based DNS client. We'll likely need to add a stream based client so it can be used over TCP. #### Phase 0.7 - Rebuild wireguard-go/tun/netstack/Net type With all these things in place we are ready to build the wireguard netstack with Lneto and put it to the test. ### Phase 2 - Testbuild: WASM Build and Browser tests Put Lneto on the browser for the ssh client, enable only for testers or have a way to test locally. Test connectivity via SSH. Stress tests. Compare with gVisor. Feedback loop with Lneto fixes. ### Phase 3 - Gradual Deployment Release Lneto behind opt-in feature. Measure Lneto's performance. Latency, bandwidth, CPU, error rate. Enable packet capture printing via [pcap](https://github.com/soypat/lneto/tree/main/internet/pcap) to be able to debug degenerate or buggy modes of operation ### Phase 4 - Full Rollout If Lneto is a clear win over gVisor in the browser then enable Lneto by default in WASM client and offer a fallback to gVisor webassembly binary. Monitor long-term: stability, performance. Impact: Broad use. Evaluate user experience (latency, throughput). ### Phase 5 - Evaluate more Lneto More Lneto? If you got up to this point maybe we want to replace gVisor in Netbird everywhere. Think on it. [^1]: https://www.usenix.org/system/files/hotcloud19-paper-young.pdf
saavagebueno added the feature-request label 2026-08-05 01:29:08 -04:00
Author
Owner

@jnfrati commented on GitHub (Apr 16, 2026):

Hey @soypat thanks for the suggestion![/+]
Lnato looks like a great library and I'll keep an eye on it, hope to see it grow and achieve more usage across different projects!
Unfortunately I don't think such a refactor is in the roadmap, the gVisor library might be slower but it also provides a battle tested implementation that we can rely on as we approach a v1 of the project.
I'll close this issue for now!

edit: My bad, this was discussed with the team and shouldn't have been closed!

<!-- gh-comment-id:4260129557 --> @jnfrati commented on GitHub (Apr 16, 2026): > Hey @soypat thanks for the suggestion![/+] Lnato looks like a great library and I'll keep an eye on it, hope to see it grow and achieve more usage across different projects! Unfortunately I don't think such a refactor is in the roadmap, the gVisor library might be slower but it also provides a battle tested implementation that we can rely on as we approach a v1 of the project. I'll close this issue for now! edit: My bad, this was discussed with the team and shouldn't have been closed!
Sign in to join this conversation.
No Label feature-request
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#11272