addresses two issues in the WebRTC same-LAN test:
1. "Cannot set remote answer in state stable" on retry
the offerer's pc enters stable after the first successful accept-
answer; subsequent clicks fail. add a reset-connection button that
close()s the current pc, clears all SDP textareas + status lines,
detaches inbound rx decoders, & restores the disconnected UI.
user can now retry without reloading the page.
2. silent NAT/mDNS diagnostic gap
browsers anonymize host IPs as <uuid>.local mDNS names. routers
with multicast-blocking client isolation (common on guest Wi-Fi)
silently prevent host-candidate pairing on the same LAN. add:
* a details/summary section under the rtc dot documenting the
Firefox + Chromium config knobs to disable host obfuscation;
* pc.onicecandidate logging that prints candidate type
(host/srflx/relay) + an explicit '(mdns-anonymized)' tag
for .local foundations, so the user can see WHAT is being
gathered without opening devtools;
* an err logLine on connectionState=failed pointing at the
reset button.
unchanged: protocol, frame codec, crypto, audio path, signaling
flow. UI surface gains one button + one collapsed details panel.
major architectural rework. previously chat.html modulated its own
GainNode and depended on a native zebrad daemon polling local PA
to close the receive loop. that worked single-machine multi-tab
but had no cross-laptop path without bridging external audio (voice
call, virtual sink, etc.).
new architecture: each peer's modulated audio is the outbound track
of a WebRTC peer connection. each peer decodes the inbound RTC
track in-page via an AudioWorklet energy detector + the same UART
state machine zebrad uses. cross-laptop works because WebRTC carries
the modulated audio between machines. native daemon not required.
outbound: oscillators (440/441 Hz) → modulation gain (MARK/SPACE)
→ channel merger → MediaStreamDestination → RTCPeer-
Connection.addTrack(). also → optional local monitor
gain (muted by default; toggleable for debugging).
inbound: RTCPeerConnection.ontrack → MediaStreamSource
→ AudioWorklet (per-quantum peak detection, 128 samples)
→ main thread postMessage → UartDecoder → FrameAssembler
→ onRxFrame (same handler as before).
signaling: v1 uses manual SDP paste via two textareas. role A
("start a new connection") creates an offer, waits for
gathered ICE, exposes JSON-serialized localDescription
for copy. role B ("join") pastes that, generates an
answer, gets pasted back to A. STUN: stun.l.google.com.
v2 will add a tiny signaling server for auto-pair.
threat: SRTP carries the modulated audio. Wireshark sees only
encrypted RTP packets; chat content lives in audio
amplitude transitions inside the encrypted payload.
additional app-layer AES-GCM (passphrase mode PBKDF2,
pubkey mode ECDH P-256) preserved unchanged.
zebrad daemon (src/zebrad.c) stays in the repo as the V2 disclosure
demo (a same-UID host process polling PulseAudio sink-input volumes
of an arbitrary same-UID tab) but is no longer required for chat.
preserved unchanged from prior chat.html:
* crypto modes (passphrase + pubkey), mode toggle
* sender id (random per-tab in passphrase mode; SHA-256(pubkey)[0:4] in pubkey mode)
* self-echo filter via sid match
* frame codec (OFFER/READY at 50 baud, DATA/HELLO with CRC-32)
* benchmark + handshake state machine
* peer list, chat log
* threat model + mitigation panel
* BroadcastChannel dev loopback via ?loopback=1
file size: 1258 lines, ~46 KB. JS syntax-clean (node --check).
HTML balanced.
zebrad reads @DEFAULT_MONITOR@ which mixes every PA stream on
a machine, so each browser tab hears its own carrier echoed
back. without a filter, every outbound message would render
twice in the chat log (once as 'me' at send time, once as
'peer' on echo arrival).
filter: compare frame.sid (carried in DATA + HELLO) against
our own senderIdBytes(). if equal, drop frame before any UI
work. loopback mode unaffected since BroadcastChannel never
echoes to sender by spec.
senderIdBytes returns a stable per-tab id:
* passphrase mode: random 4 bytes cached for tab lifetime
* pubkey mode: first 4 of SHA-256(pubkey), stable across reloads
single C file (~620 LOC), no third-party deps beyond libpulse.
closes a receive loop for web/chat.html: a browser tab cannot read
another tab's PulseAudio state, so each peer runs zebrad locally
to forward decoded frames to ws://127.0.0.1:7777.
components inline:
* SHA-1 (RFC 3174) + base64 encode — for the WebSocket handshake;
verified against the canonical RFC 6455 example
(key dGhlIHNhbXBsZSBub25jZQ== → accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)
* CRC-32 (IEEE 802.3) — verifies DATA + HELLO frame payloads
* UART symbol decoder with adaptive threshold:
running peak with 2-second half-life decay, threshold = 50% of peak,
falling-edge start detect, sample at 1.5..8.5 × sps from edge
* frame assembler: magic-sync ZB, type dispatch OFFER/READY/DATA/HELLO,
overflow + corrupt-length guards, resync on bad checksum
* embedded WebSocket server: TCP listener on 127.0.0.1:7777,
handshake responder, binary frames only (opcode 0x82), no masking
(server→client), any inbound data closes the client (browser
auto-reconnects); max 16 concurrent clients
* pulseaudio: pa_mainloop integration, record stream on @DEFAULT_MONITOR@
by default, PA_SAMPLE_FLOAT32LE mono @ 8 kHz, 10 ms fragsize, peak
energy extracted from |abs(sample)|
adaptive baud: starts at ZEBRA_BAUD_HANDSHAKE (50). On valid READY frame
locks in negotiated baud from payload. Matches the chat.html send path.
binds 127.0.0.1 only — never reachable from the LAN, even though the
chat.html page itself may be served over HTTPS from a public domain.
smoke tested: PA connect + stream ready + WS handshake (RFC example
verified) + clean shutdown. -Wall -Wextra clean.
Makefile: zebrad now part of `make all`; clean target updated.
README: new section documenting it.
without zebrad running, the page is TX-only: tabs see their own
outbound frames but nothing crosses. for UX validation while the
introspector daemon doesn't exist yet, gate a BroadcastChannel
fallback on the ?loopback=1 query param.
behavior:
* with ?loopback=1: after each txFrame completes its modulation,
the raw frame bytes are postMessage'd on a same-origin
BroadcastChannel('zebra-report-loopback'). other tabs of the
same browser subscribe & feed received bytes into onRxFrame
exactly as zebrad would. BroadcastChannel suppresses echo to
sender by spec, so the originating tab does not see itself.
* without ?loopback=1: behavior unchanged. real PA transport
stays the production path.
a yellow banner at the top of the page warns when loopback is
active: "frames cross via BroadcastChannel between same-browser
tabs, NOT via PulseAudio". no risk of mistaking dev mode for
production.
single self-contained page implementing the recovered zebra-report
protocol as a peer-to-peer chatroom whose data path is PulseAudio
sink-input state — chat content never enters an IP packet.
architecture:
page (TX) modulates audio output via Web Audio GainNode
between MARK (0.80) and SPACE (0.20). silent stereo
carrier 440/441 Hz published to PulseAudio.
zebrad separate program (companion, not in this commit):
polls PA sink-input volumes, decodes frames,
forwards to ws://127.0.0.1:7777 — the page connects
and renders received messages.
RX path the page is TX-only without zebrad running; with
it, full bidirectional chat.
crypto modes (toggle in UI):
passphrase PBKDF2-SHA256 (600k iter) → AES-GCM-256 group key.
everyone with the same phrase joins the room.
pubkey ECDH P-256 keypair per browser → AES-GCM-256
pairwise. peers added by pasting their pubkey.
both modes use Web Crypto API. no external crypto deps.
frame protocol (matches include/zebra.h):
OFFER magic(ZB) + 0x01 + baud_le(2) + xor(1) — 6B
READY magic(ZB) + 0x02 + baud_le(2) + xor(1) — 6B
HELLO magic + 0x04 + sid(4) + maxBaud_le(2)
+ handleLen(1) + handle(n) + crc32_le(4)
DATA magic + 0x03 + sid(4) + len_le(2)
+ payload(n) + crc32_le(4)
handshake:
1. user clicks "benchmark self" — page schedules 200 gain
events at 0.5ms intervals, measures avg, divides by 2 for
safety, clamps to [ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX].
2. user clicks "send OFFER" — sends OFFER frame at the
fixed handshake baud (50). also broadcasts HELLO.
3. peer's introspector decodes; their page replies with READY
at their max baud, plus their HELLO.
4. negotiated baud = min(my max, every peer's max).
5. data frames at negotiated baud.
UI sections:
identity handle + mode toggle
room passphrase OR pubkey, depending on mode
carrier start audio, live gain meter
handshake benchmark, OFFER button, baud display
receive ws://127.0.0.1:7777 status + connect
chat peer list, log, message box
about threat model, mitigation, whitepaper link
defensive framing prominent: the protocol is documented as a
research artifact demonstrating PA-IPC's open same-UID volume
read access. "what this does not protect against" enumerated.
mitigation panel cites foxhop.net/linux-audio-ipc-attack-surface.
known v1 tradeoffs:
- no CSMA: simultaneous TX from two peers will collide.
- pubkey mode broadcasts one frame per peer per message.
- GainNode scheduling has OS-level jitter — actual sustained
baud will be lower than the optimistic benchmark.
- base carrier is audible at 440/441 Hz; can be lowered or
moved ultrasonic in a follow-up.
reuses visual identity from web/index.html (chunkfive font,
monospace, black/white, no framework, no build step).
file: 1062 lines, 40 KB, JS syntax-clean (node --check), HTML
balanced.
aligned with `make clean` — ignores compiled binaries (tx/rx/chat/bt/
carrier, test/{unit,integration,functional}), object files, and the
generated blog output under web/blog/.
recovered code verified at this commit:
make all : 5 binaries built, no -Wall/-Wextra warnings
test/unit : 84/84 passed
test/integration : 17/17 passed (benchmark: 836 baud measured)
test/functional : 8/10 passed
- t_loopback_fixed_baud (50 baud) ok — "ZEBRA" tx->rx round-trip
- t_autoneg_pipeline FAIL: autoneg handshake succeeds; data
transmission at benchmarked baud (1994) times out. cause is
environmental — RX polling under loadavg 2.0 can't sustain
sub-millisecond symbol timing on a non-RT kernel. not a defect
in the recovered code; same path passes at fixed 50 baud.
quality verification:
- confirmed all 8 unapplied edits in phase 1 also failed in their
original sessions (tool-result is_error=1, "String to replace
not found"). Our reconstruction is faithful to live execution.
gaps closed:
- include/modem.h: re-ran replay with cat>>heredoc handling in
chronological position. BATTLE TOADS dual-channel stereo UART
block (4495 bytes, 102 lines) now appended at correct point in
timeline. Two previously-failing edits now apply against the
post-append baseline. unapplied edits dropped 8 -> 6.
- web/fonts/: chunkfive-regular-webfont.{woff,woff2} restored
from live source /home/fox/git/www.unturf.com/css/chunkfive/.
HTML+CSS @font-face references now resolve.
remaining unapplied edits (6) confirmed legitimate live-session
failures, not reconstruction artifacts.
internal references audit:
- all #include directives resolve within recovered tree
- all font url() references resolve to recovered web/fonts/
- no other Bash file-creation ops target zebra-report
final tree: 22 files, ~160KB.