- moderation: 'boot' button onclick now .catch'es and logs server errors. Server now returns 'boot target not found (stale uuid?)' instead of silently no-op'ing when the page's member roster lagged the room — the moderator was clicking 'boot' and seeing nothing happen because their page held a stale uuid. - diagnostic: watchFirstFrame logs 'still black after 2500ms — no keyframe?' on any fresh SFU video track (screen/camera/game) whose decoder never unmutes. Pairs with the SFU's extended kfBurst so we can tell next session which path actually broke when a camera tile renders black. multi-peer-mesh test harness extracts the new fn alongside watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end in the headless sandbox. Stamp date refresh on the other pages (no behavior change).
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-03</span><br>
|
|
md5 <span class="stamp-md5">24217bcab41f06970625faecdc0723e2</span><br>
|
|
sha256 <span class="stamp-sha">9dda09c0e6405bd71a611c12722235ba1188cd11679575763a3c67d708ff3cc5</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>
|