11. Private streams stay private
A live stream meant for one person can't be read by someone who guesses its name.
Add `auth` to a channel in ratcloud.conf, and the app decides who may listen: nginx asks it first, with RC_CHANNEL and RC_CHANNEL_ID.
This is the code serving the zoo right now: read from disk for this request, from commit
77ff74344d (staging). The zoo's own self-check fetches this
page and compares it byte for byte with the file it runs.
exhibits/11-private-streams.php sha256 ad7353cdf58f · raw
1<?php 2// Exhibit 11. A channel declared with `auth` (`channels = mine auth`) makes nginx ask the app 3// before anyone may listen: it calls index.php with RC_CHANNEL and RC_CHANNEL_ID set, and the 4// app answers 204 (yes) or 403 (no). The names are guessable on purpose ("u" + your user id): 5// the gate, not the name, keeps them private. 6return [ 7 "n" => 11, "wing" => "Realtime", 8 "title" => "Private streams stay private", 9 "promise" => "A live stream meant for one person can't be read by someone who guesses its name.", 10 "block" => "Add `auth` to a channel in ratcloud.conf, and the app decides who may listen: nginx asks it first, with RC_CHANNEL and RC_CHANNEL_ID.", 11 "files" => ["public/index.php", "lib/robot.php"], 12 "channels" => [ 13 "mine" => fn(string $id, ?array $me): bool => $me !== null && $id === "u" . $me["id"], 14 ], 15 "show" => function (?array $me): string { 16 $own = $me ? '<p>Your stream is <code>mine/u' . (int)$me["id"] . '</code>. <button id="ping">Send myself a ping</button></p><ul class="list" id="pings"></ul>' 17 : '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a> to get a stream of your own.</p>'; 18 $guess = $me ? (int)$me["id"] + 1 : 1; 19 return $own . '<p><button id="eavesdrop" data-guess="u' . $guess . '">Listen to someone else\'s (mine/u' . $guess . ')</button> <span class="out" id="eaves-out"></span></p>'; 20 }, 21 "api" => function (string $do, ?array $me, array $in, bool $post): ?array { 22 if ($do !== "ping" || !$post) return null; 23 if (!$me) return ["error" => "sign in to have a stream", "status" => 401]; 24 return ["published" => publish("mine", "u" . $me["id"], ["text" => "ping for @{$me['username']}", "at" => gmdate("c")])]; 25 }, 26 // The robot hears its own stream; nobody else, signed in or not, may listen to it. 27 "check" => function (): array { 28 require_once ZOO_ROOT . "/lib/robot.php"; 29 $host = "https://" . env("RC_HOST"); 30 $b = robot_at_zoo(); 31 $id = "u" . robot_user()["id"]; 32 [$status, $msgs, $ms] = $b->listen(["$host/rc/sub/mine/$id"], fn() => $b->post("$host/api/11/ping"), 33 fn($m) => count(array_filter($m[0], fn($x) => is_array($x) && str_starts_with($x["text"] ?? "", "ping"))) > 0, 2000); 34 if ($status[0] !== 200) return [false, "the robot was refused its own stream ($status[0])"]; 35 if (!array_filter($msgs[0], fn($x) => is_array($x) && str_starts_with($x["text"] ?? "", "ping"))) return [false, "the robot's own stream opened but its ping never came"]; 36 $tries = [ 37 "a visitor, on the robot's stream" => [new Browser(), "$host/rc/sub/mine/$id"], 38 "the robot, on someone else's" => [$b, "$host/rc/sub/mine/u" . (robot_user()["id"] + 1)], 39 "the robot, on a stream with no name" => [$b, "$host/rc/sub/mine"], 40 ]; 41 foreach ($tries as $who => [$br, $url]) { 42 [$s] = $br->listen([$url], fn() => null, fn() => true, 0); 43 if ($s[0] !== 403) return [false, "$who: answered {$s[0]}, not refused"]; 44 } 45 return [true, "the robot heard its own ping ({$ms}ms); a visitor and the robot on someone else's stream were refused (403)"]; 46 }, 47 "script" => <<<'JS' 48(() => { 49 const list = document.getElementById("pings"); 50 if (list && zoo.me) { 51 const es = new EventSource("/rc/sub/mine/u" + zoo.me); 52 es.onmessage = (m) => { const j = JSON.parse(m.data); list.insertAdjacentHTML("afterbegin", "<li>" + zoo.esc(j.text) + " · " + zoo.ago(j.at) + "</li>"); }; 53 document.getElementById("ping").addEventListener("click", () => zoo.call("/api/11/ping", {})); 54 } 55 document.getElementById("eavesdrop")?.addEventListener("click", async (ev) => { 56 const out = document.getElementById("eaves-out"); 57 const ctl = new AbortController(); 58 try { 59 const r = await fetch("/rc/sub/mine/" + ev.target.dataset.guess, { headers: { Accept: "text/event-stream" }, signal: ctl.signal }); 60 out.innerHTML = r.status === 403 ? '<span class="tag refused">refused</span>' : '<span class="tag opened">connected (' + r.status + ')</span>'; 61 } catch (e) { out.textContent = "error: " + e.message; } 62 ctl.abort(); 63 }); 64})(); 65 66JS, 67];
public/index.php sha256 60760e49711f · raw
1<?php 2/* 3 * The zoo's one entry point. 4 * 5 * / every exhibit and its light 6 * /api/<n>/<do> an exhibit's JSON (check: run its check now; seen: "this worked for me") 7 * /how/<n> the exhibit's source, read from the running checkout (exhibit 19) 8 * /lights.json every light, for tests/exhibits.php and anyone curious 9 * /index.txt one line per exhibit (promise and building block), for a model's brief 10 * /how/<n>.txt exhibit n's running files as plain text (the builder's example(n)) 11 * /platform.md what an app gets: root's PLATFORM.md, the running version, for anyone 12 * 13 * nginx also calls this file to ask whether a browser may listen on an `auth` channel; then 14 * RC_CHANNEL and RC_CHANNEL_ID are set and the answer is 204 (yes) or 403 (no). 15 */ 16require __DIR__ . "/../lib/zoo.php"; 17 18$channel = getenv("RC_CHANNEL"); 19if ($channel !== false && $channel !== "") { 20 $ok = false; 21 foreach (exhibits() as $e) { 22 if (isset($e["channels"][$channel])) $ok = ($e["channels"][$channel])((string)getenv("RC_CHANNEL_ID"), auth_user()); 23 } 24 http_response_code($ok ? 204 : 403); 25 exit; 26} 27 28$path = (string)parse_url($_SERVER["REQUEST_URI"] ?? "/", PHP_URL_PATH); 29$me = auth_user(); 30 31function reply(array $j, int $code = 200): never { 32 http_response_code($code); 33 header("Content-Type: application/json"); 34 header("Cache-Control: no-store"); 35 echo json_encode($j, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n"; 36 exit; 37} 38 39if (preg_match('#^/api/(\d+)/([a-z-]+)$#D', $path, $m)) { 40 $e = exhibits()[(int)$m[1]] ?? reply(["error" => "no such exhibit"], 404); 41 $do = $m[2]; 42 if (!rc_csrf_ok()) reply(["error" => "refused: not from the zoo's own page"], 403); 43 $post = ($_SERVER["REQUEST_METHOD"] ?? "GET") === "POST"; 44 $in = $post ? (json_decode((string)file_get_contents("php://input"), true) ?: []) : $_GET; 45 try { 46 if ($do === "check" && $e["check"]) { 47 // POST runs it now, unless it ran (or started) in the last 15 seconds: anyone can press 48 // this, and a run costs a robot sign-in, a model call, B2 and push. The slot is taken 49 // atomically, so a burst runs it once. GET (an <img> anywhere) only reads the light. 50 if ($post && claim_check($e["n"], 15)) run_check($e); 51 $s = stored($e["n"]); 52 if (!$s) reply(["n" => $e["n"], "title" => $e["title"], "ok" => false, "detail" => "not checked yet", "at" => null, "data" => null]); 53 reply(["n" => $e["n"], "title" => $e["title"], "ok" => (bool)$s["ok"], "detail" => $s["detail"], "at" => gmdate("c", $s["at"]), "data" => $s["data"]]); 54 } 55 if ($do === "seen" && $post && $e["kind"] === "human") { 56 if (!$me) reply(["error" => "sign in first, so the zoo can say who saw it work"], 401); 57 seen($e["n"], $me); 58 reply(["ok" => true, "light" => light($e)]); 59 } 60 if ($e["api"]) { 61 $r = ($e["api"])($do, $me, $in, $post); 62 if ($r !== null) reply($r, (int)($r["status"] ?? 200)); 63 } 64 } catch (Throwable $x) { 65 error_log("zoo: /api/{$e['n']}/$do: " . $x->getMessage()); 66 reply(["error" => $x->getMessage()], 500); 67 } 68 reply(["error" => "exhibit {$e['n']} has no '$do'"], 404); 69} 70 71if (preg_match('#^/how/(\d+)$#D', $path, $m)) { 72 require ZOO_ROOT . "/lib/how.php"; 73 how((int)$m[1]); 74 exit; 75} 76 77if ($path === "/lights.json") { 78 $out = ["env" => env("RC_ENV"), "checked" => ($t = meta("checked_at")) ? gmdate("c", (int)$t) : null, "lights" => []]; 79 foreach (exhibits() as $n => $e) { 80 $l = light($e); 81 $out["lights"][] = ["n" => $n, "title" => $e["title"], "kind" => $e["kind"], "color" => $l["color"], 82 "text" => $l["text"], "at" => isset($l["at"]) ? gmdate("c", $l["at"]) : null]; 83 } 84 require_once ZOO_ROOT . "/lib/alerts.php"; 85 $out["alerts"] = array_map(fn($a) => ["at" => gmdate("c", (int)$a["at"]), "kind" => $a["kind"], "title" => $a["title"], "body" => $a["body"], "results" => $a["results"]], alerts_recent(10)); 86 reply($out); 87} 88 89// Root publishes PLATFORM.md into every app's RC_FACTS; the zoo, being the documentation, serves 90// its copy so a model working in a clone (no RC_FACTS there) reads the version that is running. 91// The builder's brief lists the exhibits, and its model reads one's running source as an example: 92// plain text, from the same checkout "how" shows (exhibit 19), so it is the code that is live. 93if ($path === "/index.txt") { 94 header("Content-Type: text/plain; charset=utf-8"); 95 header("Cache-Control: max-age=300"); 96 foreach (exhibits() as $n => $e) { 97 echo "$n. {$e["title"]} ({$e["wing"]}" . ($e["kind"] === "waiting" ? ", not built yet" : "") . "): {$e["block"]}\n"; 98 } 99 exit; 100} 101if (preg_match('#^/how/(\d+)\.txt$#D', $path, $m)) { 102 require ZOO_ROOT . "/lib/how.php"; 103 $e = exhibits()[(int)$m[1]] ?? null; 104 header("Content-Type: text/plain; charset=utf-8"); 105 header("Cache-Control: no-store"); 106 if (!$e) { http_response_code(404); exit("no such exhibit\n"); } 107 echo "Exhibit {$e["n"]}: {$e["title"]}\nPromise: {$e["promise"]}\nBlock: {$e["block"]}\n"; 108 foreach (how_files($e) as $f) echo "\n=== $f ===\n", file_get_contents(ZOO_ROOT . "/$f"); 109 exit; 110} 111 112if ($path === "/platform.md") { 113 $doc = @file_get_contents(env("RC_FACTS") . "/PLATFORM.md"); 114 http_response_code($doc === false ? 404 : 200); 115 header("Content-Type: text/markdown; charset=utf-8"); 116 header("Cache-Control: max-age=300"); 117 echo $doc === false ? "PLATFORM.md is not published here yet.\n" : $doc; 118 exit; 119} 120 121if ($path === "/") { 122 require ZOO_ROOT . "/lib/page.php"; 123 page($me); 124 exit; 125} 126 127http_response_code(404); 128header("Content-Type: text/html; charset=utf-8"); 129header("Cache-Control: no-store"); 130echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">', 131 '<meta name="color-scheme" content="light dark"><title>Not found · Zoo</title>', rc_head(), '</head><body>', rc_header("Zoo", $me, "/api/3/signout", false), 132 '<main class="rc-main"><div class="rc-empty"><b>Nothing here</b><p>The zoo has no page at this address.</p>', 133 '<a class="button primary" href="/">Go to the zoo</a></div></main></body></html>';
lib/robot.php sha256 d7d4eb72ec40 · raw
1<?php 2/* 3 * The zoo's robot: a real account, `zoo-robot`, visible in the directory like anyone, that the 4 * self-check signs in as. Its passkey is a software key kept in $RC_DATA/robot.json (the zoo's 5 * own data; nothing of root's or account's). It signs in exactly as a person does: a WebAuthn 6 * assertion to account, then the silent upgrade into the zoo. 7 * 8 * Live and staging each have their own account, so each environment has its own robot. 9 */ 10 11require_once __DIR__ . "/web.php"; 12 13const ROBOT_NAME = "zoo-robot"; 14 15function b64u(string $b): string { return rtrim(strtr(base64_encode($b), "+/", "-_"), "="); } 16 17// CBOR, just enough for a COSE key and an attestation object. 18function cbor(int $major, int $n): string { 19 return $n < 24 ? chr(($major << 5) | $n) : ($n < 256 ? chr(($major << 5) | 24) . chr($n) : chr(($major << 5) | 25) . pack("n", $n)); 20} 21function cbor_int(int $n): string { return $n >= 0 ? cbor(0, $n) : cbor(1, -1 - $n); } 22function cbor_bytes(string $s): string { return cbor(2, strlen($s)) . $s; } 23function cbor_text(string $s): string { return cbor(3, strlen($s)) . $s; } 24function cbor_map(array $kv): string { $o = cbor(5, count($kv)); foreach ($kv as [$k, $v]) $o .= $k . $v; return $o; } 25 26function robot_path(): string { return env("RC_DATA") . "/robot.json"; } 27function rp_id(): string { return parse_url(env("RC_ACCOUNTS"), PHP_URL_HOST); } 28 29function account_api(Browser $b, string $name, array $in = []): array { 30 [$code, , $j] = $b->go("POST", env("RC_ACCOUNTS") . "/api/$name", $in, ["X-RC: 1"]); 31 return [$code, is_array($j) ? $j : []]; 32} 33 34// Make the robot's account (once per environment) and give it a picture. 35function robot_register(Browser $b): array { 36 $key = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_EC, "curve_name" => "prime256v1"]); 37 $credId = random_bytes(16); 38 // If a previous robot.json was lost, its name is retired for good: take the next free one. 39 for ($i = 0; $i < 5; $i++) { 40 $name = ROBOT_NAME . ($i ? "-$i" : ""); 41 [$code, $o] = account_api($b, "register-start", ["username" => $name]); 42 if ($code === 200) break; 43 } 44 if ($code !== 200) throw new RuntimeException("account would not register a robot: " . json_encode($o)); 45 $ec = openssl_pkey_get_details($key)["ec"]; 46 $cose = cbor_map([[cbor_int(1), cbor_int(2)], [cbor_int(3), cbor_int(-7)], [cbor_int(-1), cbor_int(1)], 47 [cbor_int(-2), cbor_bytes(str_pad($ec["x"], 32, "\0", STR_PAD_LEFT))], [cbor_int(-3), cbor_bytes(str_pad($ec["y"], 32, "\0", STR_PAD_LEFT))]]); 48 $ad = hash("sha256", rp_id(), true) . chr(0x45) . pack("N", 0) . str_repeat("\0", 16) . pack("n", 16) . $credId . $cose; 49 $att = cbor_map([[cbor_text("fmt"), cbor_text("none")], [cbor_text("attStmt"), cbor_map([])], [cbor_text("authData"), cbor_bytes($ad)]]); 50 $cdj = json_encode(["type" => "webauthn.create", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS"), "crossOrigin" => false]); 51 [$code, $j] = account_api($b, "register-finish", ["clientDataJSON" => b64u($cdj), "attestationObject" => b64u($att)]); 52 if ($code !== 200) throw new RuntimeException("robot registration failed: " . json_encode($j)); 53 openssl_pkey_export($key, $pem); 54 $r = ["username" => $j["user"]["username"], "id" => $j["user"]["id"], "cred" => b64u($credId), "key" => $pem, "count" => 0]; 55 robot_save($r); 56 account_api($b, "profile", ["name" => "Zoo Robot"]); 57 account_api($b, "picture", ["image" => base64_encode(robot_picture())]); 58 return $r; 59} 60 61// The robot's passkey and counter, written whole or not at all: a half-written file would read as 62// "no robot", the next run would register zoo-robot-1, and the real robot's name would be stranded. 63function robot_save(array $r): void { 64 $tmp = robot_path() . "." . bin2hex(random_bytes(4)); 65 $old = umask(077); 66 $ok = file_put_contents($tmp, json_encode($r)) === strlen(json_encode($r)); 67 umask($old); 68 if (!$ok || !rename($tmp, robot_path())) { @unlink($tmp); throw new RuntimeException("could not save the robot's state"); } 69} 70 71// A little robot face, drawn here so the guestbook has a picture to show. 72function robot_picture(): string { 73 $im = imagecreatetruecolor(128, 128); 74 imagefill($im, 0, 0, imagecolorallocate($im, 46, 125, 90)); 75 $w = imagecolorallocate($im, 240, 240, 230); 76 imagefilledrectangle($im, 24, 34, 104, 100, $w); 77 $k = imagecolorallocate($im, 30, 30, 30); 78 imagefilledellipse($im, 48, 60, 16, 16, $k); 79 imagefilledellipse($im, 80, 60, 16, 16, $k); 80 imagefilledrectangle($im, 46, 82, 82, 88, $k); 81 imagefilledrectangle($im, 61, 18, 67, 34, $w); 82 ob_start(); imagepng($im); return ob_get_clean(); 83} 84 85// A browser signed in as the robot at account, or an exception saying why not. One sign-in per 86// process; it signs out again when the process ends, so robot sessions never pile up. 87function robot(): Browser { 88 static $b = null; 89 if ($b) return $b; 90 $lock = fopen(robot_path() . ".lock", "c"); 91 flock($lock, LOCK_EX); // the cron check and a web check never sign in with one counter value 92 try { 93 $nb = new Browser(); 94 $r = json_decode((string)@file_get_contents(robot_path()), true) ?: robot_register($nb); 95 account_api($nb, "signout"); // registering signed it in; start clean either way 96 [$code, $o] = account_api($nb, "login-start"); 97 if ($code !== 200) throw new RuntimeException("account login-start answered $code"); 98 // The signature counter must go up every time. Time does, even after a restore from backup. 99 $r["count"] = max($r["count"] + 1, time()); 100 $ad = hash("sha256", rp_id(), true) . chr(0x05) . pack("N", $r["count"]); 101 $cdj = json_encode(["type" => "webauthn.get", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS")]); 102 openssl_sign($ad . hash("sha256", $cdj, true), $sig, openssl_pkey_get_private($r["key"]), OPENSSL_ALGO_SHA256); 103 robot_save($r); 104 [$code, $j] = account_api($nb, "login-finish", ["id" => $r["cred"], "clientDataJSON" => b64u($cdj), 105 "authenticatorData" => b64u($ad), "signature" => b64u($sig)]); 106 if ($code !== 200) throw new RuntimeException("the robot could not sign in at account ($code): " . ($j["error"] ?? "")); 107 } finally { 108 flock($lock, LOCK_UN); 109 } 110 register_shutdown_function(fn() => account_api($nb, "signout")); 111 return $b = $nb; 112} 113 114function robot_user(): array { 115 $r = json_decode((string)@file_get_contents(robot_path()), true) ?: []; 116 return ["id" => (int)($r["id"] ?? 0), "username" => (string)($r["username"] ?? "")]; 117} 118 119// The robot's browser after a page load at the zoo, so it holds the zoo's own token. 120function robot_at_zoo(): Browser { 121 static $done = null; 122 $b = robot(); 123 if (!$done) { 124 [$code] = $b->visit("https://" . env("RC_HOST") . "/"); 125 if ($code !== 200) throw new RuntimeException("the zoo answered the robot with $code"); 126 $done = true; 127 } 128 return $b; 129}