web: host-your-own page + nav links; deploy-to-both-repos reminder
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.
This commit is contained in:
parent
b269fce2f6
commit
5e1c4402b2
5 changed files with 418 additions and 15 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -122,10 +122,10 @@ Handshake frame: `[0x5A 0x42 0x01 baud_lo baud_hi xor_cksum]` — 6 bytes at 50
|
|||
|
||||
### Web page integrity stamping
|
||||
|
||||
Each deployed page (`web/chat.html`, `web/zebra-audio.html`, `web/how-it-works.html`)
|
||||
carries a footer with the build date + its own MD5 + SHA-256. **Run `make stamp`
|
||||
before deploying any page change** — it sets today's date and recomputes the
|
||||
hashes (`web/stamp.js`).
|
||||
Each deployed page (`web/chat.html`, `web/zebra-audio.html`, `web/how-it-works.html`,
|
||||
`web/host-your-own.html`) carries a footer with the build date + its own MD5 + SHA-256.
|
||||
**Run `make stamp` before deploying any page change** — it sets today's date and
|
||||
recomputes the hashes (`web/stamp.js`).
|
||||
|
||||
- A file can't hold its own hash, so the hashes are computed with the two hash
|
||||
fields **zeroed**, then written back (same length). Self-consistent and
|
||||
|
|
@ -135,6 +135,12 @@ hashes (`web/stamp.js`).
|
|||
injects it in the browser. `stamp.js` is build tooling, never loaded by a page.
|
||||
- **Verify a served page**: blank the md5 field to 32 zeros and the sha256 field
|
||||
to 64 zeros, then re-hash with `sha256sum`/`md5sum` — must match the footer.
|
||||
- **Deploy flow**: edit `web/*.html` → `make stamp` → commit here → copy the
|
||||
page(s) into `~/git/www.unturf.com/zebra-report/` (`chat.html` → `index.html`,
|
||||
others same name) → commit + push there.
|
||||
- **Deploy = push to BOTH repos.** A page change is not live until it lands in
|
||||
both. Pushing only the source changes nothing served; pushing only the deploy
|
||||
repo orphans the source of truth. Both, every time:
|
||||
1. **source** — this repo (`zebra-report`): edit `web/*.html` → `make stamp` →
|
||||
commit → **push to origin**.
|
||||
2. **served** — `www.unturf.com`: copy the page(s) into
|
||||
`~/git/www.unturf.com/zebra-report/` (`chat.html` → `index.html`;
|
||||
`zebra-audio.html`, `how-it-works.html`, `host-your-own.html` keep their
|
||||
names) → commit → **push to origin**.
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -8,7 +8,7 @@ PULSE = src/pulse.c
|
|||
|
||||
# stamp each web page with today's date + its own md5/sha256 (run before deploy)
|
||||
stamp:
|
||||
@node web/stamp.js web/chat.html web/zebra-audio.html web/how-it-works.html
|
||||
@node web/stamp.js web/chat.html web/zebra-audio.html web/how-it-works.html web/host-your-own.html
|
||||
|
||||
all: tx rx chat bt carrier zebrad
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
<h1>zebra report</h1>
|
||||
<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="host-your-own.html" style="color:#555">host your own</a> ·
|
||||
<a href="/" style="color:#555">unturf</a></p>
|
||||
|
||||
<div class="app">
|
||||
|
|
@ -1649,9 +1650,10 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
|||
</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">
|
||||
<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>
|
||||
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
md5 <span class="stamp-md5">9dfd76a0e07db313af57a2ea310918cc</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><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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
393
web/host-your-own.html
Normal file
393
web/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>
|
||||
<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">
|
||||
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">
|
||||
<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>
|
||||
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
md5 <span class="stamp-md5">ed504d5893ed6df262ac8b3b623352a0</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><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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue