From eca76e831ae478a7dbc1196711b886701fd58319 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 14 Oct 2026 19:10:44 -0400 Subject: [PATCH 1/3] bug: the worker serves a clip or lineup file only on a signed link that has not expired The backblaze proxy now refuses a file under clips/ unless its link carries a valid `exp` and `sig` from the api, so a private clip can no longer be fetched by anyone who has or guesses its URL. - The signing key is HMAC-SHA256(S3_SECRET, "5stack-file-url-v1"), the same key the api derives and signs with. - The signature is checked with crypto.subtle.verify before the edge cache; a missing, expired or forged one gets a 403 with Cache-Control: no-store. - A key that B2 could resolve into clips/ (dot segments, escapes, empty segments) is held to the same signature. - exp and sig stay out of the cache key, so every signed link to a file shares one edge copy; name, dl, ranges and view counting are unchanged. - Keys outside clips/ (maps, news, events, demos) are served as before. Part of 5stackgg/5stack-panel#544 --- .../backblaze-proxy/index.test.ts | 217 +++++++++++++++++- cloudflare-workers/backblaze-proxy/index.ts | 116 +++++++++- .../backblaze-proxy/wrangler.toml | 4 +- 3 files changed, 320 insertions(+), 17 deletions(-) diff --git a/cloudflare-workers/backblaze-proxy/index.test.ts b/cloudflare-workers/backblaze-proxy/index.test.ts index 2c5dc94..d6308cd 100644 --- a/cloudflare-workers/backblaze-proxy/index.test.ts +++ b/cloudflare-workers/backblaze-proxy/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, it, mock } from "node:test"; import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; import worker from "./index.ts"; const env = { @@ -10,6 +11,18 @@ const env = { }; const ORIGIN = "https://5stack.gg"; +// The query string the api's computed fields put on a clip link +// (file_url_signature.sql), worked out the way the api does it. +function signed(key: string, exp = Math.floor(Date.now() / 1000) + 21600) { + const signingKey = createHmac("sha256", env.S3_SECRET) + .update("5stack-file-url-v1") + .digest(); + const sig = createHmac("sha256", signingKey) + .update(`${key}\n${exp}`) + .digest("base64url"); + return `exp=${exp}&sig=${sig}`; +} + const realFetch = globalThis.fetch; let upstream: ReturnType; @@ -107,7 +120,9 @@ describe("backblaze-proxy upstream failures", () => { ); globalThis.fetch = upstream as unknown as typeof fetch; - const { response } = await get("clips/missing.mp4"); + const { response } = await get( + `clips/missing.mp4?${signed("clips/missing.mp4")}`, + ); assert.equal(upstream.mock.callCount(), 3); assert.equal(response.status, 403); @@ -170,6 +185,7 @@ describe("backblaze-proxy health", () => { describe("backblaze-proxy clip views", () => { const API_URL = "https://api.5stack.gg"; const CLIP = "clips/76561198000000001/0b7c.mp4"; + const SIGNED = signed(CLIP); const UA = "Mozilla/5.0 (Macintosh) Chrome/141"; let waited: Promise[]; @@ -222,29 +238,29 @@ describe("backblaze-proxy clip views", () => { }); it("counts a played clip", async () => { - await send("GET", `${CLIP}?name=ace.mp4&v=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`); assert.equal(viewCalls().length, 1); }); it("does not count a request marked noview", async () => { - await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1&${SIGNED}`); assert.equal(viewCalls().length, 0); }); it("keeps one edge copy of a clip whether or not the request is marked noview", async () => { - await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1&${SIGNED}`); const marked = [...cacheKeys]; cacheKeys = []; - await send("GET", `${CLIP}?name=ace.mp4&v=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`); assert.deepEqual(marked, cacheKeys); assert.ok(cacheKeys.every((key) => !key.includes("noview"))); }); it("drops noview from the cache key without re-encoding the rest", async () => { - await send("GET", `${CLIP}?name=ace%20on%20B.mp4&noview=1&v=1`); + await send("GET", `${CLIP}?name=ace%20on%20B.mp4&noview=1&v=1&${SIGNED}`); assert.ok( cacheKeys.every( @@ -256,10 +272,10 @@ describe("backblaze-proxy clip views", () => { }); it("still keeps a named download apart from the inline copy", async () => { - await send("GET", `${CLIP}?name=ace.mp4&v=1&dl=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&dl=1&${SIGNED}`); const download = [...cacheKeys]; cacheKeys = []; - await send("GET", `${CLIP}?name=ace.mp4&v=1`); + await send("GET", `${CLIP}?name=ace.mp4&v=1&${SIGNED}`); assert.notDeepEqual(download, cacheKeys); }); @@ -284,3 +300,188 @@ describe("backblaze-proxy clip views", () => { ); }); }); + +describe("backblaze-proxy signed clip links", () => { + const CLIP = "clips/76561198000000001/0b7c.mp4"; + const LINEUP_STILL = + "clips/utility/ffe58c95-4581-4320-a14e-f2efb95ccebe/aim.jpg"; + + let cacheKeys: string[]; + let cached: Response | undefined; + + beforeEach(() => { + cacheKeys = []; + cached = undefined; + (globalThis as any).caches = { + default: { + match: async (request: Request) => { + cacheKeys.push(request.url); + return cached; + }, + put: async (request: Request) => { + cacheKeys.push(request.url); + }, + }, + }; + }); + + async function send( + path: string, + init: { method?: string; headers?: Record } = {}, + ) { + const ctx = { waitUntil: () => {}, passThroughOnException: () => {} }; + return worker.fetch( + new Request(`https://cf.5stack.gg/${path}`, { + method: init.method ?? "GET", + headers: { Origin: ORIGIN, ...init.headers }, + }), + env, + ctx as any, + ); + } + + async function refused(path: string, method = "GET") { + const response = await send(path, { method }); + + assert.equal(response.status, 403); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + assert.equal(response.headers.get("Access-Control-Allow-Origin"), ORIGIN); + assert.equal(upstream.mock.callCount(), 0); + assert.deepEqual(cacheKeys, []); + } + + const now = () => Math.floor(Date.now() / 1000); + + it("refuses a clip without a signature, before the cache or B2", async () => { + await refused(`${CLIP}?name=ace.mp4&v=1`); + }); + + it("refuses the HEAD a player sends first, too", async () => { + await refused(CLIP, "HEAD"); + }); + + it("refuses a utility lineup file without a signature", async () => { + await refused(`${LINEUP_STILL}?v=1`); + }); + + it("refuses a clip named by ?file= on another route", async () => { + await refused(`demo?file=${encodeURIComponent(CLIP)}`); + }); + + it("refuses an expired link", async () => { + await refused(`${CLIP}?${signed(CLIP, now() - 1)}`); + }); + + it("refuses a link signed for another file", async () => { + await refused(`${CLIP}?${signed(LINEUP_STILL)}`); + }); + + it("refuses a link whose expiry was pushed out", async () => { + const sig = new URLSearchParams(signed(CLIP)).get("sig"); + await refused(`${CLIP}?exp=${now() + 365 * 86400}&sig=${sig}`); + }); + + it("refuses a forged signature", async () => { + await refused(`${CLIP}?exp=${now() + 600}&sig=${"A".repeat(43)}`); + }); + + it("refuses a link signed with another bucket's secret", async () => { + const exp = now() + 600; + const otherKey = createHmac("sha256", "another-secret") + .update("5stack-file-url-v1") + .digest(); + const sig = createHmac("sha256", otherKey) + .update(`${CLIP}\n${exp}`) + .digest("base64url"); + await refused(`${CLIP}?exp=${exp}&sig=${sig}`); + }); + + for (const path of [ + `maps/%252e%252e/${CLIP}`, + `maps//${CLIP}`, + `maps/%5C..%5C${CLIP}`, + `demo?file=${encodeURIComponent(`/${CLIP}`)}`, + ]) { + it(`refuses ${path}, which B2 could resolve into clips/`, async () => { + await refused(path); + }); + } + + it("does not hand a cached copy of a clip to a request without a signature", async () => { + cached = new Response("cached clip", { status: 200 }); + + const response = await send(CLIP); + + assert.equal(response.status, 403); + assert.deepEqual(cacheKeys, []); + }); + + it("serves a clip on a link the api signed, by the key B2 holds it under", async () => { + const response = await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP)}`); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("Content-Type"), "video/mp4"); + assert.equal( + response.headers.get("Content-Disposition"), + 'inline; filename="ace.mp4"', + ); + assert.equal( + String(upstream.mock.calls[0].arguments[0]), + `https://5stack.s3.example.test/${CLIP}`, + ); + }); + + it("serves a signed link as a named download when asked", async () => { + const response = await send(`${CLIP}?${signed(CLIP)}&dl=1&name=ace.mp4`); + + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Content-Disposition"), + 'attachment; filename="ace.mp4"', + ); + }); + + it("answers a range on a signed link out of the cached copy", async () => { + cached = new Response("ace", { + status: 206, + headers: { "Content-Range": "bytes 0-2/100" }, + }); + + const response = await send(`${CLIP}?v=1&${signed(CLIP)}`, { + headers: { Range: "bytes=0-2" }, + }); + + assert.equal(response.status, 206); + assert.equal(upstream.mock.callCount(), 0); + }); + + it("keeps one edge copy of a file for every link signed for it", async () => { + await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP, now() + 21600)}`); + const first = [...cacheKeys]; + cacheKeys = []; + await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP, now() + 43200)}`); + + assert.deepEqual(first, cacheKeys); + assert.ok( + cacheKeys.every( + (key) => + key === + `https://cf.5stack.gg/${CLIP}?name=ace.mp4&v=1#origin=${ORIGIN}`, + ), + ); + }); + + for (const path of [ + "maps/25537370/manifest.json", + "news/9a3c.webp", + "events/4f1e/banner.jpg", + "demo/0b7c.dem", + ]) { + it(`serves ${path} without a signature`, async () => { + const response = await send(path); + + assert.equal(response.status, 200); + assert.equal(upstream.mock.callCount(), 1); + }); + } +}); diff --git a/cloudflare-workers/backblaze-proxy/index.ts b/cloudflare-workers/backblaze-proxy/index.ts index b38bd6d..8edadf6 100644 --- a/cloudflare-workers/backblaze-proxy/index.ts +++ b/cloudflare-workers/backblaze-proxy/index.ts @@ -235,23 +235,113 @@ function registerView( ); } -// `noview` only decides whether the request is counted, so it stays out of -// the cache key: the clip player's marked warm-up of the next clip and the -// unmarked play share the single copy fetched from B2. Filtered on the raw -// query so the other parameters keep the exact encoding existing cache -// entries were stored under. +// `noview` only decides whether the request is counted, and `exp`/`sig` only +// whether it is allowed, so they stay out of the cache key: the clip player's +// marked warm-up of the next clip, the unmarked play and every signed link to +// a file share the single copy fetched from B2. Filtered on the raw query so +// the other parameters keep the exact encoding existing cache entries were +// stored under. +const UNCACHED_PARAMS = new Set(["noview", "exp", "sig"]); + function cacheableUrl(url: URL): string { - if (!url.searchParams.has("noview")) { + if (![...UNCACHED_PARAMS].some((param) => url.searchParams.has(param))) { return url.toString(); } const query = url.search .slice(1) .split("&") - .filter((part) => part.split("=")[0] !== "noview") + .filter((part) => !UNCACHED_PARAMS.has(part.split("=")[0])) .join("&"); return `${url.origin}${url.pathname}${query ? `?${query}` : ""}`; } +// Clips and utility lineup files are private to who may see them, and the +// worker cannot tell from a key whose they are. The api's computed fields +// sign a link, for rows the viewer may read, with a key derived from +// S3_SECRET (api FileUrlSigningService, file_url_signature.sql), so a file +// under clips/ is served only on a signed link that has not run out. +const SIGNED_PREFIX = "clips/"; +const FILE_URL_KEY_LABEL = "5stack-file-url-v1"; + +const fileUrlKeys = new Map>(); + +function fileUrlKey(secret: string): Promise { + let key = fileUrlKeys.get(secret); + if (!key) { + key = (async () => { + const encoder = new TextEncoder(); + const bucketKey = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const derived = await crypto.subtle.sign( + "HMAC", + bucketKey, + encoder.encode(FILE_URL_KEY_LABEL), + ); + return crypto.subtle.importKey( + "raw", + derived, + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + })(); + fileUrlKeys.set(secret, key); + } + return key; +} + +// B2 is asked for whatever the upstream URL resolves to, so a key that would +// resolve to something else (dot segments, escapes, a smuggled ?, empty +// segments) could reach clips/ while not starting with it. Such a key is never +// one the api signs, so it is held to the signature like a clip. +function requiresSignature(key: string): boolean { + if (key.startsWith(SIGNED_PREFIX)) { + return true; + } + if ( + key.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + return true; + } + try { + return ( + decodeURIComponent(new URL(`https://bucket.invalid/${key}`).pathname) !== + `/${key}` + ); + } catch { + return true; + } +} + +async function signatureValid( + url: URL, + key: string, + secret: string, +): Promise { + const exp = url.searchParams.get("exp") ?? ""; + const sig = url.searchParams.get("sig") ?? ""; + if ( + !secret || + !/^\d{1,12}$/.test(exp) || + Number(exp) * 1000 < Date.now() || + !/^[A-Za-z0-9_-]{43}$/.test(sig) + ) { + return false; + } + // crypto.subtle.verify compares in constant time. + return crypto.subtle.verify( + "HMAC", + await fileUrlKey(secret), + base64urlToBytes(sig), + new TextEncoder().encode(`${key}\n${exp}`), + ); +} + // Decorates a response on its way to the client: mp4 Content-Type fix and the // view beacon. The body is never read or rewritten here. function clientResponse( @@ -347,6 +437,18 @@ export default { } const key = resolveKey(url); + // Before the cache: a copy of the file is there for every signed link. + if ( + key && + requiresSignature(key) && + !(await signatureValid(url, key, env.S3_SECRET)) + ) { + return new Response("forbidden", { + status: 403, + headers: { ...corsHeaders(reqOrigin), "Cache-Control": "no-store" }, + }); + } + const track = shouldTrackView(request, url, key, env); const rangeHeader = request.method === "GET" ? request.headers.get("range") : null; diff --git a/cloudflare-workers/backblaze-proxy/wrangler.toml b/cloudflare-workers/backblaze-proxy/wrangler.toml index e391c18..77f46b0 100644 --- a/cloudflare-workers/backblaze-proxy/wrangler.toml +++ b/cloudflare-workers/backblaze-proxy/wrangler.toml @@ -13,8 +13,8 @@ # Renaming spawns a new worker while the old one keeps serving. # # Secrets (S3_ACCESS_KEY, S3_SECRET) are set with `wrangler secret`. S3_SECRET -# doubles as the key the worker checks demo upload tokens (PUT) with: the API -# signs them with the same B2 credential. +# doubles as the key the worker checks demo upload tokens (PUT) and signed clip +# links with: the API signs both with the same B2 credential. name = "5stack" main = "index.ts" From a88b80f14969a6c44e6c84d9652ba6d663f02f04 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 14 Oct 2026 19:50:31 -0400 Subject: [PATCH 2/3] bug: the worker requires signed clip links only once the panel's api is seen signing them Enforcing in front of an api that does not sign, or signs with another S3_SECRET, refused every clip, and backblaze-proxy.sh deployed the worker with no check. - The worker refuses unsigned clips/ links only when deployed with REQUIRE_SIGNED_CLIPS=1; otherwise it serves them as before. - backblaze-proxy.sh sets it only when the panel's database (asked through its kubeconfig) has a clip link function that signs and a stored key matching the worker's S3_SECRET, compared by hash; otherwise it deploys without it and says why. - Re-running the script after an update, a rollback or an S3_SECRET change re-decides, and the secrets example and wrangler.toml say when to. - The worker is tested in both modes and the check against a stubbed kubectl; the workflow also runs for the script and its check. Part of 5stackgg/5stack-panel#544 --- .github/workflows/cloudflare-workers.yml | 10 +- backblaze-proxy.sh | 18 ++ .../backblaze-proxy/index.test.ts | 36 +++- cloudflare-workers/backblaze-proxy/index.ts | 6 + .../backblaze-proxy/signed-clip-links.test.ts | 154 ++++++++++++++++++ .../backblaze-proxy/wrangler.toml | 8 +- overlays/local-secrets/s3-secrets.env.example | 3 + utils/signed_clip_links.sh | 88 ++++++++++ 8 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts create mode 100644 utils/signed_clip_links.sh diff --git a/.github/workflows/cloudflare-workers.yml b/.github/workflows/cloudflare-workers.yml index c505a48..2e7aa31 100644 --- a/.github/workflows/cloudflare-workers.yml +++ b/.github/workflows/cloudflare-workers.yml @@ -3,9 +3,15 @@ name: Cloudflare workers on: push: branches: [main] - paths: ["cloudflare-workers/**"] + paths: + - "cloudflare-workers/**" + - "backblaze-proxy.sh" + - "utils/signed_clip_links.sh" pull_request: - paths: ["cloudflare-workers/**"] + paths: + - "cloudflare-workers/**" + - "backblaze-proxy.sh" + - "utils/signed_clip_links.sh" jobs: test: diff --git a/backblaze-proxy.sh b/backblaze-proxy.sh index bcd2b36..6fa0dcf 100755 --- a/backblaze-proxy.sh +++ b/backblaze-proxy.sh @@ -19,6 +19,12 @@ # Safe to run again; it updates the worker in place and keeps any routes it # already has on other hostnames, so older links keep working. # +# The worker refuses a clip or lineup file without a signed link only when the +# panel's database shows its API signing them with the S3_SECRET the worker +# gets (utils/signed_clip_links.sh); otherwise it serves them as before. Run it +# again after updating the panel to a release that signs them, after changing +# S3_SECRET, and after rolling the panel back. +# # ./backblaze-proxy.sh [hostname] PANEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -27,6 +33,7 @@ source "$PANEL_DIR/utils/interactive_select.sh" source "$PANEL_DIR/utils/print_domains_and_hosts.sh" source "$PANEL_DIR/utils/update_env_var.sh" source "$PANEL_DIR/utils/cloudflare_workers.sh" +source "$PANEL_DIR/utils/signed_clip_links.sh" # Existing Cloudflare routes point at this name; renaming spawns a second # worker while the old one keeps serving. @@ -270,6 +277,12 @@ if [ -n "$API_DOMAIN" ]; then VARS+=(--var "API_URL:https://$API_DOMAIN") fi +step "Checking that the panel signs clip links" +SIGNED_CLIPS="$(signed_clip_links_state "$S3_SECRET")" +if require_signed_clip_links "$SIGNED_CLIPS"; then + VARS+=(--var "REQUIRE_SIGNED_CLIPS:1") +fi + step "Ready to deploy" ok "Worker: $WORKER_NAME" ok "Hostname: https://$WORKER_HOST (${ROUTE_PATHS[*]})" @@ -277,6 +290,11 @@ ok "Bucket: $S3_BUCKET at $S3_ENDPOINT" if [ -n "$API_DOMAIN" ]; then ok "API: https://$API_DOMAIN (clip views are counted there)" fi +if [ "$SIGNED_CLIPS" = signing ]; then + ok "Clips: signed links required" +else + warn "Clips: signed links not required" +fi read -r -p " Deploy it? [Y/n] " CONFIRM if [[ "$CONFIRM" =~ ^[Nn] ]]; then warn "Nothing deployed." diff --git a/cloudflare-workers/backblaze-proxy/index.test.ts b/cloudflare-workers/backblaze-proxy/index.test.ts index d6308cd..c004ba4 100644 --- a/cloudflare-workers/backblaze-proxy/index.test.ts +++ b/cloudflare-workers/backblaze-proxy/index.test.ts @@ -308,10 +308,12 @@ describe("backblaze-proxy signed clip links", () => { let cacheKeys: string[]; let cached: Response | undefined; + let requireSigned: boolean; beforeEach(() => { cacheKeys = []; cached = undefined; + requireSigned = true; (globalThis as any).caches = { default: { match: async (request: Request) => { @@ -335,7 +337,7 @@ describe("backblaze-proxy signed clip links", () => { method: init.method ?? "GET", headers: { Origin: ORIGIN, ...init.headers }, }), - env, + requireSigned ? { ...env, REQUIRE_SIGNED_CLIPS: "1" } : env, ctx as any, ); } @@ -484,4 +486,36 @@ describe("backblaze-proxy signed clip links", () => { assert.equal(upstream.mock.callCount(), 1); }); } + + // Deployed this way until ./backblaze-proxy.sh has seen the panel's api sign + // with this S3_SECRET: enforcing in front of an api that does not would + // refuse every clip. + describe("without REQUIRE_SIGNED_CLIPS", () => { + beforeEach(() => { + requireSigned = false; + }); + + for (const [label, path] of [ + ["a clip without a signature", `${CLIP}?name=ace.mp4&v=1`], + ["a lineup file without a signature", `${LINEUP_STILL}?v=1`], + ["an expired link", `${CLIP}?${signed(CLIP, now() - 1)}`], + ["a forged link", `${CLIP}?exp=${now() + 600}&sig=${"A".repeat(43)}`], + ]) { + it(`serves ${label}, as before signed links`, async () => { + const response = await send(path); + + assert.equal(response.status, 200); + assert.equal(upstream.mock.callCount(), 1); + }); + } + + it("keeps one edge copy of a clip for its signed and unsigned links", async () => { + await send(`${CLIP}?name=ace.mp4&v=1`); + const unsigned = [...cacheKeys]; + cacheKeys = []; + await send(`${CLIP}?name=ace.mp4&v=1&${signed(CLIP)}`); + + assert.deepEqual(unsigned, cacheKeys); + }); + }); }); diff --git a/cloudflare-workers/backblaze-proxy/index.ts b/cloudflare-workers/backblaze-proxy/index.ts index 8edadf6..e47d5a3 100644 --- a/cloudflare-workers/backblaze-proxy/index.ts +++ b/cloudflare-workers/backblaze-proxy/index.ts @@ -260,6 +260,10 @@ function cacheableUrl(url: URL): string { // sign a link, for rows the viewer may read, with a key derived from // S3_SECRET (api FileUrlSigningService, file_url_signature.sql), so a file // under clips/ is served only on a signed link that has not run out. +// +// Only with REQUIRE_SIGNED_CLIPS=1, which ./backblaze-proxy.sh sets once it has +// seen the panel's api sign with this S3_SECRET: in front of an api that does +// not, every clip would be refused. const SIGNED_PREFIX = "clips/"; const FILE_URL_KEY_LABEL = "5stack-file-url-v1"; @@ -408,6 +412,7 @@ export default { BUCKET_NAME: string; S3_ENDPOINT: string; API_URL?: string; + REQUIRE_SIGNED_CLIPS?: string; }, ctx: ExecutionContext, ) { @@ -439,6 +444,7 @@ export default { const key = resolveKey(url); // Before the cache: a copy of the file is there for every signed link. if ( + env.REQUIRE_SIGNED_CLIPS === "1" && key && requiresSignature(key) && !(await signatureValid(url, key, env.S3_SECRET)) diff --git a/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts b/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts new file mode 100644 index 0000000..14e725e --- /dev/null +++ b/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash, createHmac } from "node:crypto"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const UTILS = join(import.meta.dirname, "..", "..", "utils"); +const SECRET = "bucket-secret"; +const KUBECONFIG = "/etc/5stack/kubeconfig"; + +function keyHash(secret: string) { + const key = createHmac("sha256", secret) + .update("5stack-file-url-v1") + .digest(); + return createHash("sha256").update(key).digest("hex"); +} + +// Stands in for kubectl: records how it was called and answers the check's two +// queries (the link function, then the stored key's hash) from the environment. +const KUBECTL = `#!/bin/sh +printf '%s\\n' "$*" >> "$STUB_DIR/calls" +if [ -n "$STUB_FAIL" ]; then + exit 1 +fi +for sql; do :; done +case "$sql" in + *pg_get_functiondef*) printf '%s\\n' "$STUB_SIGNS" ;; + *sha256*) if [ -n "$STUB_KEY_HASH" ]; then printf '%s\\n' "$STUB_KEY_HASH"; fi ;; +esac +`; + +// backblaze-proxy.sh turns on REQUIRE_SIGNED_CLIPS only when this says +// "signing": a worker refusing unsigned clip links in front of an API that does +// not sign them, or signs with another S3_SECRET, refuses every clip. +describe("backblaze-proxy.sh signed clip links check", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "signed-clip-links-")); + writeFileSync(join(dir, "kubectl"), KUBECTL); + chmodSync(join(dir, "kubectl"), 0o755); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function run( + script: string, + stub: Record, + ...args: string[] + ) { + const result = spawnSync( + "bash", + [ + "-c", + `source "${UTILS}/colors.sh"; source "${UTILS}/signed_clip_links.sh"; ${script}`, + "bash", + ...args, + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + PANEL_KUBECONFIG: KUBECONFIG, + STUB_DIR: dir, + ...stub, + }, + }, + ); + return { status: result.status, output: result.stdout.trim() }; + } + + const state = (stub: Record) => + run('signed_clip_links_state "$1"', stub, SECRET).output; + + const calls = () => + readFileSync(join(dir, "calls"), "utf8").trim().split("\n"); + + it("is signing when the link function signs with the key the worker's S3_SECRET derives", () => { + assert.equal( + state({ STUB_SIGNS: "t", STUB_KEY_HASH: keyHash(SECRET) }), + "signing", + ); + for (const call of calls()) { + assert.match( + call, + new RegExp( + `^--kubeconfig=${KUBECONFIG} -n 5stack exec timescaledb-0 -c timescaledb -- sh -c `, + ), + ); + } + }); + + it("never asks for the key itself, only its hash", () => { + state({ STUB_SIGNS: "t", STUB_KEY_HASH: keyHash(SECRET) }); + + assert.match(calls()[1], /encode\(sha256\(key\), 'hex'\)/); + }); + + it("is a mismatch when the API signs with another S3_SECRET", () => { + assert.equal( + state({ STUB_SIGNS: "t", STUB_KEY_HASH: keyHash("rotated-secret") }), + "mismatch", + ); + }); + + it("is unsigned when the database's link function does not sign", () => { + assert.equal(state({ STUB_SIGNS: "f" }), "unsigned"); + assert.equal(calls().length, 1); + }); + + it("is unsigned when the database predates the link function", () => { + assert.equal(state({ STUB_SIGNS: "" }), "unsigned"); + }); + + it("is unsigned when the API has not stored its key yet", () => { + assert.equal(state({ STUB_SIGNS: "t" }), "unsigned"); + }); + + it("is unreachable when kubectl cannot reach the panel", () => { + assert.equal(state({ STUB_FAIL: "1" }), "unreachable"); + }); + + for (const [current, required] of [ + ["signing", true], + ["mismatch", false], + ["unsigned", false], + ["unreachable", false], + ] as const) { + it(`${required ? "requires" : "does not require"} signed links when the panel is ${current}`, () => { + const { status, output } = run( + 'require_signed_clip_links "$1"', + {}, + current, + ); + + assert.equal(status === 0, required); + if (!required) { + assert.match(output, /can be downloaded by anyone who has its link/); + assert.match(output, /run this\s+again/i); + } + }); + } +}); diff --git a/cloudflare-workers/backblaze-proxy/wrangler.toml b/cloudflare-workers/backblaze-proxy/wrangler.toml index 77f46b0..40ceb64 100644 --- a/cloudflare-workers/backblaze-proxy/wrangler.toml +++ b/cloudflare-workers/backblaze-proxy/wrangler.toml @@ -14,7 +14,9 @@ # # Secrets (S3_ACCESS_KEY, S3_SECRET) are set with `wrangler secret`. S3_SECRET # doubles as the key the worker checks demo upload tokens (PUT) and signed clip -# links with: the API signs both with the same B2 credential. +# links with: the API signs both with the same B2 credential. Changing it on the +# panel means running ./backblaze-proxy.sh again, or the worker refuses every +# clip the API signs with the new one. name = "5stack" main = "index.ts" @@ -29,3 +31,7 @@ enabled = true # S3_ENDPOINT the bucket's S3 host, e.g. s3.us-east-005.backblazeb2.com # API_URL the panel's API; clip playback starts are beaconed to # {API_URL}/clip-views/play. Unset disables view counting. +# REQUIRE_SIGNED_CLIPS +# 1 refuses a file under clips/ without a signed link. Set only +# once the panel's API signs them with this S3_SECRET +# (utils/signed_clip_links.sh); unset serves them as before. diff --git a/overlays/local-secrets/s3-secrets.env.example b/overlays/local-secrets/s3-secrets.env.example index 8ad2ece..99408db 100644 --- a/overlays/local-secrets/s3-secrets.env.example +++ b/overlays/local-secrets/s3-secrets.env.example @@ -1,3 +1,6 @@ # access your s3 bucket at https://console...... , create a bucket and access key +# Behind the Backblaze proxy, its worker keeps a copy of these keys and checks +# signed clip links with S3_SECRET: after changing them, run ./update.sh and +# then ./backblaze-proxy.sh again. S3_ACCESS_KEY=$(RAND32) S3_SECRET=$(RAND32) \ No newline at end of file diff --git a/utils/signed_clip_links.sh b/utils/signed_clip_links.sh new file mode 100644 index 0000000..b01fc2a --- /dev/null +++ b/utils/signed_clip_links.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Whether the panel's API signs clip and lineup file links with the key a +# backblaze-proxy worker holding S3_SECRET checks them with. The worker refuses +# unsigned clip links only when deployed with REQUIRE_SIGNED_CLIPS=1, and only +# "signing" here may turn that on: in front of an API that does not sign, or +# signs with another S3_SECRET, the worker would refuse every clip. +# +# Asks the database rather than the API's version: it is the database's link +# functions that sign, and they are what a rollback or a stalled update leaves +# behind. Compares a hash of the stored key, so the key itself never leaves the +# cluster. +# +# signed_clip_links_state S3_SECRET +# +# Prints one of: +# signing the clip link function signs, with the key S3_SECRET derives +# mismatch it signs, with a key from a different S3_SECRET +# unsigned an API from before signed links, or one that has not stored +# its key yet +# unreachable the panel's database could not be asked +signed_clip_links_state() { + local secret="$1" signs stored expected + + if ! signs="$(signed_clip_links_psql "SELECT to_regclass('private.file_url_signing_key') IS NOT NULL AND position('file_url_signature' IN pg_get_functiondef(to_regprocedure('public.clip_download_url(public.match_clips)'))) > 0")"; then + echo unreachable + return + fi + if [ "$signs" != "t" ]; then + echo unsigned + return + fi + + if ! stored="$(signed_clip_links_psql "SELECT encode(sha256(key), 'hex') FROM private.file_url_signing_key")"; then + echo unreachable + return + fi + if [ -z "$stored" ]; then + echo unsigned + return + fi + + expected="$(S3_SECRET="$secret" node -e ' + const crypto = require("crypto"); + const key = crypto.createHmac("sha256", process.env.S3_SECRET).update("5stack-file-url-v1").digest(); + process.stdout.write(crypto.createHash("sha256").update(key).digest("hex")); + ')" + if [ "$stored" = "$expected" ]; then + echo signing + else + echo mismatch + fi +} + +# require_signed_clip_links STATE -- says what STATE means for the worker, and +# succeeds only when it may refuse unsigned clip links. +require_signed_clip_links() { + case "$1" in + signing) + ok "The panel's API signs clip links with these keys, so the worker will refuse" + ok "a clip or lineup file without one. Every panel this worker serves has to" + ok "sign them too, or its clips stop loading." + return 0 + ;; + mismatch) + warn "The panel's API signs clip links with a different S3_SECRET than the one" + warn "going to the worker, so the worker will not require them. Give the worker" + warn "the keys the API runs with (after changing them, ./update.sh restarts the" + warn "API), then run this again." + ;; + unsigned) + warn "The panel's API does not sign clip links yet, so the worker will not" + warn "require them. Update the panel (./update.sh), then run this again." + ;; + *) + warn "Could not ask the panel's database (kubeconfig $PANEL_KUBECONFIG) whether" + warn "its API signs clip links, so the worker will not require them. Run this" + warn "again where kubectl reaches the panel." + ;; + esac + warn "Until then a private clip can be downloaded by anyone who has its link." + return 1 +} + +signed_clip_links_psql() { + kubectl --kubeconfig="$PANEL_KUBECONFIG" -n 5stack exec timescaledb-0 -c timescaledb -- \ + sh -c 'psql -X -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "$1"' sh "$1" 2>/dev/null +} From a0f07022a1dbf019d36ecc6845585f6136726935 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 14 Oct 2026 20:06:01 -0400 Subject: [PATCH 3/3] bug: re-running backblaze-proxy.sh never turns signed clip links off without an explicit yes A deploy replaces every var the worker has, so a run that could not reach the panel, or ran from a panel not yet signing links, silently dropped REQUIRE_SIGNED_CLIPS for every panel on the worker. - The script reads the worker's current bindings from Cloudflare (GET /accounts/:account/workers/scripts/5stack/settings) before deciding. - A worker that requires signed links keeps requiring them when the panel's database cannot be asked, and says so. - When the panel does not sign, or signs with another S3_SECRET, turning it off takes a yes to a question that defaults to no and names every panel on the worker; no answer deploys nothing and exits non-zero. A worker whose state Cloudflare will not report is asked the same. - Turning it on says every panel using the worker must already sign links. - Tests cover the Cloudflare read, the worker on/off/unknown by panel state decision table with its answers, and the script block that applies it. Part of 5stackgg/5stack-panel#544 --- backblaze-proxy.sh | 31 ++- .../backblaze-proxy/signed-clip-links.test.ts | 249 ++++++++++++++++-- .../backblaze-proxy/wrangler.toml | 5 +- utils/signed_clip_links.sh | 94 +++++-- 4 files changed, 331 insertions(+), 48 deletions(-) diff --git a/backblaze-proxy.sh b/backblaze-proxy.sh index 6fa0dcf..a9f1a14 100755 --- a/backblaze-proxy.sh +++ b/backblaze-proxy.sh @@ -19,11 +19,12 @@ # Safe to run again; it updates the worker in place and keeps any routes it # already has on other hostnames, so older links keep working. # -# The worker refuses a clip or lineup file without a signed link only when the -# panel's database shows its API signing them with the S3_SECRET the worker -# gets (utils/signed_clip_links.sh); otherwise it serves them as before. Run it -# again after updating the panel to a release that signs them, after changing -# S3_SECRET, and after rolling the panel back. +# The worker starts refusing a clip or lineup file without a signed link once +# the panel's database shows its API signing them with the S3_SECRET the worker +# gets (utils/signed_clip_links.sh). Once it does, a run that cannot reach the +# panel keeps it on, and only an explicit yes turns it off: one worker can serve +# several panels. Run it again, where kubectl reaches the panel, after every +# panel update or rollback and after changing S3_SECRET. # # ./backblaze-proxy.sh [hostname] @@ -278,10 +279,20 @@ if [ -n "$API_DOMAIN" ]; then fi step "Checking that the panel signs clip links" -SIGNED_CLIPS="$(signed_clip_links_state "$S3_SECRET")" -if require_signed_clip_links "$SIGNED_CLIPS"; then - VARS+=(--var "REQUIRE_SIGNED_CLIPS:1") -fi +SIGNED_CLIPS_REQUIRED=false +require_signed_clip_links \ + "$(worker_signed_clip_links "$WORKER_NAME")" \ + "$(signed_clip_links_state "$S3_SECRET")" +case "$?" in + 0) + SIGNED_CLIPS_REQUIRED=true + VARS+=(--var "REQUIRE_SIGNED_CLIPS:1") + ;; + 1) ;; + *) + die "Nothing deployed, so the worker is as it was." + ;; +esac step "Ready to deploy" ok "Worker: $WORKER_NAME" @@ -290,7 +301,7 @@ ok "Bucket: $S3_BUCKET at $S3_ENDPOINT" if [ -n "$API_DOMAIN" ]; then ok "API: https://$API_DOMAIN (clip views are counted there)" fi -if [ "$SIGNED_CLIPS" = signing ]; then +if [ "$SIGNED_CLIPS_REQUIRED" = true ]; then ok "Clips: signed links required" else warn "Clips: signed links not required" diff --git a/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts b/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts index 14e725e..7953371 100644 --- a/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts +++ b/cloudflare-workers/backblaze-proxy/signed-clip-links.test.ts @@ -37,6 +37,14 @@ case "$sql" in esac `; +// Stands in for curl under cf_api: records the request and the config it was +// handed on stdin, and answers with STUB_SETTINGS. +const CURL = `#!/bin/sh +printf '%s\\n' "$*" >> "$STUB_DIR/curl-calls" +cat >> "$STUB_DIR/curl-config" +printf '%s' "$STUB_SETTINGS" +`; + // backblaze-proxy.sh turns on REQUIRE_SIGNED_CLIPS only when this says // "signing": a worker refusing unsigned clip links in front of an API that does // not sign them, or signs with another S3_SECRET, refuses every clip. @@ -45,8 +53,13 @@ describe("backblaze-proxy.sh signed clip links check", () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "signed-clip-links-")); - writeFileSync(join(dir, "kubectl"), KUBECTL); - chmodSync(join(dir, "kubectl"), 0o755); + for (const [name, body] of [ + ["kubectl", KUBECTL], + ["curl", CURL], + ]) { + writeFileSync(join(dir, name), body); + chmodSync(join(dir, name), 0o755); + } }); afterEach(() => { @@ -62,16 +75,19 @@ describe("backblaze-proxy.sh signed clip links check", () => { "bash", [ "-c", - `source "${UTILS}/colors.sh"; source "${UTILS}/signed_clip_links.sh"; ${script}`, + `source "${UTILS}/colors.sh"; source "${UTILS}/cloudflare_workers.sh"; source "${UTILS}/signed_clip_links.sh"; ${script}`, "bash", ...args, ], { encoding: "utf8", + input: stub.STDIN ?? "", env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, PANEL_KUBECONFIG: KUBECONFIG, + CLOUDFLARE_ACCOUNT_ID: "acc123", + CF_TOKEN: "cf-token", STUB_DIR: dir, ...stub, }, @@ -131,24 +147,219 @@ describe("backblaze-proxy.sh signed clip links check", () => { assert.equal(state({ STUB_FAIL: "1" }), "unreachable"); }); - for (const [current, required] of [ - ["signing", true], - ["mismatch", false], - ["unsigned", false], - ["unreachable", false], - ] as const) { - it(`${required ? "requires" : "does not require"} signed links when the panel is ${current}`, () => { - const { status, output } = run( - 'require_signed_clip_links "$1"', - {}, - current, + describe("whether the deployed worker requires signed links now", () => { + const worker = (settings: unknown) => + run( + 'worker_signed_clip_links "$1"', + { + STUB_SETTINGS: JSON.stringify(settings), + }, + "5stack", + ).output; + + const settings = (bindings: Array>) => ({ + success: true, + errors: [], + result: { bindings }, + }); + + it("is on when the worker has REQUIRE_SIGNED_CLIPS=1, asked of its settings", () => { + assert.equal( + worker( + settings([ + { type: "plain_text", name: "API_URL", text: "https://api.test" }, + { type: "plain_text", name: "REQUIRE_SIGNED_CLIPS", text: "1" }, + { type: "secret_text", name: "S3_SECRET" }, + ]), + ), + "on", + ); + assert.match( + readFileSync(join(dir, "curl-calls"), "utf8"), + /-X GET .*https:\/\/api\.cloudflare\.com\/client\/v4\/accounts\/acc123\/workers\/scripts\/5stack\/settings$/m, + ); + assert.doesNotMatch( + readFileSync(join(dir, "curl-calls"), "utf8"), + /cf-token/, + ); + assert.match(readFileSync(join(dir, "curl-config"), "utf8"), /cf-token/); + }); + + it("is off when the worker has no REQUIRE_SIGNED_CLIPS", () => { + assert.equal( + worker( + settings([ + { type: "plain_text", name: "API_URL", text: "https://api.test" }, + ]), + ), + "off", + ); + }); + + it("is off when REQUIRE_SIGNED_CLIPS is anything but 1", () => { + assert.equal( + worker( + settings([ + { type: "plain_text", name: "REQUIRE_SIGNED_CLIPS", text: "0" }, + ]), + ), + "off", + ); + }); + + it("is off when the worker has never been deployed", () => { + assert.equal( + worker({ + success: false, + errors: [ + { + code: 10007, + message: "This Worker does not exist on your account.", + }, + ], + }), + "off", + ); + }); + + it("is unknown when Cloudflare will not say", () => { + assert.equal( + worker({ + success: false, + errors: [{ code: 10000, message: "Authentication error" }], + }), + "unknown", + ); + assert.equal(worker("bad gateway"), "unknown"); + }); + }); + + // 0 requires signed links on this deploy, 1 deploys without, 2 deploys + // nothing. STDIN is the operator's answer when they are asked; "" is a run + // with nobody there to answer. + describe("what a deploy does about them", () => { + const decide = (worker: string, state: string, answer = "") => + run( + 'require_signed_clip_links "$1" "$2"', + { STDIN: answer }, + worker, + state, + ); + + const TABLE: Array<[string, string, string, number]> = [ + ["on", "signing", "", 0], + ["on", "unreachable", "", 0], + ["on", "unsigned", "", 2], + ["on", "unsigned", "n\n", 2], + ["on", "unsigned", "y\n", 1], + ["on", "mismatch", "", 2], + ["on", "mismatch", "n\n", 2], + ["on", "mismatch", "y\n", 1], + ["off", "signing", "", 0], + ["off", "unreachable", "", 1], + ["off", "unsigned", "", 1], + ["off", "mismatch", "", 1], + ["unknown", "signing", "", 0], + ["unknown", "unreachable", "", 2], + ["unknown", "unreachable", "y\n", 1], + ["unknown", "unsigned", "", 2], + ["unknown", "unsigned", "y\n", 1], + ["unknown", "mismatch", "", 2], + ["unknown", "mismatch", "y\n", 1], + ]; + + for (const [worker, state, answer, status] of TABLE) { + const asked = answer ? ` and answered ${answer.trim()}` : ""; + it(`${["requires them", "deploys without them", "deploys nothing"][status]} when the worker is ${worker}, the panel ${state}${asked}`, () => { + assert.equal(decide(worker, state, answer).status, status); + }); + } + + it("keeps requiring them when the panel cannot be asked, and says why", () => { + const { status, output } = decide("on", "unreachable"); + + assert.equal(status, 0); + assert.match(output, /keeps\s+requiring them/); + assert.match(output, new RegExp(KUBECONFIG)); + }); + + it("says turning them off reaches every panel on the worker before asking", () => { + const { output } = decide("on", "unsigned"); + + assert.match( + output, + /turns\s+that off for every panel using this worker/, ); + }); - assert.equal(status === 0, required); - if (!required) { - assert.match(output, /can be downloaded by anyone who has its link/); - assert.match(output, /run this\s+again/i); + it("warns that every panel on the worker must already sign them when it turns them on", () => { + for (const worker of ["off", "unknown"]) { + assert.match( + decide(worker, "signing").output, + /Every panel using this worker\s+must\s+already sign them/, + ); } }); - } + + it("says a private clip stays downloadable while they are off", () => { + for (const state of ["unreachable", "unsigned", "mismatch"]) { + assert.match( + decide("off", state).output, + /can be downloaded by anyone who has its link/, + ); + } + }); + }); + + // The block of backblaze-proxy.sh that acts on the decision, run as written + // with the decision stubbed: only it puts REQUIRE_SIGNED_CLIPS on the deploy. + describe("the deploy script", () => { + const script = readFileSync( + join(import.meta.dirname, "..", "..", "backblaze-proxy.sh"), + "utf8", + ); + const block = script.slice( + script.indexOf('step "Checking that the panel signs clip links"'), + script.indexOf('step "Ready to deploy"'), + ); + + const deploy = (decision: number) => { + const result = spawnSync( + "bash", + [ + "-c", + `step() { :; }; warn() { :; }; die() { exit 1; } +worker_signed_clip_links() { echo on; } +signed_clip_links_state() { echo unsigned; } +require_signed_clip_links() { return ${decision}; } +VARS=(--var "BUCKET_NAME:5stack") +${block} +printf '%s\\n' "$SIGNED_CLIPS_REQUIRED" "\${VARS[*]}"`, + ], + { encoding: "utf8" }, + ); + return { + status: result.status, + lines: result.stdout.trim().split("\n"), + }; + }; + + it("deploys with REQUIRE_SIGNED_CLIPS=1 only when the decision requires it", () => { + assert.deepEqual(deploy(0), { + status: 0, + lines: [ + "true", + "--var BUCKET_NAME:5stack --var REQUIRE_SIGNED_CLIPS:1", + ], + }); + assert.deepEqual(deploy(1), { + status: 0, + lines: ["false", "--var BUCKET_NAME:5stack"], + }); + }); + + it("stops before deploying when the operator does not agree to turn them off", () => { + assert.equal(deploy(2).status, 1); + }); + }); }); diff --git a/cloudflare-workers/backblaze-proxy/wrangler.toml b/cloudflare-workers/backblaze-proxy/wrangler.toml index 40ceb64..21af9fa 100644 --- a/cloudflare-workers/backblaze-proxy/wrangler.toml +++ b/cloudflare-workers/backblaze-proxy/wrangler.toml @@ -32,6 +32,7 @@ enabled = true # API_URL the panel's API; clip playback starts are beaconed to # {API_URL}/clip-views/play. Unset disables view counting. # REQUIRE_SIGNED_CLIPS -# 1 refuses a file under clips/ without a signed link. Set only -# once the panel's API signs them with this S3_SECRET +# 1 refuses a file under clips/ without a signed link. Set once +# the panel's API signs them with this S3_SECRET, then kept by +# every run unless the operator turns it off # (utils/signed_clip_links.sh); unset serves them as before. diff --git a/utils/signed_clip_links.sh b/utils/signed_clip_links.sh index b01fc2a..8d72661 100644 --- a/utils/signed_clip_links.sh +++ b/utils/signed_clip_links.sh @@ -52,34 +52,94 @@ signed_clip_links_state() { fi } -# require_signed_clip_links STATE -- says what STATE means for the worker, and -# succeeds only when it may refuse unsigned clip links. +# worker_signed_clip_links WORKER -- whether the deployed worker refuses +# unsigned clip links now: on, off (also when it was never deployed) or +# unknown. Asked of Cloudflare because a deploy replaces every var the worker +# has, so leaving REQUIRE_SIGNED_CLIPS out of one turns it off. +worker_signed_clip_links() { + local response + + response="$(cf_api GET "/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$1/settings")" + if [ "$(echo "$response" | cf_json 'j.success')" = "true" ]; then + if [ "$(echo "$response" | cf_json '(j.result?.bindings || []).some((b) => b.type === "plain_text" && b.name === "REQUIRE_SIGNED_CLIPS" && b.text === "1")')" = "true" ]; then + echo on + else + echo off + fi + elif [ "$(echo "$response" | cf_json '(j.errors || []).some((e) => e.code === 10007)')" = "true" ]; then + echo off + else + echo unknown + fi +} + +# require_signed_clip_links WORKER STATE -- decides REQUIRE_SIGNED_CLIPS for +# this deploy from whether the worker requires signed links now (WORKER, from +# worker_signed_clip_links) and what signed_clip_links_state found (STATE), and +# says why. One worker can serve several panels, so this only ever turns it +# off on an explicit yes. +# +# Returns 0 to require them, 1 to deploy without, 2 to deploy nothing. require_signed_clip_links() { - case "$1" in - signing) + local worker="$1" state="$2" answer + + if [ "$state" = signing ]; then + if [ "$worker" = on ]; then + ok "The panel's API signs clip links with these keys, and the worker keeps" + ok "refusing a clip or lineup file without one." + else ok "The panel's API signs clip links with these keys, so the worker will refuse" - ok "a clip or lineup file without one. Every panel this worker serves has to" - ok "sign them too, or its clips stop loading." - return 0 - ;; + ok "a clip or lineup file without one. Every panel using this worker must" + ok "already sign them, or its clips stop loading." + fi + return 0 + fi + + if [ "$worker" = on ] && [ "$state" = unreachable ]; then + warn "Could not ask the panel's database (kubeconfig $PANEL_KUBECONFIG) whether" + warn "its API signs clip links. The worker requires them now, so it keeps" + warn "requiring them; a panel's clips only stop being checked on a run that can" + warn "see it does not sign them." + return 0 + fi + + case "$state" in mismatch) warn "The panel's API signs clip links with a different S3_SECRET than the one" - warn "going to the worker, so the worker will not require them. Give the worker" - warn "the keys the API runs with (after changing them, ./update.sh restarts the" - warn "API), then run this again." + warn "going to the worker. Give the worker the keys the API runs with (after" + warn "changing them, ./update.sh restarts the API), then run this again." ;; unsigned) - warn "The panel's API does not sign clip links yet, so the worker will not" - warn "require them. Update the panel (./update.sh), then run this again." + warn "The panel's API does not sign clip links yet. Update the panel" + warn "(./update.sh), then run this again." ;; *) warn "Could not ask the panel's database (kubeconfig $PANEL_KUBECONFIG) whether" - warn "its API signs clip links, so the worker will not require them. Run this" - warn "again where kubectl reaches the panel." + warn "its API signs clip links. Run this again where kubectl reaches the panel" + warn "(on k3s, as root)." ;; esac - warn "Until then a private clip can be downloaded by anyone who has its link." - return 1 + + if [ "$worker" = off ]; then + warn "The worker does not require signed clip links, and will not until then: a" + warn "private clip can be downloaded by anyone who has its link." + return 1 + fi + + if [ "$worker" = on ]; then + warn "The worker requires signed clip links now. Deploying without them turns" + warn "that off for every panel using this worker, and their private clips can" + warn "be downloaded by anyone who has the link." + else + warn "Could not read from Cloudflare whether the worker requires signed clip" + warn "links now. If it does, deploying without them turns that off for every" + warn "panel using this worker." + fi + read -r -p " Deploy without requiring signed clip links? [y/N] " answer + if [[ "$answer" =~ ^[Yy] ]]; then + return 1 + fi + return 2 } signed_clip_links_psql() {