Overview

You have a service behind CG-NAT (home or office) and a cheap cloud VM with a public IP. zeronat exposes your local ports through the VM without accounts or third-party services, just a VPS.

The server runs on the public host. The client runs behind NAT, dials out, and holds one control connection. Traffic hitting a public port on the server is forwarded to the matching local service on the client. Every connection is authenticated and encrypted with Noise (NNpsk0, X25519 + ChaCha20-Poly1305 + BLAKE2s) keyed by a shared secret. Forwarding is terminate-and-forward at L4.

Install

curl -fsSL https://paltaio.github.io/zeronat/get.sh | sh

Picks Docker or a systemd service, generates the secret, asks what to forward, and prints the matching command to run on the machine behind CG-NAT.

docker pull ghcr.io/paltaio/zeronat

Quick start

# On the public host (VPS):
ZERONAT_SECRET=somelongsecret zeronat server \
  --control 2222 --tcp 443 --tcp 80 --udp 51820

# On the host behind CG-NAT:
ZERONAT_SECRET=somelongsecret zeronat client \
  --server <public-ip>:2222 --tcp 443 --tcp 80 --udp 51820

The secret can be passed with --secret instead of the env var. Open the control port (2222, both UDP and TCP) on the server's firewall.

Forwarding

On the server, --tcp PORT and --udp PORT name the public ports to expose. On the client, the same flags take a forward spec mapping that public port to a local target:

Client specForwards to
--tcp 443127.0.0.1:443
--tcp 443:8443127.0.0.1:8443
--tcp 443:10.0.0.5:44310.0.0.5:443

--udp works the same way. Both flags are repeatable.

Transport

The tunnel runs over UDP/KCP by default and falls back to TCP when the UDP handshake gets no reply. Both share the control port (2222). The client picks the transport with --transport auto|udp|tcp (default auto).

  • UDP (KCP): reliable ARQ over UDP carries the control channel and TCP-forward data; loss-tolerant UDP forwards ride as raw datagrams.
  • TCP: fallback for networks that block or break UDP.

Multi-client routing

Multiple clients can connect to one server. A route maps a listener (bind_ip, proto, port) to a client id. New connections go to the routed client; connections already open on the previous client drain until they close. There is no round-robin, health checking, or failover. The switch is a deliberate config change.

A client id is the --id prefix (default: short hostname) plus a short derived suffix, for example rpi-2-ab12.

Load listeners, routes, and identity from a file with --config:

zeronat server --config /etc/zeronat/server.toml
# server.toml
[server]
id = "zn01"
control = "0.0.0.0:2222"

[[listeners]]
bind_ip = "0.0.0.0"
proto = "tcp"
port = 8080

[[routes]]
bind_ip = "0.0.0.0"
proto = "tcp"
port = 8080
client = "rpi-2-ab12"

CLI --tcp/--udp forwards merge with the file's listeners. To change the topology, edit the file and restart the server. A malformed config aborts startup rather than booting half-configured.

Admin

Inspect a running server's topology over the control port (read-only, one-shot):

ZERONAT_SECRET=$SECRET zeronat admin --server <public-ip>:2222 show

It prints the server, its routes, connected clients, and active listeners.

All ports

--tun forwards every port plus ICMP to one client over an L3 tunnel, instead of naming ports. The server sends all inbound traffic except the control port (and any --except ports) to the client and source-NATs the replies; the tunnel subnet is derived from the shared secret. Both ends need --tun; it cannot be combined with --tcp/--udp or --tap.

# Server (VPS): forward every port to the client, keep SSH on 22:
ZERONAT_SECRET=s zeronat server --control 2222 --tun --except 22

# Client (behind CG-NAT): local services receive on the same port:
ZERONAT_SECRET=s zeronat client --server <public-ip>:2222 --tun

Every port except the control port routes to the client, including the server's own SSH; keep it reachable with --except <ssh-port> (repeatable). The client needs no NAT: local services receive the traffic on the same port, so they must listen on 0.0.0.0 or the tunnel address.

Linux only. The server needs root or CAP_NET_ADMIN, /dev/net/tun, and nft or iptables to program the NAT.

L2 bridge (TAP)

--tap relays raw Ethernet frames over the tunnel, joining a TAP on each end into one L2 segment. It carries anything Ethernet, including PPPoE. Both ends need --tap; it cannot be combined with --tcp/--udp.

# Near the target segment (e.g. the PPPoE concentrator):
ZERONAT_SECRET=s zeronat server --control 2222 --tap zn0 --bridge br0

# Behind CG-NAT, then run e.g. pppd on zn0:
ZERONAT_SECRET=s zeronat client --server <public-ip>:2222 --tap zn0
pppd plugin rp-pppoe.so zn0 user <user>

--bridge <name> enslaves the TAP to an existing bridge; --tap-mtu <n> sets the MTU (default 1400). Linux only. Needs CAP_NET_ADMIN: root, setcap cap_net_admin+ep zeronat, or Docker --cap-add NET_ADMIN --device /dev/net/tun (plus --device /dev/ppp for pppd).

The installer can build that bridge for you on netplan or systemd-networkd hosts: pass --bridge-nic <iface> (or pick the interface in the wizard) and it creates the bridge, enslaves the NIC, and persists the change so it survives reboot. On a single-NIC server, where the uplink itself must join the bridge, it moves that interface's address onto the bridge and waits for you to confirm you still have access, reverting automatically within ~30s if you do not.

DHT discovery

For a server on a dynamic IP, --server dht publishes its address to the BitTorrent Mainline DHT and the client looks it up. The lookup key is derived from the shared secret, so no extra configuration is needed and the published address is signed and encrypted.

# Server publishes its address; IP is auto-detected from the DHT:
ZERONAT_SECRET=s zeronat server --server dht --tcp 443

# Client finds the server through the DHT:
ZERONAT_SECRET=s zeronat client --server dht --tcp 443

--announce-ip <ip> overrides the detected IP and --announce-port <port> the announced port (default: the control port) when the server sits behind a port-forward. The client caches the last-known address and re-queries the DHT only when it stops working.

PPPoE proxy (znpppoe)

znpppoe opens many PPPoE sessions in userspace over one tunnel and exposes each as a SOCKS5 and HTTP CONNECT egress, each with its own ISP-assigned IP. It needs no kernel interface and no privileges. The server is a normal L2 bridge to the PPPoE segment (--tap, see above); the proxy credentials are separate from the PPPoE login, and the username selects the egress session.

docker run -d --name znpppoe -p 127.0.0.1:1080:1080 -p 127.0.0.1:8081:8081 \
  -e ZN_SECRET=s -e ZN_USER=<pppoe-user> -e ZN_PASSWORD=<pppoe-pass> \
  -e ZN_PROXY_USER=proxy -e ZN_PROXY_PASS=<proxy-pass> \
  ghcr.io/paltaio/znpppoe --dht --connections 50 \
  --socks-listen 0.0.0.0:1080 --http-listen 0.0.0.0:8081

Both front ends share the password auth; the username picks the egress over the live sessions: proxy round-robins, proxy_pppoe<K> pins session K, and proxy_s<token> is sticky (one IP per token).

curl --socks5 proxy:<proxy-pass>@127.0.0.1:1080 https://ifconfig.me              # rotates
curl --proxy http://proxy_sjob42:<proxy-pass>@127.0.0.1:8081 https://ifconfig.me  # sticky

Use --host <ip:port> instead of --dht for a fixed server. The ISP must allow concurrent PPPoE sessions on the login. Linux image, amd64 and arm64.

Build

cargo build --release    # dynamic binary
./build.sh               # static musl binary

build.sh uses the nightly toolchain (rust-src component) for a size-optimized static build.

CLI reference

zeronat server

--bind <ADDR>Address to bind on (default 0.0.0.0)
--control <PORT>Control port (default 2222)
--secret <SECRET>Shared secret (or env ZERONAT_SECRET)
--id <ID>Server identity label (default 0)
--config <PATH>Load listeners, routes, and identity from a config file
--tcp <PORT>Public TCP port to expose (repeatable)
--udp <PORT>Public UDP port to expose (repeatable)
--tunAll-ports mode (Linux): forward every port except control plus ICMP to the client
--except <PORT>Port to keep on the host in --tun mode (repeatable)
--tap <NAME>L2 bridge mode (Linux): create or attach this TAP device
--tap-mtu <N>TAP MTU (default 1400)
--bridge <NAME>Enslave the TAP to this existing bridge
--server dhtPublish this server's address to the DHT for discovery
--announce-ip <IP>Public IPv4 to announce (default: auto-detected via DHT)
--announce-port <P>Public port to announce (default: control port)

zeronat client

--server <ADDR>Server control address host:port, or dht to discover via DHT
--secret <SECRET>Shared secret (or env ZERONAT_SECRET)
--id <PREFIX>Client id prefix (default: short hostname)
--tcp <SPEC>Forward TCP: PORT | PORT:LOCALPORT | PORT:HOST:PORT (repeatable)
--udp <SPEC>Forward UDP, same spec as --tcp (repeatable)
--transport <MODE>auto | udp | tcp (default auto)
--tunAll-ports mode (Linux): receive every forwarded port on local services
--tap <NAME>L2 bridge mode (Linux): create or attach this TAP device
--tap-mtu <N>TAP MTU (default 1400)
--bridge <NAME>Enslave the TAP to this existing bridge

zeronat admin

showPrint the server's current topology (default command)
--server <ADDR>Server control address host:port
--secret <SECRET>Shared secret (or env ZERONAT_SECRET)

Scope

Built for a single operator with one shared secret. The secret is one master key: any client can derive the server key and other client keys, so there is no per-client isolation or revocation. It is not hardened against a hostile public internet: no connection rate limiting and unbounded session tracking. Acceptable for self-owned infrastructure.

MIT, Copyright (c) 2026 Palta Studios.