Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e655de5
feature: 5Stack can manage the panel's Cloudflare zone
lukepolo Nov 3, 2026
e7f362c
fix: Turn On turns on Tiered Cache before Smart Topology
lukepolo Nov 3, 2026
b9758f0
fix: streams stay straight to the server, so video never goes through…
lukepolo Nov 3, 2026
cc8ea37
Merge branch 'main' into feature/agent
lukepolo Nov 3, 2026
63c924e
Merge branch 'main' into feature/agent
lukepolo Nov 3, 2026
9ac9c5b
feature: the stream relay Worker serves streams from Cloudflare, so v…
lukepolo Nov 3, 2026
eedfed2
fix: managed Cloudflare keeps certificates on HTTP-01 and lets Let's …
lukepolo Nov 3, 2026
76f8d34
test: the gateway proves Let's Encrypt's check path answers from the …
lukepolo Nov 3, 2026
617af86
test: an upgrade from ingress-nginx keeps only the panel's acme-check…
lukepolo Nov 3, 2026
c63df0a
feature: the installer can keep every address one level deep, like pa…
lukepolo Nov 3, 2026
839ef57
fix: the stream host only routes WHEP playback and single-segment sna…
lukepolo Nov 3, 2026
ac5434e
feature: the installer asks which Cloudflare plan a subdomain's zone …
lukepolo Nov 3, 2026
85d72c4
Merge branch 'main' into feature/agent
lukepolo Nov 4, 2026
10d7716
feature: the installer keeps every address one level deep by default …
lukepolo Nov 4, 2026
815e8af
fix: a fresh install on k3s 1.37 waits for the Gateway API CRDs k3s i…
lukepolo Nov 4, 2026
f258ae2
fix: the TimescaleDB extension step says what it waits for, a fresh i…
lukepolo Nov 4, 2026
8cc9fce
fix: managed Cloudflare works behind the operator's own reverse proxy…
lukepolo Nov 4, 2026
4c74d2a
fix: the script join uses a panel's own pod range and tailnet tag, an…
lukepolo Nov 4, 2026
5e42850
fix: managed Cloudflare creates the panel's proxied hostnames proxied…
lukepolo Nov 4, 2026
f829d1d
fix: an install whose records the setup page just set up skips update…
lukepolo Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cloudflare-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ on:
paths:
- "cloudflare-workers/**"
- "backblaze-proxy.sh"
- "stream-relay.sh"
- "utils/signed_clip_links.sh"
pull_request:
paths:
- "cloudflare-workers/**"
- "backblaze-proxy.sh"
- "stream-relay.sh"
- "utils/signed_clip_links.sh"

jobs:
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/panel-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ on:
- "base/backups/**"
- "base/game-server-node-connector/**"
- "overlays/nvidia/**"
- cloudflare.sh
- stream-relay.sh
- "cloudflare-workers/stream-relay/**"
- "overlays/cert-manager/**"
- "overlays/http/**"
- ".github/scripts/**"
- .github/workflows/panel-scripts.yml
pull_request:
Expand All @@ -50,6 +55,11 @@ on:
- "base/backups/**"
- "base/game-server-node-connector/**"
- "overlays/nvidia/**"
- cloudflare.sh
- stream-relay.sh
- "cloudflare-workers/stream-relay/**"
- "overlays/cert-manager/**"
- "overlays/http/**"
- ".github/scripts/**"
- .github/workflows/panel-scripts.yml

Expand All @@ -58,7 +68,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: shellcheck -x service.sh utils/ensure_auth_cookie_domain.sh utils/ensure_node_ip.sh utils/dev_game_server.sh utils/gpu_nodes.sh utils/choose_node.sh .github/scripts/image_updates.sh tests/scripts/*.sh tests/scripts/stub/kubectl tests/scripts/stub/crane
- run: shellcheck -x cloudflare.sh stream-relay.sh utils/cloudflare_api.sh utils/cloudflare_setup.sh service.sh utils/ensure_auth_cookie_domain.sh utils/ensure_node_ip.sh utils/dev_game_server.sh utils/gpu_nodes.sh utils/choose_node.sh .github/scripts/image_updates.sh tests/scripts/*.sh tests/scripts/stub/kubectl tests/scripts/stub/crane tests/scripts/tailscale-stub/curl tests/scripts/tailscale-stub/kubectl
- run: bash tests/scripts/setup_env_test.sh
- run: bash tests/scripts/apply_overlay_test.sh
- run: bash tests/scripts/debug_test.sh
Expand All @@ -68,10 +78,12 @@ jobs:
- run: bash tests/scripts/node_ip_test.sh
- run: bash tests/scripts/traefik_install_test.sh
- run: bash tests/scripts/write_overlays_test.sh
- run: bash tests/scripts/cloudflare_setup_test.sh
- run: bash tests/scripts/dev_game_server_test.sh
- run: bash tests/scripts/gpu_nodes_test.sh
- run: bash tests/scripts/service_test.sh
- run: bash tests/scripts/postgres_backup_test.sh
- run: bash tests/scripts/choose_node_test.sh
- run: bash tests/scripts/nvidia_fallback_test.sh
- run: bash tests/scripts/image_updates_test.sh
- run: bash tests/scripts/tailscale_api_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe("backblaze-proxy.sh signed clip links check", () => {
assert.match(
call,
new RegExp(
`^--kubeconfig=${KUBECONFIG} -n 5stack exec timescaledb-0 -c timescaledb -- sh -c `,
`^--kubeconfig=${KUBECONFIG} -n 5stack --request-timeout=120s exec timescaledb-0 -c timescaledb -- sh -c `,
),
);
}
Expand Down
4 changes: 4 additions & 0 deletions cloudflare-workers/stream-relay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "module",
"private": true
}
158 changes: 158 additions & 0 deletions cloudflare-workers/stream-relay/worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
const NAME = "5stack-stream-relay";
const VERSION = "1";

const PLAYLIST = /\.m3u8$/i;
const SEGMENT = /\.(ts|m4s|mp4)$/i;
const PLAYLIST_SECONDS = 1;
const SEGMENT_SECONDS = 60;
const ACCESS_SECONDS = 30;
const CORS_HEADERS = [
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
];

export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json(
{ ok: true, worker: NAME, version: VERSION },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
},
},
);
}
if (request.method !== "GET") {
return fetch(request);
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length !== 2) {
return origin(request);
}
const [stream, file] = parts;
if (PLAYLIST.test(file)) {
return serve(request, env, ctx, stream, PLAYLIST_SECONDS);
}
if (SEGMENT.test(file)) {
return serve(request, env, ctx, stream, SEGMENT_SECONDS);
}
return origin(request);
},
};

function origin(request) {
return fetch(request, { cache: "no-store" });
}

async function serve(request, env, ctx, stream, seconds) {
const url = new URL(request.url);
const key = new Request(`${url.origin}${url.pathname}${url.search}`);
const pass = await accessKey(request, url, stream);
const range = request.headers.get("Range");

if (await caches.default.match(pass)) {
const hit = await caches.default.match(key);
if (hit) {
return fromCache(hit, request, env, range, seconds);
}
}

const response = await origin(request);
if (response.ok) {
ctx.waitUntil(
caches.default.put(
pass,
new Response("allowed", {
headers: { "Cache-Control": `max-age=${ACCESS_SECONDS}` },
}),
),
);
}
if (response.status !== 200 || range) {
return response;
}

const body = await response.arrayBuffer();
const headers = new Headers(response.headers);
for (const name of [...CORS_HEADERS, "Set-Cookie", "Vary"]) {
headers.delete(name);
}
headers.set("Cache-Control", `public, max-age=${seconds}`);
const stored = new Response(body, { status: 200, headers });
ctx.waitUntil(caches.default.put(key, stored.clone()));
return fromCache(stored, request, env, null, seconds);
}

async function accessKey(request, url, stream) {
const cookie = request.headers.get("Cookie") ?? "";
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(cookie),
);
const hash = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return new Request(
`${url.origin}/__5stack-access/${encodeURIComponent(stream)}/${hash}`,
);
}

async function fromCache(cached, request, env, range, seconds) {
const headers = new Headers(cached.headers);
headers.set("Cache-Control", `private, max-age=${seconds}`);
cors(headers, request, env);
if (!range) {
return new Response(cached.body, { status: 200, headers });
}
const body = await cached.arrayBuffer();
const bounds = byteRange(range, body.byteLength);
if (!bounds) {
headers.set("Content-Range", `bytes */${body.byteLength}`);
headers.delete("Content-Length");
return new Response(null, { status: 416, headers });
}
const [start, end] = bounds;
headers.set("Content-Range", `bytes ${start}-${end}/${body.byteLength}`);
headers.set("Content-Length", String(end - start + 1));
return new Response(body.slice(start, end + 1), { status: 206, headers });
}

function byteRange(header, size) {
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match || (match[1] === "" && match[2] === "")) {
return null;
}
let start;
let end;
if (match[1] === "") {
const suffix = Number(match[2]);
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
}
if (start > end || start >= size) {
return null;
}
return [start, end];
}

function cors(headers, request, env) {
for (const name of CORS_HEADERS) {
headers.delete(name);
}
const from = request.headers.get("Origin");
if (from && env.WEB_ORIGIN && from === env.WEB_ORIGIN) {
headers.set("Access-Control-Allow-Origin", from);
headers.set("Access-Control-Allow-Credentials", "true");
headers.set("Access-Control-Expose-Headers", "Location");
} else if (from) {
headers.set("Access-Control-Allow-Origin", "*");
}
headers.set("Vary", "Origin");
}
Loading
Loading