If you have ever wanted a traffic graph for a tunnel client, you know the awkward part: the client usually owns the socket. You cannot wrap it, you cannot ask it, and its status endpoint is often a step-function of "connected" or not.
When I was building MoonProxy — a cross-platform GUI desktop client for FRP — I hit exactly this. frpc exposes an /api/status endpoint, and it is genuinely useful: it tells you whether the tunnel is up. What it does not expose is a single byte counter. Yet the whole point of a tunnel dashboard is answering "how much data is moving right now?"
This is a writeup of the pattern I landed on, because it generalizes well beyond FRP.
The constraint
The naive approach is to watch the process's network usage from the outside. On Linux that is /proc/<pid>/net/dev; on macOS and Windows it is a platform-specific call. All three have the same two problems:
- They report traffic for the whole process, with no way to attribute bytes to a specific tunnel.
- They report host-level interface counters, which include every other program on the machine.
For a desktop app whose entire purpose is to show you per-tunnel traffic, neither is acceptable. The user has five tunnels and wants to see them individually.
The trick: be the middleman
frpc's local proxy config points at a localIP:localPort where your service is listening. That address is just a string in a TOML file. If you own the process that writes that file, you can point it somewhere else.
So instead of:
frpc ──────────────▶ 127.0.0.1:8080 (your service)
you get:
frpc ──────▶ 127.0.0.1:53912 ──────▶ 127.0.0.1:8080 (your service)
(MoonProxy relay) (your service)
MoonProxy opens a listener on an ephemeral port, rewrites localIP/localPort in the proxy config to point at it, and copies bytes in both directions while counting them.
The relay is a plain TCP proxy. It is not a protocol-aware proxy, it does not parse HTTP, and it does not care what is on the other end.
Counting bytes without copy_bidirectional
Tokio has tokio::io::copy_bidirectional, which is exactly the wrong tool here. It is efficient, it is correct, and it returns the total bytes moved in each direction at the end — but only at the end. For a live dashboard you want running totals you can sample at any instant.
The obvious substitute, tokio::io::copy, returns bytes copied too, but only when the stream closes. So the counter has to live somewhere that outlives the call.
I used a pair of atomic counters incremented inside the copy loop:
/// Single-direction copy: read, write, immediately accumulate the counter.
/// Equivalent to `tokio::io::copy` but with a live counter.
async fn copy_one_way<R, W>(
rd: &mut R,
wr: &mut W,
counter: &AtomicU64,
) -> std::io::Result<u64>
where
R: tokio::io::AsyncRead + Unpin + ?Sized,
W: tokio::io::AsyncWrite + Unpin + ?Sized,
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![u8; 8 * 1024];
let mut total = 0u64;
loop {
let n = rd.read(&mut buf).await?;
if n == 0 {
return Ok(total);
}
wr.write_all(&buf[..n]).await?;
total += n as u64;
counter.fetch_add(n as u64, Ordering::Relaxed);
}
}
fetch_add with Relaxed ordering is the right choice here. The counters are pure statistics — nobody reads them to establish a happens-before relationship with other memory — so paying for SeqCst fencing on every 8 KB chunk would be pure waste.
Running two directions concurrently
copy_bidirectional runs both directions at once and cancels whichever finishes first. Reproducing that with tokio::select! is straightforward:
async fn copy_bidirectional_counted<R1, W1, R2, W2>(
user_rd: &mut R1,
frpc_wr: &mut W1,
in_counter: &AtomicU64,
frpc_rd: &mut R2,
user_wr: &mut W2,
out_counter: &AtomicU64,
) -> (u64, u64)
where /* ... */ {
let in_task = copy_one_way(user_rd, frpc_wr, in_counter);
let out_task = copy_one_way(frpc_rd, user_wr, out_counter);
tokio::pin!(in_task);
tokio::pin!(out_task);
let mut in_bytes = 0u64;
let mut out_bytes = 0u64;
loop {
tokio::select! {
res = &mut in_task => {
if let Ok(n) = res { in_bytes += n; }
break;
}
res = &mut out_task => {
if let Ok(n) = res { out_bytes += n; }
break;
}
}
}
(in_bytes, out_bytes)
}
The tokio::pin! calls matter: without pinning, the two futures are dropped at the end of the select! arm and the surviving copy task is cancelled mid-stream.
Note the direction convention. I deliberately count in terms of the end user's perspective rather than the process's:
-
user_conn → frpc_conn= the user's service uploading = in (up) -
frpc_conn → user_conn= the user's service downloading = out (down)
Getting this backwards is the classic mistake in this kind of tool. A user watching their NAS upload a backup should see the up arrow move, not the down arrow.
Connection counting with an RAII guard
Counting open connections is where an early-return bug really bites. Every path out of the connection handler has to decrement, and there are a lot of them — a failed TcpStream::connect, a read error, a write error, a panic in the copy loop.
Rather than hand-decrementing at each exit, use a guard whose Drop does it:
/// Connection-count RAII guard: +1 on construction, -1 on drop,
/// so every exit path (early return, error, panic) stays balanced.
struct ConnGuard<'a>(&'a AtomicI64);
impl ConnGuard<'_> {
fn new(c: &AtomicI64) -> Self {
c.fetch_add(1, Ordering::Relaxed);
Self(c)
}
}
impl Drop for ConnGuard<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
Then the handler body is short enough to audit at a glance:
async fn handle_relay_conn(mut frpc_conn: TcpStream, entry: Arc<RelayEntry>) {
let mut user_conn = match TcpStream::connect(&entry.upstream).await {
Ok(s) => s,
Err(_) => return,
};
let _guard = ConnGuard::new(&entry.connections);
let (mut frpc_rd, mut frpc_wr) = frpc_conn.split();
let (mut user_rd, mut user_wr) = user_conn.split();
copy_bidirectional_counted(
&mut user_rd, &mut frpc_wr, &entry.in_bytes,
&mut frpc_rd, &mut user_wr, &entry.out_bytes,
).await;
}
The guard is declared after the connect succeeds, which is important: a failed connection never became a connection.
Deriving a rate from a cumulative counter
The counters are monotonic totals. The UI wants bytes per second. There are two ways to get there, and the difference matters.
Bad: sample the counter, and treat the delta as the rate. This produces a value that is really "bytes in the last interval," and it aliases badly — a single 2 MB chunk arriving between two samples reads as either 2 MB/s or 0 B/s depending entirely on where the sampling boundary lands.
Better: keep the previous sample and difference it explicitly.
// Swap-and-difference: the previous value is read and replaced atomically.
let last_in = state.last_in_bytes.swap(total_in, Ordering::Relaxed);
let last_out = state.last_out_bytes.swap(total_out, Ordering::Relaxed);
let in_rate = total_in.saturating_sub(last_in);
let out_rate = total_out.saturating_sub(last_out);
swap is the important detail. It reads and writes in one atomic operation, so two concurrent samplers can never interleave into a corrupted "last value." saturating_sub handles the reset case (the tunnel restarted and the counter went back to zero) without underflowing into a 17-exabyte "negative" rate.
With a 1-second sampling tick, the difference is bytes per second, which keeps the arithmetic in the backend trivially simple and leaves the frontend to do nothing but plot.
Wiring it to the frontend
The backend samples once per second and emits an event:
pub async fn poll_traffic(app: AppHandle, start_gen: u64) {
const TICK: Duration = Duration::from_secs(1);
loop {
tokio::time::sleep(TICK).await;
let state = match app.try_state::<crate::frpc_state::FrpcState>() {
Some(s) => s,
None => break,
};
// A new start/stop cycle invalidates this sampler.
if state.poll_gen.load(Ordering::Acquire) != start_gen {
break;
}
let payload = {
let guard = state.relay.lock().await;
match guard.as_ref() {
Some(r) => sample(r, &state).await,
None => break,
}
};
let _ = app.emit("frpc://traffic", payload);
}
}
Two details here are easy to get wrong:
The generation counter. Without poll_gen, an old sampler keeps running after a new tunnel starts, and you end up with two samplers emitting interleaved traffic events. The frontend would show a sawtooth pattern that looks like a network problem but is actually two samplers fighting.
poll_traffic must be spawned, not awaited. It is an infinite loop. Awaiting it inside a #[tauri::command] would hang the command forever and the start button would appear to hang with it. Wrap it:
tauri::async_runtime::spawn(poll_traffic(app, gen));
The frontend side is then almost trivial, because the backend already did the differencing:
export function handleTrafficPayload(p: TrafficPayload) {
totalInBytes.value = p.total_in_bytes;
totalOutBytes.value = p.total_out_bytes;
const now = Date.now();
const snapshot: TrafficSnapshot = {
timestamp: now,
in_rate: p.in_rate,
out_rate: p.out_rate,
connections: p.connections,
};
latestTraffic.value = snapshot;
// Keep a fixed-size rolling window for the chart.
const next = trafficHistory.value.concat(snapshot);
if (next.length > WINDOW_SIZE) {
next.splice(0, next.length - WINDOW_SIZE);
}
trafficHistory.value = next;
}
WINDOW_SIZE is 60, matching the 1-second backend tick — a 60-point rolling window is one minute of history, which is long enough to read a trend and short enough that the chart stays responsive.
Rewriting the client's config
The last piece is the part that makes the whole trick work: pointing frpc at the relay instead of at the user's service.
// Bind an ephemeral port on loopback.
let listener = match TcpListener::bind(("127.0.0.1", 0u16)).await {
Ok(l) => l,
Err(e) => {
crate::frpc_state::emit_log(app, "system", format!("中转端口绑定失败,跳过 {name}:{e}"));
continue;
}
};
let relay_port = match listener.local_addr() {
Ok(a) => a.port(),
Err(_) => continue,
};
// Rewrite THIS proxy's localIP/localPort to the relay.
match p {
ProxyConfig::Tcp { local_ip, local_port, .. }
| ProxyConfig::Http { local_ip, local_port, .. }
| ProxyConfig::Https { local_ip, local_port, .. } => {
*local_ip = "127.0.0.1".to_string();
*local_port = relay_port;
}
ProxyConfig::Udp { .. } => { /* unreachable: UDP is skipped above */ }
}
Two deliberate decisions:
Loopback only. The relay binds 127.0.0.1, never 0.0.0.0. The relay is an internal implementation detail; exposing it on a routable interface would create a new, unintended entry point into the user's local network.
UDP is skipped. The relay is TCP-only because it is implemented as a byte stream. UDP would need a separate, connectionless path. Rather than half-implement it, MoonProxy passes UDP proxies through untouched — they simply do not appear in the traffic graph.
Binding to port 0 and asking the OS for an ephemeral port is also worth calling out: it avoids a port collision with a user's actual service, which a fixed port would eventually cause.
Cleanup
Stopping the tunnel has to tear down the listeners. Because each accept loop owns its TcpListener, aborting the task closes the socket:
pub async fn stop_relay(state: &RelayState) {
let mut entries = state.entries.lock().await;
for (_, entry) in entries.drain(..) {
if let Some(h) = entry.accept_task.lock().await.take() {
h.abort();
}
}
}
In-flight connections end on their own: their copy loop hits a read or write error when the peer goes away, and the ConnGuard drops on the way out, so the connection count returns to zero without any explicit bookkeeping.
What this buys you
- Per-tunnel byte counts, attributed exactly, with no host-level guessing
- Rates that survive a slow network because they are differenced from cumulative totals
- A connection count that is correct on every exit path, including panics
- No changes to the tunnel client itself — you only rewrite where it points
The pattern is not FRP-specific. Any time a subprocess owns the socket and you want to observe the traffic, you can insert yourself in the middle, count the bytes, and let the subprocess do what it was always going to do.
MoonProxy
MoonProxy is a free, open-source (MIT) cross-platform GUI desktop client for FRP, built with Tauri v2, Vue 3, and Rust. It bundles the frpc binary, so you do not need to install anything separately.
- Repository: https://github.com/MoonProxyHQ/moonproxy-desktop
- Homepage: https://moonproxy.app
- Runs on macOS (Apple Silicon and Intel) and Windows x64
If this kind of low-level plumbing is your thing, the source is on GitHub and I am happy to answer questions about the details.
Top comments (0)