OpenFlux Exit Node Routing Model: Dual-NIC gVisor Stack Implementation

The OpenFlux exit node implements a split routing model using a gVisor userspace network stack with two virtual NICs—one for the tunnel subnet (10.10.10.0/24) and one bound to raw sockets for internet egress—forwarding all non-local IPv4 traffic through the physical network interface while maintaining isolated internal routing.

The OpenFlux exit node acts as a bridge between the encrypted tunnel mesh and the public internet, requiring precise control over packet forwarding. According to the p1neappleXpress/OpenFlux source code, the routing model leverages gVisor's TCP/IP stack to create a software-defined router that separates internal tunnel traffic from external egress. This architecture enables the exit node to intercept packets from connected clients and forward them to arbitrary internet destinations without kernel-level network modifications.

Dual-NIC Architecture Overview

The exit node instantiates a virtual network stack with two distinct network interface controllers (NICs) that partition traffic into internal and external paths.

  • NIC 1 (Tunnel NIC): Handles the internal tunnel network at 10.10.10.0/24. It is backed by a TunnelLinkEndpoint that sends and receives packets through the encrypted transport layer. This NIC is created in NewTCPTunnel() via t.gvisorStack.CreateNIC(tunnelNIC, tunnelEP) in main/tunnel/tunnel.go.

  • NIC 2 (Internet NIC): Represents the physical egress interface. It is backed by a RawSocketEndpoint that forwards packets directly to the host's network interface using raw sockets. This is instantiated in setupExitNode() via NewRawSocketEndpoint(tcpip.NICID(2)).

When the --exit-node flag is parsed in main/main.go, the application initializes this dual-NIC configuration to enable layer-3 routing between the tunnel and the internet.

Route Table Configuration in setupExitNode

The setupExitNode() function in main/tunnel/tunnel.go configures the routing table with two critical static routes and enables global forwarding.

First, the function enables forwarding for all IPv4 traffic across all NICs:

t.gvisorStack.SetForwardingDefaultAndAllNICs(ipv4.ProtocolNumber, true)

This ensures the gVisor stack acts as a router, forwarding packets between interfaces when no local socket owns the destination.

Next, it installs the default route (0.0.0.0/0) pointing to the internet NIC (ID 2):

t.gvisorStack.AddRoute(tcpip.Route{
    Destination: header.IPv4EmptySubnet,
    NIC:         internetNIC,
})

Then it adds a specific route for the tunnel subnet (10.10.10.0/24) pointing to the tunnel NIC (ID 1):

tunnelSubnet := tcpip.AddressWithPrefix{
    Address:   tcpip.AddrFrom4([4]byte{10, 10, 10, 0}),
    PrefixLen: 24,
}.Subnet()
t.gvisorStack.AddRoute(tcpip.Route{
    Destination: tunnelSubnet,
    NIC:         tunnelNIC,
})

With this configuration, any packet destined for the internal tunnel network stays within the virtual stack, while all other IPv4 traffic is handed to the raw socket for physical transmission.

Inbound and Outbound Packet Flow

The routing model creates a bidirectional data path between the tunnel clients and external hosts.

Outbound flow: When a connected client sends a packet to an external IP, it arrives at the exit node through the TunnelLinkEndpoint on NIC 1. The gVisor stack consults its route table, matches the destination against the default route (0.0.0.0/0), and forwards the packet to NIC 2. The RawSocketEndpoint then transmits the packet onto the physical network using the host's internet interface.

Inbound flow: Responses from external servers arrive at the host's physical interface and are captured by the raw socket. The RawSocketEndpoint injects these packets into NIC 2 of the gVisor stack. The stack performs reverse routing, matches the destination against the tunnel subnet route, and forwards the packet to NIC 1, where it is sent back through the encrypted tunnel to the originating client.

The exit node also rewrites the source address of outbound packets using the detected or overridden egress IP (localIPOverride) to ensure return traffic is correctly routed back through the tunnel.

Key Implementation Files and Functions

The routing model is distributed across several files in the repository:

  • main/main.go: Parses the --exit-node flag and initializes the transport and tunnel layers. It calls tunnel.NewTCPTunnel(trans, true) where the second argument enables exit node mode.

  • main/tunnel/tunnel.go: Contains NewTCPTunnel() for NIC creation and setupExitNode() for route configuration. It also handles connection logic in DialTCP(), which selects NIC 2 when isExitNode is true:

    if t.isExitNode {
        nic = tcpip.NICID(2)
    }
  • main/tunnel/endpoint.go: Defines TunnelLinkEndpoint, the in-process endpoint that bridges the gVisor stack with the encrypted transport.

  • main/tunnel/rawsocket_*.go: Platform-specific implementations (e.g., Linux and Windows) of the raw socket endpoint that actually push packets onto the physical network.

  • main/transport/encrypted.go: Manages the encrypted transport layer; the exitNode boolean ensures the client and exit ends use opposite directional encryption keys.

Deploying an OpenFlux Exit Node

To activate the exit node routing model, compile and run OpenFlux with the appropriate flags:


# Start OpenFlux as an exit node on a VPS

./openflux --exit-node --local-ip 203.0.113.5

From application code, you can leverage the routing stack programmatically:

// Create tunnel in exit node mode
tunnel := tunnel.NewTCPTunnel(trans, true) // true enables exit node routing

// Connect to an external service through the exit node
conn, err := tunnel.DialTCP("google.com:443")
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

// Use the connection normally
fmt.Fprintf(conn, "GET / HTTP/1.0\r\nHost: google.com\r\n\r\n")
io.Copy(os.Stdout, conn)

When DialTCP is called on an exit node, it automatically selects NIC 2 (the internet-facing raw socket), ensuring traffic egresses through the physical network rather than looping back into the tunnel.

Summary

  • The OpenFlux exit node uses a gVisor userspace network stack to implement virtual routing without modifying host kernel networking.
  • Two virtual NICs partition traffic: NIC 1 (10.10.10.0/24) for the encrypted tunnel and NIC 2 (raw socket) for internet egress.
  • Route configuration in setupExitNode() sets the default route to the internet NIC and a specific route to the tunnel NIC, with global forwarding enabled via SetForwardingDefaultAndAllNICs.
  • Bidirectional flow allows tunnel clients to reach arbitrary internet destinations while responses are correctly routed back through the encrypted tunnel.
  • The --exit-node CLI flag activates this routing model, creating a software-defined exit gateway.

Frequently Asked Questions

How does the OpenFlux exit node route packets to the internet without a TUN device?

The exit node uses gVisor's netstack to create a pure userspace routing layer. Instead of a kernel TUN device, it binds a RawSocketEndpoint to the physical network interface. This endpoint captures outgoing packets from the gVisor stack and transmits them directly via raw sockets, while incoming packets are injected back into the stack for routing to tunnel clients.

Why does the exit node use two separate NICs instead of one?

The dual-NIC architecture provides strict traffic isolation between the internal tunnel network (10.10.10.0/24) and the external internet. NIC 1 handles only mesh-tunnel traffic through the TunnelLinkEndpoint, while NIC 2 handles only raw internet traffic. This separation allows the gVisor stack to make routing decisions based on destination IP, forwarding tunnel-bound packets internally and internet-bound packets externally.

What happens if the exit node cannot determine its public IP address?

If automatic public IP detection fails, the exit node relies on the --local-ip flag or the localIPOverride configuration set via SetLocalIP. The setupExitNode() function registers this address on NIC 2 using AddProtocolAddress. If no override is provided and detection fails, the node cannot properly source NAT outbound traffic, causing return packets to be unroutable.

Is the OpenFlux exit node routing model compatible with IPv6?

The current implementation focuses on IPv4 routing, as evidenced by the use of ipv4.ProtocolNumber in SetForwardingDefaultAndAllNICs and the header.IPv4EmptySubnet constant for the default route. While gVisor supports IPv6, the OpenFlux exit node would require additional route configurations using ipv6.ProtocolNumber and appropriate subnet definitions to support dual-stack operation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →