worker: fix assets binding and SPA routes

This commit is contained in:
every.channel 2026-02-16 17:46:32 -05:00
parent 339aef50e0
commit 2e5fb0880f
No known key found for this signature in database
9 changed files with 510 additions and 11 deletions

335
nix/modules/ec-node.nix Normal file
View file

@ -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://<host>/auto/v<channel>.";
};
deviceId = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "HDHomeRun device id (used as http://<deviceId>.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);
};
}

46
nix/pkgs/ec-cli.nix Normal file
View file

@ -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;
};
}

48
nix/pkgs/ec-node.nix Normal file
View file

@ -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;
};
}