From 92f004974359b8fd04d8afe782ae29fcc85aa9ae Mon Sep 17 00:00:00 2001 From: Pietro Zanella Date: Tue, 27 Oct 2026 18:33:13 +0100 Subject: [PATCH] feat: network lab (stream.sh --netem) stream.sh --netem PROFILE shapes what the relay sends to its viewers with Linux netem: mobile, lossy, congested, satellite or off. The profiles live in lib/netem.mjs. With --dashboard, a Network lab card switches profiles at runtime through /api/netem, which only takes a profile name. Only relay -> viewer traffic is shaped. The ingest and the dashboard's HTTP API are not: a prio qdisc and a u32 filter on the relay port send only those packets to netem, on eth0 and on lo (rootless Podman hands viewer traffic to the relay over lo). NET_ADMIN is added only with --netem. New CI job smoke-test-podman-netem. --- .github/workflows/ci.yml | 64 +++++++++++++++++++++ CHANGELOG.md | 12 ++++ CONTRIBUTING.md | 2 +- Containerfile | 2 +- README.md | 51 +++++++++++++++++ dashboard/server/server.mjs | 69 +++++++++++++++++++++++ dashboard/src/App.jsx | 3 + dashboard/src/components/Network.jsx | 64 +++++++++++++++++++++ dashboard/src/lib/api.js | 17 ++++++ lib/netem.mjs | 83 ++++++++++++++++++++++++++++ run-stream.sh | 11 ++++ stream.sh | 26 ++++++++- 12 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/components/Network.jsx create mode 100644 lib/netem.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a304e5d..c88c948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -362,6 +362,70 @@ jobs: # tap sits in the pipe, so check the broadcast still comes through, then that # the server serves the built UI, its config, and an event stream carrying # both container logs and ingest-clock samples. + # stream.sh --netem: the relay's traffic to viewers is shaped, the dashboard's + # HTTP is not, and the dashboard can switch profiles at runtime. + smoke-test-podman-netem: + needs: smoke-test-podman-build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + name: moq-lab-image + path: /tmp + + - uses: actions/download-artifact@v8 + with: + name: moq-lab-assets + path: assets + + - name: Load the sandbox image + run: gunzip -c /tmp/moq-lab-image.tar.gz | podman load + + # A rootless container can't load kernel modules itself. + - name: Load the tc modules + run: sudo modprobe -a sch_netem sch_prio cls_u32 + + - name: Smoke test -- network lab + run: | + podman run -d --name ci-netem --init --cap-add NET_ADMIN \ + -e MOQ_LAB_NETEM=satellite -e MOQ_LAB_DASHBOARD_PORT=18080 -p 18080:18080/tcp \ + -v "$PWD/assets/ci-test.mp4:/media/input.mp4:ro" \ + -p 15449:15449/udp -p 15449:15449/tcp \ + moq-lab /media/input.mp4 ci-test.hang false 15449 false 30 false 6 + + ok=false + for _ in $(seq 1 30); do + if curl -sf http://localhost:15449/announced 2>/dev/null | grep -q "ci-test.hang"; then + ok=true; break + fi + sleep 1 + done + if [ "$ok" != true ]; then + echo "broadcast never announced with --netem"; podman logs ci-netem; exit 1 + fi + + fail=0 + podman logs ci-netem 2>&1 | grep -q "Network profile 'satellite': qdisc netem" \ + || { echo "satellite profile not applied at startup"; fail=1; } + + # satellite adds 300 ms to every packet the relay sends out, and nothing to the + # dashboard's HTTP (its clock-offset measurement needs a symmetric path). + slow() { curl -s -o /dev/null -w '%{time_total}' "$1" | LC_NUMERIC=C awk '{ exit !($1 >= 0.3) }'; } + slow http://localhost:15449/announced || { echo "relay traffic is not delayed"; fail=1; } + slow http://localhost:18080/api/time && { echo "dashboard traffic is delayed"; fail=1; } + + post() { + curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' \ + -d "{\"profile\":\"$1\"}" http://localhost:18080/api/netem + } + [ "$(post off)" = 200 ] || { echo "switching to off failed"; fail=1; } + slow http://localhost:15449/announced && { echo "relay traffic still delayed after off"; fail=1; } + [ "$(post 'x; reboot')" = 400 ] || { echo "an unknown profile was not refused"; fail=1; } + + [ "$fail" -ne 0 ] && podman logs ci-netem + podman rm -f ci-netem + exit "$fail" + smoke-test-podman-dashboard: needs: smoke-test-podman-build runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecc492..6d60244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ any breaking changes worth flagging for the next person to bump. ## Unreleased ### Added +- Network lab: `stream.sh --netem PROFILE` (`mobile`, `lossy`, + `congested`, `satellite`, `off`) shapes what the relay sends to its + viewers with Linux netem. The profiles are in the new `lib/netem.mjs`. With + `--dashboard`, a **Network lab** card switches profiles at runtime + (`GET`/`POST /api/netem`, which only accepts a profile name and needs + `Content-Type: application/json`). Only relay -> viewer traffic is shaped: + not the ingest, and not the dashboard's HTTP API. A prio qdisc and a u32 + filter on the relay port do this on both `eth0` and `lo`, because with + rootless Podman the viewer traffic reaches the relay over `lo`. The + container gets `NET_ADMIN` only with `--netem`. The image now includes + `iproute2`. New CI job `smoke-test-podman-netem`. See README, "Network + lab". - Dashboard **Player** card: plays the stream with `@moq/watch` 0.6.6 in every mode. With `--sgai-mode` it is the SGAI player that was missing: it reads the Event Timeline (`dashboard/src/lib/sgai.js`) and switches from diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59e0065..c759caa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ Podman, so no real content broadcast — the Media Timeline needs one, see below). It also checks that no event is lost between the publisher and the subscriber (`sgai/event-delivery.ci-check.mjs`). `smoke-test-podman-build` builds the actual sandbox image and the synthetic test clip once, sharing both via artifacts with one job per -pipeline mode (`smoke-test-podman-base`, `-auth`, `-ssai`, `-ssai-abr`, +pipeline mode (`smoke-test-podman-base`, `-auth`, `-netem`, `-ssai`, `-ssai-abr`, `-csai`, `-sgai`, `-dashboard`) — split out so each mode gets its own pass/fail check instead of one job with five sequential steps, and a failure in one doesn't block the others from reporting. Together they check the relay's HTTP API (broadcast diff --git a/Containerfile b/Containerfile index 279db08..39ead33 100644 --- a/Containerfile +++ b/Containerfile @@ -37,7 +37,7 @@ RUN pnpm build FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg ca-certificates curl nodejs && \ + ffmpeg ca-certificates curl nodejs iproute2 && \ rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/local/bin/moq /usr/local/bin/moq diff --git a/README.md b/README.md index de866cf..2094af7 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,7 @@ podman run --rm -i --entrypoint moq moq-lab auth sign --key - --subscribe '**' \ | `--auth-key FILE` | off | Like `--auth`, but sign with the key in `FILE` | | `--dashboard` | off | Serve the real-time monitoring web UI on `http://localhost:8080` | | `--dashboard-port N` | `8080` | Like `--dashboard`, on port `N` (must differ from `--port`) | +| `--netem PROFILE` | off | Shape the network between the relay and its viewers: `mobile`, `lossy`, `congested`, `satellite`, or `off` (see [Network lab](#network-lab---netem)) | ### npm/pnpm shortcut @@ -1031,6 +1032,56 @@ pnpm dev # terminal 2: http://localhost:5173 Changes to the UI reach `./stream.sh --dashboard` the next time the image builds, which `stream.sh` does on every run. +### Network lab (`--netem`) + +`--netem PROFILE` makes the network between the relay and its viewers worse, +with Linux [netem](https://man7.org/linux/man-pages/man8/tc-netem.8.html). +Use it to see how MoQ behaves on a bad network: the charts above show what +happens to RTT, bitrate, jitter, frame rate, and latency. + +```bash +./stream.sh bbb --dashboard --netem mobile +``` + +| Profile | Delay | Loss | Rate | +|---|---|---|---| +| `mobile` | 60 ms ± 20 ms | 0.5% | 8 Mbit/s | +| `lossy` | — | 3% | — | +| `congested` | 40 ms ± 10 ms | 1% | 3 Mbit/s | +| `satellite` | 300 ms ± 20 ms | — | 10 Mbit/s | +| `off` | — | — | — | + +With `--dashboard`, the **Network lab** card switches between the profiles +while the stream runs, so you can compare them on the same stream. The +profiles are defined in `lib/netem.mjs`. The dashboard can only pick one of +them by name. + +What is shaped: + +- Only what the relay sends to its viewers: players, the dashboard page's + MoQ sessions, and the host-side SGAI scripts. So the delay is one way: the + RTT goes up by the delay, not by twice the delay. +- Not the ingest pipeline: `moq import` talks to the relay over + `localhost` inside the container. +- Not the dashboard's own HTTP API: it measures the clock offset between + your machine and the container, which needs the same delay both ways. + +Good to know: + +- `stream.sh` gives the container the `NET_ADMIN` capability, but only + with `--netem`. It is limited to the container's own network. +- With rootless Podman, the viewer traffic reaches the relay on the + container's loopback interface, so the shaping is set up on `lo` as well + as on `eth0`. +- With a high delay (for example `satellite`), the browser may connect over + the WebSocket fallback (TCP) instead of WebTransport: the two race, and + the slower QUIC handshake can lose. The header shows which one is in use. + Over TCP, frames then arrive in bursts, so the frame rate and the latency + jump around, and some 1-second ticks show "—". +- On Linux, the host kernel needs the `sch_netem`, `sch_prio`, and `cls_u32` + modules. Most distributions load them on demand; if `tc` fails, run + `sudo modprobe -a sch_netem sch_prio cls_u32`. + ### Limitations - With `--sgai-mode`, the Ad Decisioning Publisher runs on your host, not diff --git a/dashboard/server/server.mjs b/dashboard/server/server.mjs index beba201..0a32217 100644 --- a/dashboard/server/server.mjs +++ b/dashboard/server/server.mjs @@ -8,6 +8,8 @@ // GET /api/time this container's clock, so the browser can correct for host/VM clock skew // GET /api/events Server-Sent Events: `log` (container stdout/stderr lines) and // `ingest` (lib/ingest-clock.mjs samples, for end-to-end latency) +// GET /api/netem the active network profile (stream.sh --netem) +// POST /api/netem switch to another profile: {"profile": ""} // // Usage: node server.mjs --port 8080 --relay-port 4443 --broadcast bbb.hang --mode base // --abr-ladder false --log-file /tmp/moq-lab.log --ingest-file /tmp/moq-lab-ingest.json @@ -17,6 +19,7 @@ import { extname, join, normalize, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "../../lib/cli.mjs"; import { createLogger } from "../../lib/log.mjs"; +import { applyProfile, currentShaping, PROFILES } from "../../lib/netem.mjs"; const log = createLogger("dashboard"); @@ -38,8 +41,17 @@ const CONFIG = { // can reach the dashboard is fine for this sandbox: they could already reach the // terminal it was printed in. jwt: process.env.MOQ_LAB_VIEWER_JWT || null, + // Network profiles can only be switched when stream.sh --netem gave the + // container NET_ADMIN, so the dashboard only offers them then. + netem: process.env.MOQ_LAB_NETEM + ? Object.fromEntries(Object.entries(PROFILES).map(([name, p]) => [name, p.label])) + : null, }; +let netemProfile = process.env.MOQ_LAB_NETEM || null; +// A profile name is a few bytes; refuse anything bigger before parsing it. +const MAX_BODY_BYTES = 1024; + // How much log history a newly opened tab gets. const LOG_BACKLOG = 1000; @@ -131,6 +143,55 @@ function sendJson(res, body) { res.end(JSON.stringify(body)); } +async function sendNetem(res) { + sendJson(res, { profile: netemProfile, shaping: await currentShaping() }); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + if (body.length > MAX_BODY_BYTES) { + reject(new Error("body too large")); + req.destroy(); + } + }); + req.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error("body is not JSON")); + } + }); + req.on("error", reject); + }); +} + +async function handleNetem(req, res) { + if (!CONFIG.netem) { + res.writeHead(404).end(); + return; + } + if (req.method !== "GET" && req.method !== "POST") { + res.writeHead(405).end(); + return; + } + if (req.method === "POST") { + // A cross-site page can't send this content type without a CORS preflight, + // which this server never answers, so only the dashboard itself can switch. + if (req.headers["content-type"] !== "application/json") throw new Error("expected application/json"); + const { profile } = await readJsonBody(req); + // applyProfile() only accepts a name from PROFILES; it never builds a command + // from the request. + await applyProfile(profile); + netemProfile = profile; + log(`network profile: ${profile}`); + } + await sendNetem(res); +} + function serveStatic(req, res, pathname) { let decoded; try { @@ -164,6 +225,14 @@ function serveStatic(req, res, pathname) { const server = createServer((req, res) => { const { pathname } = new URL(req.url, "http://localhost"); + if (pathname === "/api/netem") { + handleNetem(req, res).catch((err) => { + res.writeHead(400, { "content-type": "text/plain" }); + res.end(`${err.message}\n`); + }); + return; + } + if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end(); return; diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index 8c3ef4b..830e6f1 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Card, Stat } from "./components/Card.jsx"; import { LineChart } from "./components/LineChart.jsx"; import { LogStream } from "./components/LogStream.jsx"; +import { Network } from "./components/Network.jsx"; import { Player } from "./components/Player.jsx"; import { StatusBadge } from "./components/Status.jsx"; import { fetchConfig, measureClockOffset, relayUrlFor, subscribeEvents } from "./lib/api.js"; @@ -163,6 +164,8 @@ export default function App() { {config && } + {config?.netem && } +
{ + fetchNetem().then(setState, (err) => setError(err.message)); + }, []); + + const onChange = async (e) => { + setBusy(true); + setError(null); + try { + setState(await setNetem(e.target.value)); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const shaped = state?.profile && state.profile !== "off"; + + return ( + {shaped ? "Shaped" : "Not shaped"} + } + > +
+ +

{state?.shaping || "tc: no root qdisc"}

+ {error &&

{error}

} +

+ Only outgoing traffic is shaped, so the delay is one way: the RTT goes up by the delay, not + twice. The ingest pipeline inside the container is not affected. +

+
+
+ ); +} diff --git a/dashboard/src/lib/api.js b/dashboard/src/lib/api.js index 6212037..85b4c9e 100644 --- a/dashboard/src/lib/api.js +++ b/dashboard/src/lib/api.js @@ -6,6 +6,23 @@ export async function fetchConfig() { return res.json(); } +// The active network profile (stream.sh --netem), and a switch to another one. +export async function fetchNetem() { + const res = await fetch("/api/netem", { cache: "no-store" }); + if (!res.ok) throw new Error(`/api/netem: HTTP ${res.status}`); + return res.json(); +} + +export async function setNetem(profile) { + const res = await fetch("/api/netem", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile }), + }); + if (!res.ok) throw new Error((await res.text()).trim() || `/api/netem: HTTP ${res.status}`); + return res.json(); +} + // The relay is published on the same host the dashboard was opened from. // http:// (not https://) is deliberate: @moq/net then fetches the relay's // self-signed certificate fingerprint from /certificate.sha256 and pins it for diff --git a/lib/netem.mjs b/lib/netem.mjs new file mode 100644 index 0000000..c7f54e6 --- /dev/null +++ b/lib/netem.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Network profiles for stream.sh --netem: Linux netem on what the relay sends to +// its viewers (players, the dashboard page, the host-side SGAI scripts). Only the +// relay -> viewer direction is shaped, so a delay is one way. +// +// Where that traffic flows depends on Podman. With rootful Podman (or a real +// network) it leaves through eth0. With rootless Podman, the port forwarder hands +// it to the relay on the container's own IP, so it never leaves the container: it +// goes over lo. lo also carries the ingest pipeline (`moq import` to the relay on +// localhost), which must stay clean. So both interfaces get the same setup: a prio +// qdisc whose third band is netem, and a u32 filter that sends only packets from +// the relay port there -- on lo, only those to the container's own IP. +// +// The profiles live only here. run-stream.sh applies one at startup, and the +// dashboard server switches between them at runtime. Callers pass a profile name, +// never a raw tc command, and tc runs without a shell. +// +// Usage: MOQ_LAB_NETEM_PORT=4443 node lib/netem.mjs +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); + +// netem arguments for each profile. `off` removes the shaping. +export const PROFILES = { + off: { label: "Off", args: null }, + mobile: { label: "Mobile (60 ms ± 20 ms, 0.5% loss, 8 Mbit/s)", args: ["delay", "60ms", "20ms", "loss", "0.5%", "rate", "8mbit"] }, + lossy: { label: "Lossy (3% loss)", args: ["loss", "3%"] }, + congested: { label: "Congested (40 ms ± 10 ms, 1% loss, 3 Mbit/s)", args: ["delay", "40ms", "10ms", "loss", "1%", "rate", "3mbit"] }, + satellite: { label: "Satellite (300 ms ± 20 ms, 10 Mbit/s)", args: ["delay", "300ms", "20ms", "rate", "10mbit"] }, +}; + +// Every packet goes to band 1 unless a filter picks it; band 3 holds netem. +const PRIOMAP = Array(16).fill("0"); + +function relayPort() { + const port = Number(process.env.MOQ_LAB_NETEM_PORT); + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("MOQ_LAB_NETEM_PORT is not set to the relay port"); + return port; +} + +// The interface of the default route (eth0 in a Podman container) and its IPv4 address. +async function defaultInterface() { + const { stdout: route } = await run("ip", ["route", "show", "default"]); + const dev = route.match(/\bdev (\S+)/)?.[1] ?? "eth0"; + const { stdout: addr } = await run("ip", ["-4", "-o", "addr", "show", "dev", dev]); + const ip = addr.match(/\binet (\d+\.\d+\.\d+\.\d+)/)?.[1]; + return { dev, ip }; +} + +async function shape(dev, args, match) { + // Nothing to delete is fine: the result is the same. + await run("tc", ["qdisc", "del", "dev", dev, "root"]).catch(() => {}); + if (!args) return; + await run("tc", ["qdisc", "add", "dev", dev, "root", "handle", "1:", "prio", "bands", "3", "priomap", ...PRIOMAP]); + await run("tc", ["qdisc", "add", "dev", dev, "parent", "1:3", "handle", "30:", "netem", ...args]); + await run("tc", ["filter", "add", "dev", dev, "parent", "1:", "protocol", "ip", "prio", "1", "u32", ...match, "flowid", "1:3"]); +} + +export async function applyProfile(name) { + const profile = PROFILES[name]; + if (!profile) throw new Error(`unknown network profile '${name}' (one of: ${Object.keys(PROFILES).join(", ")})`); + const port = String(relayPort()); + const { dev, ip } = await defaultInterface(); + await shape(dev, profile.args, ["match", "ip", "sport", port, "0xffff"]); + if (ip) await shape("lo", profile.args, ["match", "ip", "sport", port, "0xffff", "match", "ip", "dst", `${ip}/32`]); + return currentShaping(); +} + +// The netem qdisc tc reports, e.g. "qdisc netem 30: parent 1:3 ... delay 60ms 20ms loss 0.5%". +export async function currentShaping() { + const { stdout } = await run("tc", ["qdisc", "show", "dev", "lo"]); + return stdout.split("\n").find((line) => line.includes("netem"))?.trim() ?? ""; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + console.log(await applyProfile(process.argv[2])); + } catch (err) { + console.error(`--netem: ${err.stderr?.trim() || err.message}`); + process.exit(1); + } +} diff --git a/run-stream.sh b/run-stream.sh index 198bcee..291a211 100755 --- a/run-stream.sh +++ b/run-stream.sh @@ -34,6 +34,17 @@ if [ -n "${MOQ_LAB_AUTH_KEY:-}" ]; then fi RELAY_URL="http://localhost:${PORT}/${JWT_QUERY}" +# Set by stream.sh --netem: shapes what the relay sends to its viewers with one of +# lib/netem.mjs's profiles. Applied before the relay starts, so no viewer ever sees +# the unshaped network, and before the dashboard server starts, which inherits +# MOQ_LAB_NETEM_PORT to switch profiles later. stream.sh adds the NET_ADMIN +# capability tc needs. +if [ -n "${MOQ_LAB_NETEM:-}" ]; then + export MOQ_LAB_NETEM_PORT="$PORT" + NETEM_SHAPING=$(node /usr/local/bin/lib/netem.mjs "$MOQ_LAB_NETEM") || exit 1 + echo "Network profile '$MOQ_LAB_NETEM': ${NETEM_SHAPING:-no shaping}" >&2 +fi + # Set by stream.sh --dashboard/--dashboard-port. Everything this script and its # children print is tee'd to a file the dashboard server tails for its log view, # and the pass-through proxies before `moq import` record an ingest clock for diff --git a/stream.sh b/stream.sh index 68151f2..08e3b95 100755 --- a/stream.sh +++ b/stream.sh @@ -2,7 +2,7 @@ # Usage: stream.sh [name] [--source URL] [--abr-ladder] [--port N] [--ssai-mode] [--ad-break-every N] # [--csai-mode] [--ad-break-length N] # [--sgai-mode] [--auth] [--auth-key FILE] -# [--dashboard] [--dashboard-port N] +# [--dashboard] [--dashboard-port N] [--netem PROFILE] # # --source URL Stream from an http(s) URL -- an MP4 file, an HLS playlist # (.m3u8), or a DASH manifest (.mpd), on demand or live -- @@ -35,6 +35,10 @@ # --dashboard Also serve the real-time monitoring web UI (dashboard/) from the # container, on http://localhost:8080. See README.md "Dashboard". # --dashboard-port N Like --dashboard, on port N instead of 8080. +# --netem PROFILE Shape the network between the relay and its viewers: mobile, +# lossy, congested, satellite, or off (see lib/netem.mjs). With +# --dashboard, you can switch profiles there while the stream runs. +# See README.md "Network lab". set -euo pipefail # Yellow only when stderr is an actual terminal -- keeps piped/redirected output @@ -65,6 +69,7 @@ AUTH=false AUTH_KEY_FILE="" DASHBOARD=false DASHBOARD_PORT=8080 +NETEM="" while [ $# -gt 0 ]; do case "$1" in @@ -83,6 +88,7 @@ while [ $# -gt 0 ]; do --dashboard) DASHBOARD=true; shift ;; --dashboard-port) DASHBOARD=true; DASHBOARD_PORT="$2"; shift 2 ;; --source) SOURCE="$2"; shift 2 ;; + --netem) NETEM="$2"; shift 2 ;; *) NAME="$1"; NAME_SET=true; shift ;; esac done @@ -137,6 +143,13 @@ if [ -n "$AUTH_KEY_FILE" ] && [ ! -f "$AUTH_KEY_FILE" ]; then exit 1 fi +# The container checks the name against lib/netem.mjs's profiles; this only keeps +# anything that isn't a plain name out of the podman command line. +if [ -n "$NETEM" ] && ! [[ "$NETEM" =~ ^[a-z]+$ ]]; then + echo "--netem: expected a profile name (mobile, lossy, congested, satellite, off), got '$NETEM'." >&2 + exit 1 +fi + if [ "$DASHBOARD" = true ] && [ "$DASHBOARD_PORT" = "$PORT" ]; then echo "--dashboard-port must differ from --port ($PORT): the relay already serves its HTTP API there." >&2 exit 1 @@ -269,6 +282,13 @@ if [ "$DASHBOARD" = true ]; then echo "Dashboard: http://localhost:${DASHBOARD_PORT} (live once the container is up)" >&2 fi +# tc needs NET_ADMIN, inside the container's own network namespace only. Without +# --netem the container doesn't get it. +NETEM_ARGS=() +if [ -n "$NETEM" ]; then + NETEM_ARGS=(--cap-add NET_ADMIN -e "MOQ_LAB_NETEM=$NETEM") +fi + # run-stream.sh's first argument: the mounted local file, or the --source URL as-is. CONTAINER_VOLUMES=() if [ -n "$SOURCE" ]; then @@ -283,7 +303,7 @@ fi if [ "$SGAI" = true ]; then podman run --name "$CONTAINER_NAME" --rm -d --init \ - ${CONTAINER_VOLUMES[@]+"${CONTAINER_VOLUMES[@]}"} ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} ${DASHBOARD_ARGS[@]+"${DASHBOARD_ARGS[@]}"} \ + ${CONTAINER_VOLUMES[@]+"${CONTAINER_VOLUMES[@]}"} ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} ${DASHBOARD_ARGS[@]+"${DASHBOARD_ARGS[@]}"} ${NETEM_ARGS[@]+"${NETEM_ARGS[@]}"} \ -p "$PORT:$PORT/udp" -p "$PORT:$PORT/tcp" \ "$IMAGE" "$CONTAINER_INPUT" "$BROADCAST" "$ABR_LADDER" "$PORT" "$SSAI" "$AD_BREAK_EVERY" "$CSAI" "$AD_BREAK_LENGTH" "$SGAI" "$AD_BROADCAST" \ >/dev/null @@ -330,7 +350,7 @@ else # "false" "" fill run-stream.sh's SGAI/AD_BROADCAST positions (unused here) so # BLACKOUT_AT/BLACKOUT_LENGTH land in its CSAI blackout positions after them. podman run --name "$CONTAINER_NAME" --rm -it --init \ - ${CONTAINER_VOLUMES[@]+"${CONTAINER_VOLUMES[@]}"} ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} ${DASHBOARD_ARGS[@]+"${DASHBOARD_ARGS[@]}"} \ + ${CONTAINER_VOLUMES[@]+"${CONTAINER_VOLUMES[@]}"} ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} ${DASHBOARD_ARGS[@]+"${DASHBOARD_ARGS[@]}"} ${NETEM_ARGS[@]+"${NETEM_ARGS[@]}"} \ -p "$PORT:$PORT/udp" -p "$PORT:$PORT/tcp" \ "$IMAGE" "$CONTAINER_INPUT" "$BROADCAST" "$ABR_LADDER" "$PORT" "$SSAI" "$AD_BREAK_EVERY" "$CSAI" "$AD_BREAK_LENGTH" \ false "" "$BLACKOUT_AT" "$BLACKOUT_LENGTH"