From 2e5fb0880f3c4427f6a58a2edda70282152a7250 Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Mon, 16 Feb 2026 17:46:32 -0500 Subject: [PATCH] worker: fix assets binding and SPA routes --- .forgejo/workflows/deploy-cloudflare.yml | 2 +- .forgejo/workflows/runner-smoke.yml | 2 +- deploy/cloudflare-worker/src/index.ts | 21 +- deploy/cloudflare-worker/wrangler.toml | 8 +- ...ECP-0064-nixos-ec-node-publisher-module.md | 48 +++ flake.nix | 11 +- nix/modules/ec-node.nix | 335 ++++++++++++++++++ nix/pkgs/ec-cli.nix | 46 +++ nix/pkgs/ec-node.nix | 48 +++ 9 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 evolution/proposals/ECP-0064-nixos-ec-node-publisher-module.md create mode 100644 nix/modules/ec-node.nix create mode 100644 nix/pkgs/ec-cli.nix create mode 100644 nix/pkgs/ec-node.nix diff --git a/.forgejo/workflows/deploy-cloudflare.yml b/.forgejo/workflows/deploy-cloudflare.yml index 8947020..dfe203b 100644 --- a/.forgejo/workflows/deploy-cloudflare.yml +++ b/.forgejo/workflows/deploy-cloudflare.yml @@ -11,7 +11,7 @@ concurrency: jobs: deploy: - runs-on: codeberg-medium + runs-on: codeberg-medium-lazy steps: - name: Fetch Source (no git required) env: diff --git a/.forgejo/workflows/runner-smoke.yml b/.forgejo/workflows/runner-smoke.yml index 3266466..f3c470e 100644 --- a/.forgejo/workflows/runner-smoke.yml +++ b/.forgejo/workflows/runner-smoke.yml @@ -5,7 +5,7 @@ on: jobs: smoke: - runs-on: codeberg-medium + runs-on: codeberg-medium-lazy steps: - name: Basic runner + secret smoke test env: diff --git a/deploy/cloudflare-worker/src/index.ts b/deploy/cloudflare-worker/src/index.ts index 52bace6..21de797 100644 --- a/deploy/cloudflare-worker/src/index.ts +++ b/deploy/cloudflare-worker/src/index.ts @@ -115,17 +115,28 @@ export default { } // Serve static assets from the Worker Assets binding. - // SPA fallback: unknown paths serve the app shell (`/index.html`). + // SPA routing: serve the app shell for "route-like" paths (e.g. `/watch`). + // + // Cloudflare's Assets binding may otherwise issue a redirect for unknown paths (e.g. `/watch` -> `/`), + // which breaks stable share links. const assets = (env as unknown as { ASSETS?: Fetcher }).ASSETS; if (!assets || typeof (assets as any).fetch !== "function") { return new Response("Assets binding not configured", { status: 500 }); } - const res = await assets.fetch(request); - if (res.status !== 404) return res; + const method = request.method.toUpperCase(); + const is_nav = method === "GET" || method === "HEAD"; + const is_likely_asset = url.pathname.includes("."); - url.pathname = "/index.html"; - return assets.fetch(new Request(url.toString(), request)); + // Use `/` as the app shell. Some asset handlers redirect `/index.html` -> `/`, + // which turns SPA routes into 307s instead of serving the HTML. + if (is_nav && !is_likely_asset && url.pathname !== "/") { + const u = new URL(request.url); + u.pathname = "/"; + return assets.fetch(new Request(u.toString(), request)); + } + + return assets.fetch(request); }, }; diff --git a/deploy/cloudflare-worker/wrangler.toml b/deploy/cloudflare-worker/wrangler.toml index 1e37734..a539c4f 100644 --- a/deploy/cloudflare-worker/wrangler.toml +++ b/deploy/cloudflare-worker/wrangler.toml @@ -12,9 +12,11 @@ routes = [ { pattern = "www.every.channel", custom_domain = true }, ] -# Static assets built by Trunk (apps/tauri/ui -> apps/tauri/dist) -[assets] -directory = "../../apps/web/dist" +# Static assets built by Trunk (apps/web -> apps/web/dist). +# +# Note: Wrangler v4 expects `assets = { ... }` (not a `[assets]` table). If misconfigured, +# `env.ASSETS` will be missing in production and the worker will 500 on SPA routes like `/watch`. +assets = { directory = "../../apps/web/dist", binding = "ASSETS", run_worker_first = ["/*"] } [[durable_objects.bindings]] name = "EC_API" diff --git a/evolution/proposals/ECP-0064-nixos-ec-node-publisher-module.md b/evolution/proposals/ECP-0064-nixos-ec-node-publisher-module.md new file mode 100644 index 0000000..122d4ec --- /dev/null +++ b/evolution/proposals/ECP-0064-nixos-ec-node-publisher-module.md @@ -0,0 +1,48 @@ +# ECP-0064: NixOS Module For `ec-node` WebTransport Publisher (Tower) + +Status: Draft + +## Decision + +Ship a first-party NixOS module in this repo that runs `ec-node wt-publish` as one or more `systemd` services. + +The module: + +- Lives in-repo and is exported from the flake as `nixosModules.ec-node`. +- Builds `ec-node` from this repo via Nix (no mutable checkout required on the target host). +- Accepts a read-only configuration (in Nix) for: + - HDHomeRun identity (either `host` IP/DNS, or `deviceId` with optional LAN discovery). + - A list of broadcasts (name + channel) to publish. + - Relay URL and a small set of `wt-publish` toggles (transcode/passthrough/TLS verify). + +`~/Projects/nix` will consume this module as a flake input and enable it on the `conrad-tower` host, deploying with the existing `deploy-flake` workflow. + +## Motivation + +Tower should run publishing continuously, reproducibly, and without "tmux as an orchestration layer". + +NixOS + systemd gives: + +- Immutable configuration for the HDHR/relay/channel list. +- Easy deployment/rollback via the existing host flake. +- Restart and journald logs for long-running publishers. + +## Scope + +In scope: + +- Nix packaging for `ec-node` sufficient to run `wt-publish`. +- A module that instantiates a `systemd` unit per broadcast. +- Optional HDHR host resolution via device-id + local network discovery (best-effort). + +Out of scope (defer): + +- ABR/multi-variant ladders from Nix config. +- Automatic lineup-based channel selection by callsign. +- Secrets management (publisher doesn't require secrets for Cloudflare relay preview). + +## Rollout / Reversibility + +- Enabling the module is per-host. +- Reversible by removing the module import and disabling the service(s); roll back with the existing deployment tooling. + diff --git a/flake.nix b/flake.nix index 30b9108..7651f9c 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,14 @@ }; outputs = { self, nixpkgs, flake-utils, rust-overlay, agenix }: - flake-utils.lib.eachDefaultSystem (system: + let + nixosModules = rec { + ec-node = import ./nix/modules/ec-node.nix; + default = ec-node; + }; + in + { inherit nixosModules; } + // flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; @@ -40,6 +47,8 @@ packages = { agenix = agenixPkg; fj = pkgs.forgejo-cli; + ec-node = pkgs.callPackage ./nix/pkgs/ec-node.nix { }; + ec-cli = pkgs.callPackage ./nix/pkgs/ec-cli.nix { }; }; devShells.default = pkgs.mkShell { diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix new file mode 100644 index 0000000..26a1a56 --- /dev/null +++ b/nix/modules/ec-node.nix @@ -0,0 +1,335 @@ +{ lib, config, pkgs, ... }: + +let + cfg = config.services.every-channel.ec-node; + + ecNodePkgDefault = pkgs.callPackage ../pkgs/ec-node.nix { }; + ecCliPkgDefault = pkgs.callPackage ../pkgs/ec-cli.nix { }; + + # Minimal normalization for host strings. + normalizeHost = host: + let + h = lib.strings.removeSuffix "/" host; + in + if lib.strings.hasPrefix "http://" h || lib.strings.hasPrefix "https://" h then h else "http://${h}"; + + sanitizeUnitName = s: + lib.concatStringsSep "-" (lib.filter (x: x != "") (lib.splitString "/" (lib.replaceStrings [ " " ":" "." ] [ "-" "-" "-" ] s))); + + hdhrBase = if cfg.hdhomerun.host != null then normalizeHost cfg.hdhomerun.host else null; + + mkInputUrl = broadcast: + let + base = + if hdhrBase != null then hdhrBase + else if cfg.hdhomerun.deviceId != null then "http://${cfg.hdhomerun.deviceId}.local" + else null; + in + "${base}/auto/v${broadcast.channel}"; + +in +{ + options.services.every-channel.ec-node = { + enable = lib.mkEnableOption "every.channel ec-node WebTransport publisher services"; + + package = lib.mkOption { + type = lib.types.package; + default = ecNodePkgDefault; + defaultText = "pkgs.callPackage /path/to/every.channel/nix/pkgs/ec-node.nix {}"; + description = "The ec-node package to run."; + }; + + discoveryPackage = lib.mkOption { + type = lib.types.package; + default = ecCliPkgDefault; + defaultText = "pkgs.callPackage /path/to/every.channel/nix/pkgs/ec-cli.nix {}"; + description = "Package used for HDHomeRun discovery when `hdhomerun.autoDiscover = true`."; + }; + + relayUrl = lib.mkOption { + type = lib.types.str; + default = "https://relay.cloudflare.mediaoverquic.com/"; + description = "MoQ relay URL for ec-node wt-publish."; + }; + + transcode = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether ec-node should transcode to H.264/AAC before fragmenting."; + }; + + passthrough = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to transmit fMP4 fragments directly (moq-mux passthrough)."; + }; + + tlsDisableVerify = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Danger: disable TLS verification for the relay."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra arguments appended to each ec-node wt-publish invocation."; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { + RUST_LOG = "info"; + }; + description = "Environment variables for the publisher services."; + }; + + hdhomerun = { + host = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "HDHomeRun host/IP. When set, inputs are built as http:///auto/v."; + }; + + deviceId = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "HDHomeRun device id (used as http://.local when host is unset)."; + }; + + autoDiscover = lib.mkOption { + type = lib.types.bool; + default = false; + description = "If true and host is unset, attempt LAN discovery for the device id (best-effort)."; + }; + }; + + broadcasts = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Broadcast name published to the relay (used in watch links)."; + }; + channel = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "HDHomeRun guide number (e.g. 4.1, 8.1). Required unless `input` is set."; + }; + input = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional explicit ffmpeg input URL/file. When set, HDHomeRun settings are ignored for this broadcast."; + }; + }; + }); + default = [ ]; + description = "List of broadcasts (name + channel, or explicit input) to publish."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.broadcasts != [ ]; + message = "services.every-channel.ec-node.broadcasts must be non-empty when enabled"; + } + { + assertion = + let + needsHdhr = builtins.any (b: b.input == null) cfg.broadcasts; + in + (!needsHdhr) || (cfg.hdhomerun.host != null) || (cfg.hdhomerun.deviceId != null); + message = "Set services.every-channel.ec-node.hdhomerun.host or .deviceId (required when any broadcast omits `input`)"; + } + { + assertion = !(cfg.hdhomerun.autoDiscover && cfg.hdhomerun.host != null); + message = "hdhomerun.autoDiscover only applies when hdhomerun.host is unset"; + } + { + assertion = builtins.all (b: (b.input != null) || (b.channel != null)) cfg.broadcasts; + message = "Each broadcast must set either `input` or `channel`"; + } + ]; + + systemd.tmpfiles.rules = [ + "d /run/every-channel 0755 root root - -" + ]; + + systemd.services = + lib.listToAttrs (map + (b: + let + unit = "every-channel-wt-publish-${sanitizeUnitName b.name}"; + runner = pkgs.writeShellApplication { + name = unit; + runtimeInputs = + [ + pkgs.coreutils + pkgs.curl + pkgs.ffmpeg + pkgs.findutils + pkgs.gawk + pkgs.iproute2 + cfg.package + ] + ++ lib.optionals cfg.hdhomerun.autoDiscover [ pkgs.jq cfg.discoveryPackage ]; + text = + let + fixedHost = if cfg.hdhomerun.host != null then normalizeHost cfg.hdhomerun.host else ""; + deviceId = cfg.hdhomerun.deviceId or ""; + extraArgsLine = + if cfg.extraArgs == [ ] then + "" + else + "cmd+=(${lib.concatStringsSep " " (map lib.escapeShellArg cfg.extraArgs)})"; + explicitInputStr = if b.input == null then "" else b.input; + channelStr = if b.channel == null then "" else b.channel; + in + '' + set -euo pipefail + + input="" + explicit_input=${lib.escapeShellArg explicitInputStr} + if [[ -n "$explicit_input" ]]; then + input="$explicit_input" + else + ch=${lib.escapeShellArg channelStr} + if [[ -z "$ch" ]]; then + echo "ec-node: broadcast missing both input and channel" >&2 + exit 2 + fi + + base="${lib.escapeShellArg fixedHost}" + if [[ -z "$base" ]]; then + dev_id="${lib.escapeShellArg deviceId}" + if [[ -z "$dev_id" ]]; then + echo "ec-node: missing hdhomerun.host and hdhomerun.deviceId" >&2 + exit 2 + fi + + try_ip() { + local ip="$1" + local json id base_url + json="$(curl -fsS --connect-timeout 0.10 --max-time 0.20 "http://$ip/discover.json" 2>/dev/null || true)" + if [[ -z "$json" ]]; then + return 1 + fi + id="$(printf '%s' "$json" | jq -r '.DeviceID // empty' 2>/dev/null || true)" + if [[ "$id" != "$dev_id" ]]; then + return 1 + fi + base_url="$(printf '%s' "$json" | jq -r '.BaseURL // empty' 2>/dev/null || true)" + if [[ -z "$base_url" ]]; then + base_url="http://$ip" + fi + printf '%s\n' "$base_url" + return 0 + } + + if ${lib.boolToString cfg.hdhomerun.autoDiscover}; then + # Primary: UDP broadcast discover. + base="$(${cfg.discoveryPackage}/bin/ec-cli discover | jq -r --arg id "$dev_id" '.[] | select(.id == $id) | .base_url // empty' | head -n1 || true)" + + # Fallback: probe known neighbors for /discover.json (fast; avoids full /24 scan). + if [[ -z "$base" ]]; then + while read -r ip; do + found="$(try_ip "$ip" || true)" + if [[ -n "$found" ]]; then + base="$found" + break + fi + done < <(ip neigh | awk '{print $1}' | sort -u) + fi + + # Fallback: scan local /24 subnets for /discover.json (slow; worst-case ~50s). + if [[ -z "$base" ]]; then + while read -r cidr; do + ip_addr="''${cidr%/*}" + prefix="''${cidr#*/}" + if [[ "$prefix" != "24" ]]; then + continue + fi + net="''${ip_addr%.*}" + for i in $(seq 1 254); do + found="$(try_ip "$net.$i" || true)" + if [[ -n "$found" ]]; then + base="$found" + break + fi + done + if [[ -n "$base" ]]; then + break + fi + done < <(ip -o -4 addr show scope global | awk '{print $4}') + fi + + if [[ -z "$base" ]]; then + echo "ec-node: HDHomeRun deviceId not found: $dev_id" >&2 + exit 2 + fi + else + # Best-effort mDNS convention. + base="http://$dev_id.local" + fi + fi + + base="''${base%/}" + if [[ "$base" != http://* && "$base" != https://* ]]; then + base="http://$base" + fi + + input="$base/auto/v$ch" + fi + + cmd=( + ${lib.escapeShellArg "${cfg.package}/bin/ec-node"} + wt-publish + --url ${lib.escapeShellArg cfg.relayUrl} + --name ${lib.escapeShellArg b.name} + --input "$input" + ) + ${lib.optionalString (!cfg.transcode) "cmd+=(--transcode=false)"} + ${lib.optionalString (!cfg.passthrough) "cmd+=(--passthrough=false)"} + ${lib.optionalString cfg.tlsDisableVerify "cmd+=(--tls-disable-verify)"} + ${extraArgsLine} + + exec "''${cmd[@]}" + ''; + }; + in + { + name = unit; + value = { + description = "every.channel WebTransport publish (${b.name} -> ${cfg.relayUrl})"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${runner}/bin/${unit}"; + Restart = "always"; + RestartSec = 2; + + DynamicUser = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + }; + + environment = cfg.environment; + }; + }) + cfg.broadcasts); + }; +} diff --git a/nix/pkgs/ec-cli.nix b/nix/pkgs/ec-cli.nix new file mode 100644 index 0000000..df5bd32 --- /dev/null +++ b/nix/pkgs/ec-cli.nix @@ -0,0 +1,46 @@ +{ lib +, rustPlatform +, stdenv +, pkg-config +, openssl +}: + +let + src = lib.cleanSourceWith { + src = ../../.; + filter = path: type: + let + base = baseNameOf path; + in + !(base == "target" || base == ".git" || base == ".direnv" || base == "tmp" || base == "node_modules"); + }; +in +rustPlatform.buildRustPackage { + pname = "ec-cli"; + version = "0.0.0"; + inherit src; + + cargoLock = { + lockFile = ../../Cargo.lock; + }; + + cargoBuildFlags = [ "-p" "ec-cli" ]; + + nativeBuildInputs = [ + pkg-config + ]; + + buildInputs = + [ + openssl + ]; + + doCheck = false; + + meta = with lib; { + description = "every.channel CLI (HDHomeRun discovery + tooling)"; + mainProgram = "ec-cli"; + platforms = platforms.unix; + license = licenses.agpl3Only; + }; +} diff --git a/nix/pkgs/ec-node.nix b/nix/pkgs/ec-node.nix new file mode 100644 index 0000000..ae08534 --- /dev/null +++ b/nix/pkgs/ec-node.nix @@ -0,0 +1,48 @@ +{ lib +, rustPlatform +, stdenv +, pkg-config +, openssl +}: + +let + # Keep the build input stable and small; avoid copying `target/`, `tmp/`, etc. into the Nix store. + src = lib.cleanSourceWith { + src = ../../.; + filter = path: type: + let + base = baseNameOf path; + in + # Skip typical build outputs and large scratch dirs. + !(base == "target" || base == ".git" || base == ".direnv" || base == "tmp" || base == "node_modules"); + }; +in +rustPlatform.buildRustPackage { + pname = "ec-node"; + version = "0.0.0"; + inherit src; + + cargoLock = { + lockFile = ../../Cargo.lock; + }; + + cargoBuildFlags = [ "-p" "ec-node" ]; + + nativeBuildInputs = [ + pkg-config + ]; + + buildInputs = + [ + openssl + ]; + + doCheck = false; + + meta = with lib; { + description = "every.channel node runner (ingest + chunk + MoQ publish)"; + mainProgram = "ec-node"; + platforms = platforms.unix; + license = licenses.agpl3Only; + }; +}