erbridge
September 10, 2026 · View on GitHub
Low-latency TCP/UDP port forwarder with a built-in reverse-connection (NAT traversal) mode and a live traffic-monitoring TUI.
Four modes
- forward: Direct forwarding,
external port -> internal target host:port, supports TCP/UDP simultaneously, multiple mappings can be configured at once. A per-mappingtransportoption can wrap the external (listen-side) TCP leg in TLS or Noise, optionally with atokenso only an authenticated peer can connect. - client: Companion to a
transport-secured forward mapping. Listens locally in plaintext and, for every connection, dials that mapping using the same transport (presenting itstokenif one is set), so a plain local client doesn't need its own TLS/Noise support to reach it. - serve (reverse mode, role A): Listens and waits for
connect(B) to connect in; traffic received on the externally exposed port is then multiplexed and forwarded to B over that A↔B connection. - connect (reverse mode, role B): Actively connects to
serve(A); every time A receives a new external connection, it opens a new multiplexed stream over the same connection, and B decides which local (or B-reachable internal) target to forward to based on the tunnel name carried by the stream.
Suitable scenario: A sits in front of the public network/firewall while B (the internal host where the actual service runs) cannot be reached directly by A and can only dial out; in that case use serve/connect to establish a reverse tunnel. When A and B can reach each other directly, just run forward on each side — no need for reverse mode.
forward connection diagram
external client erbridge forward internal target
+------------+ +-------------------------+ +------------+
| | -----------> | listen 0.0.0.0:8080 | -----------> | |
| client | <----------- | -> 10.0.0.5:80 | <----------- | target |
| | | (--map / [[forward]]) | | |
+------------+ +-------------------------+ +------------+
With transport = "tls" set on a mapping, the listen-side leg is TLS-wrapped (self-signed
cert, generated at startup); the erbridge-to-target leg is unchanged and stays plaintext:
external client erbridge forward internal target
+------------+ +-------------------------+ +------------+
| | ===TLS=====> | listen 0.0.0.0:8443 | -----------> | |
| client | <===TLS===== | -> 10.0.0.5:80 | <----------- | target |
| | | (transport = "tls") | | |
+------------+ +-------------------------+ +------------+
If the mapping also sets a token, a plain TLS client (curl -k, ...) can no longer
connect on its own — it also has to speak the token handshake right after the TLS
handshake. Use client mode as that connecting peer instead, so your own app keeps
talking plaintext to a local port:
your app erbridge client erbridge forward (on A) internal target
+--------+ +------------------------+ +------------------------------+ +------------+
| |-->| listen 127.0.0.1:1080 |==>| listen 0.0.0.0:8443 |----------->| |
| client |<--| -> A_IP:8443 |<==| -> 10.0.0.5:80 |<-----------| target |
| | | (--token change-me) | | (transport=tls, token=...) | | |
+--------+ +------------------------+ +------------------------------+ +------------+
TLS + token handshake
transport = "noise" is the same shape, but there's no generic client for it the way
curl -k speaks TLS — it only ever interoperates with erbridge's own client mode, and
token is mandatory (it's hashed into the Noise handshake's pre-shared key, not checked
as a separate step afterward). Use it when both ends are always erbridge, for a faster
handshake and authentication that's cryptographically bound to the session rather than
exchanged in a plaintext frame after the fact.
serve / connect connection diagram
Stage 1: B actively connects to A, establishing a Noise-encrypted control channel whose handshake is itself authenticated by the shared token (see Security notes below).
A (serve) B (connect)
+--------------------------+ +--------------------------+
| listen 0.0.0.0:9000 | | dial A:9000 |
| (control channel) |<================ | token is the handshake's |
| waits for B to dial in | | PSK; retry w/ backoff |
+--------------------------+ +--------------------------+
Noise_NNpsk0 handshake
Stage 2: Once the control channel is established, every external connection received on A's externally exposed port opens a new yamux stream multiplexed over the same A<->B connection, tagged with the tunnel name and handed to B; B decides which local target to forward to based on the name. Multiple external clients share the same A<->B connection, each corresponding to its own independent stream.
external client A (serve) B (connect) internal target
+------------+ +---------------------------+ +---------------------------+ +------------+
| | ---> | external 0.0.0.0:8081 | ===> | | ---> | |
| client | <--- | open yamux stream, | <=== | accept stream, read | <--- | target |
| | | tag it "web" | | "web", dial local target | | |
+------------+ +---------------------------+ +---------------------------+ +------------+
Build
cargo build --release
Cross-compilation (mirrors the other Rust sub-projects in this repo):
make windows # -> target/x86_64-pc-windows-gnu/release/erbridge.exe (on macOS, first: brew install mingw-w64)
make linux # -> target/x86_64-unknown-linux-musl/release/erbridge
make linux-arm # -> target/aarch64-unknown-linux-musl/release/erbridge (needs `cross`: cargo install cross --git https://github.com/cross-rs/cross; builds via Docker)
make osx # -> universal (Apple Silicon + Intel) build, one binary per arch under target/<target>/release/erbridge
make osx-arm # -> target/aarch64-apple-darwin/release/erbridge (Apple Silicon only, no lipo)
make osx-x86 # -> target/x86_64-apple-darwin/release/erbridge (Intel only, no lipo)
make dist # package the Windows executable + config.example.toml into dist/windows/
make dist-linux-arm # package the aarch64 Linux build + config.example.toml into dist/linux-arm64/
make dist-osx # lipo the two osx builds into a universal binary + config.example.toml under dist/osx/
make dist-osx-arm # package the Apple-Silicon-only osx-arm build + config.example.toml under dist/osx/
make dist-osx-x86 # package the Intel-only osx-x86 build + config.example.toml under dist/osx/
Install
Download a prebuilt binary
Grab an archive for your platform from the
Releases page (erbridge-<version>-linux.tar.gz,
-linux-arm64, -osx (universal), -osx-arm64, -osx-x86_64, or -windows.zip), then:
tar -xzf erbridge-*-linux.tar.gz # extracts erbridge + config.example.toml
sudo install -m 755 erbridge /usr/local/bin/erbridge
Each release also publishes a signed SHA256SUMS manifest; verify the archive against it
before extracting if you want to confirm integrity:
sha256sum -c SHA256SUMS --ignore-missing
Build and install with cargo
Install the erbridge binary onto your system's PATH via cargo install:
cargo install --path .
This builds a release binary and copies it to ~/.cargo/bin/erbridge (make sure that
directory is on your PATH; cargo install prints a warning if it isn't). Run
cargo install --path . --force to reinstall after pulling new changes.
Run as a systemd service (Linux)
A template unit is provided at
packaging/systemd/erbridge.service. It runs
erbridge --headless (JSON logs instead of the TUI) against /etc/erbridge/config.toml;
edit its ExecStart line to pick the subcommand (forward/client/serve/connect) and
adjust paths, then install the binary to /usr/local/bin (the service runs as a dedicated
erbridge system user with no home directory, so it can't resolve ~/.cargo/bin --
use a downloaded release archive, or cargo build --release and install the resulting
target/release/erbridge):
sudo install -m 755 erbridge /usr/local/bin/erbridge
sudo useradd --system --no-create-home erbridge # skip if the user already exists
sudo mkdir -p /etc/erbridge
sudo cp config.example.toml /etc/erbridge/config.toml # then edit it
sudo cp packaging/systemd/erbridge.service /etc/systemd/system/erbridge.service
sudo systemctl daemon-reload
sudo systemctl enable --now erbridge
Releases
Prebuilt binaries for Windows (x86_64), Linux (x86_64 and arm64), and macOS (universal, plus
separate arm64/x86_64-only archives) are published from the
Release workflow. Trigger it manually from the Actions tab
("Run workflow") and pick a version bump (patch/minor/major); it advances the version
tag, regenerates CHANGELOG.md from Conventional Commits
with git-cliff (config: cliff.toml),
builds every platform, and publishes a GitHub release with the archives, that release's
changelog section as the release notes, plus a signed SHA256SUMS manifest.
Verify a downloaded archive against the release:
# 1. Check the manifest itself hasn't been tampered with (keyless Sigstore signature)
cosign verify-blob \
--certificate SHA256SUMS.pem \
--signature SHA256SUMS.sig \
--certificate-identity-regexp 'https://github.com/.+/\.github/workflows/release\.yml@.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
# 2. Check the archive you downloaded matches the manifest
sha256sum -c SHA256SUMS --ignore-missing
Quick start
forward: direct forwarding
# No config file needed, single mapping at a time
erbridge forward --map "8080->10.0.0.5:80" # forwards TCP+UDP by default
erbridge forward --map "5353->10.0.0.5:53/udp" # UDP only
erbridge forward --map "8080->10.0.0.5:80" --map "5353->10.0.0.5:53/udp"
# Secure forward: wraps the listen-side TCP connection in TLS, so
# external client -> erbridge is encrypted; erbridge -> target stays plaintext.
# `/tls` is shorthand for `/tcp+tls` (a transport only supports tcp, not udp/both).
erbridge forward --map "8443->10.0.0.5:80/tls"
The cert is a fresh self-signed one generated on every start (see Security notes below), so a client connecting to it must skip certificate verification:
curl -k https://<erbridge_host>:8443/...
openssl s_client -connect <erbridge_host>:8443
# Or use a config file (can describe multiple mappings at once, see config.example.toml)
erbridge --config config.toml forward
If both ends are always erbridge (no generic TLS client needs to reach this mapping
directly), /noise wraps it in the Noise protocol instead — faster handshake, and a
mandatory token that's cryptographically bound into it rather than checked afterward:
erbridge forward --map "8444->10.0.0.5:80/noise" --token change-me
client: reach a secured forward mapping without your own TLS/Noise support
Add --token on the secure mapping to also require authentication (mandatory under
/noise; under /tls, optional and checked in a length-prefixed frame right after the
handshake) — after that, only client mode (or something implementing the same
handshake) can connect, not a bare TLS client:
# on A (the secure forward mapping, now token-gated):
erbridge forward --map "8443->10.0.0.5:80/tls" --token change-me
# on your computer: local plaintext port -> TLS + token -> A's mapping
erbridge client --map "1080->A_IP:8443" --token change-me
client's --map also takes a /tls//noise modifier naming the far side's transport,
defaulting to /tls:
erbridge client --map "1081->A_IP:8444/noise" --token change-me
Your app then just talks plaintext to the local port; client handles the transport's
handshake and token exchange to A on its behalf. Repeat --map for multiple mappings;
see the [[client]] section in config.example.toml for the config-file form.
serve / connect: reverse connection
A (listens and waits for B, and exposes a port externally):
erbridge serve --listen 0.0.0.0:9000 --token change-me --tunnel "web=0.0.0.0:8081"
B (connects to A, forwards received traffic to a local service):
erbridge connect --server A_IP:9000 --token change-me --tunnel "web=127.0.0.1:80"
Both sides use the same NAME in --tunnel NAME=... to correspond to the same tunnel; repeat the flag to define multiple tunnels. For the config-file syntax, see the [serve] / [connect] sections in config.example.toml.
After B disconnects, A's external listening port does not close; new connections wait for B to reconnect. B automatically retries connecting back to A with exponential backoff (reconnect_min_secs ~ reconnect_max_secs).
Observing traffic
By default an interactive TUI (ratatui) opens, showing aggregate traffic, a per-connection list (source/destination/protocol/bytes/lifetime), and an event log; it is read-only and cannot control connections. Press q / Esc / Ctrl+C to exit.
For background/service mode use --headless, which writes structured JSON lines to a log file (default erbridge.log, path can be set with --log-file) instead of starting the TUI:
erbridge --headless --log-file /var/log/erbridge.log serve --config config.toml
Security notes
The A↔B connection in reverse mode is encrypted and authenticated with the Noise protocol
(Noise_NNpsk0): the shared token is hashed into a 32-byte pre-shared key mixed into the
handshake itself, rather than checked afterward. A mismatched token fails the handshake's
AEAD tag verification (typically on B's very first message), so the connection just closes
the same as it would on any other handshake or network failure — there's no dedicated
"token rejected" reply frame for a remote peer to use as an oracle. This only ever
interoperates with another erbridge instance; if you need this control channel to cross a
network you don't otherwise trust, it is still recommended to add an additional trusted
channel (VPN, etc.) on top, the same as for any point-to-point secret.
forward's transport = "noise" mappings use the same Noise handshake and PSK derivation,
and likewise require a token — there's no anonymous form, since nothing but erbridge's own
client mode can speak the protocol anyway.
forward's transport = "tls" mode instead uses a fresh self-signed, unauthenticated-identity
TLS certificate generated at startup: it stops passive eavesdropping on the
external-client-to-erbridge leg but does not prove erbridge's identity to the client, so a
client connecting to it should expect (and typically must configure itself to accept) a
certificate it cannot otherwise verify. This tradeoff exists specifically so a generic TLS
client (curl -k, openssl s_client) can connect directly, which Noise has no equivalent
for. It only covers that one leg — TCP only, neither transport has a UDP equivalent here —
the erbridge-to-target leg remains plaintext either way. Adding a token to a tls mapping
layers on a post-handshake, constant-time-compared token check exchanged in a plaintext
frame: it restricts who can connect (only a peer that knows the token and speaks that
frame, i.e. client mode), but doesn't change what the TLS layer itself does or doesn't
prove — the identity caveat above still applies.
Noise depends on the snow crate built with its ring-accelerated feature (see
Cargo.toml), which runs the ChaCha20-Poly1305 and X25519 operations through ring instead
of snow's pure-Rust default resolver (BLAKE2s, which ring doesn't implement, still falls
back to the default resolver). This isn't just a speed preference: without it, the Noise
transport measured ~14% slower steady-state latency than the TLS transport it replaces;
with it, the two are indistinguishable. See
docs/benchmarks/tls-vs-noise-reverse-tunnel-latency.md
before changing that feature or the snow dependency.
Config file
See config.example.toml for a complete example. The sections are independent of each other; the same config file can fill in just one section or all of them. CLI arguments (--listen/--token/--server/--map/--tunnel) can override or supplement the config file's contents.
Tests
make test # equivalent to cargo test: covers forward's TCP/UDP forwarding, UDP idle timeout,
# TLS- and Noise-secured forward mappings, client mode against both, the Noise
# transport itself, and serve/connect's multiplexed forwarding and PSK authentication
Latency benchmark
make bench # equivalent to: cargo bench --bench latency
benches/latency.rs measures steady-state TCP round-trip latency (a 64-byte
ping over an already-open connection, not connection setup) across the three
data paths, all on loopback:
baseline_direct_tcp_roundtrip— client <-> echo server, no erbridgeforward_tcp_roundtrip— client <->forward<-> echo serverreverse_tunnel_tcp_roundtrip— client <->serve(A) <=yamux/Noise=>connect(B) <-> echo server
Criterion prints p-value-style [low mid high] estimates per run and writes
an HTML report with full distributions to target/criterion/report/index.html.
Compare forward/reverse against baseline to get erbridge's added
latency; loopback numbers isolate erbridge's own per-message overhead but
don't include real network RTT — for that, run the same three modes over an
actual link and drive them with wrk/hey (HTTP) or iperf3 -u (UDP
throughput/jitter) instead.
Comparing against frp / rathole / bore
make compare-tunnels # equivalent to: cargo run --release --example compare_tunnels
examples/compare_tunnels.rs runs the same persistent-connection ping-pong
against erbridge's serve/connect reverse tunnel and three other NAT-traversal
tools — frp, rathole,
and bore — all on the same machine, same
payload, same warmup/iteration count, so the numbers are comparable to each
other (unlike published benchmarks elsewhere, which use different payloads,
units, and hardware — see the caveats in the latency report). It compares
against erbridge's reverse mode specifically, not forward, since frp/rathole/bore
only implement the dial-out/reverse case.
Needs frpc, frps, rathole, and bore on PATH (brew install frpc frps rathole bore-cli, or point FRPC_BIN/FRPS_BIN/RATHOLE_BIN/BORE_BIN at
prebuilt binaries). A missing tool is skipped with a note, not fatal — the
rest of the comparison still runs. Per-process logs land in a temp dir printed
at the top of the output.
Related tools
- MoonProxy — Cross-platform desktop GUI client for frp (Tauri v2 + Rust) for non-technical users, featuring visual proxy rules, traffic monitoring and system tray