MONAD is a multi-hop, VPN-like TCP tunneling system built in Rust.
It provides:
- a local SOCKS5 proxy for normal applications
- hop-by-hop encrypted transport using Noise NK
- multiplexed control and data channels using HTTP/2
- arbitrary TCP tunneling via HTTP/2
CONNECT - recursive multi-hop nesting for onion-style routing
- IPv4, IPv6, and hostname support
Implemented today:
monad-server: accepts client connections, performs Noise handshake, runs an H2 session, proxiesCONNECTtunnels, enforces per-session billing with pause/resumemonad-client: exposes a local SOCKS5 proxy, connects through one or more MONAD hops, opens H2CONNECTtunnels, auto-funds sessions via the control streammonad-common: shared Noise transport, H2 stream helpers, control protocol types (ClientMessage/ServerMessage), session and billing types (RelayConnection,SessionPricing), shared bidirectional proxymonad-quic: shared QUIC transport code plus standalone echo tooling —QuicStream, pinned-key auth, echo server/client, and shared config/keygen helpers used by server and client- QUIC hop support: server dual TCP+UDP listener, QUIC connection pool,
--hop quic:client syntax,quic-pinH2 header for CONNECT forwarding - session payment system: paused-by-default sessions, Hello/SessionParams version negotiation, fake payments, totals-based billing with directional pricing, pause/resume enforcement
- integration tests for direct, nested, IPv6, hostname-resolution, QUIC single-hop, QUIC nested tunnels, mixed TCP/QUIC hop chains, and session payment lifecycle
Not implemented yet:
- real payment integration (Lightning or similar) — currently using
FakePayment - persistent route configuration file
monad-common/ Shared transport and protocol helpers
monad-client/ Local SOCKS5 proxy and multi-hop client
monad-server/ Tunnel server
monad-quic/ Shared QUIC transport code plus standalone echo tooling
cargo buildYou can also just use cargo run, which builds automatically if needed.
cargo testCurrent coverage includes:
- Noise handshake and large-payload transport tests
- session starts paused by default
- second control stream rejected
- CONNECT rejected while paused (402)
- funded data channel (payment unpauses, then data flows)
- session repauses and resumes after second payment
- session overshoot with negative balance and resume
- underpayment stays paused until balance is positive
- multiple simultaneous tunnels
- 2-hop and 3-hop nested routing
- IPv6 final targets
- IPv6 relay listeners
- mixed IPv4/IPv6 hop chains
- hostname resolution at the final hop
- SOCKS5 IPv6 parsing
- QUIC echo with pinned-key authentication
- QUIC pinned-key rejection (wrong key)
- 1,000 concurrent QUIC streams over one connection
- large (4MB) single QUIC stream payload
- multiple independent QUIC connections
- QUIC single-hop (Noise+H2 over QUIC stream)
- QUIC control + data channels
- nested QUIC tunnel (TCP relay forwarding to QUIC relay)
- client connector with QUIC hop (end-to-end
--hop quic:path) - concurrent QUIC pool access
- client QUIC first hop (direct QUIC connection from client)
- QUIC first hop then TCP second hop
cargo run -p monad-server -- keygenThis prints:
- a private key (Ed25519 seed) for the server process
- a public key (Ed25519) to give to clients
- a QUIC certificate (derived from the same key)
One key is used for both Noise and QUIC authentication.
Single hop (TCP only):
RUST_LOG=info cargo run -p monad-server -- run \
--listen 127.0.0.1:9050 \
--private-key <SERVER_PRIVATE_KEY>With QUIC enabled (add --quic):
RUST_LOG=info cargo run -p monad-server -- run \
--listen 127.0.0.1:9050 \
--private-key <SERVER_PRIVATE_KEY> --quicMulti-hop example:
RUST_LOG=info cargo run -p monad-server -- run --listen 127.0.0.1:9051 --private-key <HOP1_PRIV>
RUST_LOG=info cargo run -p monad-server -- run --listen 127.0.0.1:9052 --private-key <HOP2_PRIV> --quic
RUST_LOG=info cargo run -p monad-server -- run --listen 127.0.0.1:9053 --private-key <HOP3_PRIV> --quicDirect connection:
RUST_LOG=info cargo run -p monad-client -- \
--hop 127.0.0.1:9050,<SERVER_PUBLIC_KEY> \
--socks 127.0.0.1:1080Three-hop chain:
RUST_LOG=info cargo run -p monad-client -- \
--hop 127.0.0.1:9051,<HOP1_PUB> \
--hop 127.0.0.1:9052,<HOP2_PUB> \
--hop 127.0.0.1:9053,<HOP3_PUB> \
--socks 127.0.0.1:1080The client listens locally as a SOCKS5 proxy on 127.0.0.1:1080 by default.
Sessions start paused with zero balance. The client automatically opens a control stream and sends a fake payment to fund the session before accepting SOCKS traffic. The default funding amount is 1024 millisats per payment; override with --fake-payment-millisats.
QUIC hops:
RUST_LOG=info cargo run -p monad-client -- \
--hop 127.0.0.1:9051,<HOP1_PUB> \
--hop quic:127.0.0.1:9052,<HOP2_PUB> \
--socks 127.0.0.1:1080The quic: prefix tells the client to instruct the previous hop to connect via QUIC instead of TCP. Each hop uses a single Ed25519 public key — the Noise X25519 key and QUIC pinned key are both derived from it. See ARCHITECTURE.md for the full layering model.
QUIC first hop:
RUST_LOG=info cargo run -p monad-client -- \
--hop quic:127.0.0.1:9051,<HOP1_PUB> \
--socks 127.0.0.1:1080The client connects directly to the first hop via QUIC, then runs the same Noise+H2 session on top.
curl -x socks5h://127.0.0.1:1080 http://example.com/Use socks5h:// if you want hostname resolution to happen at the final hop instead of locally.
With ncat:
ssh -o ProxyCommand='ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p' user@example.comscp -o ProxyCommand='ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p' \
user@example.com:/path/to/file ./local-copyWhen a tunnel closes, the client and final server log plaintext proxied bytes:
tunnel closed: example.com:22 | outbound=63630 inbound=5200 total=68830
Each NoiseStream logs encrypted wire usage when the hop connection shuts down:
NoiseStream closed label=client hop 2/3 to 127.0.0.1:9052 wire_read=... wire_written=... wire_total=...
To see these, use debug logging for the Noise module:
RUST_LOG=monad_common::noise=debug,monad_client=info,monad_server=infoEach server logs every CONNECT request it receives:
CONNECT 127.0.0.1:9052
CONNECT 127.0.0.1:9053
CONNECT satsandsports.cash:22
In a multi-hop chain, only the final hop sees the actual target. Intermediate hops only see the next hop.
Both client and server handle Ctrl+C gracefully:
- stop accepting new work
- wait briefly for active tunnels/sessions to finish
- shut down H2 connections cleanly
- emit
NoiseStreamwire-byte totals
The monad-quic crate also includes a standalone QUIC echo server/client for transport testing and experimentation. The main MONAD client and server now use shared code from this crate for QUIC support.
cargo run -p monad-quic -- keygenSave the private key block to server.key, the certificate block to server.crt, and note the pinned public key hex.
RUST_LOG=info cargo run -p monad-quic -- server \
--listen 127.0.0.1:4433 \
--cert server.crt \
--key server.keyRUST_LOG=info cargo run -p monad-quic -- client \
--connect 127.0.0.1:4433 \
--pin <PINNED_PUBLIC_KEY_HEX> \
--streams 16 \
--bytes 65536This opens 16 bidirectional QUIC streams, sends 64KB of random data on each, reads the echo, and verifies correctness.
ARCHITECTURE.mdfor the protocol and layering modelAGENTS.mdfor repo-specific development guidance