From 37d34013ef4762efb91bce2661f973306a3e8489 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 29 May 2026 15:18:00 -0400 Subject: [PATCH] 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. --- zebra-report/host-your-own.html | 393 ++++++++++++++++++++++++++++++++ zebra-report/how-it-works.html | 10 +- zebra-report/index.html | 8 +- zebra-report/zebra-audio.html | 161 ++++++++++++- 4 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 zebra-report/host-your-own.html diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html new file mode 100644 index 0000000..88d6185 --- /dev/null +++ b/zebra-report/host-your-own.html @@ -0,0 +1,393 @@ + + + + + +zebra report — host your own community + + + + +

host your own

+

run your own zebra community  ·  self-hosted TURN + rendezvous on one edge box  ·  + how it works  ·  + open the chat  ·  + unturf

+ +

+ The zebra pages (the chat and the voice + call) need exactly two things from a server: a rendezvous relay + so two browsers can find each other, and a TURN server 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. +

+ +

+ This is the exact infrastructure behind www.unturf.com/zebra-report, + 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. +

+ +▶ jump to "point your members at it" + +

1 · what you are building

+

+ One internet-facing box (a $5–$6/mo VPS is plenty for a small community) + running three daemons behind a TLS reverse proxy: +

+ +

+ 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. +

+ +

2 · the shape of it

+
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 │ + └──────────────────────────────────────────────────────────────────────────────┘
+

+ When the network allows it, the two browsers talk directly 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. +

+ +

3 · coturn — the TURN relay

+

+ Install it (apt install coturn on Debian/Ubuntu) and replace + /etc/turnserver.conf with this. Swap in your box's public IP and a + DNS name you control: +

+
# /etc/turnserver.conf +external-ip=YOUR.PUBLIC.IP +relay-ip=YOUR.PUBLIC.IP +listening-port=3478 +realm=turn.example.com + +# relay allocation range — open these UDP ports in your firewall too +min-port=49152 +max-port=50151 + +# time-limited credentials: the mint computes HMAC-SHA1(secret, expiry). +# the secret is appended below at deploy and never committed. +use-auth-secret +# static-auth-secret=<injected at deploy, see step 4> + +# abuse quotas — per ephemeral user, so they stay tight as you scale +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
+

+ Run it under systemd as an unprivileged user (all ports are above 1024, so no + special capabilities are needed): +

+
# /etc/systemd/system/coturn.service +[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
+

+ Firewall: allow inbound UDP 3478 (and TCP 3478 if you + offer TCP relay) plus the whole UDP 49152-50151 range. On a cloud + provider, that means a firewall rule, not just ufw. +

+ +

4 · the shared secret

+

+ coturn and the mint share one secret. The mint signs each ephemeral credential + with it; coturn validates against the same value. Generate your own + — never reuse anyone else's, never print it, never commit it: +

+
# run once, as root, on the box +umask 077 +openssl rand -hex 32 > /etc/zebra-turn-secret +chmod 600 /etc/zebra-turn-secret + +SECRET=$(cat /etc/zebra-turn-secret) + +# wire it into coturn +sed -i '/^static-auth-secret=/d' /etc/turnserver.conf +printf 'static-auth-secret=%s\n' "$SECRET" >> /etc/turnserver.conf + +# and into the mint's environment +printf 'ZEBRA_TURN_SECRET=%s\n' "$SECRET" > /etc/zebra-signal.env +chmod 640 /etc/zebra-signal.env
+

+ Generate once and persist it: rotating the secret invalidates every credential + already handed out, dropping live calls. Keep the file 600, owned + by root. +

+ +

5 · minting credentials — /turn-cred

+

+ This is the only non-obvious piece, and it is tiny. coturn's + use-auth-secret mode accepts any username whose value is a future + unix timestamp, with the password being + base64(HMAC‑SHA1(secret, username)). So the endpoint just + stamps an expiry and signs it. In Go: +

+
const turnTTL = 12 * 3600 // seconds a credential stays valid + +func turnCred(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") // browsers (and file:// copies) can fetch + 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:turn.example.com:3478"}, + "uris": []string{ + "turn:turn.example.com:3478?transport=udp", + "turn:turn.example.com:3478?transport=tcp", + }, + }) +}
+

+ That Access-Control-Allow-Origin: * matters: it is what lets a + browser on any page — including a copy of the zebra page saved to disk and + opened from file:// — fetch a credential. The credential is + short-lived and per-user, so handing it out openly is by design. +

+ +

6 · the rendezvous relay

+

+ The relay is a stateless WebSocket server, a few hundred lines of standard + library, no database. Its whole job: +

+ +

+ Run it under systemd as an unprivileged user, reading the secret from the env + file written in step 4: +

+
# /etc/systemd/system/zebra-signal.service +[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
+ +

7 · TLS + reverse proxy

+

+ Browsers require wss:// (TLS) for WebSockets and a secure context + for the crypto, so put a reverse proxy in front that terminates TLS. With + Caddy you get automatic certificates and + the config is four lines: +

+
# Caddyfile +turn.example.com { + handle /zebra-signal* { reverse_proxy localhost:8090 } + handle /turn-cred { reverse_proxy localhost:8090 } +}
+

+ Caddy fetches a Let's Encrypt certificate on first request. The WebSocket + upgrade is proxied transparently; the * CORS header set by the mint + passes straight through. That is the entire edge. +

+ +

8 · point your members at it

+

+ Now the payoff: nobody needs a modified page. 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: +

+
https://www.unturf.com/zebra-report/zebra-audio.html?signal=wss://turn.example.com/zebra-signal&turncred=https://turn.example.com/turn-cred
+

+ 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 your + relay and, when needed, relayed through your coturn. The same two + parameters work on the text chat (index.html) and the voice call + (zebra-audio.html). +

+

+ Saved a copy to disk? It still works from file:// — the crypto + runs in a secure context and the * CORS header lets the saved file + fetch credentials — as long as your relay and TURN server are reachable. +

+ +

9 · how many users can it carry

+

+ 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. coturn is the + ceiling, 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 bps-capacity=400000000 (400 Mbit/s) the limit is + whatever your VPS's actual uplink and monthly transfer allow, long before coturn + itself strains. +

+

+ The user-quota and total-quota lines cap concurrent + allocations to blunt abuse. Raise total-quota as you grow; keep + user-quota 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. +

+ +
+ +

10 · it's a gift

+

+ 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. +

+▶ open the chat +  +▶ open the voice call + +

+ zebra report · host your own community · + unturf +

+ + + + diff --git a/zebra-report/how-it-works.html b/zebra-report/how-it-works.html index e3f055c..163fcb6 100644 --- a/zebra-report/how-it-works.html +++ b/zebra-report/how-it-works.html @@ -60,7 +60,8 @@

zebra report

how it works  ·  a volume-modem chatroom over webrtc  ·  - open the chat  ·  unturf

+ open the chat  ·  + host your own  ·  unturf

Zebra report is a two-person chatroom where your words never travel as network @@ -267,9 +268,10 @@

diff --git a/zebra-report/index.html b/zebra-report/index.html index c37ec31..7579faf 100644 --- a/zebra-report/index.html +++ b/zebra-report/index.html @@ -169,6 +169,7 @@

zebra report

volume modem chatroom  ·  e2e encrypted  ·  webrtc carrier  ·  how it works  ·  + host your own  ·  unturf

@@ -1649,9 +1650,10 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
page integrity  ·  built 2026-05-29
- md5 3ded822c9d19eedb538d02156b0eb00b
- sha256 ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f
- hashes are of this page with these two fields zeroed — to verify, blank them and re-hash + md5 9dfd76a0e07db313af57a2ea310918cc
+ sha256 e268adb6caac732ebc0dcf3ed4b635957a987225f7d9f9e99eb9b797d12ba0fe
+ hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
+ one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/zebra-report/zebra-audio.html b/zebra-report/zebra-audio.html index 3e44977..7d9518a 100644 --- a/zebra-report/zebra-audio.html +++ b/zebra-report/zebra-audio.html @@ -74,6 +74,7 @@

zebra audio

encrypted voice  ·  webrtc  ·  rendezvous  ·  + host your own  ·  unturf

@@ -104,6 +105,7 @@ 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 and code-encrypted setup data. +  run this for your own community →

@@ -485,13 +487,164 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){ } 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.'); + +/* ============================================================== * + * 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(); })(); + + + + + +