nfltr against jprq, portal, sish and the October four

You have a service on a machine behind NAT, a laptop, a box at home, a build agent, and you want it reachable from somewhere else without opening a port. Dozens of open-source tunnels do that: a relay with a public address, a client next to the service that dials out to it, and visitors who connect to the relay. They differ in what the relay can read, who may use a shared URL, how many tunnels one relay carries, and how fast bytes move. If you are choosing one, the numbers in each project's README were measured on different machines with different loads.

In October we put nfltr's relay in one Docker harness next to frp, rathole, chisel and bore (the October post). This time we added three more open-source tunnels whose relay runs on one machine: jprq, portal (gosuda/portal-tunnel) and sish, and ran Pangolin on its own stack beside them. We also measured nfltr's TCP tunnel with end-to-end encryption, which v2.1.52 made the default for nfltr tcp (what "end to end" means for nfltr tcp), and ran every core test three times in interleaved rounds on a machine that other work was also using.

What the comparison found

  • Leads: end-to-end encrypted TCP at the speed of plain TCP (1.20× frp at 50 keep-alive connections, where frp's visitor leg is plain text); the most requests at 1,000 keep-alive connections through one tunnel (103,823 a second, ahead of bore's 98,431 and twice frp's); tunnels that come back after a relay restart, where bore, jprq, sish and portal did not in our runs; bulk on one stream (1.16× frp) and new connections through the TCP tunnel (1.51×, varying by round).
  • Level with frp: small keep-alive requests (1.03), and encrypted public URLs at 50 connections with and without keep-alive.
  • Trails: single-request latency (0.79 of frp), 1 MiB responses (0.83), bulk on eight streams (0.91) and against portal on both, new connections end to end (0.57) and 10 % errors at 1,000 new connections at once; registration, memory per tunnel and size, as in October.
  • Since October: the lead on new connections shrank from 2.1× to 1.51× through the TCP tunnel, and the relay's new per-address connection limits reset a single benchmarking address's connections once its burst is spent; on your own relay they can be raised or turned off.
  • The new tools: jprq paces its copies and its server stopped with a fatal error at 50 concurrent connections in every run; portal's relay-blind HTTPS matched nfltr's on keep-alive traffic and failed without it; sish needs nothing but OpenSSH next to the service. Pangolin, on its own stack, adds a dashboard and sign-in on HTTP resources; through its userspace WireGuard agent on one CPU it moved about 2 Gbit/s and 1,800 HTTPS requests a second.

The tools, and why these

We measured only tools whose relay runs on our own machine, on a Docker network with no route out. No hosted service and no public relay carried a byte of this benchmark.

  • frp, rathole, chisel, bore: the October four, at the same versions as in October (frp 0.71.0, rathole 0.5.0, chisel 1.12.0, bore 0.6.0). frp is a full-featured reverse proxy with TCP, UDP and HTTP(S) routing; rathole and bore are small Rust tools (rathole describes itself as "lightweight, high-performance", bore as "that's all it does: no more, and no less"); chisel is "a fast TCP/UDP tunnel, transported over HTTP, secured via SSH".

  • jprq (Go, commit 3c10e25 of github.com/azimjohn/jprq): exposes HTTP and TCP services under subdomains of one domain. Its hosted service is members-only; the README states that it "still stays open and free" to run yourself. jprq publishes no server binary, so we built the server and the CLI from that commit.

  • portal (Go, v2.7.1 of github.com/gosuda/portal-tunnel): in its own words "an open-source tunnel runtime and relay network for publishing services to the agentic web". Its relay routes HTTPS by SNI and the TLS handshake completes in the local portal process, so the relay forwards ciphertext; the relay keeps the certificate's private key and signs the handshake for the tunnel (keyless TLS), and the project says plainly that this "minimizes relay trust; it does not yet eliminate it". It also leases raw TCP and UDP ports. We ran its published relay image and its release client.

  • sish (Go, v4.0.0 of github.com/antoniomika/sish): "HTTP(S)/WS(S)/TCP tunnels to localhost using only SSH". The client is OpenSSH's ssh -R, so there is nothing to install next to the service.

  • Pangolin (TypeScript and Go, release 1.26.0 of github.com/fosrl/pangolin): a tunnelled reverse proxy with a dashboard, WireGuard between its server and its agent, and sign-in on HTTP resources. It is several server processes, so we ran it on its own stack with the same limits and workloads (Pangolin).

Left out, and why:

  • localtunnel: its server (github.com/localtunnel/server) was last changed in 2019 and does not start on the Node.js 20 of Debian 13, because of the module loader it depends on; its official server image is built for amd64 only, and running it emulated on our arm64 host would measure the emulator. The client is still maintained.
  • zrok (OpenZiti): a local instance is seven containers (OpenZiti controller and router, Postgres, RabbitMQ, an init job, the zrok controller and its frontend) and a DNS zone with a wildcard record. It is a network with an overlay, not one relay process, and does not fit a harness that gives every tool one relay container with 1 CPU. It deserves its own setup.
  • ngrok, Cloudflare Tunnel, Tailscale Funnel: their relays are hosted services. We do not put load on anyone's infrastructure.

How we measured

The harness is October's (the harness and fairness choices there): per tool, a relay container and an exposer container (the tunnel client) at 1 CPU and 512 MiB each, the target (nginx serving 1 KiB and 1 MiB files, and an iperf3 server) in the exposer's network namespace under its own 1 CPU and 512 MiB, and a client container with the load generators (oha 1.16.0, iperf3) at 4 CPUs and 2 GiB, all on one internal Docker network on a 10-CPU arm64 laptop. The tests are the same: iperf3 with 1 and 8 streams, HTTP through the TCP tunnel (1 KiB at 1 and 50 connections with keep-alive, 50 connections with a new connection per request, 1 MiB at 50), the public URL on the relay where a tool has one, 1, 100 and 1,000 connections through one tunnel, a relay restart, 50 and 200 tunnels on one relay, idle CPU and memory, and binary size. CPU is the cgroup's usage, counted for relay, exposer, target and any client-side tunnel process (nfltr's tcp-connect), but not for the load generator.

What is new:

  • Three adapters, in the same style as October's: a relay command, an exposer command and, where a tool needs one, a client-side process. Each tool's TCP tunnels carry iperf3 and the nginx port; each public URL is web.bench.test on the relay.
  • nfltr's TCP tunnel end to end (nfltr-e2ee in the tables): nfltr tcp --e2ee-cert on the agent and nfltr tcp-connect --pin <fingerprint> on the client, so a TLS session between the two runs inside both relay legs and the relay carries only ciphertext. The plain nfltr rows run nfltr tcp --e2ee=false, as in October.
  • Three interleaved rounds. One full run, then two more rounds of the core throughput tests, each round going through every tool in turn; the tables give the median of the three rounds. The concurrency, restart, tunnel-count and idle tests ran once. Three tools have a second full run in place of their first: nfltr, because its relay's new per-address connection limits reset the benchmark's connections in the first (below); bore, whose client exited because it started before its relay listened (bore does not retry; the harness now waits for the relay); and jprq, for the tests after its server stopped.
  • Load recorded. The harness now records the host's load average next to its power source at the start and end of every tool. Other work shared the machine during these runs (the 1-minute load average ranged from 2.9 to 89 on 10 CPUs, median 7.8), so CPU-bound numbers carry more noise than October's; the medians and the ratios within a round are what we compare.

Fairness choices for the new tools:

  • jprq calls two hosted endpoints: its client fetches https://jprq.io/config.json to learn where its server is, and its server checks each client's token against GitHub's user API. With no route out, a small stand-in in the client container answers both (a Docker network alias and a certificate from the benchmark's own CA): the configuration names our relay, and every token is accepted as the login of the same name. jprq's own code is unchanged.
  • jprq paces its copies. Its copy loop reads up to 4 KiB, writes it, then sleeps 10 ms (Bind in server/tunnel/tunnel.go), which caps each connection at about 400 KB/s per direction, and its server allows 24 visitor connections per tunnel and 4 tunnels per user (hard-coded in server/config/config.go). These are deliberate limits of a free public service. We measured the code as it is; its rows show those limits, not the cost of its design.
  • portal ran with a manual certificate for *.bench.test (so no ACME), discovery off, and raw TCP leases enabled for the TCP tests. Its client logs a warning that its self-probe for a relay that terminates TLS could not resolve its own host name on our network; the probe samples connections and does not change the data path. Its raw TCP leases are plain between visitor and relay ("this path performs no TLS termination", in its docs) and travel over the client's TLS session to the relay.
  • sish ran with --https=false: its public URLs were plain HTTP on the relay, so its vhost rows compare with frp's plain HTTP vhost, not with the encrypted URLs. One SSH connection carried the exposer's three forwards (two TCP ports and the HTTP name), as chisel's one client does. sish closes a forwarded connection after 5 s without traffic by default (--idle-connection-timeout), which ended iperf3's control connection in the middle of every bulk test of our first run; the later rounds ran with 60 s.
  • jprq's server stopped with a fatal error ("concurrent map read and map write", in privateConnectionHandler) during the tests at 50 connections in every round, so its later tests in a round had no server; we report the tests before the stop, and its public URL and concurrency tests from a fresh stack.
  • A tool without a feature gets "not supported", never a zero; a test that failed says so.

What each row encrypts, leg by leg, as we ran it:

RowTCP tunnelPublic URL
nfltrTLS client to relay and TLS relay to agent; the relay sees the bytes (--e2ee=false)TLS from the visitor to the agent, routed by SNI; the relay sees ciphertext
nfltr, end to end (nfltr-e2ee)TLS between tcp-connect and the agent, pinned certificate, inside both relay legs; the relay sees ciphertextsame as nfltr
nfltr, inspectablenot runTLS to the relay, which reads HTTP, then TLS to the agent
nfltr, passthroughnot runTLS from the visitor to nginx; the agent forwards the TLS bytes
frpTLS frpc to frps; visitor to relay plainplain HTTP vhost; frp-https: TLS from the visitor to nginx, routed by SNI
ratholeNoise between client and server, or none (rathole-plain); visitor to relay plainnone
chiselSSH over WebSocket between client and server; visitor to relay plainnone
borenonenone
jprqnoneTLS from the visitor to the relay, plain TCP relay to client
portalvisitor to relay plain, then the client's TLS session to the relayTLS from the visitor to the portal client, routed by SNI (the relay signs the handshake)
sishSSH between client and relay; visitor to relay plainplain HTTP vhost (--https=false), then SSH
"Visitor" is the load generator. "none" under Public URL means the tool has no public URL mode; those cells read "not supported" in the results.

Results

Median of three interleaved rounds for the throughput tables; one run each for the rest. nfltr's rows ran with the relay's per-address connection limits off (see what changed since October) and its HTTP rate limit off (--agent-rate-limit 0), as in October. Single 10 s runs move a few percent either way.

Bulk TCP

Bulk TCP (Gbit/s)1 stream8 streams
nfltr12.0413.84
nfltr, end to end10.2914.69
frp10.7314.36
rathole, Noise2.152.13
rathole, plain34.5423.11
chisel2.152.13
bore40.3825.25
jprq3 Mbit/s22 Mbit/s
portal19.1719.49
sish5.326.38
no tunnel102.5882.30
Median of three rounds; each round's iperf3 value is the median of its runs.

HTTP through the TCP tunnel

HTTP through the TCP tunnel1 KiB, c=1, keep-alive1 KiB, c=50, keep-alive1 KiB, c=50, new connection each1 MiB, c=50
nfltr20,637 (0.043)67,783 (0.024)9,105 (0.198)1,601 (1.611)
nfltr, end to end19,388 (0.045)106,855 (0.018)4,343 (0.577)1,491 (2.024)
frp24,774 (0.036)77,568 (0.026)5,946 (0.249)1,860 (1.149)
rathole, Noise12,874 (0.049)51,542 (0.027)2,907 (0.458)178 (10.772)
rathole, plain12,816 (0.037)93,362 (0.017)2,949 (0.351)failed
chisel30,214 (0.026)95,020 (0.019)10,456 (0.146)576 (2.019)
bore11,623 (0.042)106,408 (0.016)3,082 (0.398)1,760 (0.911)
jprq89 (0.494)4,471 (0.063)failedfailed
portal11,994 (0.047)71,533 (0.026)1,133 (1.257)2,210 (1.015)
sish10,448 (0.066)44,701 (0.037)7,332 (0.168)546 (3.253)
no tunnel123,180 (0.004)162,060 (0.006)84,532 (0.010)7,994 (0.127)
Requests per second, median of three rounds; CPU-seconds per 1,000 requests in brackets (relay, exposer, target and nfltr's tcp-connect).

Public URLs

Public URL, 1 KiBc=1, keep-alivec=50, keep-alivec=50, new connection each
nfltr16,384 (0.043)62,029 (0.024)4,591 (0.310)
nfltr, passthrough17,882 (0.040)69,975 (0.022)4,684 (0.394)
nfltr, inspectable (relay ends TLS)6,808 (0.128)14,009 (0.102)4,119 (0.287)
frp-https (TLS passed through)23,191 (0.038)72,435 (0.027)4,542 (0.547)
frp17,126 (0.051)17,695 (0.126)13,157 (0.155)
jprq86 (0.317)failedfailed
portal12,228 (0.047)61,741 (0.030)24 (54.095), 98.6 % errors
sish6,855 (0.095)15,605 (0.093)12,793 (0.101)
Requests per second (CPU-seconds per 1,000 requests), median of three rounds. rathole, chisel and bore have no public URL mode.
1,000 connections, one tunnelTCP, keep-aliveTCP, new connection eachPublic URL, keep-alivePublic URL, new connection each
nfltr103,823, p99 19.0 ms11,398 at 100; 10 % errors at 100082,283, p99 28.7 ms1,423, p99 1146.3 ms
nfltr, end to end114,178, p99 21.7 ms4,737 at 100; 99 % errors at 1000not supportednot supported
nfltr, inspectable (relay ends TLS)not supportednot supported10,679, p99 178.8 ms3,797, p99 447.3 ms
frp50,876, p99 45.5 ms3,300, p99 560.8 ms8,134, p99 619.5 ms3,698, p99 395.4 ms
rathole, Noise44,382, p99 110.0 ms88, p99 1051.1 msnot supportednot supported
rathole, plainfailed at 1 connection (100 % errors)not supportednot supported
chisel69,807, p99 41.3 ms6,248, p99 273.0 msnot supportednot supported
bore98,431, p99 52.4 ms3,008 at 100; 100 % errors at 1000not supportednot supported
jprqnot measured: its server stopped at 50 connections, before these steps, in both full runs
portal81,598 at 100; 78 % errors at 10001,171 at 100; 99 % errors at 100064,101 at 100; 100 % errors at 1000failed at 1 connection (98 % errors)
sish46,785, p99 63.7 ms4,237, p99 492.7 ms9,106, p99 208.7 ms5,780, p99 277.7 ms
One run each, the first step over 1 % errors ends a column. Requests per second for 1 KiB, p99 latency. nfltr's TCP rows go through one tcp-connect; at 1,000 new connections at once it refused sessions beyond the most its one relay connection carries (link: connection carries its most sessions), which is the 10 % of errors. frp-https was not part of these steps. Relay memory at 1,000 keep-alive connections through the TCP tunnel: nfltr 96 MiB, frp 176, chisel 143, sish 252.
Restart, tunnels, footprintnfltrfrprathole, Noisechiselborejprqportalsish
Relay restart to working tunnel (s)0.752.273.091.23client exitsclient exitsnot back in 120 sclient exits
200 tunnels registered (s)2.841.216.310.971.20189 of 200 in 300 s2 of 200 in 300 s3.34
Relay memory with 200 idle tunnels (MiB)64.434.2106.629.54.314.4not measured31.7
Client processes for 200 tunnels (MiB)1,483600828616561,113 (189)not measured318
Idle relay, resident (MiB)47.624.84.311.91.710.435.422.5
Relay binary (MiB)42.8 (nfltr-relay)17.41.010.32.610.1198.632.2
Client binary (MiB)38.6 (nfltr)14.11.010.32.610.221.7none (OpenSSH)
One run each. Restart is one docker restart of the relay, timed from the restart command to the first request through the tunnel that succeeds. Tunnels: one client process per tunnel, started together; relay memory is the cgroup's anonymous memory. portal's tunnel-count rows are not a finding about portal: its clients registered one at a time and our setup may be at fault, so we left the memory rows out. Binaries are the harness's linux/arm64 builds, unstripped for nfltr; idle CPU was 0.1 % or less for every relay.

Evidence: nfltr against jprq, portal, sish and the October four, 2026-11-06

Setup
One 10-CPU arm64 laptop on AC power running Docker; relay, exposer and target at 1 CPU and 512 MiB each, client at 4 CPUs and 2 GiB, one internal network with no route out; nfltr built from main after v2.1.52, before v2.1.53 (relay core v0.9.0, relay server v0.49.12, link v0.12.3), frp 0.71.0, rathole 0.5.0, chisel 1.12.0, bore 0.6.0, jprq at commit 3c10e25, portal 2.7.1, sish 4.0.0 with OpenSSH 10.0p2, oha 1.16.0, iperf3 3.18, nginx 1.26.3. Host load (1 minute) from 2.9 to 89 (median 7.8) during the runs. nfltr's relay with --agent-rate-limit 0 and its per-address connection limits off.
Numbers
medians of three rounds, nfltr ÷ frp within a round: bulk 1.16 (1 stream) and 0.91 (8 streams); 1 KiB at 50 keep-alive connections 1.03 (end to end 1.20), at one connection 0.79, without keep-alive 1.51; 1 MiB 0.83; encrypted URL against frp-https 1.15 with keep-alive and 1.17 without. One run: 1,000 keep-alive connections 103,823 requests per second against frp's 50,876; relay restart to a working tunnel 0.75 s against 2.27; 200 tunnels in 2.84 s against 1.21 with 64.4 against 34.2 MiB of relay memory. With the relay's connection limits at their defaults, the encrypted URL without keep-alive failed 99.5 % of requests.
Reproduce
(a) With released binaries: the recipe in Reproduce it and the October post's. (b) Our check, in the closed nfltr repository: make bench-relay-compare (Docker, about 90 minutes with every tool; BENCH_TOOLS, BENCH_TESTS and BENCH_FLEET pick tools and tests, BENCH_NFLTR_CONNLIMIT=true keeps the relay's connection limits on), then python3 scripts/bench-relay-compare.py rounds <out> <run>... for the medians over rounds.
Limits
One host shared with other work; three rounds of the throughput tests, one run of the concurrency, restart and tunnel-count tests; nfltr's first-round numbers come from a later run than the other tools' (with the connection limits off), bore's concurrency and restart numbers from a later run (its client started before its relay in the first), jprq's public-URL and concurrency numbers from a fresh stack after its server stopped. Pangolin ran once on its own stack after the other tools' rounds. A Docker bridge has no latency or loss. Each tool at its defaults except where the method says otherwise.

Where nfltr leads

  • End-to-end encrypted TCP at the speed of plain TCP. With nfltr tcp's end-to-end mode the relay carries only ciphertext, and keep-alive traffic loses nothing: 106,855 requests per second at 50 connections, 1.20× frp's rate within the same rounds (frp's visitor leg is plain text), and 14.69 Gbit/s on eight streams. No other tool here has a TCP mode the relay cannot read.
  • Many connections through one tunnel: at 1,000 keep-alive connections the TCP tunnel did 103,823 requests per second, the most of any tool (bore 98,431, chisel 69,807, frp 50,876), with a p99 of 19 ms, and 114,178 end to end. The encrypted URL did 82,283 at 1,000, where frp's and sish's plain HTTP vhosts did 8,134 and 9,106 and portal's failed.
  • Coming back after a relay restart. nfltr's agent and tcp-connect reconnect on their own (0.75 s from the relay's restart to a working tunnel, 0.57 s end to end, against frp's 2.27 and chisel's 1.23). frp, rathole and chisel do too; bore, jprq and sish's OpenSSH client exit, and portal's client did not serve again within 120 s in our run.
  • New connections through the TCP tunnel: 9,105 requests per second without keep-alive, 1.51× frp within a round (from 0.97 to 1.88 by round), at 0.198 against 0.249 CPU-seconds per 1,000; chisel (10,456) and sish (7,332) are close or ahead.
  • Bulk on one stream against frp: 12.04 Gbit/s against 10.73 (1.16×), with both of nfltr's legs encrypted.
  • Public URLs the relay cannot read, with access control on them. nfltr's default share URL ends TLS at the agent (a relay that lies could still pose as your machine to a browser that does not check its certificate: what is proven) and carries visitor access rules (Basic auth, bearer tokens, a required header, IP allow lists, idle expiry). Of the other relay-blind URLs here, portal's offer sign-in (wallet, credentials, OIDC), and frp's https vhost and sish's SNI proxy leave visitor access to the service. At 50 keep-alive connections nfltr's did 62,029 requests per second against portal's 61,741, and it kept working without keep-alive, where portal's failed.

Where it is level

  • Small requests with keep-alive through the TCP tunnel: 67,783 requests per second at 50 connections against frp's 77,568 (1.03× within a round), at 0.024 against 0.026 CPU-seconds per 1,000. chisel (95,020) and the unencrypted tools (plain rathole 93,362, bore 106,408) are faster.
  • Encrypted public URLs at 50 connections: with keep-alive 62,029 against frp-https's 72,435 (1.15×, from 0.68 to 1.36 by round), without it 4,591 against 4,542 (1.17×). Without keep-alive both are bound by a TLS handshake per request on one CPU: the agent's in nfltr's case, nginx's in frp-https's.
  • Idle CPU: 0.1 % or less for every relay.

Where it trails

  • Latency of a single request: 20,637 requests per second at one connection through the TCP tunnel, 0.79× frp's 24,774, and chisel did 30,214; on the encrypted URL 16,384 against frp-https's 23,191 (0.72×). The client side of an nfltr TCP tunnel is a process of its own with a TLS leg of its own.
  • Large responses: 1,601 requests per second for 1 MiB, 0.83× frp's 1,860, and portal did 2,210; 1.611 CPU-seconds per 1,000 against frp's 1.149.
  • Bulk on eight streams, and against portal: 13.84 Gbit/s on eight streams, 0.91× frp's 14.36; portal moved 19.17 on one stream and 19.49 on eight with its visitor leg in plain text. The unencrypted tools are fastest (plain rathole 34.54, bore 40.38).
  • New connections end to end: 4,343 requests per second, 0.57× frp: every new connection is a TLS handshake with the agent, on the agent's one CPU.
  • 1,000 new connections at once through one tcp-connect: 10 % of them failed: one tcp-connect keeps one connection to the relay and refused sessions past the most that connection carries. frp, chisel and sish had no errors at that step (3,300 to 6,248 requests per second). A fix is to let the client open a second connection.
  • Registration, memory per tunnel, size: 200 tunnels registered in 2.84 s against chisel's 0.97, bore's 1.20 and frp's 1.21; 64.4 MiB of relay memory for them against frp's 34.2 and bore's 4.3; 1,483 MiB for 200 single-tunnel client processes against frp's 600 (one nfltr up process carries many services); a 42.8 MiB relay binary against 17.4 for frp and 1.0 for rathole.
  • The inspectable URL mode, where the relay ends TLS and reads HTTP: 14,009 at 50 keep-alive connections, about a fifth of the default mode, on the relay's one CPU.

What changed since October

  • Connection limits per source address. Since v2.1.52 nfltr relay serve by default limits each source address to 2,000 open connections and 200 new connections a second (burst 2,000) on each public port, a flood guard checked before any byte is read. In a benchmark every visitor is one address, so in our first full run the encrypted URL without keep-alive reset 99.5 % of its connections once the burst was spent. A crowd of visitors behind one NAT (an office, a carrier's NAT, a load test) meets the same limit. On your own relay they are security.conn_limit.per_ip_max, security.conn_limit.per_ip_rate and security.conn_limit.per_ip_burst (0 turns one off); the harness turns them off for nfltr, as it turns off the per-agent HTTP rate limit, and the tables above were measured that way.
  • New connections are slower than in October on nfltr's paths: through the TCP tunnel 9,105 against 15,165 then, on the encrypted URL 4,591 against 8,270. frp's (5,946, was 7,227) and frp-https's (4,542, was 4,751) moved less, so the lead over frp shrank from 2.1× to 1.51×. We have not found the cause yet. Candidates: each new TCP session now waits for the agent's answer before its first byte (the typed refusals of the connect sidecar), a round trip per connection; and on the encrypted URL the agent's one CPU is the limit (98 % busy in round 2), so a costlier handshake would show directly. Profiling it is the next step.

Beyond speed: what each tool does

Checked on 2026-11-06 against each project's documentation and source at the version we ran; each cell's source is in the notes under the table. "Relay-blind" means a mode in which the relay carries only ciphertext of the application's bytes.

ToolOpen port on the exposing machineRelay-blind modeAccess control on shared URLsProtocolsCustom domainsSeveral relay replicasClient back after a relay restartLicenseLatest release
nfltrnoyes: HTTP share URLs by default (the relay routes by SNI, TLS ends at the agent); TCP with nfltr tcp's default --e2ee and a pinned clientBasic auth, bearer token, required header, IP allow list, idle expiryTCP, HTTP(S); IP over WireGuard (nfltr wg)on nfltr.xyz from v2.1.53yes (shared directory in Redis)yes, automaticCLI and relay server closed; relay core, client SDK and link Apache-2.0v2.1.52, November 2026
frpnoyes: https vhost routes by SNI; xtcp is peer to peerBasic auth on HTTP vhosts; visitor secret for stcpTCP, UDP, HTTP(S), tcpmuxyesnoyes, automaticApache-2.0v0.72.0, 2026-11-04 (we ran v0.71.0)
ratholenonoper-service token for clients; nothing for visitorsTCP, UDPno vhostnoyes, automaticApache-2.0v0.5.0, 2023-10-01
chiselnonouser and address rules for clients; nothing for visitorsTCP, UDP, SOCKS5no vhostnoyes, with backoffMITv1.12.0, 2026-08-29
borenonooptional shared secret for clients; nothing for visitorsTCPno vhostnono, the client exitsMITv0.6.0, 2025-06-09
jprqnono (TLS ends at the relay; the relay-to-client leg is plain TCP)GitHub sign-in for clients; nothing for visitorsTCP, HTTP(S)yes (--cname)nono, the client exitsno license fileCLI 2.4, 2024-11-01
portalnoyes: HTTPS by default (SNI routing, TLS ends in the portal client; the relay signs handshakes with the certificate key it holds)SIWE, credential, OIDC sign-in with allow listsHTTPS, raw TCP, UDPyesclients use several independent relays (failover); no shared relay stateyes, automaticMITv2.7.1, 2026-10-30
sishnoyes with --sni-proxySSH keys or passwords for clients; Basic auth and IP or country allow lists for visitorsHTTP(S), WebSocket, TCP, TCP aliases, SNIyesnono: the client is OpenSSH, which exits (a wrapper such as autossh restarts it)MITv4.0.0, 2026-10-27
Pangolinnono for HTTP (TLS ends at Traefik on the server); raw TCP and UDP pass throughSSO (on by default), PIN, password, email allow list and rules on HTTP resources; nothing on raw resourcesHTTP(S), raw TCP, UDPyesEnterprise Edition only (Postgres and Redis)yes, automatic (Newt)AGPL-3 Community Edition; Enterprise parts under the Fossorial Commercial License1.26.0, 2026-11-04
Every relay needs its own listening ports open; no tool needs an inbound port on the machine with the service. For TCP tunnels, nfltr's visitor runs a client half (nfltr tcp-connect or nfltr dial); frp's stcp and xtcp do too, the other tools' TCP ports are open to anyone who reaches the relay. Activity on 2026-11-06: frp, chisel, portal and sish had commits in the last two weeks, rathole's last release is from 2023, bore's last commit is from February 2026, jprq's server and CLI last changed in June 2026.

Sources: nfltr: CLI reference (nfltr http share flags, nfltr tcp --e2ee), custom domains, the relay as several replicas, what is open. frp: README, vhost/https.go. rathole: README. chisel: README. bore: README, client.rs. jprq: readme.md, server/jprq.go, cli/jprqc.go. portal: README, concepts. sish: README, CLI flags.

Pangolin, measured on its own stack

Pangolin (github.com/fosrl/pangolin, release 1.26.0, commit aaec7f44) is a tunnelled reverse proxy you run yourself. It is not one relay process, so it did not fit the harness's one-relay-container shape, and we ran it beside the harness instead. The server side has four parts: the Pangolin server (API, dashboard and a SQLite database), Gerbil (kernel WireGuard, UDP 51820 and 21820), Traefik v3.7.16 in Gerbil's network namespace (ports 80 and 443 and one entrypoint for each raw port) and Traefik's Badger plugin v1.8.0, which asks the Pangolin server about every HTTP request. Next to the service runs Newt 1.20.0, which speaks userspace WireGuard to Gerbil and needs no extra privileges. The server needs NET_ADMIN and kernel WireGuard, and in production a wildcard domain. The Community Edition is AGPL-3; the parts under server/private are under the Fossorial Commercial License (Enterprise Edition). We pinned every image by digest, set Pangolin up through its API without a browser, and gave each container the harness's limits (1 CPU and 512 MiB, the client 4 CPUs and 2 GiB) on an internal network with no route out, with the same nginx target and the same oha and iperf3. Traefik served its own self-signed certificate, since there was no ACME server to ask.

This was one run, after the other tools' rounds, so its numbers are less certain than the medians above:

Pangolin 1.26.0, one runHTTPS resource (requests/s, p99)Raw TCP resource (requests/s, p99)nfltr, median of three rounds (URL / TCP)
1 KiB, c=1, keep-alive477, 17.9 ms7,142, 0.2 ms16,384 / 20,637
1 KiB, c=50, keep-alive1,800, 189 ms29,192, 31 ms62,029 / 67,783
1 KiB, c=50, new connection each1,587, 98 ms5,612, 49 ms4,591 / 9,105
1 MiB, c=50120, 800 ms119, 747 ms (Newt was killed at its 512 MiB limit in the first try)not run / 1,601
Bulk TCP, 1 and 8 streams (Gbit/s)not supported1.99 and 2.0412.04 and 13.84
Raw resources pass TCP through; HTTPS resources end TLS at Traefik on the server, then go through the WireGuard tunnel to Newt. Idle: Pangolin server 281 MiB and 2.2 % of a CPU, Traefik 52 MiB, Gerbil 10 MiB, Newt 14 MiB. Under the HTTPS tests the Pangolin server used up to 139 % of a CPU answering Badger's checks.

Restarts: after a restart of the Pangolin server the HTTPS resource answered 500 for about 1.9 s and worked again 2.55 s after the restart began; the raw TCP resource kept working throughout, and Newt reconnected each time on its own. After a restart of Gerbil, Traefik stayed in Gerbil's old network namespace and nothing came back within 100 s until Traefik was restarted too (8.6 s for both). Newt held about 380 MiB after the earlier tests; with no restart policy in our compose file it stayed down after the out-of-memory kill.

What Pangolin has that nfltr does not: a web dashboard for organisations, sites and resources; single sign-on on by default for HTTP resources, plus PINs, passwords, email allow lists and rules; and raw UDP. What it lacks here: a mode the server cannot read for HTTP (TLS ends at Traefik), access control on raw resources, and several server replicas outside the Enterprise image (with Postgres and Redis). Sources: README, the installer's compose file, Traefik configuration.

Reproduce it

Our harness is make bench-relay-compare in the nfltr repository, which is closed source; this round added the jprq, portal and sish adapters and the interleaved rounds to it. The recipe in the October post works with released binaries and any tool. For the three new tools:

  1. jprq: build the server and CLI from the same commit (go build ./server, go build ./cli). The server reads JPRQ_DOMAIN, JPRQ_TLS_CERT, JPRQ_TLS_KEY, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET and listens on 80, 443 and 4321. On a network with no route out, answer https://jprq.io/config.json with {"domain": "<your domain>", "events": "<relay>:4321"} and https://api.github.com/user with {"login": "<name>", "allowed": true}, and resolve jprq.<your domain> to the relay. Then jprq auth <token>, jprq tcp <port> and jprq http <port> -s <name>.
  2. portal: the relay image ghcr.io/gosuda/portal:2.7.1 with PORTAL_URL=https://<domain>, DISCOVERY=false, TCP_ENABLED=true, MIN_PORT/MAX_PORT, and fullchain.pem and privatekey.pem in its state directory; the client portal expose <port> --name <name> --relays https://<domain> --discovery=false, and --tcp for a raw TCP lease (the port it gets is in its log).
  3. sish: sish --ssh-address=:2222 --http-address=:80 --https=false --domain=<domain> --bind-random-ports=false --bind-random-subdomains=false --idle-connection-timeout=60s with the client's public key in its keys directory; the client ssh -p 2222 -R <name>:80:localhost:<port> -R <relay port>:localhost:<port> <relay>.
  4. nfltr end to end: nfltr tcp <port> --e2ee-cert <cert> --e2ee-key <key> (or the default --e2ee, which makes a certificate and prints its fingerprint) and nfltr tcp-connect <agent> <port> --listen 127.0.0.1:<local port> --pin <fingerprint>, against nfltr relay serve --agent-rate-limit 0. For load from one address, also set SECURITY_CONN_LIMIT_PER_IP_MAX=0 and SECURITY_CONN_LIMIT_PER_IP_RATE=0 in the relay's environment, or raise them.

If your numbers differ from ours, tell us.

Is it for you?

Fits if the relay should not be able to read what goes through it: nfltr is the one tool here whose TCP tunnels and public URLs both have a relay-blind mode, at no cost on keep-alive traffic, with visitor access rules on the URLs and tunnels that come back after a relay restart on their own. It also fits many connections through one tunnel, and a relay that runs as several replicas (on Kubernetes).

Not ready for the highest bulk throughput or the smallest footprint: frp and portal move more bytes per CPU (both with a plaintext visitor leg), plain rathole and bore more still with no encryption at all, and frp, chisel and bore register tunnels faster with less memory per tunnel. chisel answers single requests and new connections faster. nfltr's new-connection rate fell since October and we have not found why yet. If installing nothing next to the service matters most, sish works with OpenSSH alone. The nfltr CLI and the relay server are closed source; the relay's session core, its client SDK and link are public (what is open).

Try next nfltr tcp <port> on the machine with the service and nfltr tcp-connect <agent> <port> --pin <fingerprint> where you use it, or nfltr http <port> for a URL, against nfltr.xyz or nfltr relay serve --agent-rate-limit 0 on your own machine (Run your own relay). The October numbers and how we found their causes are in A faster, leaner relay, measured against frp, rathole, chisel and bore.

← All posts