every.channel/nix/modules/ec-node.nix

1228 lines
47 KiB
Nix

{ 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://cdn.moq.dev/anon";
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 CMAF fMP4 fragments directly (seedbox-compatible 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";
# Cloudflare's relay is WebTransport-first; moq-native's WebSocket fallback can "win"
# the race and then fail MoQ negotiation, causing a tight reconnect loop.
MOQ_CLIENT_WEBSOCKET_ENABLED = "false";
};
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).";
};
};
control = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable iroh-gossip control announcements from each wt-publish service.";
};
ttlMs = lib.mkOption {
type = lib.types.ints.positive;
default = 15000;
description = "Control announcement TTL passed to `ec-node wt-publish --control-ttl-ms`.";
};
intervalMs = lib.mkOption {
type = lib.types.ints.positive;
default = 5000;
description = "Control announcement interval passed to `ec-node wt-publish --control-interval-ms`.";
};
discovery = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "dht,mdns,dns";
description = "Optional iroh discovery mode list for control announcements.";
};
irohSecret = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional iroh secret key (hex) for control announcement identity.";
};
gossipPeers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Optional iroh endpoint addresses to seed control gossip joins.";
};
bridgeWeb = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Run `ec-node control-bridge-web` to upsert announced streams into the web directory.";
};
directoryUrl = lib.mkOption {
type = lib.types.str;
default = "https://every.channel";
description = "Directory base URL used by control-bridge-web for `/api/stream-upsert`.";
};
authTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional file containing bearer token for `/api/stream-upsert`.";
};
timeoutMs = lib.mkOption {
type = lib.types.ints.u32;
default = 30000;
description = "Bridge run timeout; service restarts and refreshes gossip peers after this window (0 = run forever).";
};
maxAgeMs = lib.mkOption {
type = lib.types.ints.positive;
default = 60000;
description = "Maximum accepted staleness for control announcements consumed by the web bridge.";
};
streamPrefix = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional stream-id prefix filter for web upserts.";
};
discovery = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "dht,mdns,dns";
description = "Optional discovery mode override for the web bridge; falls back to `control.discovery` when unset.";
};
};
};
archive = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Run relay archival workers that subscribe and persist streams into CAS storage.";
};
directoryUrl = lib.mkOption {
type = lib.types.str;
default = "https://every.channel";
description = "Directory base URL used to discover public streams (`/api/public-streams`).";
};
outputDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/every-channel/archive";
description = "CAS object root passed to `ec-node wt-archive --output-dir`.";
};
manifestDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/every-channel/manifests";
description = "Manifest/index root passed to `ec-node wt-archive --manifest-dir`.";
};
pollIntervalMs = lib.mkOption {
type = lib.types.ints.positive;
default = 15000;
description = "Discovery poll interval for public streams.";
};
relayUrlOverride = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional fixed relay URL override for archive workers (bypasses per-stream relay_url from directory entries).";
};
streamPrefix = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional broadcast-name prefix filter for archival workers.";
};
tracks = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"catalog.json"
"init.mp4"
"video0.m4s"
"audio0.m4s"
];
description = "Tracks passed to each `wt-archive` worker.";
};
tlsDisableVerify = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Danger: disable TLS verification for relay archive subscribers.";
};
serve = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Run `ec-node wt-archive-serve` HTTP endpoints for archived replay/scrubbing.";
};
listen = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0:7788";
description = "Listen address passed to `ec-node wt-archive-serve --listen`.";
};
};
};
nbc = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Linux Chrome + virtual-display support for NBC browser-backed broadcasts.";
};
chromeBinary = lib.mkOption {
type = lib.types.str;
default = "/run/current-system/sw/bin/google-chrome-stable";
description = "Chrome binary used by `ec-node nbc-bootstrap` and `ec-node nbc-wt-publish`.";
};
profileDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/every-channel/nbc-profile";
description = "Persistent Chrome profile directory used for NBC / Adobe auth sessions.";
};
authScreenshotDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/every-channel/nbc-auth";
description = "Directory for operator-facing screenshots when bootstrap hits an interactive auth page.";
};
display = lib.mkOption {
type = lib.types.str;
default = ":99";
description = "DISPLAY used by the NBC virtual display session.";
};
screen = lib.mkOption {
type = lib.types.str;
default = "1920x1080x24";
description = "Xvfb screen geometry for the NBC virtual display.";
};
noSandbox = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Pass `EVERY_CHANNEL_NBC_NO_SANDBOX=1` for Chrome worker sessions.";
};
mvpdProvider = lib.mkOption {
type = lib.types.str;
default = "Verizon Fios";
description = "MVPD provider name used when the NBC worker must choose a TV provider.";
};
mvpdUsernameFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Optional root-managed file containing the MVPD username for unattended NBC login.";
};
mvpdPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Optional root-managed file containing the MVPD password for unattended NBC login.";
};
isolateWithUserNetns = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Launch NBC browser-backed workers inside a rootless user+network namespace backed by
slirp4netns. This keeps the Chrome / ec-node process tree in its own network context
while still using the host's active upstream route.
'';
};
requireMullvad = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Refuse to start NBC browser-backed workers until `mullvad status` reports a connected
tunnel. This assumes the host Mullvad daemon is already logged in and connected.
'';
};
mullvadWaitSeconds = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = "Maximum time to wait for Mullvad connectivity before failing an NBC worker start.";
};
mullvadLocation = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "USA";
description = ''
Optional case-insensitive substring that must appear in `mullvad status` before an NBC
worker starts. Use this to pin workers to a country or city family without committing the
operational login material itself.
'';
};
vnc = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Expose the NBC virtual display over VNC so auth can be completed remotely when needed.";
};
listen = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "x11vnc listen address for the NBC virtual display.";
};
port = lib.mkOption {
type = lib.types.port;
default = 5900;
description = "x11vnc TCP port for the NBC virtual display.";
};
};
};
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.";
};
nbcUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional NBC watch/live URL for a browser-backed relay publish worker.";
};
};
});
default = [ ];
description = "List of broadcasts (HDHomeRun, explicit input, or NBC browser-backed URL) to publish.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.broadcasts != [ ]) || cfg.archive.enable;
message = "services.every-channel.ec-node.broadcasts must be non-empty unless archive.enable=true";
}
{
assertion =
let
needsHdhr = builtins.any (b: b.input == null && b.nbcUrl == 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:
(lib.length (lib.filter (value: value != null) [ b.input b.channel b.nbcUrl ])) == 1)
cfg.broadcasts;
message = "Each broadcast must set exactly one of `input`, `channel`, or `nbcUrl`";
}
{
assertion =
let
hasNbcBroadcast = builtins.any (b: b.nbcUrl != null) cfg.broadcasts;
in
(!hasNbcBroadcast) || cfg.nbc.enable;
message = "Set services.every-channel.ec-node.nbc.enable = true before configuring `broadcasts.*.nbcUrl`";
}
];
systemd.tmpfiles.rules =
[
"d /run/every-channel 1777 root root - -"
]
++ lib.optionals cfg.nbc.enable [
"d /var/lib/every-channel 0750 every-channel every-channel - -"
"d ${cfg.nbc.profileDir} 0750 every-channel every-channel - -"
"d ${cfg.nbc.authScreenshotDir} 0750 every-channel every-channel - -"
]
++ lib.optionals cfg.archive.enable [
"d ${cfg.archive.outputDir} 0750 root root - -"
"d ${cfg.archive.manifestDir} 0750 root root - -"
];
users.groups.every-channel = lib.mkIf cfg.nbc.enable { };
users.users.every-channel = lib.mkIf cfg.nbc.enable {
isSystemUser = true;
group = "every-channel";
home = "/var/lib/every-channel";
createHome = true;
};
systemd.services =
lib.listToAttrs (map
(b:
let
unit = "every-channel-wt-publish-${sanitizeUnitName b.name}";
isNbc = b.nbcUrl != null;
runner = pkgs.writeShellApplication {
name = unit;
runtimeInputs =
[
pkgs.coreutils
pkgs.curl
pkgs.ffmpeg
pkgs.findutils
pkgs.gawk
pkgs.iproute2
cfg.package
]
++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ pkgs.mullvad-vpn ]
++ lib.optionals (isNbc && cfg.nbc.isolateWithUserNetns) [
pkgs.slirp4netns
pkgs.util-linux
]
++ 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;
nbcUrlStr = if b.nbcUrl == null then "" else b.nbcUrl;
controlEndpointOutPath = "/run/every-channel/control-peer-${sanitizeUnitName b.name}.json";
controlDiscoveryStr = if cfg.control.discovery == null then "" else cfg.control.discovery;
controlIrohSecretStr = if cfg.control.irohSecret == null then "" else cfg.control.irohSecret;
controlGossipPeerLines = lib.concatMapStrings (peer: "cmd+=(--gossip-peer ${lib.escapeShellArg peer})\n") cfg.control.gossipPeers;
nbcMullvadLocationStr = if cfg.nbc.mullvadLocation == null then "" else cfg.nbc.mullvadLocation;
in
''
set -euo pipefail
wait_for_mullvad() {
local wait_seconds status expected
wait_seconds=${toString cfg.nbc.mullvadWaitSeconds}
expected=${lib.escapeShellArg nbcMullvadLocationStr}
for _ in $(seq 1 "$wait_seconds"); do
status="$(mullvad status 2>/dev/null || true)"
if [[ "$status" == Connected* ]]; then
if [[ -z "$expected" ]] || printf '%s\n' "$status" | grep -Fqi -- "$expected"; then
return 0
fi
fi
sleep 1
done
echo "ec-node: Mullvad was not connected${lib.optionalString (cfg.nbc.mullvadLocation != null) " to the expected location"} within ${toString cfg.nbc.mullvadWaitSeconds}s" >&2
mullvad status >&2 || true
return 1
}
run_in_user_netns() {
local tmpdir pid_file ready_fifo ns_pid slirp_pid status
tmpdir="$(mktemp -d /tmp/${unit}.usernet.XXXXXX)"
pid_file="$tmpdir/pid"
ready_fifo="$tmpdir/ready"
mkfifo "$ready_fifo"
# shellcheck disable=SC2016
unshare --user --map-root-user --net ${pkgs.bash}/bin/bash -lc '
set -euo pipefail
ip link set lo up
echo $$ > "$1"
read -r _ < "$2"
shift 2
exec "$@"
' bash "$pid_file" "$ready_fifo" "''${cmd[@]}" &
ns_pid=$!
for _ in $(seq 1 50); do
[[ -s "$pid_file" ]] && break
sleep 0.1
done
if [[ ! -s "$pid_file" ]]; then
echo "ec-node: timed out waiting for NBC user-netns PID" >&2
kill "$ns_pid" 2>/dev/null || true
rm -rf "$tmpdir"
return 1
fi
slirp4netns --configure --mtu=1500 "$(cat "$pid_file")" tap0 >"$tmpdir/slirp.log" 2>&1 &
slirp_pid=$!
sleep 1
printf 'go\n' > "$ready_fifo"
set +e
wait "$ns_pid"
status=$?
set -e
kill "$slirp_pid" 2>/dev/null || true
wait "$slirp_pid" 2>/dev/null || true
rm -rf "$tmpdir"
return "$status"
}
nbc_url=${lib.escapeShellArg nbcUrlStr}
input=""
if [[ -z "$nbc_url" ]]; then
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 input, channel, and nbcUrl" >&2
exit 2
fi
# Note: don't wrap lib.escapeShellArg in double-quotes, otherwise empty strings
# become a literal two-quote token and break discovery.
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
base="$(${cfg.discoveryPackage}/bin/ec-cli discover | jq -r --arg id "$dev_id" '.[] | select(.id == $id) | .base_url // empty' | head -n1 || true)"
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
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
base="http://$dev_id.local"
fi
fi
base="''${base%/}"
if [[ "$base" != http://* && "$base" != https://* ]]; then
base="http://$base"
fi
hostport="''${base#http://}"
hostport="''${hostport#https://}"
hostport="''${hostport%%/*}"
host="''${hostport%%:*}"
input="http://$host:5004/auto/v$ch"
fi
fi
if [[ -n "$nbc_url" ]]; then
cmd=(
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
nbc-wt-publish
--url ${lib.escapeShellArg cfg.relayUrl}
--name ${lib.escapeShellArg b.name}
--source-url "$nbc_url"
)
else
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)"}
fi
${lib.optionalString (!cfg.passthrough) "cmd+=(--passthrough=false)"}
${lib.optionalString cfg.tlsDisableVerify "cmd+=(--tls-disable-verify)"}
${lib.optionalString cfg.control.enable ''
cmd+=(--control-announce)
cmd+=(--control-ttl-ms ${toString cfg.control.ttlMs})
cmd+=(--control-interval-ms ${toString cfg.control.intervalMs})
control_discovery=${lib.escapeShellArg controlDiscoveryStr}
if [[ -n "$control_discovery" ]]; then
cmd+=(--discovery "$control_discovery")
fi
control_iroh_secret=${lib.escapeShellArg controlIrohSecretStr}
if [[ -n "$control_iroh_secret" ]]; then
cmd+=(--iroh-secret "$control_iroh_secret")
fi
cmd+=(--control-endpoint-addr-out ${lib.escapeShellArg controlEndpointOutPath})
${controlGossipPeerLines}
''}
${extraArgsLine}
# Keep the unit alive even if the relay is temporarily unreachable.
# This avoids `switch-to-configuration test` failing due to a unit that exits
# quickly during activation.
trap 'exit 0' INT TERM
while true; do
${lib.optionalString (isNbc && cfg.nbc.requireMullvad) ''
if ! wait_for_mullvad; then
sleep 2
continue
fi
''}
${lib.optionalString (isNbc && cfg.nbc.isolateWithUserNetns) "run_in_user_netns || true"}
${lib.optionalString (!isNbc || !cfg.nbc.isolateWithUserNetns) ''
"''${cmd[@]}" || true
''}
sleep 2
done
'';
};
in
{
name = unit;
value = {
description = "every.channel WebTransport publish (${b.name} -> ${cfg.relayUrl})";
wantedBy = [ "multi-user.target" ];
after =
[ "network-online.target" ]
++ lib.optionals isNbc [ "every-channel-nbc-display.service" ]
++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ "mullvad-daemon.service" ];
wants =
[ "network-online.target" ]
++ lib.optionals isNbc [ "every-channel-nbc-display.service" ]
++ lib.optionals (isNbc && cfg.nbc.requireMullvad) [ "mullvad-daemon.service" ];
# Keep the unit from entering "failed" due to rapid restarts (deploy-flake treats
# failed units during `switch-to-configuration test` as a deployment failure).
#
# If the relay is temporarily unreachable, we want systemd to keep retrying without
# tripping rate limits.
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "simple";
ExecStart = "${runner}/bin/${unit}";
Restart = "always";
RestartSec = 2;
DynamicUser = !isNbc;
User = lib.mkIf isNbc "every-channel";
Group = lib.mkIf isNbc "every-channel";
NoNewPrivileges = true;
PrivateTmp = !isNbc;
ProtectSystem = "strict";
ProtectHome = !isNbc;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = !isNbc;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths =
lib.optionals cfg.control.enable [ "/run/every-channel" ]
++ lib.optionals isNbc [ "/tmp" ]
++ lib.optionals isNbc [ cfg.nbc.profileDir cfg.nbc.authScreenshotDir ];
};
environment =
cfg.environment
// lib.optionalAttrs isNbc (
{
DISPLAY = cfg.nbc.display;
EVERY_CHANNEL_NBC_CHROME_PATH = cfg.nbc.chromeBinary;
EVERY_CHANNEL_NBC_MVPD_PROVIDER = cfg.nbc.mvpdProvider;
EVERY_CHANNEL_NBC_PROFILE_DIR = cfg.nbc.profileDir;
EVERY_CHANNEL_NBC_NO_SANDBOX = if cfg.nbc.noSandbox then "1" else "0";
HOME = "/var/lib/every-channel";
}
// lib.optionalAttrs (cfg.nbc.mvpdUsernameFile != null) {
EVERY_CHANNEL_NBC_MVPD_USERNAME_FILE = toString cfg.nbc.mvpdUsernameFile;
}
// lib.optionalAttrs (cfg.nbc.mvpdPasswordFile != null) {
EVERY_CHANNEL_NBC_MVPD_PASSWORD_FILE = toString cfg.nbc.mvpdPasswordFile;
}
);
};
})
cfg.broadcasts)
// lib.optionalAttrs (cfg.control.enable && cfg.control.bridgeWeb.enable)
(let
bridgeUnit = "every-channel-control-bridge-web";
bridgePeerFiles = map (b: "/run/every-channel/control-peer-${sanitizeUnitName b.name}.json") cfg.broadcasts;
bridgeDiscoveryStr =
if cfg.control.bridgeWeb.discovery != null then cfg.control.bridgeWeb.discovery
else if cfg.control.discovery != null then cfg.control.discovery
else "";
bridgeStreamPrefixStr = if cfg.control.bridgeWeb.streamPrefix == null then "" else cfg.control.bridgeWeb.streamPrefix;
bridgeAuthTokenFile = if cfg.control.bridgeWeb.authTokenFile == null then "" else cfg.control.bridgeWeb.authTokenFile;
staticGossipPeerLines = lib.concatMapStrings (peer: ''
cmd+=(--gossip-peer ${lib.escapeShellArg peer})
have_peer=1
'') cfg.control.gossipPeers;
peerFileLoopLines = lib.concatMapStrings (peerFile: ''
if [[ -s ${lib.escapeShellArg peerFile} ]]; then
peer="$(tr -d '\r\n' < ${lib.escapeShellArg peerFile})"
if [[ -n "$peer" ]]; then
cmd+=(--gossip-peer "$peer")
have_peer=1
fi
fi
'') bridgePeerFiles;
bridgeRunner = pkgs.writeShellApplication {
name = bridgeUnit;
runtimeInputs = [
pkgs.coreutils
cfg.package
];
text = ''
set -euo pipefail
directory_url=${lib.escapeShellArg cfg.control.bridgeWeb.directoryUrl}
bridge_discovery=${lib.escapeShellArg bridgeDiscoveryStr}
stream_prefix=${lib.escapeShellArg bridgeStreamPrefixStr}
auth_token_file=${lib.escapeShellArg bridgeAuthTokenFile}
while true; do
cmd=(
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
control-bridge-web
--directory-url "$directory_url"
--timeout-ms ${toString cfg.control.bridgeWeb.timeoutMs}
--max-age-ms ${toString cfg.control.bridgeWeb.maxAgeMs}
)
if [[ -n "$bridge_discovery" ]]; then
cmd+=(--discovery "$bridge_discovery")
fi
if [[ -n "$stream_prefix" ]]; then
cmd+=(--stream-prefix "$stream_prefix")
fi
if [[ -n "$auth_token_file" && -r "$auth_token_file" ]]; then
token="$(tr -d '\r\n' < "$auth_token_file")"
if [[ -n "$token" ]]; then
cmd+=(--auth-token "$token")
fi
fi
have_peer=0
${peerFileLoopLines}
${staticGossipPeerLines}
if [[ "$have_peer" -eq 0 ]]; then
sleep 2
continue
fi
"''${cmd[@]}" || true
sleep 2
done
'';
};
in
{
"${bridgeUnit}" = {
description = "every.channel control bridge to web directory";
wantedBy = [ "multi-user.target" ];
after =
[ "network-online.target" ]
++ map (b: "every-channel-wt-publish-${sanitizeUnitName b.name}.service") cfg.broadcasts;
wants =
[ "network-online.target" ]
++ map (b: "every-channel-wt-publish-${sanitizeUnitName b.name}.service") cfg.broadcasts;
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "simple";
ExecStart = "${bridgeRunner}/bin/${bridgeUnit}";
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;
};
})
// lib.optionalAttrs cfg.archive.enable
(let
archiveUnit = "every-channel-wt-archive-auto";
archivePrefix = if cfg.archive.streamPrefix == null then "" else cfg.archive.streamPrefix;
archiveTrackLines = lib.concatMapStrings (track: " cmd+=(--track ${lib.escapeShellArg track})\n") cfg.archive.tracks;
archiveRunner = pkgs.writeShellApplication {
name = archiveUnit;
runtimeInputs = [
pkgs.coreutils
pkgs.curl
pkgs.gawk
pkgs.jq
cfg.package
];
text = ''
set -euo pipefail
directory_url=${lib.escapeShellArg (cfg.archive.directoryUrl + "/api/public-streams")}
output_dir=${lib.escapeShellArg cfg.archive.outputDir}
manifest_dir=${lib.escapeShellArg cfg.archive.manifestDir}
relay_fallback=${lib.escapeShellArg cfg.relayUrl}
relay_override=${lib.escapeShellArg (if cfg.archive.relayUrlOverride == null then "" else cfg.archive.relayUrlOverride)}
stream_prefix=${lib.escapeShellArg archivePrefix}
state_dir="/run/every-channel/archive"
pids_dir="$state_dir/pids"
logs_dir="$state_dir/logs"
mkdir -p "$pids_dir" "$logs_dir"
poll_secs="$(awk 'BEGIN { printf "%.3f", ${toString cfg.archive.pollIntervalMs} / 1000.0 }')"
cleanup_children() {
for pid_file in "$pids_dir"/*.pid; do
[[ -e "$pid_file" ]] || continue
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
done
}
trap cleanup_children INT TERM EXIT
while true; do
entries_json="$(curl -fsS "$directory_url" || true)"
if [[ -z "$entries_json" ]]; then
sleep "$poll_secs"
continue
fi
while IFS= read -r entry; do
name="$(printf '%s\n' "$entry" | jq -r '.broadcast_name // empty')"
relay="$(printf '%s\n' "$entry" | jq -r '.relay_url // empty')"
if [[ -z "$name" ]]; then
continue
fi
if [[ -n "$stream_prefix" && "$name" != "$stream_prefix"* ]]; then
continue
fi
if [[ -n "$relay_override" ]]; then
relay="$relay_override"
elif [[ -z "$relay" ]]; then
relay="$relay_fallback"
fi
key="$(printf '%s' "$name" | tr -c 'A-Za-z0-9_.-' '_')"
pid_file="$pids_dir/$key.pid"
if [[ -s "$pid_file" ]]; then
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
continue
fi
fi
cmd=(
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
wt-archive
--url "$relay"
--name "$name"
--output-dir "$output_dir"
--manifest-dir "$manifest_dir"
)
${lib.optionalString cfg.archive.tlsDisableVerify "cmd+=(--tls-disable-verify)"}
${archiveTrackLines}
log_file="$logs_dir/$key.log"
(
exec "''${cmd[@]}"
) >>"$log_file" 2>&1 &
echo "$!" > "$pid_file"
done < <(printf '%s\n' "$entries_json" | jq -rc '.entries[]?')
for pid_file in "$pids_dir"/*.pid; do
[[ -e "$pid_file" ]] || continue
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$pid_file"
fi
done
sleep "$poll_secs"
done
'';
};
in
{
"${archiveUnit}" = {
description = "every.channel relay archival auto-worker";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "simple";
ExecStart = "${archiveRunner}/bin/${archiveUnit}";
Restart = "always";
RestartSec = 2;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths = [
"/run/every-channel"
cfg.archive.outputDir
cfg.archive.manifestDir
];
};
environment = cfg.environment;
};
})
// lib.optionalAttrs (cfg.archive.enable && cfg.archive.serve.enable)
(let
archiveServeUnit = "every-channel-wt-archive-serve";
archiveServeRunner = pkgs.writeShellApplication {
name = archiveServeUnit;
runtimeInputs = [
cfg.package
];
text = ''
set -euo pipefail
exec ${lib.escapeShellArg "${cfg.package}/bin/ec-node"} \
wt-archive-serve \
--output-dir ${lib.escapeShellArg cfg.archive.outputDir} \
--manifest-dir ${lib.escapeShellArg cfg.archive.manifestDir} \
--listen ${lib.escapeShellArg cfg.archive.serve.listen}
'';
};
in
{
"${archiveServeUnit}" = {
description = "every.channel archived replay HTTP server";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "simple";
ExecStart = "${archiveServeRunner}/bin/${archiveServeUnit}";
Restart = "always";
RestartSec = 2;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths = [
cfg.archive.outputDir
cfg.archive.manifestDir
];
};
environment = cfg.environment;
};
})
// lib.optionalAttrs cfg.nbc.enable
(let
displayUnit = "every-channel-nbc-display";
displayNumber = lib.strings.removePrefix ":" cfg.nbc.display;
displayRunner = pkgs.writeShellApplication {
name = displayUnit;
runtimeInputs = [
pkgs.coreutils
pkgs.xorg.xorgserver
];
text = ''
set -euo pipefail
exec ${pkgs.xorg.xorgserver}/bin/Xvfb ${lib.escapeShellArg cfg.nbc.display} \
-screen 0 ${lib.escapeShellArg cfg.nbc.screen} \
-nolisten tcp \
-ac \
+extension RANDR
'';
};
vncUnit = "every-channel-nbc-vnc";
vncRunner = pkgs.writeShellApplication {
name = vncUnit;
runtimeInputs = [
pkgs.x11vnc
];
text = ''
set -euo pipefail
exec ${pkgs.x11vnc}/bin/x11vnc \
-display ${lib.escapeShellArg cfg.nbc.display} \
-forever \
-shared \
-nopw \
-listen ${lib.escapeShellArg cfg.nbc.vnc.listen} \
-rfbport ${toString cfg.nbc.vnc.port}
'';
};
in
({
"${displayUnit}" = {
description = "every.channel NBC virtual display";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${displayRunner}/bin/${displayUnit}";
Restart = "always";
RestartSec = 2;
User = "every-channel";
Group = "every-channel";
NoNewPrivileges = true;
PrivateTmp = false;
ProtectSystem = "strict";
ProtectHome = false;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = false;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths = [ "/tmp" "/var/lib/every-channel" ];
};
environment = cfg.environment // {
HOME = "/var/lib/every-channel";
};
};
}
// lib.optionalAttrs cfg.nbc.vnc.enable {
"${vncUnit}" = {
description = "every.channel NBC virtual display VNC bridge";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "${displayUnit}.service" ];
wants = [ "network-online.target" "${displayUnit}.service" ];
serviceConfig = {
Type = "simple";
ExecStart = "${vncRunner}/bin/${vncUnit}";
Restart = "always";
RestartSec = 2;
User = "every-channel";
Group = "every-channel";
NoNewPrivileges = true;
PrivateTmp = false;
ProtectSystem = "strict";
ProtectHome = false;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = false;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths = [ "/tmp" "/var/lib/every-channel" ];
};
environment = cfg.environment // {
DISPLAY = cfg.nbc.display;
HOME = "/var/lib/every-channel";
};
};
}));
};
}