eBPF packet path
Rose ships ttl_editor.o, a Linux eBPF classifier compiled from src/ebpf/ttl_editor.c. It runs at the traffic control (TC) egress hook on attached WSL2 interfaces. STATIC makes the upstream connection; the kernel then gives the classifier a chance to change selected fields of an outgoing packet. This is a separate part of the stack from STATIC’s HTTP, TLS, and JavaScript shaping.
Where the classifier sits
Windows browser
│ local proxy request
▼
STATIC inside Rose ── creates upstream TCP/TLS connection
│
▼ outgoing Linux packet
WSL2 eth* → TC clsact / egress → ttl_editor.o → WSL networking → route → website
▲ │
└── fingerprint_profiles[0] ← STATIC's active profile
The classifier sees egress packets on interfaces where it is attached. It does not change Windows browser packets before they reach STATIC, choose the exit route, or guarantee that a value will survive WSL/host networking and later hops. A website may see a lower TTL after intervening routers. Capture traffic on the actual path when you need to know what is visible outside the distro.
Attachment during startup
The fallback launcher mounts bpffs at /sys/fs/bpf, discovers active eth* interfaces (or uses EGRESS_IFACES), adds a clsact qdisc, and attaches the egress classifier with tc. It attempts to load and pin the program with bpftool, with an object-file attach path as a fallback. It then pins the map at /sys/fs/bpf/404/fingerprint_profiles and starts STATIC. The desktop path is documented as orchestrating its own startup; its app implementation could not be verified in this revision.
The attach and pin steps are best effort in the shell script. STATIC can be listening even if the classifier missed the routed interface or map synchronization failed. Rose includes bpftool and iproute2; the executable and object file are separate release artifacts. See Distribution anatomy and Build and release.
From JSON profile to kernel map
ebpf.rs converts the active, materialized profile into a C-compatible PacketProfile. It picks a platform default from profile_identity.platform (with a fingerprint OS fallback), then applies optional packet_profile overrides. Profile selection through the local API attempts another sync. On Linux the Rust process opens the pinned map and writes the profile to key 0 using BPF_MAP_UPDATE_ELEM.
profile JSON → materialized persona → PacketProfile
│
▼
/sys/fs/bpf/404/fingerprint_profiles
│ key 0
▼
TC classifier reads profile per packet
The map has one entry, so the packet program uses one active profile on that classifier, not a separate profile per browser connection. A failed sync logs a warning and does not stop the proxy. The C program contains a compiled fallback profile if map lookup fails, but a pinned array map can have a key 0 even before it has been populated with the intended values. Do not assume the compiled fallback is the active persona; inspect the map.
Packet fields and scope
| Field | What the classifier does | When |
|---|---|---|
| IPv4 | Rewrites ToS and TTL; optionally randomizes IP ID when fragment offset is zero. | Outgoing IPv4 packets on the attached interface. |
| IPv6 | Sets traffic class and hop limit; can randomize the flow label. | Outgoing IPv6 packets with a directly readable base header. |
| TCP window | Replaces the advertised window value and updates its checksum. | Outgoing TCP packets it can parse, including non-SYN packets. |
| TCP options | Reorders and fills selected MSS, window-scale, SACK, and timestamp options; may resize an option block. | Initial SYN without ACK with an existing option block and valid bounds; other packets keep their option layout. |
| Protocol counter | Increments the indexed counter for recognized IP next-protocol values. | Matching outgoing IPv4/IPv6 frames, whether or not a later rewrite succeeds. |
This is Linux egress, not a general TCP implementation. The parser expects an Ethernet frame followed by IPv4 or a basic IPv6 header. The IPv6 TCP path checks the base header’s immediate nexthdr, so extension-header chains are not walked. Non-TCP traffic can receive IP-level edits but not TCP-specific edits. The classifier returns TC_ACT_OK on ordinary passes and many early exits; a counter or an attached filter does not prove every change was applied.
Reading a SYN on the wire
Here is the relationship between fields, not a literal capture or a promise about exact values:
Ethernet | IPv4: ToS · ID · TTL · checksum | TCP: ports · flags=SYN · window · checksum
| | data offset → [MSS][NOP][WS][SACK]…
↑ ordered by packet_profile.options
Ethernet | IPv6: traffic class · flow label · hop limit | TCP: flags=SYN · window
| | data offset → options…
The options array in a profile names tokens in order: mss, nop, window_scale, sack_permitted, timestamp, and eol. Rust encodes them, records offsets for variable values, pads to a four-byte boundary, and rejects layouts exceeding TCP’s 40-byte options budget. Timestamp randomization requires a timestamp option. The classifier can change header length for a payload-free SYN, update the IP length and TCP pseudo-header length, and recompute relevant checksums. It skips option rewriting for a SYN with no existing options or malformed/unsupported lengths. Other fields may already have been edited on such a packet.
The Rust platform defaults include Windows TTL 128 and window 64240, macOS TTL 64 and window 65535, and Linux TTL 64 and window 65535; explicit profile settings can override them. Read Profile anatomy for how a persona is materialized. A chosen packet target does not itself establish on-wire parity.
Packet defaults and override types
These are STATIC’s built-in platform targets, traced to ebpf.rs, not a universal measurement of native operating systems. Explicit JSON and seeded overlays can change them before map synchronization.
| Platform target | TTL / hop limit | ToS / traffic class | TCP window | MSS | Scale | Randomize timestamps | Initial SYN option tokens |
|---|---|---|---|---|---|---|---|
| Windows / unknown fallback | 128 | 0x10 | 64240 | 1460 | 8 | false (no timestamp token) | MSS, NOP, WS, NOP, NOP, SACK |
| macOS | 64 | 0x10 | 65535 | 1460 | 6 | true | MSS, NOP, WS, NOP, NOP, TS, SACK, EOL |
| Linux / Android | 64 | 0x10 | 65535 | 1460 | 7 | true | MSS, SACK, TS, NOP, WS |
All three defaults enable IPv4-ID and IPv6-flow-label randomization. Platform lookup prefers profile_identity.platform, then fingerprint.os, and normalizes platform aliases; missing/unknown platform uses Windows targets. The classifier’s compiled fallback also targets Windows-like values, but map lookup/fill state must be inspected rather than assumed.
packet_profile override | Accepted representation / constraint |
|---|---|
ttl, tos, tcp_window_scale | Unsigned integer fitting u8 (0–255). This parser bound is not a guarantee every scale value is meaningful on TCP. |
tcp_window, tcp_mss | Unsigned integer fitting u16 (0–65535). |
randomize_tcp_timestamp, randomize_ipv4_id, randomize_ipv6_flow | Boolean. Explicit timestamp randomization requires a timestamp option. |
options | Nonempty string array; tokens mss, nop, window_scale/ws, sack_permitted/sack, timestamp/timestamps/ts, eol. |
Options are encoded and padded with NOPs to a four-byte boundary within TCP’s 40-byte option budget. Without a timestamp token, the runtime disables timestamp randomization; explicitly requesting it without that token is an error. A raw JSON target is not proof that the classifier applied it or the destination observed it.
Verify each boundary
- Attach: in the distro, check the routed interface and
tc filter show dev <interface> egress. The classifier must be on the interface used by the upstream flow. - Map: inspect the pinned map and entry
0usingscripts/inspect-ebpf-state.sh. Check that it reflects the currently selected profile. - Wire: capture an outbound SYN on that interface with
tcpdump -i <interface> -nnvv -Q out 'tcp[tcpflags] & tcp-syn != 0'and compare TTL/hop limit, window, MSS, scale, timestamps, and option order with the map. Offload and capture location affect what a local capture shows; use an external capture for claims about the final destination.
Changing a profile in STATIC and seeing /status succeed only confirms part of this chain. Missing privileges for BPF_OBJ_GET or BPF_MAP_UPDATE_ELEM, a missing pin, attach failure, wrong interface, or WSL forwarding can break packet fidelity independently. The eBPF implementation notes document the helper scripts and capabilities for manual Linux testing; the STATIC packet boundary describes the userspace handoff.
Build and operate outside Rose
On a Linux development host, install clang, LLVM/llvm-strip, libbpf and kernel headers, iproute2/tc, and bpftool prerequisites described in the eBPF README. Build with make -C src/ebpf clean all. The classifier requires kernel support for its TC/eBPF helpers, including bpf_skb_change_tail and checksum helpers. Verify helper availability on the target kernel; its version alone does not prove support.
sudo tc qdisc add dev <interface> clsact
sudo tc filter add dev <interface> egress bpf da obj src/ebpf/ttl_editor.o sec classifier
sudo tc filter show dev <interface> egress
# Remove this filter and qdisc only if you own this interface's TC configuration:
sudo tc filter del dev <interface> egress
sudo tc qdisc del dev <interface> clsact
The repository helpers provide a repeatable development loop: scripts/verify-ebpf-attach.sh mounts bpffs, discovers active interfaces, attaches and pins; scripts/inspect-ebpf-state.sh prints the routed path, attached filters, decoded map key 0, and protocol counters; capture outgoing SYNs directly with sudo tcpdump -i <interface> -nnvv -Q out -c 10 'tcp[tcpflags] & tcp-syn != 0'; scripts/run-static-with-ebpf-caps.sh --profile firefox-windows builds and launches the proxy with map access (WSL uses a sudo fallback). Inspect bpftool map show pinned /sys/fs/bpf/404/fingerprint_profiles as well. The map path can be overridden for tests with STATIC_EBPF_MAP_PATH.
The classifier is Linux-only, egress-only, and focused on supported IP/TCP fields. It does not traverse arbitrary IPv6 extension chains or resize TCP options on a payload-bearing SYN. Attaching requires network privileges, and map access can still require BPF-related capabilities even after filesystem ownership changes. If testing through an extra VM, use a deliberate bridged/host-only routing and forwarding setup and inspect the actual egress interface. VM forwarding is a separate deployment choice from the managed Rose/WSL path.
Optional routed VM lab
The legacy reference also described a separate Linux gateway VM experiment: give the guest a bridged internet adapter and a host-only adapter, enable forwarding in the guest, permit forwarded packets, masquerade traffic leaving the bridged adapter, and point the host’s default route to the guest’s host-only IP. This changes routing for the entire host and needs a rollback plan; it is not how the Windows WSL2 operator bundle is installed.
# Inside the Linux gateway VM, with actual interface names substituted:
sudo sysctl -w net.ipv4.ip_forward=1
sudo sysctl -w net.ipv6.conf.all.forwarding=1
sudo iptables -A FORWARD -i <host-only-interface> -j ACCEPT
sudo iptables -A FORWARD -o <host-only-interface> -j ACCEPT
sudo ip6tables -A FORWARD -i <host-only-interface> -j ACCEPT
sudo ip6tables -A FORWARD -o <host-only-interface> -j ACCEPT
sudo iptables -t nat -A POSTROUTING -o <bridged-interface> -j MASQUERADE
sudo ip6tables -t nat -A POSTROUTING -o <bridged-interface> -j MASQUERADE
Persist forwarding only if this is an intentional gateway configuration. The host route, IPv6 NAT support, firewall policy, and TC attachment on the actual bridged egress all need separate verification. Removing the test means restoring the original host route and removing only the rules you added.