A from-scratch, multi-protocol VPN/proxy tunnel in Rust. A local TUN tunnel terminates connections in a userspace netstack and forwards them through pluggable tunnel transports (first: a plain TCP tunnel). Performance and small binary size are first-class constraints. Targets desktop (Linux/macOS/Windows) and mobile (Android/iOS) over a shared Rust core.
The planning and design work is done; implementation is staged across many sessions.
- CLAUDE.md — standing instructions Claude Code loads automatically (code standards, locked stack, patterns/anti-patterns, process model).
- docs/GOAL.md — north star; read first every session.
- docs/PLAN.md — the session operating protocol (pacing) + the M0–M11 milestone ladder with binary pass/fail gates.
- docs/STATE.md — cross-session memory; currently set to start at M0.
- docs/tun-to-proxy-design.md — netstack + tunnel-transport architecture.
- docs/process-architecture-and-ipc.md — privileged tunnel process vs. unprivileged client, control-plane IPC, and cross-platform permissions.
- netstack-spike/ — a compiling reference for the netstack bridge (verified on the 0.1.x API / rustc 1.75; re-verify against vendored 0.2.x on ≥1.85 at M0).
Open Claude Code in this directory (select your model there) and say:
Read GOAL.md, PLAN.md, and STATE.md, then execute the next chunk.
The protocol does the rest: confirm a green start, do one bounded chunk, run the gate, commit, update STATE.md, and stop. Repeat next session.
Creates the Cargo workspace, pins the toolchain (stable ≥ 1.85), vendors netstack-smoltcp
into vendor/, and proves the netstack compiles via a netstack_smoke example. Gate
details in docs/PLAN.md §4 (M0). Bringing up real TUN devices and the privileged service
(M7) will require elevated privileges and your approval on those commands.
make lists every build target. Each one wraps a script under packaging/ or scripts/;
the Makefile exists so those don't have to be found by reading the release workflow.
make # list targets
make check # fmt + clippy + the full test suite — run before pushing
make release # release build; binary at target/release/spark
make macos # the macOS product DMG (see the warning below)Use make macos, not npm run tauri build.
npm run tauri build produces only the Tauri UI shell: no org.getlantern.spark.tunnel
system extension and an ad-hoc signature. It launches and looks correct, and it cannot tunnel —
which is why it is worth stating rather than leaving to be rediscovered.
make macos builds the UI, builds and embeds the system extension, signs with a Developer ID
identity derived from the installed provisioning profiles (nothing to name or configure), then
notarizes and staples.
Notarization is not optional: macOS refuses to activate an un-notarized system extension, so a build without it launches fine and the tunnel silently never comes up.
Credentials come from the environment — AC_USERNAME (Apple ID) and AC_PASSWORD (an
app-specific password). On a machine that already exports them, make macos needs nothing else;
check with env | grep '^AC_' before assuming they are absent. Otherwise, either export those two
or store a notarytool profile once:
xcrun notarytool store-credentials spark \
--apple-id <apple-id> --team-id ACZRKC3LQ9 --password <app-specific-password>
NOTARY_PROFILE=spark make macos # -> dist/Spark.dmgmake macos-fast skips notarization for UI-only iteration. The tunnel will not work in that build.
cargo test -p spark-core # unit tests (packet parser + checksums)
cargo run --example netstack_smoke -p spark-core # prints NETSTACK OK (M0 gate)spark is configured by --config <file.toml> or, when that is absent, by the individual
CLI flags (--name, --addr, --prefix, --mtu, --server, --debug). When --config
is given it provides the full configuration and the flags are ignored.
Every field has a default, so a partial file is valid; unknown keys are rejected. The full schema (all values shown at their defaults):
[tun]
# name = "tun0" # requested device name; omit to let the OS choose (utunN on macOS)
addr = "10.0.0.1" # IPv4 address assigned to the interface
prefix = 24 # IPv4 prefix length
# mtu = 1500 # MTU override; omit to use the device default
[transport]
# server = "203.0.113.1:8388" # tunnel server; omit to dial destinations directly
[udp]
idle_timeout_secs = 60 # reclaim a UDP NAT association after this much silence
[log]
debug = false # log src/dst addresses and disable IP redaction (also: --debug, RUST_LOG=debug)Log hygiene: addresses are logged only at debug level, and at the default level the
log writer additionally redacts any IP literal as a backstop — so default-level logs never
contain destination IPs. --debug (or debug = true) disables both.
Two binaries: the privileged spark-service daemon and the unprivileged spark client.
Service-install units (systemd / launchd), an example config, the size-budget check, and the
per-target cross-build status live in packaging/. Quick check:
cargo build --release && ./scripts/size-budget.sh # both binaries must be < 3 MB strippedspark brings up a TUN device, logs each IP packet, and answers ICMP echo requests.
Creating a TUN device needs elevated privileges on every desktop OS.
Linux:
sudo RUST_LOG=debug ./target/release/spark --name tun0 --addr 10.0.0.1 --prefix 24
# in another terminal — ping a peer address in the TUN subnet (not the local 10.0.0.1,
# which the host answers itself), so the request is routed out the TUN to our responder:
ping 10.0.0.2macOS (TUN devices are named utunN; the OS may pick the number):
sudo RUST_LOG=debug ./target/release/spark --addr 10.0.0.1 --prefix 24
# note the assigned utun name in the startup log, then:
ping 10.0.0.2Expected: ping reports replies, and the spark log shows rx proto=icmp lines (plus
rx addresses lines at RUST_LOG=debug). Without --debug/RUST_LOG=debug, addresses
are never logged — a deliberate privacy property (see docs/GOAL.md).
At M2, spark no longer inspects packets itself: it bridges the TUN into a userspace
TCP/IP stack (netstack-smoltcp), accepts each terminated TCP flow, and forwards it to
the flow's original destination via a direct dial (no tunnel transport yet — that is
M3/M4). The gate is a TCP request that traverses TUN → netstack → upstream → back.
⚠️ Loop hazard (intrinsic to M2's direct dial). The upstreamsparkdials is the original destination. So you must NOT add a routing-table entry that sends that destination into the TUN — it would also catchspark's own outbound dial and loop forever. The clean M2 test forces only the client's socket into the TUN (per-socket bind) while leavingspark's dial on the default route. This awkwardness disappears at M4, wheresparkdials a tunnel server at a different address, so routing the destination into the TUN no longer captures the dial.
Linux (clean — per-socket SO_BINDTODEVICE, no routing-table change):
sudo RUST_LOG=info ./target/release/spark --name tun0 --addr 10.0.0.1 --prefix 24
# Return packets arrive on tun0 with a src the main route table reaches via eth0, so
# loosen reverse-path filtering on the device (else the kernel silently drops them):
sudo sysctl -w net.ipv4.conf.tun0.rp_filter=0 net.ipv4.conf.all.rp_filter=0
# `--interface tun0` binds curl's socket to tun0 (SO_BINDTODEVICE), forcing the request
# INTO the tun; spark's unbound upstream dial follows the default route out eth0:
curl -v --interface tun0 https://1.1.1.1macOS: there is no SO_BINDTODEVICE; curl --interface utunN only sets the source
address, and egress is then chosen by the route table — so getting traffic into the tun
requires a route add -host <dst> -interface utunN, which re-triggers the loop above.
Run the M2 gate on Linux; defer the full macOS route test to M4 (no loop there).
Expected: curl completes the TLS handshake and returns a response; the spark log shows
a tcp flow completed line with to_upstream/to_app byte counts (addresses only at
RUST_LOG=debug).
Status: the bridge + accept loop + forwarder are implemented, compile green, and are unit-tested (hermetic loopback forward test). The live root-required curl gate above is pending a privileged run — see
docs/STATE.mdBlockers.