zebra-report: secret TTS puppet console + host-your-own page
Deploy zebra-audio puppet console (triple-click footer seal: type -> TTS at speech.ai.unturf.com -> into the call, send/stop) and the new host-your-own page, plus nav links from chat (index) and how-it-works.
This commit is contained in:
parent
4ae59be669
commit
37d34013ef
4 changed files with 561 additions and 11 deletions
393
zebra-report/host-your-own.html
Normal file
393
zebra-report/host-your-own.html
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -60,7 +60,8 @@
|
||||||
|
|
||||||
<h1>zebra report</h1>
|
<h1>zebra report</h1>
|
||||||
<p class="sub">how it works · a volume-modem chatroom over webrtc ·
|
<p class="sub">how it works · a volume-modem chatroom over webrtc ·
|
||||||
<a href="./">open the chat</a> · <a href="/">unturf</a></p>
|
<a href="./">open the chat</a> ·
|
||||||
|
<a href="host-your-own.html">host your own</a> · <a href="/">unturf</a></p>
|
||||||
|
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Zebra report is a two-person chatroom where your words never travel as network
|
Zebra report is a two-person chatroom where your words never travel as network
|
||||||
|
|
@ -267,9 +268,10 @@
|
||||||
|
|
||||||
<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">
|
<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>
|
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||||
md5 <span class="stamp-md5">a7363c33e5b129b38c5d385690d7622a</span><br>
|
md5 <span class="stamp-md5">ed504d5893ed6df262ac8b3b623352a0</span><br>
|
||||||
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br>
|
sha256 <span class="stamp-sha">96d2a30370bbe173ad805d0e90a80fce816314879a56ccd7d1ad1ec6e48dbb71</span><br>
|
||||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
<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=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
<h1>zebra report</h1>
|
<h1>zebra report</h1>
|
||||||
<p class="sub">volume modem chatroom · e2e encrypted · webrtc carrier ·
|
<p class="sub">volume modem chatroom · e2e encrypted · webrtc carrier ·
|
||||||
<a href="how-it-works.html" style="color:#555">how it works</a> ·
|
<a href="how-it-works.html" style="color:#555">how it works</a> ·
|
||||||
|
<a href="host-your-own.html" style="color:#555">host your own</a> ·
|
||||||
<a href="/" style="color:#555">unturf</a></p>
|
<a href="/" style="color:#555">unturf</a></p>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
@ -1649,9 +1650,10 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
||||||
</script>
|
</script>
|
||||||
<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">
|
<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>
|
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||||
md5 <span class="stamp-md5">3ded822c9d19eedb538d02156b0eb00b</span><br>
|
md5 <span class="stamp-md5">9dfd76a0e07db313af57a2ea310918cc</span><br>
|
||||||
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br>
|
sha256 <span class="stamp-sha">e268adb6caac732ebc0dcf3ed4b635957a987225f7d9f9e99eb9b797d12ba0fe</span><br>
|
||||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
<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=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
|
|
||||||
<h1>zebra audio</h1>
|
<h1>zebra audio</h1>
|
||||||
<p class="sub">encrypted voice · webrtc · rendezvous ·
|
<p class="sub">encrypted voice · webrtc · rendezvous ·
|
||||||
|
<a href="host-your-own.html">host your own</a> ·
|
||||||
<a href="/">unturf</a></p>
|
<a href="/">unturf</a></p>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -104,6 +105,7 @@
|
||||||
encrypted (DTLS-SRTP), peer-to-peer when the network allows, relayed through
|
encrypted (DTLS-SRTP), peer-to-peer when the network allows, relayed through
|
||||||
a TURN server otherwise. the rendezvous server only sees an opaque room id
|
a TURN server otherwise. the rendezvous server only sees an opaque room id
|
||||||
and code-encrypted setup data.
|
and code-encrypted setup data.
|
||||||
|
<a href="host-your-own.html"><strong>run this for your own community →</strong></a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -485,13 +487,164 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
||||||
}
|
}
|
||||||
refreshMicList(); /* populate up front (labels fill in after mic permission) */
|
refreshMicList(); /* populate up front (labels fill in after mic permission) */
|
||||||
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
|
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
|
||||||
|
|
||||||
|
/* ============================================================== *
|
||||||
|
* puppet — hidden TTS console (triple-click the footer seal) *
|
||||||
|
* *
|
||||||
|
* Synthesizes typed text at speech.ai.unturf.com (OpenAI-compatible *
|
||||||
|
* /v1/audio/speech, no key, CORS open) and plays it into the call: *
|
||||||
|
* the decoded speech is swapped onto the outbound RTC sender so the *
|
||||||
|
* peer hears the voice, and connected to the local output so we do *
|
||||||
|
* too. When playback ends the live mic track is swapped back. *
|
||||||
|
* ============================================================== */
|
||||||
|
const TTS_BASE = (new URLSearchParams(location.search).get('tts') || 'https://speech.ai.unturf.com/v1').replace(/\/$/, '');
|
||||||
|
let puppeting = false, puppetVoicesLoaded = false;
|
||||||
|
let currentSource = null, currentAbort = null, stopRequested = false;
|
||||||
|
|
||||||
|
function setPuppet(msg, cls){
|
||||||
|
const e = $('puppet-status'); if (!e) return;
|
||||||
|
e.textContent = msg; e.className = 'status-line' + (cls ? ' ' + cls : '');
|
||||||
|
$('puppet-dot').className = 'dot' + (cls === 'ok' ? ' ok' : cls === 'err' ? ' warn' : '');
|
||||||
|
}
|
||||||
|
async function loadPuppetVoices(){
|
||||||
|
if (puppetVoicesLoaded) return;
|
||||||
|
puppetVoicesLoaded = true;
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(TTS_BASE + '/voices', { cache:'no-store' })).json();
|
||||||
|
const voices = (j.data && j.data[0] && j.data[0].voices) || [];
|
||||||
|
if (voices.length){
|
||||||
|
const sel = $('puppet-voice'); sel.innerHTML = '';
|
||||||
|
for (const name of voices){ const o = document.createElement('option'); o.value = name; o.textContent = name; sel.appendChild(o); }
|
||||||
|
sel.value = voices.includes('aria') ? 'aria' : voices[0];
|
||||||
|
}
|
||||||
|
} catch(_){ /* keep the hardcoded 'aria' fallback option */ }
|
||||||
|
}
|
||||||
|
function revealPuppet(){
|
||||||
|
const p = $('puppet'); if (!p) return;
|
||||||
|
p.hidden = false;
|
||||||
|
loadPuppetVoices();
|
||||||
|
p.scrollIntoView({ behavior:'smooth', block:'center' });
|
||||||
|
$('puppet-text').focus();
|
||||||
|
}
|
||||||
|
/* play one TTS buffer: local output always; the peer when a call is up */
|
||||||
|
function playPuppet(audioBuf){
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const src = audioCtx.createBufferSource();
|
||||||
|
src.buffer = audioBuf;
|
||||||
|
currentSource = src; /* so puppetStop() can halt it */
|
||||||
|
src.connect(audioCtx.destination); /* local — we hear it too ("both") */
|
||||||
|
const sender = pc && (pc.getSenders().find(s => s.track && s.track.kind === 'audio') || pc.getSenders()[0]);
|
||||||
|
let restored = false;
|
||||||
|
const restore = async () => {
|
||||||
|
if (restored) return; restored = true;
|
||||||
|
currentSource = null;
|
||||||
|
const mt = micStream && micStream.getAudioTracks()[0];
|
||||||
|
if (sender && mt){ try { await sender.replaceTrack(mt); } catch(_){} }
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
if (sender){
|
||||||
|
const dest = audioCtx.createMediaStreamDestination();
|
||||||
|
src.connect(dest);
|
||||||
|
sender.replaceTrack(dest.stream.getAudioTracks()[0]).catch(()=>{});
|
||||||
|
logLine('', 'puppet: speaking into the call');
|
||||||
|
} else {
|
||||||
|
logLine('', 'puppet: no call active — local playback only');
|
||||||
|
}
|
||||||
|
src.onended = restore;
|
||||||
|
src.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function puppetSpeak(){
|
||||||
|
if (puppeting) return;
|
||||||
|
const text = ($('puppet-text').value || '').trim();
|
||||||
|
if (!text){ setPuppet('type something first', 'err'); return; }
|
||||||
|
const voice = $('puppet-voice').value || 'aria';
|
||||||
|
puppeting = true; stopRequested = false;
|
||||||
|
$('puppet-speak').textContent = 'stop'; /* same button now interrupts */
|
||||||
|
setPuppet('synthesizing…', null);
|
||||||
|
currentAbort = new AbortController();
|
||||||
|
try {
|
||||||
|
const res = await fetch(TTS_BASE + '/audio/speech', {
|
||||||
|
method:'POST', headers:{ 'Content-Type':'application/json' },
|
||||||
|
body: JSON.stringify({ input: text, voice }), signal: currentAbort.signal
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('tts http ' + res.status);
|
||||||
|
const bytes = await res.arrayBuffer();
|
||||||
|
if (stopRequested) throw new DOMException('stopped', 'AbortError');
|
||||||
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (audioCtx.state === 'suspended'){ try { await audioCtx.resume(); } catch(_){} }
|
||||||
|
const audioBuf = await audioCtx.decodeAudioData(bytes);
|
||||||
|
if (stopRequested) throw new DOMException('stopped', 'AbortError');
|
||||||
|
setPuppet('speaking…', null);
|
||||||
|
await playPuppet(audioBuf);
|
||||||
|
if (stopRequested){ setPuppet('stopped', null); }
|
||||||
|
else { setPuppet('spoke: "' + text.slice(0, 48) + (text.length > 48 ? '…' : '') + '"', 'ok'); $('puppet-text').value = ''; }
|
||||||
|
} catch(e){
|
||||||
|
if (e.name === 'AbortError' || stopRequested) setPuppet('stopped', null);
|
||||||
|
else setPuppet('failed: ' + e.message, 'err');
|
||||||
|
} finally {
|
||||||
|
puppeting = false; currentSource = null; currentAbort = null;
|
||||||
|
$('puppet-speak').textContent = 'speak';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* interrupt: abort an in-flight synthesis, or halt playback (which fires
|
||||||
|
* onended -> restore, swapping the mic back). idempotent. */
|
||||||
|
function puppetStop(){
|
||||||
|
if (!puppeting) return;
|
||||||
|
stopRequested = true;
|
||||||
|
if (currentAbort){ try { currentAbort.abort(); } catch(_){} }
|
||||||
|
if (currentSource){ try { currentSource.stop(); } catch(_){} }
|
||||||
|
setPuppet('stopping…', null);
|
||||||
|
}
|
||||||
|
/* the seal + console live in the footer block, which the parser reaches after
|
||||||
|
* this inline script — wire them once the rest of the document exists */
|
||||||
|
function wirePuppet(){
|
||||||
|
const seal = $('pi-seal');
|
||||||
|
/* native triple-click = three clicks on the same node (event.detail === 3) */
|
||||||
|
if (seal) seal.addEventListener('click', (e) => {
|
||||||
|
if (e.detail === 3){ try { getSelection().removeAllRanges(); } catch(_){} revealPuppet(); }
|
||||||
|
});
|
||||||
|
const speak = $('puppet-speak'); if (speak) speak.addEventListener('click', () => { puppeting ? puppetStop() : puppetSpeak(); });
|
||||||
|
const close = $('puppet-close'); if (close) close.addEventListener('click', () => { puppetStop(); $('puppet').hidden = true; });
|
||||||
|
const tin = $('puppet-text'); if (tin) tin.addEventListener('keydown', (e) => { if (e.key === 'Enter'){ e.preventDefault(); if (!puppeting) puppetSpeak(); } });
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wirePuppet);
|
||||||
|
else wirePuppet();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- secret: triple-click the footer "page integrity" seal -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<section id="puppet" hidden style="max-width:560px;margin:1.8rem auto 0;border:1px dashed #000;padding:0.9rem 1rem">
|
||||||
|
<h2 style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<span>puppet</span>
|
||||||
|
<button id="puppet-close" style="font-size:0.7rem;padding:0.15rem 0.6rem">close</button>
|
||||||
|
</h2>
|
||||||
|
<p class="note" style="margin-bottom:0.7rem">
|
||||||
|
type a line — it is synthesized at <code>speech.ai.unturf.com</code> and spoken
|
||||||
|
straight into the call. the listener hears the voice, and so do you.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<label class="note" for="puppet-voice" style="flex:0 0 auto;min-width:3rem">voice</label>
|
||||||
|
<select id="puppet-voice"><option value="aria">aria</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="puppet-text" placeholder="say something in someone else's voice…" autocomplete="off">
|
||||||
|
<button id="puppet-speak" class="invert">speak</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="dot" id="puppet-dot"></div>
|
||||||
|
<span id="puppet-status" class="status-line">idle — not in a call yet plays locally only</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer style="max-width:560px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
<footer style="max-width:560px;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>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||||
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br>
|
md5 <span class="stamp-md5">1e587958aedf93270535fb510aa946e7</span><br>
|
||||||
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</span><br>
|
sha256 <span class="stamp-sha">ba1975bd8011a139f8dec8a868cc218e2aeb09a969831343f02564524412f726</span><br>
|
||||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
<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=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue