Add web/host-your-own.html and link it from the chat and how-it-works headers and footers. make stamp now stamps it alongside the other pages. CLAUDE.md: turn the deploy flow into an explicit push-BOTH-repos reminder (zebra-report source + www.unturf.com served) and list host-your-own.html as a deployed page.
393 lines
17 KiB
HTML
393 lines
17 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<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> and the <a href="zebra-audio.html">voice
|
|
call</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 id="point">8 · 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>9 · 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>10 · 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-05-29</span><br>
|
|
md5 <span class="stamp-md5">521949cd4653a08a47afd841f53addc1</span><br>
|
|
sha256 <span class="stamp-sha">81bbc00c63ed4343885ab2dd575355da92ff6c0bc683d693e36a06e6015b6241</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>
|
|
</body>
|
|
</html>
|