zebra-report: zebra-spaces share URL + terminal-block UX; host-your-own SFU section
This commit is contained in:
parent
d5e1e62e1e
commit
e4d146728f
2 changed files with 126 additions and 7 deletions
|
|
@ -334,7 +334,56 @@ turn.example.com {
|
|||
passes straight through. That is the entire edge.
|
||||
</p>
|
||||
|
||||
<h2 id="point">8 · point your members at it</h2>
|
||||
<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
|
||||
|
|
@ -354,7 +403,7 @@ turn.example.com {
|
|||
fetch credentials — as long as your relay and TURN server are reachable.
|
||||
</p>
|
||||
|
||||
<h2>9 · how many users can it carry</h2>
|
||||
<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
|
||||
|
|
@ -374,7 +423,7 @@ turn.example.com {
|
|||
|
||||
<hr>
|
||||
|
||||
<h2>10 · it's a gift</h2>
|
||||
<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
|
||||
|
|
@ -392,8 +441,8 @@ turn.example.com {
|
|||
|
||||
<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-31</span><br>
|
||||
md5 <span class="stamp-md5">d993e1f54688b3cbbdb4e40c7351d6cb</span><br>
|
||||
sha256 <span class="stamp-sha">9b89f3771707cd91e2dcfc39c5da1575e55b294d99858899a3fc67f18121dc55</span><br>
|
||||
md5 <span class="stamp-md5">b112cc063dcc4337a086775c97cdfc59</span><br>
|
||||
sha256 <span class="stamp-sha">6c4d87467ef58fe408d5a8cf55a34c4d51f13ad3f8f21178977177d6ca2560f5</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>
|
||||
|
|
|
|||
|
|
@ -245,6 +245,22 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-share" class="hidden">
|
||||
<h2>share</h2>
|
||||
<p class="note" style="margin-bottom:0.5rem">
|
||||
anyone who opens this URL lands in this space (joins as listener).
|
||||
the rendezvous code is embedded — share only with people you trust to
|
||||
hear the room. self-contained: works offline, no server-side index.
|
||||
</p>
|
||||
<div class="row">
|
||||
<input type="text" id="share-url" readonly>
|
||||
<button id="btn-copy-share" class="small">copy</button>
|
||||
</div>
|
||||
<div id="qr-host" class="row" style="display:none">
|
||||
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-room" class="hidden">
|
||||
<h2>room</h2>
|
||||
<div id="members"></div>
|
||||
|
|
@ -524,6 +540,7 @@ async function sfuPublish(){
|
|||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (res.status === 403){ pc.close(); handleBlocked('publish'); return; }
|
||||
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
|
|
@ -570,6 +587,7 @@ async function sfuSubscribe(){
|
|||
const offerRes = await fetch(subUrl, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
|
||||
});
|
||||
if (offerRes.status === 403){ pc.close(); handleBlocked('subscribe'); return; }
|
||||
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
|
||||
const offer = await offerRes.json();
|
||||
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
|
||||
|
|
@ -764,6 +782,10 @@ async function joinSpace(){
|
|||
if (!code){ setStatus('enter a rendezvous code','err'); return; }
|
||||
if (!myHandle){ setStatus('pick a handle first (under "identity")','err'); $('handle').focus(); return; }
|
||||
if (ws){ setStatus('already in a space — leave first',null); return; }
|
||||
/* fresh attempt — clear any previous terminal-block state from an
|
||||
* earlier session in a different room */
|
||||
blocked = false;
|
||||
hideNotice();
|
||||
await refreshTurnCred();
|
||||
/* mic isn't acquired here — listeners don't broadcast. We grab it
|
||||
* lazily when our role becomes speaker (or we entered as host). */
|
||||
|
|
@ -810,6 +832,7 @@ async function handleSignal(raw){
|
|||
$('dot-call').className='dot ok';
|
||||
$('btn-leave').disabled = false;
|
||||
$('sec-room').classList.remove('hidden');
|
||||
renderShareUrl();
|
||||
onRoleEntered();
|
||||
renderRoom();
|
||||
break;
|
||||
|
|
@ -956,6 +979,8 @@ async function handleSignal(raw){
|
|||
case 'error':
|
||||
logLine('err','signal: '+m.message);
|
||||
setStatus('signal: '+m.message,'err');
|
||||
/* terminal-block signal: stop the reconnect loop and tell the user */
|
||||
if (/blocked/i.test(m.message)) handleBlocked('signal');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1223,6 +1248,28 @@ function hideNotice(){
|
|||
}
|
||||
$('btn-notice-close').addEventListener('click', hideNotice);
|
||||
|
||||
/* terminal-block handler — called on any "blocked" signal (signal-server
|
||||
* error, SFU 403). Stops every reconnect loop, surfaces a clear notice,
|
||||
* and shuts the session down so the user sees the boot landed instead of
|
||||
* watching their UI spin trying to rejoin a room they can't enter. */
|
||||
let blocked = false;
|
||||
function handleBlocked(source){
|
||||
if (blocked) return;
|
||||
blocked = true;
|
||||
wantConnected = false;
|
||||
showNotice('You are blocked from this space.', 'warn');
|
||||
logLine('err','blocked ('+source+') — stopping reconnects');
|
||||
setStatus('blocked from this space','err');
|
||||
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
||||
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
||||
for (const u of [...peers.keys()]) tearPeer(u);
|
||||
sfuUnpublish().catch(()=>{});
|
||||
sfuUnsubscribe().catch(()=>{});
|
||||
dropMic();
|
||||
$('btn-leave').disabled = true;
|
||||
$('btn-enter').disabled = false;
|
||||
}
|
||||
|
||||
$('btn-accept-mic').addEventListener('click', () => {
|
||||
if (!outstandingInvite) return;
|
||||
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
|
||||
|
|
@ -1271,6 +1318,28 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
|||
* ================================================================== */
|
||||
$('btn-enter').addEventListener('click', joinSpace);
|
||||
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
|
||||
|
||||
/* ?code=… autofills the rendezvous code (used by the share URL). The code
|
||||
* stays in the URL so a refresh keeps you in the same space; if you want
|
||||
* to leave it cleanly, hit the leave button or close the tab. */
|
||||
function renderShareUrl(){
|
||||
const code = $('rdv-code').value.trim();
|
||||
if (!code) return;
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.set('code', code);
|
||||
const url = u.toString();
|
||||
$('share-url').value = url;
|
||||
$('sec-share').classList.remove('hidden');
|
||||
}
|
||||
$('btn-copy-share').addEventListener('click', async () => {
|
||||
const v = $('share-url').value;
|
||||
try { await navigator.clipboard.writeText(v); $('btn-copy-share').textContent = 'copied'; setTimeout(()=>{ $('btn-copy-share').textContent='copy'; }, 1500); }
|
||||
catch(_){ $('share-url').select(); document.execCommand('copy'); }
|
||||
});
|
||||
{
|
||||
const c = new URLSearchParams(location.search).get('code');
|
||||
if (c) $('rdv-code').value = c;
|
||||
}
|
||||
$('btn-leave').addEventListener('click', async () => {
|
||||
wantConnected = false;
|
||||
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
||||
|
|
@ -1282,6 +1351,7 @@ $('btn-leave').addEventListener('click', async () => {
|
|||
$('sec-room').classList.add('hidden');
|
||||
$('sec-invite').classList.add('hidden');
|
||||
$('sec-listener-actions').classList.add('hidden');
|
||||
$('sec-share').classList.add('hidden');
|
||||
hideNotice();
|
||||
$('dot-call').className='dot warn';
|
||||
setStatus('left', null);
|
||||
|
|
@ -1298,8 +1368,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-05-31</span><br>
|
||||
md5 <span class="stamp-md5">24817c5ddaa87097a6eece30b0dcc5cb</span><br>
|
||||
sha256 <span class="stamp-sha">03ac79a343c537d43c3faf50478954bd0e0f17aeec21222bb3ff907be79f5a59</span><br>
|
||||
md5 <span class="stamp-md5">f46e2dfdf2c37cae463f3514d8e693fd</span><br>
|
||||
sha256 <span class="stamp-sha">48d4ea81bbd2337161e755e84ae85a3c79f51de2cbc4d5ecba2af3a324d13e14</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 against these hashes; point 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue