Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.
Two coupled defects:
(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.
(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.
Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.
Tests:
- handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
fresh MediaStream wrap rescues the attach (chain built, audible
path verified)
Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
535 lines
24 KiB
HTML
535 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>zebra report — host your own community</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: 'chunkfiveregular';
|
|
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
|
|
url('fonts/chunkfive-regular-webfont.woff') format('woff');
|
|
font-weight: normal; font-style: normal;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: monospace; background: #fff; color: #000;
|
|
padding: 2rem; max-width: 820px; margin: 0 auto; line-height: 1.6;
|
|
}
|
|
h1 {
|
|
font-family: 'chunkfiveregular', serif;
|
|
font-size: 3rem; font-weight: normal;
|
|
letter-spacing: 0.02em; line-height: 1; margin-bottom: 0.2rem;
|
|
}
|
|
.sub {
|
|
font-size: 0.75rem; color: #555; margin-bottom: 2.5rem;
|
|
letter-spacing: 0.05em; text-transform: uppercase;
|
|
}
|
|
.sub a { color: #555; }
|
|
h2 {
|
|
font-family: 'chunkfiveregular', serif;
|
|
font-size: 1.3rem; font-weight: normal;
|
|
border-bottom: 1px solid #000;
|
|
padding-bottom: 0.3rem; margin: 2.4rem 0 0.9rem;
|
|
}
|
|
p { margin-bottom: 0.9rem; }
|
|
ul, ol { margin: 0 0 0.9rem 1.4rem; }
|
|
li { margin-bottom: 0.35rem; }
|
|
code {
|
|
background: #f0f0f0; padding: 0 0.2rem;
|
|
font-size: 0.85em; border: 1px solid #ddd;
|
|
}
|
|
.lead { font-size: 1.05rem; }
|
|
.note { font-size: 0.8rem; color: #555; }
|
|
.code {
|
|
border: 1px solid #000; background: #fafafa;
|
|
padding: 0.9rem 1rem; font-size: 0.78rem; line-height: 1.5;
|
|
overflow-x: auto; white-space: pre; margin: 1rem 0;
|
|
}
|
|
.code b { font-weight: bold; }
|
|
.diagram {
|
|
border: 1px solid #000; background: #fafafa;
|
|
padding: 1rem; font-size: 0.72rem; line-height: 1.45;
|
|
overflow-x: auto; white-space: pre; margin: 1rem 0;
|
|
}
|
|
hr { border: none; border-top: 1px solid #ddd; margin: 2.5rem 0; }
|
|
.foot { font-size: 0.75rem; color: #777; margin-top: 2rem; }
|
|
.cta {
|
|
display: inline-block; margin: 0.4rem 0;
|
|
background: #000; color: #fff; border: 1px solid #000;
|
|
padding: 0.5rem 1.2rem; text-decoration: none; font-size: 0.9rem;
|
|
}
|
|
.cta:hover { background: #333; }
|
|
@media (max-width: 600px) {
|
|
body { padding: 1.25rem; line-height: 1.55; }
|
|
h1 { font-size: 2.2rem; }
|
|
h2 { font-size: 1.1rem; margin: 1.8rem 0 0.7rem; }
|
|
.lead { font-size: 0.95rem; }
|
|
/* long URLs / hashes wrap instead of pushing the page wider */
|
|
p, li { overflow-wrap: anywhere; }
|
|
}
|
|
|
|
/* ---- shared dark mode: pure black canvas, dim text, low light ---- */
|
|
html.theme-dark { background: #000; }
|
|
html.theme-dark body { background: #000; color: #ccc; }
|
|
html.theme-dark a { color: #9cf; }
|
|
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
|
|
html.theme-dark .foot, html.theme-dark label,
|
|
html.theme-dark .status-line { color: #888; }
|
|
html.theme-dark h2 { border-bottom-color: #333; }
|
|
html.theme-dark code, html.theme-dark .key-display {
|
|
background: #1a1a1a; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .key-display:hover { background: #222; }
|
|
html.theme-dark button {
|
|
background: #000; color: #ccc; border-color: #444;
|
|
}
|
|
html.theme-dark button:hover:not(:disabled) { background: #1a1a1a; }
|
|
html.theme-dark button.invert, html.theme-dark .cta {
|
|
background: #ccc; color: #000; border-color: #ccc;
|
|
}
|
|
html.theme-dark button.invert:hover:not(:disabled),
|
|
html.theme-dark .cta:hover { background: #999; }
|
|
html.theme-dark input[type=text], html.theme-dark input[type=password],
|
|
html.theme-dark textarea, html.theme-dark select {
|
|
background: #0a0a0a; color: #ccc; border-color: #333;
|
|
}
|
|
html.theme-dark .dot { background: #000; border-color: #444; }
|
|
html.theme-dark .dot.on { background: #ccc; border-color: #ccc; }
|
|
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
|
|
html.theme-dark .meter { background: #000; border-color: #444; }
|
|
html.theme-dark .meter-fill { background: #ccc; }
|
|
html.theme-dark .status-line.ok { color: #6c6; }
|
|
html.theme-dark .status-line.err { color: #f66; }
|
|
html.theme-dark .log, html.theme-dark .diagram {
|
|
background: #050505; border-color: #333; color: #ccc;
|
|
}
|
|
html.theme-dark .log-line .ts { color: #666; }
|
|
html.theme-dark .log-line.sys { color: #888; font-style: italic; }
|
|
html.theme-dark .log-line.err { color: #f66; }
|
|
html.theme-dark hr { border-top-color: #222; }
|
|
html.theme-dark footer { color: #555 !important; }
|
|
html.theme-dark footer span { color: #555 !important; }
|
|
html.theme-dark footer a { color: #777 !important; }
|
|
.theme-toggle {
|
|
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
|
|
cursor: pointer; line-height: 1;
|
|
}
|
|
.theme-toggle:hover { background: #f0f0f0; }
|
|
html.theme-dark .theme-toggle { background: #000; color: #ccc; border-color: #444; }
|
|
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
|
|
</style>
|
|
<script>
|
|
/* shared zebra dark-mode pre-paint script — default IS dark, only an
|
|
* explicit 'light' choice opts out so first-time visitors land dark
|
|
* without a white flash */
|
|
try {
|
|
var pref = localStorage.getItem('zebra-theme-v1');
|
|
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy
|
|
if (pref !== 'light'){
|
|
document.documentElement.classList.add('theme-dark');
|
|
}
|
|
} catch(_){ document.documentElement.classList.add('theme-dark'); }
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">light</button>
|
|
|
|
|
|
<h1>host your own</h1>
|
|
<p class="sub">run your own zebra community · self-hosted TURN + rendezvous on one edge box ·
|
|
<a href="how-it-works.html">how it works</a> ·
|
|
<a href="./">open the chat</a> ·
|
|
<a href="/">unturf</a></p>
|
|
|
|
<p class="lead">
|
|
The zebra pages (the <a href="./">chat</a>, the <a href="zebra-audio.html">1:1 voice
|
|
call</a>, and the <a href="zebra-spaces.html">multi-party spaces</a>) need exactly two things from a server: a <strong>rendezvous relay</strong>
|
|
so two browsers can find each other, and a <strong>TURN server</strong> so they
|
|
can still connect when both sit behind NAT. Everything else — the crypto,
|
|
the modem, the audio — runs in the browser. This page hands you the whole
|
|
back end so you can run it for your own community on a single small box.
|
|
</p>
|
|
|
|
<p>
|
|
This is the exact infrastructure behind <code>www.unturf.com/zebra-report</code>,
|
|
written out so you can stand up your own. It is a gift — reproduce it,
|
|
fork it, harden it. Your members then point the existing pages at your servers
|
|
with two URL parameters; nothing about the client needs to change.
|
|
</p>
|
|
|
|
<a class="cta" href="#point">▶ jump to "point your members at it"</a>
|
|
|
|
<h2>1 · what you are building</h2>
|
|
<p>
|
|
One internet-facing box (a $5–$6/mo VPS is plenty for a small community)
|
|
running three daemons behind a TLS reverse proxy:
|
|
</p>
|
|
<ul>
|
|
<li><strong>coturn</strong> — the STUN/TURN server. STUN tells a browser
|
|
its public address; TURN relays the encrypted media when a direct path is
|
|
impossible. This is the part that makes calls work across mobile networks and
|
|
strict NATs.</li>
|
|
<li><strong>a rendezvous relay</strong> — a tiny WebSocket service that
|
|
pairs two browsers in a "room" and forwards their encrypted connection setup.
|
|
It never sees plaintext: the room id is an opaque hash and the setup data is
|
|
encrypted with the shared code before it ever reaches the server.</li>
|
|
<li><strong>a credential mint</strong> — a single HTTP endpoint that hands
|
|
each browser a short-lived TURN username/password, so every user draws on
|
|
their own quota instead of sharing one static login.</li>
|
|
</ul>
|
|
<p class="note">
|
|
The mint and the relay are the same small program here, but they are independent
|
|
— split them if you like. coturn is off-the-shelf.
|
|
</p>
|
|
|
|
<h2>2 · the shape of it</h2>
|
|
<div class="diagram"> browser A rendezvous (wss, encrypted SDP) browser B
|
|
┌──────────┐ ◄──────────────────────────────────────────────────────► ┌──────────┐
|
|
│ zebra │ │ zebra │
|
|
│ page │ ──┐ ┌── │ page │
|
|
└──────────┘ │ GET /turn-cred (https) → short-lived HMAC cred │ └──────────┘
|
|
▲ │ │ ▲
|
|
│ └────────────────────────┐ ┌────────────────┘ │
|
|
│ media (DTLS-SRTP, encrypted) ▼ ▼ media (DTLS-SRTP) │
|
|
│ ┌─────────────────────────────┐ │
|
|
└─────────────────────────►│ coturn TURN/STUN :3478 │◄──────────────┘
|
|
│ relay UDP 49152-50151 │
|
|
┌──────────────────────────────┴─────────────────────────────┴───────────────┐
|
|
│ your edge box Caddy (auto-TLS) │
|
|
│ wss://you/zebra-signal ─► relay :8090 │
|
|
│ https://you/turn-cred ─► mint :8090 │
|
|
└──────────────────────────────────────────────────────────────────────────────┘</div>
|
|
<p>
|
|
When the network allows it, the two browsers talk <strong>directly</strong> and
|
|
coturn never touches the media. coturn is the fallback that guarantees a
|
|
connection; the rendezvous relay is only used for the few hundred bytes of
|
|
setup, then sits idle.
|
|
</p>
|
|
|
|
<h2>3 · coturn — the TURN relay</h2>
|
|
<p>
|
|
Install it (<code>apt install coturn</code> on Debian/Ubuntu) and replace
|
|
<code>/etc/turnserver.conf</code> with this. Swap in your box's public IP and a
|
|
DNS name you control:
|
|
</p>
|
|
<div class="code"><b># /etc/turnserver.conf</b>
|
|
external-ip=<b>YOUR.PUBLIC.IP</b>
|
|
relay-ip=<b>YOUR.PUBLIC.IP</b>
|
|
listening-port=3478
|
|
realm=<b>turn.example.com</b>
|
|
|
|
<b># relay allocation range — open these UDP ports in your firewall too</b>
|
|
min-port=49152
|
|
max-port=50151
|
|
|
|
<b># time-limited credentials: the mint computes HMAC-SHA1(secret, expiry).</b>
|
|
<b># the secret is appended below at deploy and never committed.</b>
|
|
use-auth-secret
|
|
# static-auth-secret=<injected at deploy, see step 4>
|
|
|
|
<b># abuse quotas — per ephemeral user, so they stay tight as you scale</b>
|
|
total-quota=2000
|
|
user-quota=6
|
|
bps-capacity=400000000
|
|
max-bps=2000000
|
|
stale-nonce=600
|
|
|
|
fingerprint
|
|
no-cli
|
|
no-loopback-peers
|
|
no-multicast-peers
|
|
log-file=/var/log/coturn/coturn.log
|
|
simple-log</div>
|
|
<p>
|
|
Run it under systemd as an unprivileged user (all ports are above 1024, so no
|
|
special capabilities are needed):
|
|
</p>
|
|
<div class="code"><b># /etc/systemd/system/coturn.service</b>
|
|
[Unit]
|
|
Description=coturn TURN/STUN relay for WebRTC NAT traversal
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=turnserver
|
|
Group=turnserver
|
|
ExecStart=/usr/bin/turnserver -c /etc/turnserver.conf
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
LogsDirectory=coturn
|
|
LogsDirectoryMode=0750
|
|
PrivateTmp=true
|
|
ProtectSystem=full
|
|
ProtectHome=true
|
|
NoNewPrivileges=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target</div>
|
|
<p class="note">
|
|
Firewall: allow inbound <code>UDP 3478</code> (and <code>TCP 3478</code> if you
|
|
offer TCP relay) plus the whole <code>UDP 49152-50151</code> range. On a cloud
|
|
provider, that means a firewall rule, not just <code>ufw</code>.
|
|
</p>
|
|
|
|
<h2>4 · the shared secret</h2>
|
|
<p>
|
|
coturn and the mint share one secret. The mint signs each ephemeral credential
|
|
with it; coturn validates against the same value. <strong>Generate your own</strong>
|
|
— never reuse anyone else's, never print it, never commit it:
|
|
</p>
|
|
<div class="code"><b># run once, as root, on the box</b>
|
|
umask 077
|
|
openssl rand -hex 32 > /etc/zebra-turn-secret
|
|
chmod 600 /etc/zebra-turn-secret
|
|
|
|
SECRET=$(cat /etc/zebra-turn-secret)
|
|
|
|
<b># wire it into coturn</b>
|
|
sed -i '/^static-auth-secret=/d' /etc/turnserver.conf
|
|
printf 'static-auth-secret=%s\n' "$SECRET" >> /etc/turnserver.conf
|
|
|
|
<b># and into the mint's environment</b>
|
|
printf 'ZEBRA_TURN_SECRET=%s\n' "$SECRET" > /etc/zebra-signal.env
|
|
chmod 640 /etc/zebra-signal.env</div>
|
|
<p class="note">
|
|
Generate once and persist it: rotating the secret invalidates every credential
|
|
already handed out, dropping live calls. Keep the file <code>600</code>, owned
|
|
by root.
|
|
</p>
|
|
|
|
<h2>5 · minting credentials — <code>/turn-cred</code></h2>
|
|
<p>
|
|
This is the only non-obvious piece, and it is tiny. coturn's
|
|
<code>use-auth-secret</code> mode accepts any username whose value is a future
|
|
unix timestamp, with the password being
|
|
<code>base64(HMAC‑SHA1(secret, username))</code>. So the endpoint just
|
|
stamps an expiry and signs it. In Go:
|
|
</p>
|
|
<div class="code"><b>const turnTTL = 12 * 3600 // seconds a credential stays valid</b>
|
|
|
|
func turnCred(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") <b>// browsers (and file:// copies) can fetch</b>
|
|
w.Header().Set("Content-Type", "application/json")
|
|
secret := os.Getenv("ZEBRA_TURN_SECRET")
|
|
if secret == "" { http.Error(w, `{"error":"turn unavailable"}`, 503); return }
|
|
|
|
username := strconv.FormatInt(time.Now().Unix()+turnTTL, 10)
|
|
mac := hmac.New(sha1.New, []byte(secret))
|
|
mac.Write([]byte(username))
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"username": username,
|
|
"credential": base64.StdEncoding.EncodeToString(mac.Sum(nil)),
|
|
"ttl": turnTTL,
|
|
"stun": []string{"stun:<b>turn.example.com</b>:3478"},
|
|
"uris": []string{
|
|
"turn:<b>turn.example.com</b>:3478?transport=udp",
|
|
"turn:<b>turn.example.com</b>:3478?transport=tcp",
|
|
},
|
|
})
|
|
}</div>
|
|
<p>
|
|
That <code>Access-Control-Allow-Origin: *</code> matters: it is what lets a
|
|
browser on any page — including a copy of the zebra page saved to disk and
|
|
opened from <code>file://</code> — fetch a credential. The credential is
|
|
short-lived and per-user, so handing it out openly is by design.
|
|
</p>
|
|
|
|
<h2>6 · the rendezvous relay</h2>
|
|
<p>
|
|
The relay is a stateless WebSocket server, a few hundred lines of standard
|
|
library, no database. Its whole job:
|
|
</p>
|
|
<ul>
|
|
<li>A browser connects to <code>/zebra-signal?room=<hash></code>. The room
|
|
is a SHA-256 of the shared code, so the server learns nothing about the code.</li>
|
|
<li>The first peer in a room is told it is the offerer; the second is the
|
|
answerer. (Or "whoever is already present offers when the other joins" —
|
|
either rule works, as long as it is deterministic.)</li>
|
|
<li>Every message a peer sends is forwarded verbatim to the other peer in the
|
|
same room. The payload is the WebRTC offer/answer, <strong>already encrypted</strong>
|
|
in the browser with a key derived from the shared code (PBKDF2 → AES-GCM).
|
|
The relay forwards ciphertext it cannot read.</li>
|
|
<li>The server sends periodic WebSocket pings so idle calls don't get reaped by
|
|
intermediaries, and drops a room when both peers leave.</li>
|
|
</ul>
|
|
<p>
|
|
Run it under systemd as an unprivileged user, reading the secret from the env
|
|
file written in step 4:
|
|
</p>
|
|
<div class="code"><b># /etc/systemd/system/zebra-signal.service</b>
|
|
[Unit]
|
|
Description=zebra-signal — WebRTC rendezvous relay + TURN credential mint
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=www-data
|
|
Group=www-data
|
|
Environment=ZEBRA_SIGNAL_ADDR=:8090
|
|
EnvironmentFile=-/etc/zebra-signal.env
|
|
ExecStart=/usr/local/bin/zebra-signal
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectSystem=full
|
|
ProtectHome=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target</div>
|
|
|
|
<h2>7 · TLS + reverse proxy</h2>
|
|
<p>
|
|
Browsers require <code>wss://</code> (TLS) for WebSockets and a secure context
|
|
for the crypto, so put a reverse proxy in front that terminates TLS. With
|
|
<a href="https://caddyserver.com">Caddy</a> you get automatic certificates and
|
|
the config is four lines:
|
|
</p>
|
|
<div class="code"><b># Caddyfile</b>
|
|
turn.example.com {
|
|
handle /zebra-signal* { reverse_proxy localhost:8090 }
|
|
handle /turn-cred { reverse_proxy localhost:8090 }
|
|
}</div>
|
|
<p>
|
|
Caddy fetches a Let's Encrypt certificate on first request. The WebSocket
|
|
upgrade is proxied transparently; the <code>*</code> CORS header set by the mint
|
|
passes straight through. That is the entire edge.
|
|
</p>
|
|
|
|
<h2>8 · zebra-spaces — multi-party voice rooms (optional)</h2>
|
|
<p>
|
|
The 1:1 voice call (<a href="zebra-audio.html">zebra-audio</a>) needs only the
|
|
relay and coturn. For <a href="zebra-spaces.html">zebra-spaces</a> — a host
|
|
plus co-hosts plus speakers plus an audience of listeners — you also stand
|
|
up two small Go services:
|
|
</p>
|
|
<ul>
|
|
<li><strong>zebra-spaces-signal</strong> — a multi-member sibling of the
|
|
1:1 rendezvous relay. Tracks roles (host > cohost > speaker > listener),
|
|
enforces an authority chain (mods can mic-invite/promote/demote/boot,
|
|
co-hosts can’t boot the host), and verifies an Ed25519 signature on every
|
|
role-change. The relay sees only opaque room ids and ciphertext SDP — a
|
|
compromised relay can refuse to forward but can’t forge promotions.
|
|
Defaults to port <code>:8091</code>.</li>
|
|
<li><strong>zebra-spaces-sfu</strong> — a tiny audio-only Selective
|
|
Forwarding Unit built on <a href="https://github.com/pion/webrtc">pion/webrtc</a>.
|
|
Speakers form a low-latency P2P mesh among themselves; they ALSO publish one
|
|
mic track to the SFU. Each listener holds one WebRTC connection to the SFU
|
|
and receives every speaker as a separate track — the listener fan-out
|
|
is what the mesh alone can’t do. RTP forwards unchanged: no mixing, no
|
|
decoding, no transcoding. Listener identity is mandatory (no anonymous
|
|
listening), so a moderator’s <code>boot</code> evicts the offender from
|
|
every path. HTTP signaling on <code>:8092</code>, ICE on a single UDP mux
|
|
port <code>:7882</code>.</li>
|
|
</ul>
|
|
<p>
|
|
Both services live in the same repo as <code>zebra-signal</code>:
|
|
<a href="https://git.unturf.com/engineering/unturf/proxy.unturf.com">git.unturf.com/engineering/unturf/proxy.unturf.com</a>
|
|
— under <code>cmd/zebra-spaces-signal/</code> and <code>cmd/zebra-spaces-sfu/</code>.
|
|
Public domain. Each ships a systemd unit and standard build target; the deploy
|
|
pattern mirrors <code>zebra-signal</code> exactly. The Caddyfile gets two more
|
|
routes:
|
|
</p>
|
|
<div class="code"><b># Caddyfile (added to your existing block)</b>
|
|
handle /zebra-spaces-signal* { reverse_proxy localhost:8091 }
|
|
handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1 } }</div>
|
|
<p>
|
|
<code>flush_interval -1</code> matters: the SFU pushes renegotiation offers
|
|
over Server-Sent Events when speakers join or leave, and Caddy will buffer
|
|
those events into silence without it.
|
|
</p>
|
|
<p>
|
|
The SFU advertises a public IP as its host ICE candidate, so set
|
|
<code>ZEBRA_SFU_NAT1TO1_IP=<your.public.ip></code> on the unit and open
|
|
UDP <code>7882</code> in your firewall — that single port carries all
|
|
ICE/RTP via Pion’s UDP mux. No port range like coturn needs.
|
|
</p>
|
|
|
|
<h2 id="point">9 · point your members at it</h2>
|
|
<p>
|
|
Now the payoff: <strong>nobody needs a modified page.</strong> The published
|
|
zebra pages read two URL parameters and fall back to the unturf servers only if
|
|
they are absent. Send your community a link with your own endpoints:
|
|
</p>
|
|
<div class="code">https://www.unturf.com/zebra-report/zebra-audio.html<b>?signal=</b>wss://turn.example.com/zebra-signal<b>&turncred=</b>https://turn.example.com/turn-cred</div>
|
|
<p>
|
|
Or host the page yourself (it is a single self-contained HTML file) and serve it
|
|
from the same box. Either way, the call is established through <em>your</em>
|
|
relay and, when needed, relayed through <em>your</em> coturn. The same two
|
|
parameters work on the text chat (<code>index.html</code>) and the voice call
|
|
(<code>zebra-audio.html</code>).
|
|
</p>
|
|
<p class="note">
|
|
Saved a copy to disk? It still works from <code>file://</code> — the crypto
|
|
runs in a secure context and the <code>*</code> CORS header lets the saved file
|
|
fetch credentials — as long as your relay and TURN server are reachable.
|
|
</p>
|
|
|
|
<h2>10 · how many users can it carry</h2>
|
|
<p>
|
|
The rendezvous relay is nearly free: it moves a few hundred bytes per call setup
|
|
and then idles, so a tiny box pairs thousands of rooms. <strong>coturn is the
|
|
ceiling</strong>, and only for relayed calls (direct peer-to-peer calls cost it
|
|
nothing). Each relayed voice call is bidirectional audio — tens of kbit/s
|
|
per leg. With <code>bps-capacity=400000000</code> (400 Mbit/s) the limit is
|
|
whatever your VPS's actual uplink and monthly transfer allow, long before coturn
|
|
itself strains.
|
|
</p>
|
|
<p>
|
|
The <code>user-quota</code> and <code>total-quota</code> lines cap concurrent
|
|
allocations to blunt abuse. Raise <code>total-quota</code> as you grow; keep
|
|
<code>user-quota</code> small (a handful of allocations per credential is plenty
|
|
for one call). Because credentials are per-user and expire, a leaked one is
|
|
worthless within hours.
|
|
</p>
|
|
|
|
<hr>
|
|
|
|
<h2>11 · it's a gift</h2>
|
|
<p>
|
|
This stack is open intellectual capital — take it and run a community the
|
|
unturf servers will never see or meter. Patch it, harden it, pass it on. Every
|
|
box that runs its own relay makes the whole mesh more resilient and less
|
|
centralised, which is the entire point.
|
|
</p>
|
|
<a class="cta" href="./">▶ open the chat</a>
|
|
|
|
<a class="cta" href="zebra-audio.html">▶ open the voice call</a>
|
|
|
|
<p class="foot">
|
|
zebra report · host your own community ·
|
|
<a href="/" style="color:#777">unturf</a>
|
|
</p>
|
|
|
|
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
|
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-07</span><br>
|
|
md5 <span class="stamp-md5">97108d6b240474c7e1b3bea7ae889cc4</span><br>
|
|
sha256 <span class="stamp-sha">9ac307a6b7bdedc95d44e5395a984be9ffd68d726459f96432373eb9bb67497f</span><br>
|
|
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
|
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=</span>
|
|
</footer>
|
|
<script>
|
|
/* shared zebra dark-mode toggle handler */
|
|
(function(){
|
|
var btn = document.getElementById('btn-theme'); if (!btn) return;
|
|
function applyLabel(){
|
|
btn.textContent = document.documentElement.classList.contains('theme-dark') ? 'light' : 'dark';
|
|
}
|
|
applyLabel();
|
|
btn.addEventListener('click', function(){
|
|
var root = document.documentElement;
|
|
var goDark = !root.classList.contains('theme-dark');
|
|
root.classList.toggle('theme-dark', goDark);
|
|
try { localStorage.setItem('zebra-theme-v1', goDark ? 'dark' : 'light'); } catch(_){}
|
|
applyLabel();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|