How the code is organized, how to build and test it, and the conventions and pitfalls that aren't obvious from the code. The design itself is in webjet-architecture.md; section numbers (§) in code comments refer to it.
| Crate | Depends on | Notes |
|---|---|---|
webjet-proto |
FlatBuffers types, generated by planus. | |
webjet-core |
proto | Pure, deterministic logic: no I/O, no clocks except where passed in. |
webjet-net |
core, proto | Transports. Feature anytls (default) pulls in BoringSSL. |
webjet-release |
core, proto | What webjetup, webjetd and webjetctl share about releases: targets, file names, version order, the remote upgrade spool (QueuedRequest, Trust, plan: the helper's own check of a signed UpgradeRequest, §19.8) and, with feature http, the release server client. |
webjetd |
release, core, net, proto | The agent (library + binary). Feature anytls forwards to webjet-net. upgrade.rs: remote upgrade requests (check, queue for the helper, pass on as a controller). |
webjet-tools |
webjetd, release, core, net, proto | webjetctl (network, local, root); config.rs is the TOML network/node configuration document (parse, scope checks, wire conversion, show, get/set/unset); also the shared account-key code (Account::unlock, sign); upgrade.rs pins a release, signs per-node UpgradeRequests and orders a rollout. |
webjetup |
release | Installation manager; remote.rs is the remote upgrade helper (webjetup apply-upgrade) and its systemd/launchd/scheduled-task triggers. |
webjet-app |
webjetd, tools, core, proto | GPUI desktop app. model, client, graph (network graph and layout) and openmetrics are GUI-free and unit-tested; ui is the GPUI front end, one file per tab, rendered headless in a test. |
Edition 2024, rust-version = "1.88".
cargo build --workspace # needs cmake and Go for BoringSSL
cargo build --workspace --no-default-features # without AnyTLS: no cmake or Go neededA C compiler is always needed: aws-lc (rustls) and zstd (zstd-sys, control
plane compression, architecture §9.8) are C libraries built by their crates.
BoringSSL makes the first build slow (several minutes). For quick iteration on
code that doesn't touch AnyTLS, use --no-default-features.
cargo fmt
cargo clippy --workspace --all-targets
cargo clippy --workspace --all-targets --no-default-features
cargo test --workspace
scripts/deny.sh # dependencies (cargo-deny)
scripts/fuzz-lock.sh && cargo test --release --manifest-path fuzz/Cargo.toml --no-default-features # fuzz targets, smokeAll of them must be clean: the workspace has no clippy warnings, including in tests.
- No
unwrap()/expect()outside tests. The workspace lints warn onclippy::unwrap_usedandclippy::expect_used;clippy.tomlallows them in tests. Parse untrusted bytes with the checked readers inwebjet-core/src/bytes.rs, which returnNoneon short input, and drop bad packets instead of panicking. Sort floats withtotal_cmp. - The generated FlatBuffers module is exempt (
#[rustfmt::skip]and lint allows inwebjet-proto/src/lib.rs). Don't editgenerated.rsby hand. - Some code is compiled only on one OS (
cfg(target_os = "linux"),cfg(windows)). Clippy on macOS doesn't see it, so check the other targets too when you touch platform code:- Linux:
docker run --rm -v "$PWD":/src -w /src rust:1-bookworm cargo clippy --workspace --exclude webjet-app --all-targets(or build the Docker image).webjet-appis left out because GPUI links against libxkbcommon, libxkbcommon-x11 and libxcb, which the plain Rust image doesn't have (apt-get install libxkbcommon-dev libxkbcommon-x11-dev libxcb1-devadds them; the CI image has them). - Windows:
cargo xwin clippy --target x86_64-pc-windows-msvc -p webjetd -p webjet-tools --no-default-features. - FreeBSD (OPNsense):
scripts/freebsd-cargo.sh clippy -p webjetd -p webjet-tools -p webjetup --all-targets -- -D warnings. The script runs cargo forx86_64-unknown-freebsdwith Clang and lld against a FreeBSD 14.4 sysroot, whichscripts/freebsd-sysroot.shdownloads to~/.cache/freebsd-sysroot/x86_64the first time (base.txz, checksum pinned). On a Mac it needs Homebrew'sllvmandlld; elsewhereWEBJET_FREEBSD_LLVMnames their directory if they aren't on PATH.scripts/freebsd-cargo.sh build --release -p webjetd -p webjet-tools -p webjetupbuilds the programs. They run on FreeBSD 14.0 and later 14.x (OPNsense 24.7 and later):scripts/release.shrefuses a build that needs a libc symbol newer than 14.0's (FBSD_1.7).
- Linux:
deny.toml configures cargo-deny,
which scripts/deny.sh runs (it installs a pinned release on Linux if
cargo-deny isn't on PATH; on a Mac use cargo install --locked cargo-deny).
It fails on:
- a RustSec advisory (vulnerable, unsound, yanked or unmaintained crate);
- a licence that isn't in the allow list (permissive licences and MPL-2.0);
- a crate from anywhere but crates.io.
An unmaintained crate that can't be replaced yet goes into [advisories] ignore with the reason (today: crates pinned by gpui 0.2.2 in
webjet-app). For a vulnerability, update the
crate (cargo update -p <crate>) instead; ignore it only if the vulnerable
code is unreachable, and say why. Duplicate versions are reported as
warnings (scripts/deny.sh bans lists them). The workspace crates are
publish = false, so path dependencies without a version are fine.
gpui, gpui_media, gpui_http_client and reed-solomon-erasure come from
patched copies in vendor/ ([patch.crates-io]; vendor/README.md says
what changed and how to update them), so cargo update never moves them to
a new release. reed-solomon-erasure is patched to pick its SIMD kernels at
run time (AVX2, SSSE3 or the portable code on x86_64), so one release build
runs fast on new CPUs and still runs on old ones.
gpui_http_client is patched only so that it no longer pulls in xattr 0.2,
which needs the Linux constant libc::ENOATTR that libc 0.2.190 removed;
libc can be updated freely.
scripts/gpui-vendor-check.sh asks crates.io whether there is a newer one
(exit 1 if so). The pre-commit hook in .githooks/ runs it with --hook:
a warning only, looked up at most once a day and skipped without network.
Enable the hook once per clone with git config core.hooksPath .githooks
(WEBJET_SKIP_GPUI_CHECK=1 or git commit --no-verify skips it).
fuzz/ fuzzes every parser that reads bytes from the network or a local
client (§20). It is its own workspace, outside cargo test --workspace.
Its Cargo.lock is not committed: it is a copy of the main Cargo.lock
(scripts/fuzz-lock.sh, which CI and scripts/fuzz.sh run first), so the
targets build against the same dependency versions as the programs and the
lock never needs updating by hand. Without the copy, cargo resolves the
fuzz workspace on its own (newest versions).
fuzz/src/lib.rs has one function per target:
| Target | What it feeds |
|---|---|
proto |
Every FlatBuffers message type (decode, re-encode, decode again), signed envelopes, tree leaves |
frames |
e2e frame headers, handshake and relay frames, AEAD open, the replay window; seal/open round trips |
packet |
IP packets: parsing, NAT rewrite, ECN, ICMP replies, LZ4 round trip |
decompress |
zstd and LZ4 decompression of hostile input: the size cap holds, compressed data round-trips |
gate |
The UDP gate (UdpObfs::open) and seal/open round trips |
kcp_session |
FEC shards and KCP segments after the gate, as a session processes them, and raw packets into KCP (kcp::fuzzing) |
hop_record |
Hop record framing (TLS/AnyTLS/KCP hops), CONTROL_ZSTD payloads, HopControl |
dns |
The local resolver: client queries and forwarded zones' answers (dns::fuzzing) |
control_request |
Control-channel request lines (control::Request): parse, and parsed requests round-trip |
sntp |
SNTP answers from time servers (sntp::parse); a well-formed answer gives the exact offset |
multicast_report |
Multicast membership reports from peers (multicast::decode_report): decoded reports round-trip |
padding |
Padding schemes from the TransportConfig (padding::parse_line, Scheme::from_wire): plans carry the whole payload |
Two ways to run them:
# Stable, seconds: seeds plus random mutations (CI runs this).
scripts/fuzz-lock.sh
cargo test --release --manifest-path fuzz/Cargo.toml --no-default-features
WEBJET_FUZZ_ITERS=100000 WEBJET_FUZZ_SEED=1 cargo test ... # longer / repeatable
# Coverage-guided with libFuzzer (nightly + cargo-fuzz):
rustup toolchain install nightly --profile minimal && cargo install cargo-fuzz
scripts/fuzz.sh 300 # every target for 300 s
scripts/fuzz.sh 0 dns # one target until stopped (Ctrl-C)fuzz.sh starts from the seeds in fuzz/src/seeds.rs (valid messages of
every kind). Corpus and findings stay in fuzz/corpus/ and
fuzz/artifacts/ (gitignored). For a finding: reproduce it with
cargo +nightly fuzz run <target> fuzz/artifacts/<target>/<file>, fix the
bug, and commit the input as fuzz/regressions/<target>/<name>; the smoke
test replays every file there. A target fails by panicking, so new targets
should assert the invariants they can (round trips, size caps), not only
call the parser. When adding a parser for untrusted input, add a target and
seeds for it.
KCP is our own adaptation of the kcp crate 0.6.0 (MIT,
crates/webjet-net/src/kcp/proto.rs, architecture §17.5). Its sequence
numbers and millisecond timestamps wrap by design: use wrapping_* and the
diff helper, never plain +/-, and keep malformed input an Err, not a
panic. The kcp-wrapping-sn and kcp-wrapping-ts regressions came from
the crate's plain arithmetic.
Schemas are crates/webjet-proto/schema/webjet.fbs (signed objects, tree
leaves) and wire.fbs (hop messages, RPC, Raft). After editing them, run
crates/webjet-proto/regen.sh (needs cargo install planus-cli --version 1.3.0) and commit the regenerated src/generated.rs.
- planus 1.3 has no fixed-size arrays in structs, so 32-byte keys, 64-byte
signatures, 16-byte ids and CIDRs are structs of little-endian
u64words (b0..b3) with the same byte layout. Their JSON form (control channel,--json) is hand-written inwebjet-proto/src/serde_impls.rs: ids, keys and signatures as hex, addresses asip,ip:port,ip/prefix.regen.shstrips the derived serde impls of those structs; reading still accepts the old{"b0": …}form. - Only append fields to tables, never reorder or remove them: older nodes must still decode new messages (§15.7). New union variants go at the end.
- Signed objects are envelopes:
type || body, signed with Ed25519 over a context string per message type (webjet-protomessage-type registry). - New hop record types (
webjet-nethop::rec) and e2e frame types (webjet-coreframes::ftype) are sent only to peers that advertise a feature for them in their EndpointRecord (gossip::features); older nodes drop types they don't know. Compression works this way:CONTROL_ZSTD,RPC_ZSTD,DATA_LZ4,PROXY_LZ4behind thezstd/lz4features (webjetd/src/compression.rs,webjet-core/src/compress.rs); broadcast and multicast (MCAST,GROUPS) behindmulticast(webjetd/src/multicast.rs,webjet-core/src/multicast.rs).
webjet-core/src/statemachine.rs applies committed log entries. It must be
deterministic: every controller computes the same tree from the same log
(§15.5).
- No wall clock: use the entry's
timestamp. - No randomness: use the entry's
randomfields, chosen by the leader when it prepares the entry. - Only ordered maps (
BTreeMap/BTreeSet) where iteration order matters.
The leader prepares an entry: it fills in the timestamp, the random values and the resulting leaf bytes. Followers recompute the leaves and halt on any difference, so a bug that makes replicas disagree shows up as a halted follower, not as silent divergence.
webjetd/src/controller/raft.rs is a pure state machine: time is passed in,
randomness comes from a seeded RNG, storage can be in memory. Pre-vote,
check-quorum, single-server membership changes tracked from the log
(classify_entry in controller/mod.rs).
Testing it:
cargo test -p webjetd --lib raft # ~1 min
WEBJET_RAFT_SEEDS=2000 cargo test --release -p webjetd --lib randomized # soak
WEBJET_RAFT_STATS=1 WEBJET_RAFT_TRACE=1 ... # per-seed stats / seed being run
scripts/raft-mutations.py # ~10 minraft_tests.rs has targeted scenarios and a simulator. The simulator injects
crashes, restarts, partitions, message loss, duplication, reordering,
snapshots and membership changes, checks the Raft safety properties after
every step, and checks liveness once faults stop.
scripts/raft-mutations.py re-introduces ten classic Raft bugs one at a time
and fails if the tests miss one. Run it after changing raft.rs or the
tests. Its patterns match the source with any whitespace; if a refactor
changes the code it looks for, it reports a setup error (exit status 2) and
the pattern must be updated.
Offline recovery (controller/recovery.rs, ops guide 4.1) writes into a
stopped controller's log: force-new-cluster replays the log, and restore
installs an exported ControllerSnapshot. Either way it appends one
ForceNewClusterEntry in a new term this server voted itself in
(Raft::append_offline), and classify_entry turns it into Set with one
voter. When the log is below --min-version, it is replaced by a snapshot
at that index first, so the new tree heads are above any version a lost
controller signed. Machine::prepare refuses the entry, so a running leader
never proposes one (prepare_offline is for this path only).
When a randomized run fails, rerun the failing test with
WEBJET_RAFT_TRACE=1 to find the seed, then reproduce with that one seed.
Every failing seed so far has been a real bug or a test-harness bug (for
example, waiting for a value whose leader was deposed), never noise.
The agent shares state between many tasks; deadlocks have been the most common bug. Rules:
- Never call a function that takes a lock while holding another lock it
might take. Clone what you need, drop the guard, then call. Especially:
Node::records,Node::hops, the store, a session'sinner, andNode::circuits. Node::store.read()is recursive (read_recursive), so nested reads are fine, but never takewrite()while holding a read.- Code that needs only the derived view, the overlay pool or the store's
generation reads
n.store.snapshot()instead: no lock, so packets never wait behind a writer rebuilding the view. The write guard republishes the snapshot when it drops, if the store's generation moved, so every change to the store must bumpStore::generation. Don't hold a snapshot guard across.await(snapshot_arc()gives an owned one). - Never hold a
parking_lotguard across.await. - Helpers that iterate over all sessions or hops (e.g. counting handshakes in progress) must not run while the caller holds one of them; take the count first.
- In edition 2024 a temporary in an
if letor a let-chain lives until the end of the wholeif, including its body. Soif let Some(c) = n.circuits.lock().first() && send(c)holds the circuit lock whilesendruns. Bind the value withletfirst, then test it. This deadlocked the e2e re-key (found byscripts/soak-test.sh).
sample <pid> (macOS) or gdb -p <pid> -batch -ex 'thread apply all bt'
(Linux) on a hung agent shows which locks the threads wait on.
The agent's long-running loops are owned by Node::tasks
(webjetd/src/tasks.rs), not started with a bare tokio::spawn:
n.tasks.service(name, fut)(orservice_result) for a loop that must run as long as the agent: gossip, e2e, paths, metrics, health, punch, connections, secrets, the controller, the hop listeners, the tun reader and writer, the data plane refresh. If one returns, fails or panics, the agent logs it and exits with an error, and the service manager restarts it (systemdRestart=on-failure, launchdKeepAlive, Docker's restart policy). Before, such a task died silently and left a running agent that no longer gossiped or routed.n.tasks.oneshot(name, fut)for a task that may finish (starting the data plane, port mapping, the control socket and metrics endpoint, whose errors are logged but don't stop the overlay). Only a panic there is fatal.- At shutdown
abort_allstops every task before the agent persists its state and removes routes and DNS settings.
Per-connection and per-request tasks stay plain tokio::spawn; a panic
ends only that connection. Every panic is logged through tracing (the panic
hook from tasks::install_panic_hook, installed by logging::init).
Every local user can talk to the agent's control socket (control/), so
every command is listed in control::COMMANDS with its access class: Read
(shows state; changes nothing), Signed (only acts on material whose
account, admin, root or emergency signature is checked) or Capability
(needs a secret, the enrollment token). Requests are one typed enum,
control::Request (control/request.rs), which webjetd parses and
webjetctl, webjet-app and webjetctl local enroll send through
control::client::call; its wire form is the plain JSON line {"cmd": "...", <arguments>} (binary arguments base64, node ids hex), so agents and
clients of other versions still understand each other, and a new argument
must be optional. handle refuses commands that aren't in the table, then
parses the arguments and hands the Request to control/commands.rs (one
function per command). Unit tests check that the table and the enum name the
same commands, that the enum writes the same JSON as before, and that only
the known state-changing commands are not Read. A new command needs a
variant and an entry; a new state-changing one also has to be added to that
test, together with its signature check. Requests are limited to 4 MiB per
line and 64 connections at once. log and snapshot take admin-signed
requests (controller::audit); a snapshot reply holds the PSKs and the
camouflage key, so compression::secret_rpc keeps it uncompressed like Raft
traffic.
The signer (controller/signer.rs) listens with ipc::Access::Private, or
Group (mode 0660) with --group-access, which the systemd unit from
webjetup install --signer uses: the signer runs as webjet-signer in the
agent's group, so the agent can sign but never read the key. serve
refuses Everyone.
cargo test --workspace --exclude webjet-app runs everything below in a
few seconds; nothing needs root, a network or Docker.
- A real network state in memory.
webjet_core::testutil(built for this crate's tests, and for other crates with thetestutilfeature, as webjetd's dev-dependencies do) hasnet()(root, emergency and admin keys and a genesis) andCluster: a controller state machine you enroll nodes into (data_node,enroll(NodeSpec { .. })) and configure (set_network,set_policy,set_node_config), which hands out stores that trust its tree (store(),sync()).testutil::packetsbuilds checksum-correct IPv4 packets. - An agent without sockets. In webjetd,
testnode::node(&mut cluster, id, key)gives aNodeover such a store with a temporary data directory.Node::test_hop(peer, transport)registers a hop with no connection behind it; what the agent sends on it is read from the returned receivers (control()decodes the messages,relay()gives the relay frames).Dataplane::for_test(&node)is a data plane without a tun interface whose tun-bound packets arrive on a channel. The circuit, data plane, reverse proxy, path, metrics, compression, state and controller modules are tested this way. - Property tests (proptest) compare code with a simple model of what it
should do: policy evaluation and port sets, path search (against brute
force over every path), the replay window, frames, packet rewriting and
ICMP errors, connection tracking counters and eviction order, and the
compressors. A failure shrinks to a small case and is saved in
crates/webjet-core/proptest-regressions/; commit that file, so the case runs first from then on, and add a named regression test. - The offline root commands run as the real
webjetctlbinary incrates/webjet-tools/tests/root.rs. - Coverage:
cargo llvm-cov --workspace --exclude webjet-app --summary-only(cargo install cargo-llvm-cov,rustup component add llvm-tools-preview).
| Script | Needs | What it checks |
|---|---|---|
scripts/local-net.sh [N] |
nothing (no root) | A full network on 127.0.0.1 by operations guide 2.1, tun disabled. scripts/local-net.sh stop ends it. |
scripts/cluster-test.sh |
nothing | Three controllers, leader failover, writes still commit. |
scripts/soak-test.sh [s] |
nothing | Runs the local network past the e2e re-key interval (120 s) and checks every agent still answers. Catches timer-driven deadlocks. |
cargo test -p webjet-app --test live |
scripts/local-net.sh 2 |
The app's sign-in and operations against a live network (admin vs. normal account), and the data behind its tabs. Skipped without a network. |
docker/e2e.sh |
Docker, image webjet:test |
Real tun interfaces: kernel ping, magic DNS, policy, subnet gateway. |
docker/realip-test.sh |
Docker, image webjet:test |
Reverse proxy real client addresses (architecture §11.11): a target without accept_real_ip sees the ingress, then turns it on and restarts its agent; new TCP/UDP flows and flows that kept running (UDP moves, TCP is reset and reconnects) get the client's address; turned off again, back to the ingress. WAN_PREFIX=10.88 if 172.30.0.0/24 is taken. |
docker/multicast-test.sh |
Docker, image webjet:test |
Broadcast and multicast on five nodes (architecture §9.5.1): Bedrock-style discovery pings with unicast pongs, the pool broadcast, IGMP membership, the echo guard, the per-node budget and its alarm, turning it off per node. docker/mcast.py sends and counts datagrams. |
docker/capture-test.sh |
Docker, image webjet:test; Go (or the golang image) builds docker/raknet-lan |
Capture (architecture §9.5.2) on four nodes: Bedrock clients broadcasting from 0.0.0.0 out of eth0, as the real game does. No world found without capture; with [multicast] capture every world found within seconds and RakNet play works; a node's capture = [] override; a datagram sent both ways carried once. About 3 minutes. |
docker/mdns-test.sh |
Docker, image webjet:test (adds Avahi, nss-mdns and D-Bus as webjet:mdns) |
mDNS/DNS-SD on five nodes with Avahi on the tun interface, IPv4 and IPv6 pools: service browsing, .local names, ping and connect, policy keeping a node out, a node that hasn't joined 224.0.0.251 receiving nothing, goodbyes, steady-state bandwidth. About 3 minutes. |
docker/raknet-storm-test.sh |
Docker, image webjet:test; Go (or the golang image) builds docker/raknet-lan |
Minecraft Bedrock LAN discovery on five nodes with real RakNet (go-raknet): every world seen, 20 RakNet connections, and whether it storms: aggressive clients, naive LAN bridges rebroadcasting every ping (exact and changed). Measures wire bandwidth against an idle baseline. About 5 minutes. |
docker/nat-test.sh |
Docker, image webjet:test |
Hole punching through two NAT routers (MODE=symmetric for a symmetric NAT), a public node without NAT, a fake wg0 VPN that must not be used as a direct path, and loopback pings. |
docker/load-test.sh |
Docker, image webjet:test |
Path stability under load: two data nodes behind shaped access links (netem), two relays. A bulk transfer must keep its circuit (at most one path switch), the busy circuit's probes pause, the idle one keeps being probed at the backup rate (§11.2, §11.4). SECS=, RATE=, DELAY= adjust it. |
docker/deploy-test.sh |
Docker | The deployment image as documented in docs/docker.md. |
docker/tpm-test.sh |
Docker | TPM key sealing against a software TPM (swtpm), refusal by another TPM. |
docker/windows-test.sh |
Docker, cargo-xwin, LLVM |
Windows unit tests under Wine. |
Notes:
local-net.shuses fixed passphrases from the environment (WEBJET_ROOT_PASSPHRASE,WEBJET_ACCOUNT_PASSPHRASE) and keys in/tmp/webjet-local/keys. Query a node withtarget/debug/webjetctl --socket /tmp/webjet-local/node-02/control.sock local status.- macOS has no
timeout; useperl -e 'alarm shift; exec @ARGV' 5 <cmd>. docker/e2e.sh,docker/realip-test.sh,docker/multicast-test.sh,docker/nat-test.shanddocker/load-test.shuse thetestimage:docker build --target test -t webjet:test .- When the host already uses the test subnets, move them:
WAN_PREFIX=10.88(first two octets) fore2e.sh,realip-test.sh,multicast-test.sh,capture-test.sh,mdns-test.shandraknet-storm-test.sh;INET_PREFIX=10.89.0fornat-test.sh;NET_PREFIX=10.89.0fordeploy-test.sh(first three octets). docker/tpm-test.shsetsTPM2TOOLS_TCTIto the software TPM and runs the TPM unit test withWEBJET_TEST_TPM=1. Without a kernel resource manager, every TPM operation must flush transient objects or the TPM runs out of slots after a few operations; the keystore does this.- Windows: install with
cargo install cargo-xwinandbrew install llvm(forclang-cl/lld-link).windows-test.shruns each test in its own Wine process. On Apple Silicon, Wine runs under x86 emulation, which sometimes crashes the emulator ("rosetta error"); those tests are counted as EMU, not as failures. Wintun, NRPT and the service manager need a real Windows machine. - Shell scripts that create a file and run it in the same step:
chmod +xfirst, or start them withsh script.sh.
e2e::tests::data_path_cost (ignored by default) times one 1200-byte
packet through an established session on a relayed circuit: send (seal,
circuit and hop choice, relay header, hop record) and on_frame (open,
replay window, data plane). Run it in release before and after touching
the hot path:
cargo test --release -p webjetd --lib e2e::tests::data_path_cost -- --ignored --nocaptureWEBJET_BENCH_ROUNDS sets the packet count (default 200 000); with a large
count, sample <pid> (macOS) or perf record shows where the time goes.
On an M-series Mac it measured about 2.8/2.5 µs per packet (send/receive)
with RustCrypto's ChaCha20-Poly1305 and 1.5/1.3 µs after frames::SessionKey
moved to aws-lc's (the same library rustls uses); the AEAD is now most of
the remaining cost. The KCP gate (webjet_net::gate) still uses RustCrypto's
XChaCha20-Poly1305, which aws-lc-rs doesn't offer.
What a node sends and receives only to measure links and circuits: hop
keepalives (§11.2: from the dialer only, every 2 s for 30 s then every 5 s,
each answered) and end-to-end probes (§11.4: from the circuit's source only,
1 s until measured, then 5 s on the circuit in use and 15 s on a backup;
only while the session was used in the last minute). Unit tests check the
cadence with the agent's own tasks: probe_traffic::keepalives_per_hop,
probe_traffic::probe_record_sizes and
e2e::handshake_tests::probe_traffic_on_an_idle_circuit.
probe_traffic::wire::wire_cost (ignored by default) runs two agents (edge
and relay: hop writer/reader/keepalive, path loop, session timers) over a
real UDP+KCP+FEC+TLS and TCP+TLS hop on loopback through a counting proxy
and prints bytes and packets per second:
cargo test -p webjetd --lib probe_traffic -- --ignored --nocaptureWEBJET_PROBE_SECS sets the measured window (default 30 s). An idle KCP
hop measured ≈ 2.5 datagrams/s and ≈ 480 B/s each way (with IP/UDP
headers) before 0.10.2, ≈ 0.4–0.7 datagrams/s and ≈ 130–150 B/s after.
The root Dockerfile has a runtime target (default, the deployment image)
and a test target (adds ping, dig, nc and the test scripts). Build
caches are keyed by platform. docker/entrypoint.sh turns WEBJET_*
variables into webjetd.toml and handles first-start enrollment; it writes
values into TOML strings, so it rejects values containing quotes,
backslashes or newlines. Everything an operator needs is in docs/docker.md.
.forgejo/workflows/ runs on the project's Forgejo runner (linux/amd64,
label ubuntu-latest), with every job in the builder image
forgejo.wolf109909.top/webj8-internal/webjet-builder:5:
| Workflow | When | What |
|---|---|---|
ci.yml |
push to main, pull requests |
cargo fmt --check; clippy with -D warnings for Linux (with and without AnyTLS), Windows, FreeBSD and macOS (arm64); scripts/deny.sh; cargo test; the fuzz targets' smoke test and clippy. webjet-app is checked for every target; macOS and Windows cross-build through the patched gpui in vendor/ (vendor/README.md). |
fuzz.yml |
manually only (Run workflow) | Installs nightly Rust and cargo-fuzz, builds the targets, then runs scripts/fuzz.sh for 30 minutes in total, split evenly over the targets (input minutes); findings are uploaded as an artifact. |
release.yml |
a pushed v* tag |
tests, then scripts/release.sh <tag> --publish for every target: Linux x86_64 natively; Linux arm64, Windows x86_64, FreeBSD x86_64 and macOS arm64 and x86_64 cross-compiled |
Every job of ci.yml and release.yml first runs scripts/cargo-mirror.sh:
the runners are in China, where crates.io is slow. It tries one
cargo fetch --locked from each source in turn and keeps the first that
has every crate in Cargo.lock: rsproxy.cn (index
and crates both served in China; a cold fetch takes ~35 s on the runner,
also with four jobs at once), then the sparse index of TUNA (a
MirrorZ member; the
MirrorZ endpoint mirrors.cernet.edu.cn itself doesn't answer from the
runner), then USTC, then crates.io. TUNA mirrors only the index and syncs
it periodically, so for a few hours after a cargo update it may lack the
newest versions, and its crates download from static.crates.io (a cold
fetch took 138 s in October 2026, beyond the 60 s limit below, which
failed every job of v0.12.2's first runs). USTC proxies crates.io live but
rate-limits (HTTP 429) or stops answering the runner, which is why it
isn't earlier. Cargo retries each request up to 10 times (net.retry), and when no source works the script goes through the list again, up to three times a minute apart: the jobs of a run start together from one address. A source that doesn't answer its config.json within 10 s is skipped (USTC sometimes stops answering the runner instead of sending a 429, and cargo would wait on it for minutes), and a fetch is stopped after 60 s (FETCH_TIMEOUT); what it downloaded stays, so the next attempt continues. The
choice is a source replacement in $CARGO_HOME/config.toml, so the later
steps use it and the crates are already downloaded. Cargo.lock still
names crates.io and cargo checks every crate against its checksum there,
so --locked and cargo-deny's source check work as before. Other sources:
scripts/cargo-mirror.sh <URL>... (crates.io for none). The runner's job
environment (runner-config.yml) lists the mirrors in NO_PROXY.
fuzz.yml doesn't use the mirror.
The builder image is Debian 11 (glibc 2.31, so the Linux binaries run on
Debian 11+, Ubuntu 20.04+, RHEL 9+) with Rust, the aarch64 cross toolchain,
LLVM 20 (libclang for bindgen, clang-cl/lld-link for Windows, Clang and
lld for FreeBSD and macOS), NASM, CMake 3.31, cargo-xwin with the Microsoft
CRT and SDK already downloaded, the FreeBSD target with a FreeBSD 14.4
sysroot (/opt/freebsd-sysroot), webjet-app's X11 libraries (libxkbcommon,
libxkbcommon-x11, libxcb) for Linux amd64 and arm64 and in the FreeBSD
sysroot, the
macOS targets with a macOS SDK (/opt/macos-sdk/MacOSX.sdk), and Node.js
for actions/checkout. Building it accepts Microsoft's license for the CRT
and SDK, as cargo-xwin does on first use.
The image is built from its own repository,
webj8-internal/webjet-builder
(~/Projects/webjet-builder on the Mac), whose README covers building,
pushing and the macOS SDK choice. It holds Apple's macOS SDK, which
Apple's license allows using on Apple computers only and forbids
redistributing, so the repository and its image live in the private
organization webj8-internal, never under the public webj8.
Packages of a private organization can only be read by its members, so
every job's container: logs in with the repository secrets BUILDER_USER
(a member of webj8-internal) and BUILDER_TOKEN (that user's token with
read:package). Pull requests from forks get no secrets, so their CI can't
pull the image.
The runner pulls job images on every run (force_pull), so the image must be
in the registry before a workflow names it. To change it: edit and push it
from webjet-builder with a new tag (./build.sh --push), then bump the
tag in ci.yml, release.yml and fuzz.yml here.
scripts/freebsd-sysroot.sh exists in both repositories (the image runs
its copy): keep them the same (FREEBSD_VERSION, BASE_SHA256 and the
pinned packages in PKGS). Besides the base system it unpacks the FreeBSD
packages webjet-app links against (libxkbcommon, libxcb and their
dependencies) from the release's frozen package set (release_4 for 14.4),
each checked against its SHA-256 from the repository catalogue
(data.pkg); GNU tar needs zstd to unpack them.
RELEASE_TOKEN (repository secret, write:repository) is optional; without
it, release.yml publishes with the workflow's automatic token.
- Set the version in the workspace
Cargo.toml(version = "X.Y.Z"), update the version in the install commands inREADME.md,scripts/install.sh,scripts/install.ps1anddocs/webjet-operations-guide.md, commit, and tag the commit:git tag -a vX.Y.Zwith release notes as the message (they become the release's description). - Push the tag:
git push origin vX.Y.Z.release.ymltests, builds Linux x86_64 and arm64, Windows, FreeBSD and macOS arm64 and x86_64, packageswebjet-<tag>-<target>.tar.gzwithwebjetd,webjetctland the desktop appwebjet-app(Windows:.zip, also withwintun.dlland its license), thewebjetupbinaries,install.shandinstall.ps1, each with a.sha256, and creates the Forgejo release. - To rebuild one target and replace its files, from a checkout of the tag:
WEBJET_FORGEJO_TOKEN=… scripts/release.sh vX.Y.Z --publish \ --targets "aarch64-apple-darwin x86_64-apple-darwin"--targetsuploads into the existing release; files with the same name are replaced. - Check the published release the way hosts install it:
docker/release-test.sh vX.Y.Zrunsinstall.shandwebjetupin fresh Debian 11, Ubuntu 20.04 and Debian 12 containers on amd64 and arm64, andRELEASE=vX.Y.Z docker/runbook-test.shruns most of the operations guide (its "Testing this guide" section lists which sections) on systemd hosts installed from it.
scripts/release.sh without --targets still builds everything on a Mac
(Linux in Docker with docker/release.Dockerfile, Windows with cargo-xwin,
FreeBSD with scripts/freebsd-cargo.sh, macOS natively),
and without --publish it only builds and packages into dist/<tag>.
Before tagging, docker/webjetup-test.sh tests webjetup and install.sh
from the working tree end to end: it builds the Linux release files, serves
them from a stand-in for the Forgejo release pages, starts a controller, and
on a container that boots systemd runs curl … install.sh | sh with an
enrollment string, checks the agent runs as webjet with its capabilities
and pings the controller, then reinstall (an outdated unit is replaced,
also by the upgrade helper), remove (identity kept), install again, remote
upgrades (the release's webjetup sets the node up), a hand-over from an
older webjetup, and nuke. install.ps1 has no automated test. docker/runbook-test.sh without RELEASE does the same for the
operations guide.
Building Windows on a Mac needs cargo install cargo-xwin and Homebrew's llvm and
lld. Without Homebrew's lld-link on PATH, cargo-xwin links rustup's
rust-lld into its cache, which can't run on its own (it misses
libLLVM.dylib), and CMake-built dependencies (BoringSSL, aws-lc) fail their
compiler check. If that happened once, delete ~/Library/Caches/cargo-xwin/lld-link
and the boring-sys-*/aws-lc-sys-* directories under
target/x86_64-pc-windows-msvc/release/build, since CMake keeps the linker
path in its cache. Wintun's version and checksum are pinned in
scripts/release.sh; its license allows shipping wintun.dll with software
that uses it through its API, with the license file alongside.
The Linux build uses Debian 11 so the binaries need only glibc 2.31. Debian
11 is archived, which docker/release.Dockerfile works around: CMake comes
from Kitware (BoringSSL needs 3.22+), and on amd64 empty stand-in packages
replace the 32-bit multilib libraries libclang depends on, because the
archive no longer serves the matching glibc security update.
webjetd/src/platform.rs does routes, split DNS and gateway NAT per OS by
running the system tools (ip, route, resolvectl, netsh, PowerShell
for NRPT, iptables). ipc.rs abstracts the control channel (Unix socket or
named pipe), winsec.rs builds Windows ACLs, keystore.rs the key storage
backends, service.rs the Windows service and signal handling.
Unix paths must also work on macOS, whose root volume is sealed read-only:
there is no /run and nothing can create it. That's why the default sockets
are under /var/run (ipc::default_control/default_signer, on Linux a
symlink to /run). webjetctl and webjet-app take their default from
ipc::default_control rather than spelling out a path. webjetd logs to stdout,
so the launchd plist that webjetup writes sends both stdout and stderr to
/var/log/webjetd.log.