wai merge train E: twenty-five PRs onto main, the relay's upstream window bounded, and three fixes the merged tree needed #499

Merged
dcharlot merged 293 commits from train/wai-e into main 2026-10-26 20:23:52 -04:00
1183 changed files with 294043 additions and 1124 deletions

View file

@ -36,6 +36,12 @@ on:
- 'sandbox/sandbox-rs/crates/sandbox-proof/**'
- 'proof/proof-rs/crates/proof-cascade/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**'
# The sink's readers and its table procedure (wai/extensions/sink-energy.md §4.4).
- 'wai/wai-rs/src/sink_meter.rs'
- 'wai/wai-rs/src/sink_energy.rs'
- 'wai/wai-rs/src/sink_energy/**'
- 'wai/wai-rs/src/bin/wai_sink_table.rs'
- 'wai/live-conformance/sink_energy_verify.py'
- '.github/workflows/energy-hardware.yml'
push:
branches:
@ -48,6 +54,12 @@ on:
- 'sandbox/sandbox-rs/crates/sandbox-proof/**'
- 'proof/proof-rs/crates/proof-cascade/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**'
# The sink's readers and its table procedure (wai/extensions/sink-energy.md §4.4).
- 'wai/wai-rs/src/sink_meter.rs'
- 'wai/wai-rs/src/sink_energy.rs'
- 'wai/wai-rs/src/sink_energy/**'
- 'wai/wai-rs/src/bin/wai_sink_table.rs'
- 'wai/live-conformance/sink_energy_verify.py'
- '.github/workflows/energy-hardware.yml'
jobs:
@ -132,3 +144,101 @@ jobs:
CARGO_TARGET_DIR="$HOME/hw-target/jouleclaw-rs" nice -n 10 cargo test -p jouleclaw-energy --features rapl,nvml --test rapl_hardware --test nvml_hardware -- --nocapture --test-threads=1 2>&1 | tee "$log"
grep -q 'package-only inner' "$log" || { echo "::error::the RAPL live path never ran"; exit 1; }
grep -q 'nvml hardware: 1 GPU' "$log" || { echo "::error::NVML saw no GPU"; exit 1; }
# The sink's decode table (wai/extensions/sink-energy.md §4.4) on the two platforms whose
# counters energy-measurement §9 profiles. Each job surveys its runner's counters first.
# Where no sink profile is live there, the measurement step is skipped — shown as skipped,
# with the survey in the run's summary — and never reported as passed. Where one is, the
# procedure takes seven rounds of one work item (the committed int_motion vector) and
# writes a signed table with its readings and procedure, which the reference checks as it
# writes it and the independent verifier checks again. No figure is asserted: a busy
# runner gives an unmetered row with its reason, which is the procedure working, not
# failing (feedback on energy tests and host load: assert what the meter reports).
sink-table-linux:
name: sink decode table · RAPL on jetson-hub (NVML surveyed)
runs-on: linux-hw
timeout-minutes: 90
env:
CARGO_PROFILE_RELEASE_DEBUG: '0'
CARGO_BUILD_JOBS: '8'
steps:
- name: Fetch the source
shell: bash
run: |
set -euo pipefail
ref="${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}"
want="${{ github.event.pull_request.head.sha || github.sha }}"
rm -rf src && mkdir src && cd src
git init -q
git fetch -q --depth 1 http://100.64.224.0:3400/Transaction-Science/open-standards.git "$ref"
git checkout -q FETCH_HEAD
got="$(git rev-parse HEAD)"
echo "fetched $ref at $got"
if [ "$got" != "$want" ]; then
echo "::error::$ref is at $got, not $want: it moved after this run started, and a newer run tests it"
exit 1
fi
- name: Survey the sink profiles
id: survey
shell: bash
working-directory: src/wai/wai-rs
run: |
set -euo pipefail
CARGO_TARGET_DIR="$HOME/hw-target/wai-rs" nice -n 10 cargo build --locked --release --no-default-features --features sink_energy,video_int --bin wai_sink_table
"$HOME/hw-target/wai-rs/release/wai_sink_table" survey | tee "$RUNNER_TEMP/survey.txt"
if grep -qE '^linux-rapl +live' "$RUNNER_TEMP/survey.txt"; then
echo "live=true" >> "$GITHUB_OUTPUT"
else
echo "live=false" >> "$GITHUB_OUTPUT"
{ echo "### sink decode table: SKIPPED (not passed)"; echo; echo "No sink profile is live on this runner:"; echo '```'; cat "$RUNNER_TEMP/survey.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
echo "::warning::sink decode table SKIPPED: no live counter on this runner"
fi
- name: Measure one work item and verify the table
if: steps.survey.outputs.live == 'true'
shell: bash
working-directory: src/wai/wai-rs
run: |
set -euo pipefail
out="$RUNNER_TEMP/sink-table"
rm -rf "$out"
nice -n 10 "$HOME/hw-target/wai-rs/release/wai_sink_table" run --out "$out" --rounds 7 --filter int_motion
python3 -I ../live-conformance/sink_energy_verify.py --table "$out"
sink-table-macos:
name: sink decode table · per-task energy and the GPU rail on macOS
runs-on: macos
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
- name: Survey the sink profiles
id: survey
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --locked --release --no-default-features --features sink_energy,video_int --bin wai_sink_table
bin="$(cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')/release/wai_sink_table"
echo "bin=$bin" >> "$GITHUB_OUTPUT"
"$bin" survey | tee "$RUNNER_TEMP/survey.txt"
if grep -qE '^macos-task +live' "$RUNNER_TEMP/survey.txt"; then
echo "live=true" >> "$GITHUB_OUTPUT"
else
echo "live=false" >> "$GITHUB_OUTPUT"
{ echo "### sink decode table: SKIPPED (not passed)"; echo; echo "No sink profile is live on this runner:"; echo '```'; cat "$RUNNER_TEMP/survey.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
echo "::warning::sink decode table SKIPPED: no live counter on this runner"
fi
- name: Measure one work item and verify the table
if: steps.survey.outputs.live == 'true'
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
out="$RUNNER_TEMP/sink-table"
rm -rf "$out"
"${{ steps.survey.outputs.bin }}" run --out "$out" --rounds 7 --filter int_motion --platform-decoder /nonexistent
python3 -I ../live-conformance/sink_energy_verify.py --table "$out"

View file

@ -39,6 +39,16 @@ on:
# joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors
# and grants to the live-rooms corpus.
- 'wai/live-conformance/rooms/**'
# joule-moq-ingest's tests/conformance_corpus.rs regenerates the Enhanced RTMP and the
# cue-injection corpora byte for byte, and holds these texts' reason tables to the codes
# it reports.
- 'wai/live-conformance/ertmp/**'
- 'wai/live-conformance/injection/**'
- 'wai/extensions/live-ingest.md'
- 'wai/extensions/cue-injection.md'
# joule-moq-ingest's tests/link_corpus.rs and its generator's --check read the
# contribution link records' corpus.
- 'wai/live-conformance/links/**'
# joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs
# read the end-to-end encrypted rooms corpus, and the latter that extension's §13.
- 'wai/live-conformance/e2ee/**'
@ -46,6 +56,11 @@ on:
# joule-moq's tests/rating_vectors.rs reads every rating claim and serving request of
# the age-assurance corpus, and the relay's ratings are held to the same reading.
- 'wai/live-conformance/age/**'
# joule-moq's tests/loc_vectors.rs regenerates and checks the LOC carriage corpus, from
# access units of the H.265 vector it reads, and requires loc-carriage §6's table.
- 'wai/live-conformance/loc/**'
- 'wai/format-probe-conformance/ext/hevc/**'
- 'wai/extensions/loc-carriage.md'
- '.github/workflows/joule-code-conformance.yml'
push:
branches:
@ -62,6 +77,16 @@ on:
# joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors
# and grants to the live-rooms corpus.
- 'wai/live-conformance/rooms/**'
# joule-moq-ingest's tests/conformance_corpus.rs regenerates the Enhanced RTMP and the
# cue-injection corpora byte for byte, and holds these texts' reason tables to the codes
# it reports.
- 'wai/live-conformance/ertmp/**'
- 'wai/live-conformance/injection/**'
- 'wai/extensions/live-ingest.md'
- 'wai/extensions/cue-injection.md'
# joule-moq-ingest's tests/link_corpus.rs and its generator's --check read the
# contribution link records' corpus.
- 'wai/live-conformance/links/**'
# joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs
# read the end-to-end encrypted rooms corpus, and the latter that extension's §13.
- 'wai/live-conformance/e2ee/**'
@ -69,6 +94,11 @@ on:
# joule-moq's tests/rating_vectors.rs reads every rating claim and serving request of
# the age-assurance corpus, and the relay's ratings are held to the same reading.
- 'wai/live-conformance/age/**'
# joule-moq's tests/loc_vectors.rs regenerates and checks the LOC carriage corpus, from
# access units of the H.265 vector it reads, and requires loc-carriage §6's table.
- 'wai/live-conformance/loc/**'
- 'wai/format-probe-conformance/ext/hevc/**'
- 'wai/extensions/loc-carriage.md'
- '.github/workflows/joule-code-conformance.yml'
jobs:
@ -111,14 +141,14 @@ jobs:
$SUDO apt-get install -y -qq z3
z3 --version
# joule-moq-relay and joule-moq-ingest run in jobs of their own below, on
# linux-arm64: they need nothing x86-specific, and this runner is the one there is
# least of.
# joule-moq-relay, joule-moq-ingest and joule-contrib run in jobs of their own
# below, on linux-arm64: they need nothing x86-specific, and this runner is the one
# there is least of.
- name: Reference library tests
working-directory: joule-code/joule-code-rs
env:
JOULE_FIX_EXPECT_Z3: '1'
run: cargo test --workspace --exclude joule-moq-relay --exclude joule-moq-ingest
run: cargo test --workspace --exclude joule-moq-relay --exclude joule-moq-ingest --exclude joule-contrib
# joule-moq's QUIC transport and its example compile only with `quic`. Named
# here so they stay built and tested whatever the workspace's other members
@ -235,13 +265,45 @@ jobs:
shell: bash
run: cargo test --locked -p wtransport --lib
# The WHIP and WHEP gateway. Every test is on loopback, against a relay that checks
# every receipt at its door: a WHIP publisher (str0m as the client) of H.264 and Opus
# whose frames a relay subscriber receives whole with every receipt verified, and DELETE
# ending the tracks; a WHEP player that gets the same frames from the group it joined;
# VP8, VP9 and AV1 through WHIP; the refusals of RFC 9725 and RFC 6750; trickle ICE and
# an ICE restart with the media carried across it; and the room reader against the
# live-rooms corpus. `--all-targets` runs the unit tests and builds the binary.
# The relay's own energy on a counter (WAI live-metering §8): tests/relay_energy.rs on
# macOS, where the relay reads its process's per-task CPU energy unprivileged. The test
# is required here (JOULE_MOQ_RELAY_ENERGY_REQUIRE): a host with no counter fails it
# rather than skipping. On linux-arm64 above the same test prints SKIPPED, which is not a
# pass of the measurement: that VM has no counter the relay reads.
moq-relay-energy:
name: joule-moq-relay, its own energy on a counter (macos)
runs-on: macos
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: The room's record carries the relay's measured energy, and the marker where it is not the room's
working-directory: joule-code/joule-code-rs
shell: bash
env:
JOULE_MOQ_RELAY_ENERGY_REQUIRE: '1'
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo test --locked -p joule-moq-relay --test relay_energy -- --nocapture --test-threads=1 2>&1 | tee "$RUNNER_TEMP/relay-energy.log"
grep -q "test result: ok. 3 passed; 0 failed" "$RUNNER_TEMP/relay-energy.log"
grep -q "measured: .* scope task" "$RUNNER_TEMP/relay-energy.log"
! grep -q "SKIPPED" "$RUNNER_TEMP/relay-energy.log"
# The ingest gateway. Every test is on loopback, against a relay that checks every
# receipt at its door: a WHIP publisher (str0m as the client) of H.264 and Opus whose
# frames a relay subscriber receives whole with every receipt verified, and DELETE ending
# the tracks; a WHEP player that gets the same frames from the group it joined; VP8, VP9
# and AV1 through WHIP; the refusals of RFC 9725 and RFC 6750; trickle ICE and an ICE
# restart with the media carried across it; the room reader against the live-rooms
# corpus; an Enhanced RTMP publisher of the tests' own over RTMP and RTMPS (every payload
# framing, multitrack layers, the stream key's refusals, the reconnect request); an SCTE
# 104 automation system of the tests' own driving the injector, the chain read at the
# relay; and the Enhanced RTMP and cue-injection corpora regenerated byte for byte.
# `--all-targets` runs the unit tests and builds the binary. tests/ertmp_ffmpeg.rs, which
# runs a local encoder as the test client, is ignored here: this runner has no encoder,
# and the run lists it as ignored, not passed.
moq-ingest:
name: joule-moq-ingest, every target
runs-on: linux-arm64
@ -264,3 +326,18 @@ jobs:
working-directory: joule-code/joule-code-rs
shell: bash
run: cargo test --locked -p joule-moq-ingest --all-targets
# SRT and RIST contribution (WAI extensions/live-contribution.md). joule-contrib's
# transports on loopback through lossy paths, each with and without the feature
# `rist` (off by default, its §10); the gateway's SRT and RIST sessions into a relay
# that checks every receipt; the link record against its corpus, which the generator
# regenerates and compares byte for byte. The tests against the reference tools are
# ignored here: they run where the tools are installed.
- name: joule-contrib and joule-moq-ingest with RIST, every target
working-directory: joule-code/joule-code-rs
shell: bash
run: |
cargo test --locked -p joule-contrib --all-targets
cargo test --locked -p joule-contrib --all-targets --features rist
cargo test --locked -p joule-moq-ingest --all-targets --features rist
cargo run --locked -q -p joule-moq-ingest --bin joule-moq-ingest-link-vectors -- --check ../../wai/live-conformance/links

93
.github/workflows/joule-moq-remote.yml vendored Normal file
View file

@ -0,0 +1,93 @@
name: joule-moq-remote host agent
# The remote desktop host agent (joule-code/joule-code-rs/crates/joule-moq-remote), a workspace
# of its own with its own lock file (WAI extensions/remote-desktop.md): its room files, the
# pure-function H.264 encoder (every macroblock I_PCM), the pattern source and the recorder, and
# tests/loopback.rs, a session over a relay on loopback: three viewers verify every frame against
# its receipt, a click under the floor applied, a viewer with no control power and a controller
# off the floor refused, the floor handed over, a paused screen refused, latency records signed,
# and the session's chains verified whole by wai-rs; and the gate's order, a participant's records
# taken in its chain's order as a room service's clock takes them; alice's typing sealed to the
# host's sealing key, injected as typed and absent from what a viewer receives, and the session
# verified at the opened level too; and tests/tls.rs, the clients of the room service over
# https:// and of the relay over wss:// through a TLS front, every certificate verified. The same
# host in a room the room service keeps is joule-moq-room's tests/remote_room.rs. On Linux arm64 the Linux adapters (X11
# capture, the kernel's input device) compile and the session runs with the pure-function
# encoder; on macOS it runs again with the platform's hardware encoder, whose units are checked
# for the Annex B the capability carries.
#
# It builds on wai-rs's room and remote desktop records, on the relay and on joule-moq, so a
# change to any of them runs it. Push filters match the push's delta, so every path the job
# reads is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
paths:
- 'joule-code/joule-code-rs/crates/joule-moq-remote/**'
- 'joule-code/joule-code-rs/crates/joule-moq/**'
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'
- 'joule-code/joule-code-rs/crates/jwp/**'
- 'joule-code/joule-code-rs/vendor/**'
- 'wai/wai-rs/**'
- 'wai/wai-ops/**'
- '.github/workflows/joule-moq-remote.yml'
jobs:
# Forgejo matches `paths:` against the push delta, so a rebase fires this workflow
# whatever the pull request changes. This job reads the pull request's own diff against
# the paths above (scripts/pr-touches-paths.py, which fails open).
relevant:
name: joule-moq-remote · does this change touch it
runs-on: linux
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/joule-moq-remote.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches the host agent: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
test:
name: joule-moq-remote, every target (${{ matrix.os }})
needs: relevant
if: needs.relevant.outputs.run == 'true'
strategy:
fail-fast: false
matrix:
os: [linux-arm64, macos]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# One test at a time: the sessions share the host with other jobs, and each takes a lock.
- name: joule-moq-remote, every target, and clippy
working-directory: joule-code/joule-code-rs/crates/joule-moq-remote
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo test --locked --all-targets -- --test-threads=1
cargo clippy --locked --all-targets --no-deps -- -D warnings

View file

@ -12,11 +12,25 @@ name: joule-moq-room room service
# Privacy Pass origin whose tokens' thresholds its grants state and the relay serves by; and a
# watch room (WAI co-watch): viewers admitted by a proof of their key, their controls and
# statements carried when co-watch accepts them, the clock's skew statements and leaves, and the
# room the log carries verified by wai-rs.
# room the log carries verified by wai-rs; and a remote desktop room (WAI remote-desktop,
# tests/remote_room.rs): the host agent of joule-moq-remote enters by an invitation over https://
# and wss:// through TLS fronts, viewers make their keys, the host hands control over by a grant
# exchange it proves its key for, inputs are applied and refused by the rules, a controller's
# typing sealed to the host and absent from what a viewer receives, refused floor statements
# change nothing, a record out of its chain's order is cut and its author removed, the session's
# records are read under a grant of the room alone, and the session verified by wai-rs as the
# clock and the host hold it, at the commitment and the opened levels; a viewer flooding past its
# own record bound (max_records_per_principal), cut and removed while the session goes on and
# verifies; and a session the clock ends at its record bound on the whole session (max_records),
# whole and verified; a conversation (WAI live-messaging, tests/messaging_room.rs):
# members keying through real MLS groups, messaging across a restart of the service from its
# history store, a thread, reactions, an edit and a retraction, a removed member served nothing
# past its removal, every record verified offline; and a call (tests/call_room.rs) rung, accepted
# and carrying audio and video through the relay, and another missed.
#
# It builds on wai-rs's room, event, composition and co-watch records, on the relay and on
# joule-moq-mls, so a change to any of them runs it. Push filters match the push's delta, so every path the job reads is
# listed.
# It builds on wai-rs's room, event, composition, co-watch and remote desktop records, on the
# relay, on joule-moq-mls and on the remote desktop host agent, so a change to any of them runs
# it. Push filters match the push's delta, so every path the job reads is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -26,6 +40,7 @@ on:
pull_request:
paths:
- 'joule-code/joule-code-rs/crates/joule-moq-room/**'
- 'joule-code/joule-code-rs/crates/joule-moq-remote/**'
- 'joule-code/joule-code-rs/crates/joule-moq/**'
- 'joule-code/joule-code-rs/crates/joule-moq-mls/**'
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'

View file

@ -59,6 +59,37 @@ name: joule-moq-web browser client
# pause and a seek applied by every page at their instants, a late joiner
# brought to the room's position, the clock's skew statements in sync within
# the bound, and the room verified by co_watch_verify.py.
# termination stream termination (WAI stream-termination, capability-constraints §4):
# live-conformance/termination in the page; then a receiving page driven over
# the DevTools protocol alone, so its tab really goes to the background: after
# the grace it signs a termination as hidden and cancels its video subscription
# while the audio goes on, the log verifies on the host in wai-rs and in the
# Python verifier, a segment receipt replayed after the termination is refused,
# the video is subscribed again under a new log once the tab is in front, a
# smaller screen is declared again with each receipt's match, and a canvas out
# of the viewport ends the video as occluded.
# remote a remote desktop room (WAI remote-desktop) the room service keeps: the host
# agent enters with an invitation, a page makes its key and is granted control,
# decodes the host's screen, asks for the floor, clicks and types, and signs
# latency records; a viewer's page sends nothing, and an input sent past its
# guard is refused by the host; and the sessions the host and the service's
# clock hold verified by remote_desktop_verify.py.
# world a room with a shared world (WAI live-worlds): two pages converge through the
# relay on one session hash, a late page fetches every world track from group 0,
# a viewer acts in nothing, the relay refuses what a grant does not allow, and
# its door drops a group receipted by another key.
# privateworld a private world (an end-to-end encrypted room): sealed world objects, the
# members' carries bring later members the world, a page outside the group
# opens nothing, and a member removed opens nothing after.
# messaging a conversation (WAI live-messaging) kept by a room service with a history store
# (e2e/messaging.e2e.mjs): three pages join and key through the service's log,
# message one another and open what the others seal; one page goes offline, the
# service restarts from its store, and the page catches up, the thread, the
# reaction, the edit and the retraction shown as verified; acknowledgements; a
# member removed opens nothing written after; the store holds no plaintext.
# call a call between two pages: the callee's inbox checks the invitation before it
# rings, the call is accepted and both rooms decode the other's audio and video;
# a call never answered is missed, signed by the room's clock.
#
# Before the browser, wai-live-web's own tests: the room's records a page reads, co-performance's
# among them, each statement read and the corpus verified through the crate.
@ -97,11 +128,24 @@ on:
- 'wai/live-conformance/age/**'
- 'wai/live-conformance/coperformance/**'
- 'wai/live-conformance/browser/**'
# The termination scenario runs this corpus in the page, and verifies the page's logs on
# the host with its second implementation, which reads the strict Ed25519 rule beside it.
- 'wai/live-conformance/termination/**'
- 'wai/live-conformance/stream_termination_verify.py'
- 'wai/claim-conformance/_ed25519.py'
- 'wai/wai-rs/tests/vectors/int_video/**'
# The page's co-watch module is tested against the co-watch corpus, and the cowatch
# scenario verifies its room with the corpus's second implementation.
- 'wai/live-conformance/cowatch/**'
- 'wai/live-conformance/co_watch_verify.py'
# The page's remote desktop module is tested against the remote corpus, and the remote
# scenario runs the host agent and verifies its session with the corpus's second
# implementation.
- 'joule-code/joule-code-rs/crates/joule-moq-remote/**'
- 'wai/live-conformance/remote/**'
- 'wai/live-conformance/remote_desktop_verify.py'
# wai-live-web's own tests run the live-worlds corpus's accepted world through the page's path.
- 'wai/live-conformance/worlds/**'
- '.github/workflows/joule-moq-web.yml'
pull_request:
paths:
@ -124,11 +168,24 @@ on:
- 'wai/live-conformance/age/**'
- 'wai/live-conformance/coperformance/**'
- 'wai/live-conformance/browser/**'
# The termination scenario runs this corpus in the page, and verifies the page's logs on
# the host with its second implementation, which reads the strict Ed25519 rule beside it.
- 'wai/live-conformance/termination/**'
- 'wai/live-conformance/stream_termination_verify.py'
- 'wai/claim-conformance/_ed25519.py'
- 'wai/wai-rs/tests/vectors/int_video/**'
# The page's co-watch module is tested against the co-watch corpus, and the cowatch
# scenario verifies its room with the corpus's second implementation.
- 'wai/live-conformance/cowatch/**'
- 'wai/live-conformance/co_watch_verify.py'
# The page's remote desktop module is tested against the remote corpus, and the remote
# scenario runs the host agent and verifies its session with the corpus's second
# implementation.
- 'joule-code/joule-code-rs/crates/joule-moq-remote/**'
- 'wai/live-conformance/remote/**'
- 'wai/live-conformance/remote_desktop_verify.py'
# wai-live-web's own tests run the live-worlds corpus's accepted world through the page's path.
- 'wai/live-conformance/worlds/**'
- '.github/workflows/joule-moq-web.yml'
jobs:
@ -179,7 +236,8 @@ jobs:
working-directory: joule-code/joule-code-rs
run: nice -n 10 cargo check --locked -p joule-moq --no-default-features
# The page's records library, natively: its co-performance reading checked against the
# corpus live-conformance/coperformance (src/co.rs) beside its own cases.
# corpus live-conformance/coperformance (src/co.rs) and its stream logs against
# live-conformance/termination (src/termination_tests.rs), beside its own cases.
- name: wai-live-web, its own tests
shell: bash
working-directory: wai/wai-live-web
@ -197,4 +255,6 @@ jobs:
nice -n 10 bash joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/moq-web-e2e.log"
grep -qE '^room e2e: 1 of 1 runs passed; ' "$RUNNER_TEMP/moq-web-e2e.log" \
|| { echo "::error::the browser end-to-end run did not pass"; exit 1; }
grep -qE '^messaging e2e: 1 of 1 runs passed; ' "$RUNNER_TEMP/moq-web-e2e.log" \
|| { echo "::error::the messaging and call end-to-end run did not pass"; exit 1; }
if grep -q '^FAIL' "$RUNNER_TEMP/moq-web-e2e.log"; then echo "::error::a check failed"; exit 1; fi

136
.github/workflows/wai-audit.yml vendored Normal file
View file

@ -0,0 +1,136 @@
name: wai-audit collector and player integration
# WAI's stream audit kit (wai/wai-audit, extensions/stream-audit.md): the collector's tests (every
# session of the corpus live-conformance/audit posted gets the corpus's outcome, and the stored
# sessions report exactly the corpus's served reports, byte for byte and signature; CORS, body
# limits, per-client limits and CMCD intake), clippy with warnings as errors, on Linux and on the
# macOS runner; and on the macOS runner, the host with the browser installed, the player
# integration's unit tests against the corpus and the browser end to end (e2e/run.sh): an HLS
# stream made with the host's media tool, served from another origin, played through an
# open-source web player twice (its loader adapter, and the network tap), each session verified,
# stored and reported by a collector on loopback, and the report recomputed from the bundles.
# The packages come from e2e/package-lock.json with install scripts off. Nothing listens beyond
# loopback.
#
# Push filters match the push's delta, so every path the jobs read is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-audit/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/audit/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/extensions/stream-audit.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-audit.yml'
pull_request:
paths:
- 'wai/wai-audit/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/audit/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/extensions/stream-audit.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-audit.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-audit.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-audit: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
collector-linux:
name: collector · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Tests and clippy
shell: bash
working-directory: wai/wai-audit
run: |
set -euo pipefail
cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
cargo clippy --locked --release --all-targets -- -D warnings
kit-macos:
name: collector, player integration and the browser end to end · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo and the host's packages (the media tool, node) on the macOS
# host runner.
- name: Put cargo and the host's tools on PATH
shell: bash
run: |
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
- name: The collector's tests
shell: bash
working-directory: wai/wai-audit
run: |
set -euo pipefail
nice -n 10 cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
- name: The player integration and the browser end to end
shell: bash
env:
BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome # allow-name: the runner host's installed browser, by its path
AUDIT_E2E_OUT: ${{ runner.temp }}/wai-audit-e2e
run: |
set -euo pipefail
test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; }
command -v ffmpeg >/dev/null || { echo "::error::this runner has no media tool to make the stream with"; exit 1; }
nice -n 10 bash wai/wai-audit/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/audit-e2e.log"
grep -qE '^ℹ pass [1-9][0-9]*$' "$RUNNER_TEMP/audit-e2e.log" && ! grep -qE '^ℹ fail [1-9]' "$RUNNER_TEMP/audit-e2e.log" \
|| { echo "::error::the player integration's unit tests did not pass"; exit 1; }
grep -q '^all checks ok$' "$RUNNER_TEMP/audit-e2e.log" || { echo "::error::the browser end to end did not pass"; exit 1; }
if grep -q '^FAIL' "$RUNNER_TEMP/audit-e2e.log"; then echo "::error::a check failed"; exit 1; fi
# The page's integration and the native sink are two implementations of stream-audit's
# player: the page replays the native sink corpus's page-parity case call for call and
# signs the same bundle byte for byte (extensions/native-sink.md §10).
- name: The page replays the native sink's corpus
shell: bash
run: |
set -euo pipefail
node --test wai/wai-audit/web/native-sink.test.mjs 2>&1 | tee "$RUNNER_TEMP/native-parity.log"
grep -qE '^ℹ pass [1-9][0-9]*$' "$RUNNER_TEMP/native-parity.log" && ! grep -qE '^ℹ fail [1-9]' "$RUNNER_TEMP/native-parity.log" \
|| { echo "::error::the page did not sign the native sink's bundle"; exit 1; }

View file

@ -106,6 +106,10 @@ on:
# e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/confidentiality.md'
# tests/live_messaging.rs requires this extension's §12 to list the codes the reference
# reports, live_messaging_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/live-messaging.md'
# tests/live_ads.rs requires this extension's §10 to list the codes the reference
# reports, live_ads_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
@ -114,9 +118,17 @@ on:
# reports, ad_presentation_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/ad-presentation.md'
# tests/key_release.rs requires this extension's §3.1, §3.2, §7.2 and §11 tables to be the
# reference's codes, key_release_verify.py reads those tables and §3.3, and the registry's
# wire identifiers test reads its sections.
- 'wai/extensions/key-release.md'
# The ad-presentation step runs the browser client's reference producer against the
# corpus.
# corpus, and the stitched-presentation step runs its stitched producer.
- 'joule-code/joule-code-rs/crates/joule-moq-web/web/ads/**'
# tests/stitched_presentation.rs requires this extension's §9 to list the codes the
# reference adds, stitched_presentation_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
- 'wai/extensions/stitched-presentation.md'
# tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA
# codes the reference reports, c2pa_live_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
@ -135,6 +147,20 @@ on:
# reports, upscale_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/exact-upscale.md'
# tests/prior_wire.rs requires this extension's §13 to list the codes the reference
# reports, prior_forms_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/prior-carriage.md'
# tests/scene_map.rs requires this extension's §8 to list the codes the reference
# reports, scene_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/scene-carriage.md'
# tests/avatar_face.rs requires this extension's §10 to list the codes the reference
# reports and its §7.9 to state the training report's figures, avatar_face_verify.py
# reads §10, and the registry's tests read SPEC §5's registration of wai.avatar.face.
- 'wai/extensions/avatars.md'
# The tool that trained the corpus's generator and wrote its inputs and its report.
- 'wai/tools/wai_avatar_face_train.py'
# tests/ai_usage.rs requires this extension's §9 to list the codes the reference reports,
# ai_usage_verify.py reads that table, and the registry's wire identifiers test reads its
# sections.
@ -143,16 +169,35 @@ on:
# reports, contribution_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/contribution.md'
# live_contribution_verify.py reads this extension's §7 list of the link record's
# reasons and requires the corpus to raise each one.
- 'wai/extensions/live-contribution.md'
# tests/settlement.rs requires this extension's §11 to list the codes the reference
# reports, settlement_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/settlement.md'
# tests/receipt_ledger.rs requires this extension's §10 to list the codes the reference
# reports, receipt_ledger_verify.py reads that table, and the registry's wire identifiers
# test reads its sections. Its corpus is generated from the metering, settlement, ads,
# presentation and stitched corpora and the packs of pack-conformance, all under the paths
# above.
- 'wai/extensions/receipt-ledger.md'
# tests/stream_audit.rs requires this extension's §14 to list the codes the reference
# reports, stream_audit_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/stream-audit.md'
# tests/native_sink.rs requires this extension's §8 to list exactly the reasons its
# corpus reaches, and native_sink_verify.py reads that table.
- 'wai/extensions/native-sink.md'
# tests/browser_sink.rs requires this extension's §11 to list the codes the reference
# reports, browser_sink_verify.py reads its §2, §4.1, §4.2 and §11 tables and
# capability-constraints' §3.1 register, and the registry's wire identifiers test reads
# its sections.
- 'wai/extensions/browser-sink.md'
- 'wai/extensions/capability-constraints.md'
# sink_energy's unit tests require this extension's §8 to list exactly the codes the
# reference reports, and sink_energy_verify.py reads that table.
- 'wai/extensions/sink-energy.md'
# The browser sink's functions for a page, held to the same corpus natively.
- 'wai/wai-live-web/**'
# tests/takedown.rs requires this extension's §11 to list the codes the reference
@ -182,12 +227,23 @@ on:
# reports and its §3.2 table to name the reference's descriptors, exploration_verify.py
# reads §13, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/exploration.md'
# tests/platform_encode.rs requires this extension's §12 to list the reasons the reference
# reports, and encode_verify.py reads that table.
- 'wai/extensions/platform-encode.md'
# tests/co_watch.rs requires this extension's §12 to list the codes the reference reports,
# co_watch_verify.py reads that table, and the registry's wire identifiers test reads its
# sections.
- 'wai/extensions/co-watch.md'
# tests/live_world.rs requires this extension's §11 to list the codes the reference reports.
- 'wai/extensions/live-worlds.md'
# The co-watch step runs the browser client's co-watch module against the corpus.
- 'joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch*'
# tests/remote_desktop.rs requires this extension's §12 to list the codes the reference
# reports, remote_desktop_verify.py reads its four tables, and the registry's wire
# identifiers test reads its sections.
- 'wai/extensions/remote-desktop.md'
# The remote desktop step runs the browser viewer's module against the corpus.
- 'joule-code/joule-code-rs/crates/joule-moq-web/web/room/remote*'
# tests/dred_exact.rs requires this extension's §8 to list the codes the reference
# reports and its §4 to state the reference's digests, tests/dred_exact_measure.rs its §6
# table, dred_verify.py reads §4 and §8, and the registry's tests read SPEC §5 against it.
@ -221,6 +277,25 @@ on:
# reports, pq_verify.py reads that table, and the registry's wire identifiers test reads
# its §2.
- 'wai/extensions/pq-signatures.md'
# tests/format_probe.rs requires format-probes §2.4 and §12 to list the reference's probes
# and codes, and projection.md §4 and §5 to state its codes; format_probe_verify.py reads
# format-probes' tables; and the registry's wire identifiers test reads projection.md,
# loc-carriage.md and delivery-telemetry.md (whose §6.6 cmsd_read_verify.py reads).
- 'wai/extensions/format-probes.md'
- 'wai/extensions/projection.md'
- 'wai/extensions/loc-carriage.md'
- 'wai/extensions/delivery-telemetry.md'
# The determinism-cost record, its verifier, and the two texts whose quoted figures
# the verifier checks against the record.
- 'wai/research/determinism-cost.md'
- 'wai/research/determinism-cost/**'
- 'wai/tools/wai_determinism_cost_verify.py'
- 'wai/extensions/determinism-tiers.md'
# tests/stream_termination.rs requires this extension's §7 to list the codes the reference
# reports and capability-constraints §4.2 to name the match's bits,
# stream_termination_verify.py reads both, and the registry's wire identifiers test reads
# its sections.
- 'wai/extensions/stream-termination.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -321,7 +396,7 @@ jobs:
# input) fails here rather than wrapping.
- name: Library unit tests (the fixed-point determinism floors)
working-directory: wai/wai-rs
run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http
run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http,format_probe
# The JPEG AI exact decoder reproduces across platforms only if no platform math
# library is on its path. Two guards, each failing the step: clippy's
# disallowed-methods (wai-rs/clippy.toml), which each module on the decode path
@ -451,13 +526,17 @@ jobs:
# int_video_ops.rs and wir1_ops.rs hold WIV1 and WIR1, which decode through the
# operator set's stage operators with no graph (integer-operators §13.5), to their
# stage forms, goldens and refusals; `i32_forms` builds E9's 32-bit form, which they
# and int_ops_differential.rs hold to the exact one.
# and int_ops_differential.rs hold to the exact one. tests/int_ops_conformance.rs
# decodes every graph vector of ops-conformance/ through the fast kernels and holds
# each to the integers it states. Each of these runs every kernel set the leg's
# processor runs (int_kernels::equivalence_policies: the wide kernels, and NEON on
# arm64, AVX2 with or without VNNI on x86_64); int-kernels-amd below runs AVX-512.
- name: Fast integer kernels ≡ reference kernels (overflow checks on)
working-directory: wai/wai-rs
run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration
run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_ops_conformance --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration
- name: Fast integer kernels ≡ reference kernels (release, overflow checks off)
working-directory: wai/wai-rs
run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration
run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_ops_conformance --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration
# The integer decode path builds under each of its features alone, so a sink can take
# one medium without the others, and for wasm32 (a browser sink); `int_payload` arms a
# medium only under its feature, and none needs `neural`. So does the staged-measurement
@ -469,7 +548,7 @@ jobs:
working-directory: wai/wai-rs
run: |
set -euo pipefail
for f in neural_int video_int audio_int splat_int staged stage_measure telemetry http; do
for f in neural_int video_int audio_int splat_int staged stage_measure telemetry http format_probe; do
cargo check --lib --no-default-features --features "$f"
done
# The decode bench prints its machine line from bench_env; no other step builds it.
@ -484,6 +563,8 @@ jobs:
cargo build --lib --target wasm32-unknown-unknown --no-default-features --features receipt_claims,stage_measure
# So is delivery telemetry: a browser sink writes the same reports.
cargo build --lib --target wasm32-unknown-unknown --no-default-features --features telemetry
# And the header probes: a browser reads a payload's parameters the same way.
cargo build --lib --target wasm32-unknown-unknown --no-default-features --features format_probe
# carriage: every registered capability round-trips byte-exact. cli_wrap_intent and
# cli_registry run the `wai` binary, which builds without the codecs feature: the
# intent rule of `wai wrap`, its refusal of candidate capabilities (SPEC §5), its media
@ -711,6 +792,50 @@ jobs:
# above runs natively.
# One leg: wasm32 is the same machine on every host. Linux, so it does not queue on the
# single macOS runner.
# The fast integer kernels on the AVX-512 host (labelled linux-amd), whose processor runs
# every x86_64 kernel set (wide, AVX2, AVX2 with VNNI, AVX-512, AVX-512 with VNNI), none
# of whose AVX-512 kernels the other legs' processors run. The same tests as the kernel
# steps of cargo-classes, in both profiles; the runner declares its sets, and a set the
# tests did not find fails the job instead of passing as a skip.
int-kernels-amd:
name: fast integer kernels, every x86_64 kernel set (AVX-512 silicon)
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux-amd
timeout-minutes: 90
env:
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_BUILD_JOBS: '12'
steps:
- name: Fetch the source
shell: bash
run: |
set -euo pipefail
want="${{ github.event.pull_request.head.sha || github.sha }}"
rm -rf src && mkdir src && cd src
git init -q
# Fetch the commit itself, not the ref. The host names its own route to the
# forge (WAI_FORGE_URL).
git fetch -q --depth 1 "${WAI_FORGE_URL:-http://100.64.224.0:3400}/Transaction-Science/open-standards.git" "$want"
git checkout -q FETCH_HEAD
got="$(git rev-parse HEAD)"
[ "$got" = "$want" ] || { echo "::error::fetched $got, not $want"; exit 1; }
- name: Fast integer kernels ≡ reference kernels, every kernel set, both profiles
shell: bash
working-directory: src/wai/wai-rs
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
export CARGO_TARGET_DIR="$HOME/hw-target/wai-rs-int-kernels"
# The sets this host's processor runs, declared here (or by the runner); the
# library test the_kernel_sets_found_are_the_declared_ones fails on any other.
export WAI_INT_ISAS_EXPECT="${WAI_INT_AMD_ISAS_EXPECT:-wide,avx2,avx2-vnni,avx512,avx512-vnni}"
F=neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms
T="--test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp_ops --test int_world_ops --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_ops_conformance"
for p in "" --release; do
nice -n 10 cargo test --locked $p --no-default-features --features $F --lib $T
done
quantum-wasip2:
needs: relevant
if: needs.relevant.outputs.run == 'true'
@ -852,6 +977,65 @@ jobs:
wasmtime run --dir ../quantum-conformance::/corpus \
target/wasm32-wasip2/debug/wai_quantum_vectors.wasm verify /corpus
# The face generator under wasm32-wasip2, run by wasmtime (extensions/avatars.md §7.8):
# wai_avatar_face_vectors verify, built for wasm32, regenerates every file of
# live-conformance/avatar-face from its seeds and inputs, every frame through the integer
# operator set's interpreter compiled to wasm32, and requires the committed bytes, which a
# native build wrote: the frames are the same on wasm32 as on the CPU. One leg: wasm32 is
# the same machine on every host. Linux, so it does not queue on the single macOS runner.
avatar-face-wasip2:
needs: relevant
if: needs.relevant.outputs.run == 'true'
name: faces from keypoints under wasm32-wasip2 (wasmtime)
runs-on: linux
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0 with the wasm32-wasip2 target
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
rustup target add --toolchain 1.98.0 wasm32-wasip2
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The release binary, pinned by version and SHA-256, rather than a build from source.
- name: Install wasmtime 49.0.2
shell: bash
run: |
set -euo pipefail
[ "$(uname -m)" = x86_64 ] || { echo "::error::the pinned wasmtime archive is x86_64; this host is $(uname -m)"; exit 1; }
if ! command -v xz >/dev/null 2>&1; then
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq xz-utils
fi
v=49.0.2
f="wasmtime-v$v-x86_64-linux.tar.xz"
curl --proto '=https' --tlsv1.2 -sSfL -o "$RUNNER_TEMP/$f" \
"https://github.com/bytecodealliance/wasmtime/releases/download/v$v/$f"
echo "a4d6e9e3a5a60f527cf7793d674c48930c80c2e8977995b8a275cad3254b9322 $RUNNER_TEMP/$f" | sha256sum -c -
tar -xJf "$RUNNER_TEMP/$f" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/wasmtime-v$v-x86_64-linux" >> "$GITHUB_PATH"
# One codegen unit for the wai package: see the quantum job's last step for the LLD 22
# cdylib crash this avoids.
- name: wai_avatar_face_vectors verify under wasmtime
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
cargo build --locked --release --target wasm32-wasip2 --no-default-features --features avatar_face --bin wai_avatar_face_vectors \
--config 'profile.release.package.wai.codegen-units=1'
t="${CARGO_TARGET_DIR:-target}"
wasmtime run --dir ../live-conformance/avatar-face::/corpus \
"$t/wasm32-wasip2/release/wai_avatar_face_vectors.wasm" verify /corpus | tee "$RUNNER_TEMP/face-wasm.log"
[ "$(grep -c '^PASS ' "$RUNNER_TEMP/face-wasm.log")" = 3 ] || { echo "::error::not every check passed under wasm32"; exit 1; }
# The generated v2 vocabulary (wai/vocab/v2.*) is checked as a consumer uses it:
# sample manifests, typed and untyped, WAI1 and WAI2, are expanded through the
# JSON-LD context exactly as written on the wire and validated against the SHACL
@ -896,9 +1080,9 @@ jobs:
name: independent verifiers · claims, JWP, key discovery and integer payloads (Python)
runs-on: linux-arm64
env:
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging
WAI_PQ_FEATURES: synthesis,pq_signatures
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face
steps:
- uses: actions/checkout@v5
@ -964,9 +1148,13 @@ jobs:
mkdir -p "$RUNNER_TEMP/wai-bins/union" "$RUNNER_TEMP/wai-bins/pq"
cargo build --no-default-features --features "$WAI_FEATURES" \
--bin wai_http_vectors --bin wai_stage_measure --bin wai_browser_sink_vectors \
--bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors
--bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors \
--bin wai_sink_energy_vectors --bin wai_stitched_presentation \
--bin wai_stream_termination_vectors --bin wai_format_probe
for b in wai_http_vectors wai_stage_measure wai_browser_sink_vectors \
wai_claim_vectors wai_jwp_vectors wai_signer_vectors; do
wai_claim_vectors wai_jwp_vectors wai_signer_vectors \
wai_sink_energy_vectors wai_stitched_presentation \
wai_stream_termination_vectors wai_format_probe; do
cp "${CARGO_TARGET_DIR:-target}/debug/$b" "$RUNNER_TEMP/wai-bins/union/"
done
# Copied before this build replaces target/debug's copies of the three it shares.
@ -983,9 +1171,13 @@ jobs:
--test browser_sink --test takedown --test influence --test direction --test co_performance \
--test age_assurance --test bearer_receipts --test exploration --test co_watch \
--test telemetry_aggregate --test exact_tonemap --test splat_exact --test live_watermark \
--test derive_edge
--test derive_edge --test det_cost_bin --test sink_energy --test key_release \
--test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \
--test stream_audit --test native_sink --test platform_encode --test remote_desktop \
--test stream_termination --test live_world --test format_probe --test live_messaging
cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \
--test avatar_face
# The integer payload corpus's second implementation (int-payload-conformance/README):
# it parses every payload with the extension's rules and check order, converts every
@ -996,6 +1188,21 @@ jobs:
working-directory: wai
shell: bash
run: python3 int-payload-conformance/int_payload_verify.py int-payload-conformance
# The determinism-cost record (research/determinism-cost.md): its summary recomputed
# from its readings by a second implementation that shares no code with the tool that
# wrote it (tools/wai_determinism_cost_verify.py, standard library only, run isolated),
# and every figure the research page and determinism-tiers §7 quote from it checked
# against it (each is followed by a `det` marker naming its place in the record). Then
# wai_det_cost, the decoder the record ran, held to the committed int_hyper vector and
# every WIH1 refusal fixture of int-payload-conformance/.
- name: determinism cost · the record, the text quoting it, and wai_det_cost
working-directory: wai
shell: bash
run: |
set -euo pipefail
python3 -I tools/wai_determinism_cost_verify.py research/determinism-cost/record.json \
research/determinism-cost.md extensions/determinism-tiers.md
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test det_cost_bin)
# The staged-delivery corpus's second implementation (staged-conformance/README): it
# replays every case from its stage-0 buffers through its own reading of the
# extension (refines, WIR1, the sink's steps, holds, batches, conflicts, deadlines,
@ -1237,6 +1444,32 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib energy_steering::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test energy_steering)
python3 -I live-conformance/energy_steering_verify.py live-conformance/steering
# wai.sink.energy (extensions/sink-energy.md): the reference module's unit tests (a
# bracket summary is its intervals in integers, a round's figure and least declaration
# are energy-measurement §6.2's — checked against energy_report's reference — a round is
# refused by its first broken rule, a row seals its median and covers its spread, a
# segment's bounded window has no count term, the estimators and shares round half up,
# records sign and their identities cover the signature, §8 lists exactly the codes), the
# readers' unit tests (a counter is live only as a live rail advances, an advance crosses
# the wrap; on macOS, the per-process counter reads this process) and
# tests/sink_energy.rs (the committed corpus regenerates byte for byte from its fixed
# seeds, every case gets its stated outcome, every reason of §8 refuses a case, and every
# measured table under tables/ is accepted with its readings and procedure); then the
# corpus's second implementation, sink_energy_verify.py, written from the extension's text
# and sharing no code with wai-rs, which recomputes every round, row, segment and split in
# integers and requires every mutated copy of the corpus to fail; standard library only,
# run isolated (-I). These are the integer checks; the counters themselves are read by
# energy-hardware.yml's sink jobs, on the runners that have them.
- name: sink energy · wai-rs and sink_energy_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib sink_energy::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib sink_meter::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test sink_energy)
(cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_sink_energy_vectors" verify ../live-conformance/sink-energy)
python3 -I live-conformance/sink_energy_verify.py live-conformance/sink-energy
# wai.live.moderation (extensions/live-moderation.md): the reference module's unit tests
# (policies, statements and notices round-trip and their rules refuse, the export's
# strings and dates, the content types, the states that show an item) and
@ -1308,6 +1541,28 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_ssai::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_ads)
python3 -I live-conformance/live_ads_verify.py live-conformance/ads
# The proxy form of wai.video.keys (extensions/key-release.md): the reference module's unit
# tests (the commitment list, the entitlement and the ticket round-trip and their rules
# refuse, each check of the decision in its order, offline licences and renewals, each own
# rule of the receipt, its strict signature, the CPIX reader and the derivation of a list
# from a CPIX document), form 1's unit tests (its legacy pin unchanged), and
# tests/key_release.rs (every file of the committed corpus regenerates byte for byte from
# its fixed seeds and no other file is there, every log gets its stated outcome, and the
# extension's tables are the reference's codes); then the corpus's second implementation,
# key_release_verify.py, written from the extension's text and sharing no code with wai-rs,
# which reads the extension's tables, derives every list from its CPIX document with the
# standard library's XML parser, recomputes every decision of every log, verifies every
# signature with WAI's strict Ed25519 rule and BLAKE3 from claim-conformance, and requires
# every mutated copy of the corpus to fail; standard library only, run isolated (-I).
- name: key release · wai-rs and key_release_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib key_release::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_keys::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test key_release)
python3 -I live-conformance/key_release_verify.py live-conformance/keys
# wai.live.ad_presentation (extensions/ad-presentation.md): the reference module's unit
# tests (claims round-trip and their rules refuse, the quartile, the qualifying run, the
# touched objects, a policy's presentation fields, RFC 9577's TokenChallenge) and
@ -1330,6 +1585,33 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test ad_presentation)
python3 -I live-conformance/ad_presentation_verify.py live-conformance/presentation
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs
# wai.video.stitched_presentation (extensions/stitched-presentation.md): the reference
# module's unit tests (claims and pod-input records round-trip and their rules refuse,
# markers' bodies, a CPM price scaled to one impression, the XML subset) and the
# live-compose ad frames' (a squeeze's window and L tile the canvas, a double box's
# boxes), and tests/stitched_presentation.rs (every file of the committed corpus
# regenerates byte for byte from its fixed seeds and no other file is there, wai-rs gives
# every record, claim and ad its stated facts and every vector its spend by currency, and
# §9 lists exactly the codes the reference adds); then the tool's own check of the corpus
# through its binary; then the corpus's second implementation,
# stitched_presentation_verify.py, written from the extension's text and sharing no code
# with wai-rs, which reads every playlist, asset list, MPD, VAST document, form-1 receipt,
# record and claim itself, requires every mutated copy of the corpus to fail and catches
# each of its own checks dropped; standard library only, run isolated (-I). Last, the
# browser client's stitched producer (joule-moq-web/web/ads/stitched.js), which reads the
# markers from the corpus's manifests, measures an ad's region, and writes and signs the
# corpus's claims, byte for byte.
- name: stitched presentation · wai-rs, stitched_presentation_verify.py and the reference producer
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stitched_presentation::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib live_compose::layout::frame_tests)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stitched_presentation)
(cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stitched_presentation" vectors verify ../live-conformance/stitched)
python3 -I live-conformance/stitched_presentation_verify.py live-conformance/stitched
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/stitched.test.mjs
# wai.live.provenance (extensions/live-provenance.md): the reference module's unit tests
# (boxes, chunks, emsg and the BMFF hash; the JUMBF store; COSE_Sign1 and the OKP key;
# certificates written and read, their profile and chain; identifiers, track renderings,
@ -1442,6 +1724,61 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib robustness::)
(cd wai-rs && cargo test --release --no-default-features --features "$WAI_RELEASE_FEATURES" --test robustness)
python3 -I live-conformance/robustness_verify.py live-conformance/robustness
# Tensor files as prior forms (extensions/prior-carriage.md §9 to §14): the module's unit
# tests (each layout read strictly and written canonically, the arithmetic coder's integers
# round-tripping and a damaged payload caught, the profile's checks in their order, both
# bindings through every form) and tests/prior_wire.rs (the committed corpus regenerates
# byte for byte from its pinned native artifacts; §13 lists exactly the reference's codes;
# every corpus prior in every form loads to its native bytes, and the two trained WSR1
# priors give the same upscaled picture as their native files); then the corpus's second
# implementation, prior_forms_verify.py, written from the extension's text and sharing no
# code with wai-rs, which also requires every byte-flipped copy of an accepted file to be
# caught; standard library only, run isolated (-I).
- name: prior forms · wai-rs and prior_forms_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- prior_wire:: strict_json:: codecs::prior_form)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test prior_wire)
python3 -I live-conformance/prior_forms_verify.py live-conformance/prior-forms
# Scene carriage (extensions/scene-carriage.md): the module's unit tests (the conversions
# and spellings of §3 against fixed values, the containers an import builds equal to the
# ones wai-rs's world and audio-scene modules write, base64 and the USD subset's reader)
# and tests/scene_map.rs (the committed corpus regenerates byte for byte: every bundle
# written as glTF and USD and read back, every refusal reached; §8 lists exactly the
# reference's codes); then the corpus's second implementation, scene_verify.py, written
# from the extension's text and sharing no code with wai-rs, which also requires every
# byte-flipped copy of an exported document to be caught; standard library only, run
# isolated (-I).
- name: scene carriage · wai-rs and scene_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- scene_map:: strict_json::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test scene_map)
python3 -I live-conformance/scene_verify.py live-conformance/scene
# wai.avatar.face (extensions/avatars.md §6 to §13): the module's unit tests (the
# generator's wai-ops/1 graph against §7.6's arithmetic written out loop by loop; the warp
# at rest is the reference; the mask's table; the files read and refused in their order),
# and tests/avatar_face.rs (the committed corpus regenerates byte for byte from its fixed
# seeds and its pinned inputs, and no other file is there; wai-rs gives every form, frame
# and case what it states; §10 lists exactly the reference's codes; §7.9's figures are
# training.json's; the registered generator meets acc64 and not acc32); then the corpus's
# second implementation, avatar_face_verify.py, written from the extension's text and
# sharing no code with wai-rs, which runs the driver and the generator's arithmetic in
# Python's integers, verifies the claims, the log and the label policy with
# synthesis_verify.py, and requires every mutated copy of the corpus to fail; standard
# library only, run isolated (-I).
- name: faces from keypoints · wai-rs and avatar_face_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib avatar_face::)
(cd wai-rs && cargo test --release --no-default-features --features "$WAI_RELEASE_FEATURES" --test avatar_face)
python3 -I live-conformance/avatar_face_verify.py live-conformance/avatar-face
# wai.ai.usage (extensions/ai-usage.md): the reference module's unit tests (RFC 9651's
# Dictionary parser against the vocabulary's examples and the RFC's edges, statements
# read and refused in their steps' order, the precedence rule, HTTP fields compared by
@ -1490,6 +1827,21 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib contribution::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test contribution)
python3 -I live-conformance/contribution_verify.py live-conformance/contribution
# wai.live.contribution (extensions/live-contribution.md): the link records' corpus
# under live-conformance/links/, which joule-moq-ingest's reference generates and its
# own tests check (joule-code-conformance.yml); here its second implementation,
# live_contribution_verify.py, written from the extension's text and sharing no code
# with the reference, reads every part and series itself, checks every signature with
# WAI's strict Ed25519 rule from claim-conformance, requires each vector's stated outcome
# and reason and every reason of §7's last list, requires every mutated copy to be
# refused, and drops each of its own checks in turn and requires the corpus to catch the
# weakened verifier; standard library only, run isolated (-I).
- name: live contribution · live_contribution_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
python3 -I live-conformance/live_contribution_verify.py live-conformance/links
# wai.delivery.settlement (extensions/settlement.md): the reference module's unit tests
# (range proofs over every range around every chunk boundary of objects of many lengths
# against BLAKE3's own root, tampered proofs refused, the group tree of jwp-receipts §2.2,
@ -1513,6 +1865,71 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib settlement::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test settlement)
python3 -I live-conformance/settlement_verify.py live-conformance/settlement
# wai.delivery.ledger (extensions/receipt-ledger.md): the reference module's unit tests and
# tests/receipt_ledger.rs (vectors.json regenerates byte for byte from its fixed seeds and
# from the sources it takes from the metering, settlement, ads, presentation and stitched
# corpora and the packs of pack-conformance, and no other file is there; wai-rs gives every vector its
# stated outcome; every reason of §10 and every reused code refuses a vector, and so does
# each source kind's own extension; §10 lists exactly the reference's codes); then the
# corpus's second implementation, receipt_ledger_verify.py, written from the extension's
# text and sharing no code with wai-rs, which verifies each source with that corpus's own
# second implementation (live_metering_verify.py, settlement_verify.py, live_ads_verify.py
# with ad_presentation_verify.py, stitched_presentation_verify.py, pack_verify.py) and
# reports a stitched stream's statement as that verifier gives it, checks signatures with
# WAI's strict Ed25519 rule, recomputes every line, citation, energy row and report,
# requires every mutated copy of the corpus to fail, and drops each of its own checks in turn and requires
# the corpus to catch the weakened verifier; standard library only, run isolated (-I).
- name: receipt ledger · wai-rs and receipt_ledger_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib receipt_ledger::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test receipt_ledger)
python3 -I live-conformance/receipt_ledger_verify.py live-conformance/ledger
# wai.delivery.audit (extensions/stream-audit.md): the reference module's unit tests (the
# acknowledgement windows, chains and repeats of §4.2, the origin rule, the report's bytes,
# channel names) and tests/stream_audit.rs (vectors.json regenerates byte for byte from its
# fixed seeds and no other file is there, wai-rs gives every session and report its stated
# outcome, §14 lists exactly the reference's codes, each of which the corpus gives, beside
# every code it borrows); then the corpus's second implementation, stream_audit_verify.py,
# written from the extension's text and sharing no code with wai-rs, which reads
# acknowledgements with settlement_verify.py, claims with ad_presentation_verify.py and logs
# and energy classes with live_metering_verify.py, checks every signature with WAI's strict
# Ed25519 rule, recomputes every report, requires every mutated copy of the corpus to fail,
# and drops each of its own checks in turn and requires the corpus to catch the weakened
# verifier; standard library only, run isolated (-I). The collector and the player
# integration run in wai-audit.yml.
- name: stream audit · wai-rs and stream_audit_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stream_audit::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stream_audit)
python3 -I live-conformance/stream_audit_verify.py live-conformance/audit
# The native sink (extensions/native-sink.md): the reference module's unit tests (dates,
# durations and attribute lists read as a player reads them, origins serialized as RFC 6454
# does, names that state a content hash and names that do not, the acknowledgements
# rebuilt from hashes alone equal to stream-audit's reference, a bundle that verifies and
# one refused for another owner or channel) and tests/native_sink.rs (vectors.json
# regenerates byte for byte from its seeds and no other file is there, every call gets the
# answer it states, §8 lists exactly the reasons the corpus reaches, and every case run
# through the C interface's symbols gets the same answers); then the corpus's second
# implementation, native_sink_verify.py, written from the extension's text and sharing no
# code with wai-rs, which replays every case, reads playlist receipts and their chain with
# http_verify.py, holds every bundle to its own session field by field and verifies it with
# stream_audit_verify.py, requires every mutated copy of the corpus to fail, and drops each
# of its own checks in turn and requires the corpus to catch the weakened verifier;
# standard library only, run isolated (-I). The Swift package runs in wai-sink-swift.yml.
- name: native sink · wai-rs and native_sink_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib native_sink::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test native_sink)
python3 -I live-conformance/native_sink_verify.py live-conformance/native-sink
# The browser sink (extensions/browser-sink.md): the reference module's unit tests (the
# registry gate's rows, registered tiers, integers as §5 reads them, a binding's
# canonical form, an answer for an unregistered codec string never read, powerEfficient
@ -1699,6 +2116,27 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib exploration::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test exploration)
python3 -I live-conformance/exploration_verify.py live-conformance/exploration
# wai.video.encode (extensions/platform-encode.md): the reference module's unit tests (the
# Exp-Golomb reader, emulation prevention, NAL splitting, length-prefixed samples, the
# resampler, the source reader, the strict JSON scan and the date-time) and
# tests/platform_encode.rs (the fixture streams are the pinned bytes, every generated file
# regenerates byte for byte, every case gets its outcome, every record reason has a case, and
# §12 lists exactly the reference's reasons); then the corpus's second implementation,
# encode_verify.py, written from the extension's text and sharing no code with wai-rs or
# wai-pack, which reads every Annex B stream, its access units and parameter sets itself,
# verifies every record with WAI's strict Ed25519 rule and every object with BLAKE3 from
# claim-conformance, checks the encoded renditions against the base packaging golden,
# requires every mutated copy of the corpus to fail, and drops each of its own checks in turn
# and requires the corpus or its mutations to catch the weakened verifier; standard library
# only, run isolated (-I). The encoders themselves run on the macOS leg (codec-wrappers).
- name: platform-encode · wai-rs and encode_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --locked --no-default-features --features "$WAI_FEATURES" --lib platform_encode::)
(cd wai-rs && cargo test --locked --no-default-features --features "$WAI_FEATURES" --test platform_encode)
python3 -I encode-conformance/encode_verify.py encode-conformance
# wai.live.cowatch (extensions/co-watch.md): the reference module's unit tests (the policy
# and its rules, statements read back and refused, the controlled position in the order of
# §5.4, each branch of the catch-up rule, percentiles by nearest rank, the control attribute)
@ -1723,6 +2161,35 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test co_watch)
python3 -I live-conformance/co_watch_verify.py live-conformance/cowatch
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs
# wai.live.remote (extensions/remote-desktop.md): the reference module's unit tests (every
# statement read back and refused when a byte is short or over, the screen statement's
# field rules, the host's rules of §5.3 in their order, the grant attributes, a latency's
# legs, sealed actions: a seal with no plaintext in it that opens under the host's key
# alone, the commitment and what it binds, a seal that does not fit its actions, rules 8
# and 9 after the others) and the crate's HPKE against RFC 9180 A.1.1; tests/remote_desktop.rs
# (vectors.json regenerates byte for byte from its fixed seeds and no other file is there,
# wai-rs gives every room its stated outcome at the commitment level and at the opened
# level, and §12 lists exactly the reference's codes, host refusals, floor refusals and
# the reasons a seal is not opened, each of which the corpus gives);
# then the corpus's second implementation, remote_desktop_verify.py, written from the
# extension's text and sharing no code with wai-rs, which verifies each room with
# live_rooms_verify.py, every signature with WAI's strict Ed25519 rule and every seal with
# the aggregation verifier's HPKE, recomputes every outcome from the rules, the floor and
# every latency's legs at both levels, requires every mutated copy of the corpus to fail and
# drops each of its own checks in turn and requires the corpus to catch the weakened
# verifier; standard library only, run isolated (-I). Last, the browser viewer's module
# (joule-moq-web/web/room/remote.js), which writes the corpus's input statements, floor
# statements and latency records byte for byte, seals every sealed input that opens again
# byte for byte, and gives every input the outcome the corpus states.
- name: remote desktop · wai-rs, remote_desktop_verify.py and the page's module
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- remote_desktop:: hpke::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test remote_desktop)
python3 -I live-conformance/remote_desktop_verify.py live-conformance/remote
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/room/remote.test.mjs
# wai.telemetry.aggregate (extensions/telemetry-aggregation.md): the reference module's unit
# tests (the fields against a wide-arithmetic-free product, the generators' orders,
# TurboSHAKE128 against RFC 9861, HPKE against RFC 9180 A.1.1, the FLP accepting a valid
@ -1795,6 +2262,46 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib splat_exact::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test splat_exact)
python3 -I live-conformance/splat_exact_verify.py live-conformance/splat
# wai.live.termination (extensions/stream-termination.md) and the display match of
# capability-constraints §4: the reference module's unit tests (sides without orientation,
# each exceedance alone, the least rendition alone where none is within, a writer that
# refuses after its termination) and tests/stream_termination.rs (the committed corpus
# regenerates byte for byte from its fixed seeds, wai-rs gives every match and log its
# stated outcome and facts, every reason refuses a log, §7 lists exactly the reference's
# codes and capability-constraints §4.2 its match bits); wai-live-web's functions for a
# page held to the same corpus natively, a page's log written through them verified; then
# the corpus's second implementation, stream_termination_verify.py, written from the two
# extensions' text and sharing no code with wai-rs, which recomputes every match and every
# log's outcome and facts and requires every mutated copy of the corpus to fail; standard
# library only, run isolated (-I). The browser's own run is joule-moq-web.yml's scenario
# termination.
- name: stream termination · wai-rs, wai-live-web and stream_termination_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stream_termination::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stream_termination)
(cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stream_termination_vectors" verify ../live-conformance/termination)
(cd wai-live-web && cargo test --locked --features int_video --lib termination_tests)
python3 -I live-conformance/stream_termination_verify.py live-conformance/termination
# wai.live.world (extensions/live-worlds.md): the reference module's unit tests (the
# statement and its rules, the grant attribute) and tests/live_world.rs (vectors.json
# regenerates byte for byte from its fixed seeds and no other file is there, wai-rs gives
# every world its stated outcome, and §11 lists exactly the reference's codes, each of which
# a verifier raises the corpus gives); then the corpus's second implementation,
# live_worlds_verify.py, written from the extension's text and the sections it cites and
# sharing no code with wai-rs: the statement, each grant's signature and attributes, each
# receipt in its binary form under the owner's chain, each op's signature, owner, kind,
# Lamport rule and equivocation, and the session hash. Standard library only, run isolated.
- name: live worlds · wai-rs and live_worlds_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib live_world::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_world)
python3 -I live-conformance/live_worlds_verify.py live-conformance/worlds
# wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation,
# e2ee_verify.py, written from the extension's text and the documents it reads and
# sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls,
@ -1809,6 +2316,24 @@ jobs:
working-directory: wai
shell: bash
run: python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee
# wai.live.message and wai.live.call (extensions/live-messaging.md): tests/live_messaging.rs
# (vectors.json regenerates byte for byte from its fixed seeds and no other file is there,
# wai-rs gives every conversation, history request, call and record its stated outcome, and
# §12 lists exactly the reference's codes, each of which the corpus gives); then the corpus's
# second implementation, live_messaging_verify.py, written from the extension's text and
# sharing no code with wai-rs, which reads descriptors with live_rooms_verify.py and Key IDs,
# keys, AES-GCM and plaintexts with e2ee_verify.py, implements the receipts' binary form and
# every rule of the extension itself, verifies every signature with WAI's strict Ed25519
# rule, requires every mutated copy of the corpus to fail, and drops each of its own checks
# in turn and requires the corpus to catch the weakened verifier; standard library only, run
# isolated (-I).
- name: live messaging · wai-rs and live_messaging_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_messaging)
python3 -I live-conformance/live_messaging_verify.py live-conformance/messaging
# wai.live.watermark (extensions/forensic-watermark.md): the reference module's unit tests
# (the CBOR reader's deterministic-encoding rules, tokens of both COSE structures and
# every mode read and their rules refuse, a record's rules, big-endian bit addressing,
@ -1853,6 +2378,25 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib png_still::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test derive_edge)
python3 -I edge-conformance/edge_verify.py edge-conformance
# wai.live.injection (extensions/cue-injection.md) and Enhanced RTMP ingest
# (extensions/live-ingest.md §12): both corpora are generated through joule-moq-ingest,
# whose tests/conformance_corpus.rs regenerates them byte for byte
# (joule-code-conformance.yml). Here their second implementations, written from the
# texts and sharing no code with the reference: injection_verify.py translates every
# SCTE 104 message to its SCTE 35 sections and cues (the standard's sample among them,
# byte for byte), requires every refusal's result code and reason, runs the injector's
# rules over every scenario, verifies every chain with WAI's strict Ed25519 and derives
# its breaks, and refuses every negative chain at its event; ertmp_verify.py reads every
# RTMP message of every session and requires every object of every room track and what
# was not carried. Each then requires every mutated copy of its corpus to fail
# (--self-test). Standard library only, run isolated (-I).
- name: cue injection and Enhanced RTMP · injection_verify.py and ertmp_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
python3 -I live-conformance/injection_verify.py --self-test live-conformance/injection
python3 -I live-conformance/ertmp_verify.py --self-test live-conformance/ertmp
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,
@ -1920,6 +2464,45 @@ jobs:
echo "the $build build: every differential agrees"
echo "::endgroup::"
done
# Header probes (extensions/format-probes.md, projection.md): the reference module's unit
# tests (bits, Exp-Golomb and emulation prevention, the codes) and tests/format_probe.rs (the
# committed corpus regenerates byte for byte from its external vectors and holds no other
# generated file, every vector, binding and fact gets its stated outcome, and the
# extensions' tables list exactly the reference's probes and codes); then the five LCEVC
# streams fetch.sh fetches and checks by SHA-256, each of which wai_format_probe and the
# second implementation require (a missing one is a failure); then
# format_probe_verify.py, written from the two texts and sharing no code with wai-rs,
# which probes every vector itself and requires every mutated copy of the corpus to fail.
# Standard library only, run isolated (-I).
- name: header probes · wai-rs, the fetched LCEVC streams and format_probe_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib format_probe::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test format_probe)
fetched=$(mktemp -d)
bash format-probe-conformance/fetch.sh "$fetched"
(cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_format_probe" verify ../format-probe-conformance --fetched "$fetched")
python3 -I format-probe-conformance/format_probe_verify.py format-probe-conformance --fetched "$fetched"
# CMSD reading (extensions/delivery-telemetry.md §6.6): the second implementation, written
# from that text and RFC 9651 with its own field parser, reads every field of
# telemetry-conformance/cmsd-read/ and every field expected/cmsd.json states, and requires
# every mutated copy to fail. wai-rs's reading is checked by wai_telemetry verify above.
- name: cmsd_read_verify.py
working-directory: wai
shell: bash
run: python3 -I telemetry-conformance/cmsd_read_verify.py telemetry-conformance
# LOC carriage (extensions/loc-carriage.md): the second implementation of joule-moq's loc
# module (whose tests/loc_vectors.rs runs in joule-code-conformance), written from the text,
# with its own MOQT -22 property decoder and BLAKE3 (claim-conformance/_blake3.py): it reads
# every object back to its envelope, writes it again byte for byte, gives every refused
# object and envelope its code, and requires every mutated copy, and the corpus under a
# copy of itself with a check dropped, to fail.
- name: loc_verify.py
working-directory: wai
shell: bash
run: python3 -I live-conformance/loc_verify.py live-conformance/loc
# Each feature list a step of the independent verifiers built alone, before those steps
# shared one build of the union, still compiles alone: a feature that compiles only beside
@ -1957,7 +2540,9 @@ jobs:
exact_upscale robustness ai_usage contribution settlement browser_sink browser_sink,receipt_claims \
takedown influence direction co_performance age_assurance bearer_receipts exploration co_watch \
telemetry_aggregate exact_tonemap splat_exact live_watermark derive_edge pq_signatures \
synthesis,pq_signatures; do
synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit native_sink \
platform_encode remote_desktop stream_termination live_world format_probe live_messaging; do
echo "::group::$f"
cargo check --no-default-features --features "$f" --tests
echo "::endgroup::"
@ -2074,6 +2659,23 @@ jobs:
exit 1
fi
# The platform's media framework encodes each codec through its hardware engine, the stream
# holds to platform-encode §4.3, its signed record verifies with the stream and the source
# held, and the platform's decoder gives one picture per frame. On this runner the test
# must run and pass: a host without the engine fails it rather than skipping it, so the
# step requires all five tests of the binary, the encode among them, to have run.
- name: platform-encode · the media framework's hardware encoder, end to end
working-directory: wai/wai-rs
shell: bash
run: |
set -o pipefail
cargo test --locked --no-default-features --features platform_encode --test platform_encode \
2>&1 | tee "$RUNNER_TEMP/platform-encode.log"
grep -q '^test the_platform_encoder_makes_a_stream_that_holds_and_decodes \.\.\. ok$' "$RUNNER_TEMP/platform-encode.log" \
|| { echo "::error::the platform encoder test did not run and pass"; exit 1; }
grep -qE '^test result: ok\. 5 passed' "$RUNNER_TEMP/platform-encode.log" \
|| { echo "::error::expected the five tests of tests/platform_encode.rs to pass"; exit 1; }
# wai_prov needs `codecs`, so no other job builds it. Its --carry path writes the
# provenance asset and receipt that wai-web's reach vectors commit: carried onto
# its own receipt, a run must write both again byte for byte. A receipt changed

88
.github/workflows/wai-key-proxy.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: wai-key-proxy
# The signing proxy and key server of extensions/key-release.md (wai/wai-key-proxy): its
# tests, which run the proxy, the key server and a fixture licence server on loopback, clippy
# with warnings as errors, and the release binary run end to end by smoke.sh.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-key-proxy/**'
- 'wai/wai-rs/src/key_release.rs'
- 'wai/wai-rs/src/key_release/**'
- 'wai/wai-rs/src/wai_video_keys.rs'
- 'wai/wai-rs/Cargo.toml'
- 'wai/live-conformance/keys/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-key-proxy.yml'
pull_request:
paths:
- 'wai/wai-key-proxy/**'
- 'wai/wai-rs/src/key_release.rs'
- 'wai/wai-rs/src/key_release/**'
- 'wai/wai-rs/src/wai_video_keys.rs'
- 'wai/wai-rs/Cargo.toml'
- 'wai/live-conformance/keys/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-key-proxy.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-key-proxy.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-key-proxy: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
proxy-linux:
name: proxy · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Tests and clippy
shell: bash
working-directory: wai/wai-key-proxy
run: |
set -euo pipefail
cargo test --locked 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. 2 passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::the end-to-end tests did not run"; exit 1; }
cargo clippy --locked --all-targets -- -D warnings
# smoke.sh runs a fixture licence server and picks free ports with Python.
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: The release binary end to end
shell: bash
working-directory: wai/wai-key-proxy
run: |
set -euo pipefail
cargo build --locked --release
bash smoke.sh

137
.github/workflows/wai-ledger.yml vendored Normal file
View file

@ -0,0 +1,137 @@
name: wai-ledger service
# The WAI receipt ledger as a service (wai/wai-ledger, extensions/receipt-ledger.md §9): its
# tests (the corpus's invoices issued byte for byte, the chain recovered from the directory
# after a restart, a source billed once, one process per directory), clippy with warnings as
# errors, and its smoke checks over HTTP against an instance started here with the corpus's
# seller key, the invoice's verification document checked by receipt_ledger_verify.py; on
# Linux and on the macOS runner (the host the service would be deployed to).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-rs/**'
- 'wai/wai-ledger/**'
- 'wai/live-conformance/**'
- 'wai/pack-conformance/**'
- 'wai/claim-conformance/**'
- 'wai/extensions/receipt-ledger.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-ledger.yml'
pull_request:
paths:
- 'wai/wai-rs/**'
- 'wai/wai-ledger/**'
- 'wai/live-conformance/**'
- 'wai/pack-conformance/**'
- 'wai/claim-conformance/**'
- 'wai/extensions/receipt-ledger.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-ledger.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ledger.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-ledger: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
service-linux:
name: service · linux
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal --component clippy
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Tests and clippy
shell: bash
working-directory: wai/wai-ledger
run: |
set -euo pipefail
cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
cargo clippy --locked --release --all-targets -- -D warnings
# smoke.sh ends with the independent verifier, receipt_ledger_verify.py, which reads
# packages with pack_verify.py and so needs the standard library's tomllib (Python 3.11):
# the runner image's own Python is older.
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: The smoke checks over HTTP
shell: bash
working-directory: wai/wai-ledger
run: |
set -euo pipefail
python3 -c 'import sys, tomllib; print(sys.version.split()[0])' \
|| { echo "::error::python3 here has no tomllib (Python 3.11 or later), which the independent verifier needs"; exit 1; }
cargo build --locked --release
dir="$RUNNER_TEMP/ledger"; key="$RUNNER_TEMP/ledger.key"; rm -rf "$dir" "$key"
# The corpus's seller key, from its fixed seed (a test key, never a deployment's).
python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:ledger-corpus\x01seller").digest())' > "$key"
WAI_LEDGER_KEY="$key" WAI_LEDGER_DIR="$dir" WAI_LEDGER_ADDR=127.0.0.1:18251 \
./target/release/wai-ledger serve 2> "$RUNNER_TEMP/service.log" &
pid=$!
trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT
for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18251/v1/health && break; sleep 0.2; done
bash smoke.sh http://127.0.0.1:18251
service-macos:
name: service · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo on the macOS host runner.
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Tests
shell: bash
working-directory: wai/wai-ledger
run: |
set -euo pipefail
nice -n 10 cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log"
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; }
- name: The smoke checks over HTTP
shell: bash
working-directory: wai/wai-ledger
run: |
set -euo pipefail
nice -n 10 cargo build --locked --release
dir="$RUNNER_TEMP/ledger"; key="$RUNNER_TEMP/ledger.key"; rm -rf "$dir" "$key"
python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:ledger-corpus\x01seller").digest())' > "$key"
WAI_LEDGER_KEY="$key" WAI_LEDGER_DIR="$dir" WAI_LEDGER_ADDR=127.0.0.1:18251 \
./target/release/wai-ledger serve 2> "$RUNNER_TEMP/service.log" &
pid=$!
trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT
for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18251/v1/health && break; sleep 0.2; done
bash smoke.sh http://127.0.0.1:18251

View file

@ -13,7 +13,8 @@ name: wai-ops-gpu integer operators on GPUs
# at both widths, against the committed results and the interpreter; the shader
# mutants, every one of which the corpus must catch; every committed vector and
# refusal fixture of every capability that runs a wai-ops/1 graph, the jpegai/v1
# inputs included, against the decode on the CPU; and the corpus again in a
# inputs included, against the decode on the CPU; the exact-upscale derivations and
# the face generator's frames, against the CPU's; and the corpus again in a
# headless browser's WebGPU, the kernels compiled by the browser's own WGSL
# compiler, with the semantics suite there and its vectors held to the reference
# compiled to wasm32, in the page and under Node. WAI_OPS_GPU_EXPECT declares the one adapter the host must offer, and
@ -65,6 +66,11 @@ on:
- 'wai/wai-rs/src/splat_exact/**'
- 'wai/wai-rs/tests/splat_exact_gpu.rs'
- 'wai/live-conformance/splat/**'
# The face generator's graph on the device, over the corpus's frames.
- 'wai/wai-rs/src/avatar_face.rs'
- 'wai/wai-rs/src/avatar_face/**'
- 'wai/wai-rs/tests/avatar_face_gpu.rs'
- 'wai/live-conformance/avatar-face/**'
- 'wai/wai-rs/tests/vectors/**'
- 'wai/int-payload-conformance/**'
- 'wai/neural-conformance/**'
@ -154,6 +160,7 @@ jobs:
cargo test --locked --no-default-features --features ops_gpu,video_int,audio_int,splat_int,world_int --test ops_gpu --no-run
cargo test --locked --no-default-features --features ops_gpu,exact_upscale --test exact_upscale_gpu --no-run
cargo test --locked --no-default-features --features splat_exact,ops_gpu --test splat_exact_gpu --no-run
cargo test --locked --no-default-features --features ops_gpu,avatar_face --test avatar_face_gpu --no-run
- name: wai-rs builds the DRED profile's GPU test target
working-directory: wai/wai-rs
run: cargo test --locked --no-default-features --features dred_exact,ops_gpu --test dred_exact_gpu --no-run
@ -274,6 +281,23 @@ jobs:
grep -q '^test result: ok\. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out' "$log" || { echo "::error::the splat GPU test did not run and pass"; exit 1; }
grep -qE '^splat-exact gpu: key=gpu-metal-0 views=[1-9][0-9]* differ=0 mutants_survived=0 ' "$rep" \
|| { echo "::error::no equal splat run on gpu-metal-0, or a mutant survived"; exit 1; }
# The face generator (extensions/avatars.md §7.8): every frame the avatar-face corpus
# names, from the seeded and the registered generator, is the CPU's frame and the corpus's
# digest with the generator's graph on the device, every graph run on the device and
# compared with the interpreter.
- name: The face generator's frames with its graph on the GPU
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
log="$RUNNER_TEMP/avatar-face-gpu.log"
rep="$RUNNER_TEMP/avatar-face-gpu.report"
: > "$rep"
WAI_OPS_GPU_REPORT="$rep" nice -n 10 cargo test --locked --release --no-default-features --features ops_gpu,avatar_face \
--test avatar_face_gpu -- --nocapture 2>&1 | tee "$log"
grep -q '^test result: ok\. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out' "$log" || { echo "::error::the test did not run and pass"; exit 1; }
grep -qE "^face-gpu: key=gpu-metal-0 frames=[1-9][0-9]* same=true graphs_on_gpu=[1-9][0-9]* .* on_cpu=0 .* differed=0 errors=\[\] " "$rep" \
|| { echo "::error::no equal run on the GPU"; exit 1; }
- name: wasm-bindgen CLI 0.2.127, and the wasm32-unknown-unknown target
shell: bash
run: |

182
.github/workflows/wai-ops-npu.yml vendored Normal file
View file

@ -0,0 +1,182 @@
name: wai-ops-npu integer operators on neural processors
# wai-ops/1-npu on neural processors (wai/wai-ops-npu): the integer operator set's
# accelerator subset (extensions/integer-operators.md §9.2, §9.5) with its arithmetic
# on one compute core of a tile array. The crate builds the core's program with the
# array's open toolchain (Apache-2.0 with the LLVM exception, pinned below) and its
# host side with the system's C++ compiler against the device runtime, both at run
# time, into a cache keyed by their sources and the toolchain.
#
# - npu-hw (`linux-amd`, framework-128gb, its neural processor): the probes (the
# unit's rounding modes at every shift, its saturation, its accumulator lanes,
# and the kernel's requantisation on 786,432 cases); every graph vector of
# wai/ops-conformance whose graph is in the subset, against the committed results
# and the interpreter; the twelve kernel edits, each its own program, every one of
# which the corpus must catch; and the graphs the certificate keeps out of the
# subset by acc32 alone, which must not all agree. WAI_OPS_NPU_EXPECT declares the
# one device the host must have, and WAI_OPS_NPU_REQUIRE makes an absent device a
# failure, never a skip; the step then requires the report lines with their
# counts, so a filtered or empty run fails too.
# - checks (`linux-arm64`, no such device): the crate and its tests build, its unit
# tests pass, the device tests print SKIPPED and pass, and the same tests under
# WAI_OPS_NPU_REQUIRE=1 fail: an absent device is not a pass.
#
# Time and energy (`ops-npu-bench`) are measured by hand and recorded in
# integer-operators §9.5; this workflow does not run the bench.
# One run per branch at a time; a push supersedes the run before it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
paths:
- 'wai/wai-ops-npu/**'
- 'wai/wai-ops/**'
- 'wai/ops-conformance/**'
- 'rust-toolchain.toml'
- '.github/workflows/wai-ops-npu.yml'
schedule:
- cron: '41 7 * * *'
env:
# The tile array's toolchain: the compiler and its core back end, the build the
# compiler's release pins (its utils/peano-requirements.txt). The same environment
# as the wai-quantum npu-cores member's, at the same path.
TOOLCHAIN_VERSION: 1.4.3
CORE_COMPILER: llvm-aie==22.0.0.2026090701+3e93bf7b
jobs:
# Does this pull request's own diff touch the paths below? This forge matches a
# workflow's `paths:` against the push, so a merge of main into a branch fires the
# workflow whatever the pull request changes; every job here waits on this answer.
# A push to main, a schedule, or anything the script cannot determine answers
# `true` (scripts/pr-touches-paths.py).
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ops-npu.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-ops-npu: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
npu-hw:
name: neural processor (framework-128gb silicon)
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux-amd
timeout-minutes: 90
env:
CARGO_PROFILE_DEV_DEBUG: '0'
CARGO_BUILD_JOBS: '12'
steps:
- name: Fetch the source
shell: bash
run: |
set -euo pipefail
want="${{ github.event.pull_request.head.sha || github.sha }}"
rm -rf src && mkdir src && cd src
git init -q
# Fetch the commit itself, not the ref. The host names its own route to the
# forge (WAI_FORGE_URL).
git fetch -q --depth 1 "${WAI_FORGE_URL:-http://100.64.224.0:3400}/Transaction-Science/open-standards.git" "$want"
git checkout -q FETCH_HEAD
got="$(git rev-parse HEAD)"
[ "$got" = "$want" ] || { echo "::error::fetched $got, not $want"; exit 1; }
- name: The toolchain (cached)
shell: bash
run: |
set -euo pipefail
venv="$HOME/aie-$TOOLCHAIN_VERSION"
if ! "$venv/bin/python" -I -c 'import aie.iron' 2>/dev/null; then
rm -rf "$venv"
python3 -m venv "$venv"
tag="$(python3 -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')"
"$venv/bin/pip" install -q "https://github.com/Xilinx/mlir-aie/releases/download/v$TOOLCHAIN_VERSION/mlir_aie-$TOOLCHAIN_VERSION-$tag-$tag-manylinux_2_35_x86_64.whl" # allow-name: the toolchain's release URL
"$venv/bin/pip" install -q -f https://github.com/Xilinx/llvm-aie/releases/expanded_assets/nightly "$CORE_COMPILER" # allow-name: the core compiler's release index
"$venv/bin/pip" install -q "numpy>=2.5.2,<3.0" rich ml_dtypes cloudpickle aiofiles
"$venv/bin/pip" install -q -f https://llvm.github.io/eudsl "eudsl-python-extras==0.1.0.20260801.905+68a0d7a" --config-settings=EUDSL_PYTHON_EXTRAS_HOST_PACKAGE_PREFIX=aie
fi
echo "WAI_OPS_NPU_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
- name: The probes, the corpus, the kernel edits and the width, on the device
shell: bash
working-directory: src/wai/wai-ops-npu
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
export WAI_OPS_NPU_CACHE="$HOME/ops-npu-cache"
# The device this host has, declared here (or by the runner).
k="${WAI_OPS_NPU_HOST_EXPECT:-npu-1022-17f0}"
log="$RUNNER_TEMP/ops-npu.log"
export WAI_OPS_NPU_REPORT="$RUNNER_TEMP/ops-npu.report"
: > "$WAI_OPS_NPU_REPORT"
WAI_OPS_NPU_EXPECT="$k" WAI_OPS_NPU_REQUIRE=1 CARGO_TARGET_DIR="$HOME/hw-target/wai-ops-npu" \
nice -n 10 cargo test --locked --release -- --nocapture --test-threads=1 2>&1 | tee "$log"
# Fourteen tests in five binaries: the library's eight, corpus's one, probe's
# three, mutants' one and width's one, none filtered, ignored or failed.
ran=$(awk '/^test result: ok\. [0-9]+ passed; 0 failed; 0 ignored; 0 measured; 0 filtered out/ { n += $4 } END { print n + 0 }' "$log")
[ "$ran" = 14 ] || { echo "::error::$ran tests ran and passed, not 14"; exit 1; }
! grep -q '^test result: FAILED' "$log" || { echo "::error::a test failed"; exit 1; }
rep="$WAI_OPS_NPU_REPORT"
grep -qxF "ops-npu devices: as declared ($k)" "$rep" || { echo "::error::the device found is not $k"; exit 1; }
grep -qE "^ops-npu requantisation: key=$k [0-9]+ cases over shifts 0\.\.63 .*, 0 differ$" "$rep" || { echo "::error::the requantisation probe did not agree"; exit 1; }
grep -qE "^ops-npu corpus: key=$k .* vectors=[1-9][0-9]* decodes=[1-9][0-9]* .* declined=0 mismatches=0 errors=0 " "$rep" \
|| { echo "::error::no equal corpus run on $k"; exit 1; }
grep -qxF "ops-npu mutants: key=$k killed 12 of 12" "$rep" || { echo "::error::a kernel edit survived on $k, or the mutant test did not run"; exit 1; }
grep -qE "^ops-npu width: key=$k .* on the device [1-9][0-9]* differ " "$rep" || { echo "::error::the width test did not run"; exit 1; }
cat "$rep"
checks:
name: builds · no device is not a pass (linux-arm64)
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux-arm64
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# This host has no such device. The device tests must say so and pass; under
# WAI_OPS_NPU_REQUIRE=1 the same tests must fail.
- name: The crate's tests, and an absent device under WAI_OPS_NPU_REQUIRE
shell: bash
working-directory: wai/wai-ops-npu
env:
WAI_OPS_NPU_EXPECT: none
run: |
set -euo pipefail
log="$RUNNER_TEMP/ops-npu.log"
rep="$RUNNER_TEMP/ops-npu.report"
: > "$rep"
WAI_OPS_NPU_REPORT="$rep" cargo test --locked --release -- --nocapture --test-threads=1 2>&1 | tee "$log"
ran=$(awk '/^test result: ok\. [0-9]+ passed; 0 failed; 0 ignored; 0 measured; 0 filtered out/ { n += $4 } END { print n + 0 }' "$log")
[ "$ran" = 14 ] || { echo "::error::$ran tests ran and passed, not 14"; exit 1; }
grep -qxF 'ops-npu devices: as declared (none)' "$rep" || { echo "::error::the absent case was not established"; exit 1; }
for t in corpus mutants width 'requantisation probe' 'rounding probe' 'lane probe'; do
grep -qF "ops-npu $t: SKIPPED" "$rep" || { echo "::error::the $t did not say SKIPPED"; exit 1; }
done
grep -qE '^ops-npu subset: vectors=[1-9][0-9]* .* in_subset=[1-9][0-9]* ' "$rep" || { echo "::error::the corpus was not read"; exit 1; }
if WAI_OPS_NPU_REQUIRE=1 cargo test --locked --release --test corpus --test probe --test mutants --test width > "$RUNNER_TEMP/require.log" 2>&1; then
echo "::error::with no device, the device tests passed under WAI_OPS_NPU_REQUIRE=1"; exit 1
fi
grep -q 'WAI_OPS_NPU_REQUIRE is set' "$RUNNER_TEMP/require.log" \
|| { echo "::error::the tests failed under WAI_OPS_NPU_REQUIRE=1, but not for the absent device"; cat "$RUNNER_TEMP/require.log"; exit 1; }
echo "no device: SKIPPED without WAI_OPS_NPU_REQUIRE, a failure with it"

View file

@ -39,6 +39,8 @@ on:
- 'wai/wai-pack-gpu/**'
- 'wai/wai-rs/**'
- 'wai/pack-conformance/**'
# tests/encode_rung.rs checks the encoded renditions of the base golden.
- 'wai/encode-conformance/rendition/**'
- 'wai/claim-conformance/_blake3.py'
- 'wai/claim-conformance/_ed25519.py'
- 'wai/claim-conformance/claim_verify.py'
@ -137,6 +139,7 @@ jobs:
--lib 39
--test=cmaf 4
--test=conformance 1
--test=encode_rung 3
--test=fabric 3
--test=floor_only 2
--test=ladder 3

167
.github/workflows/wai-sink-android.yml vendored Normal file
View file

@ -0,0 +1,167 @@
name: wai-sink-android native sink library
# The native sink's Kotlin library (wai/wai-sink-android, extensions/native-sink.md).
#
# On the linux-arm64 runner, the platform-free half's unit tests on the host JVM: libwai and the
# JNI layer built for the runner, then the corpus live-conformance/native-sink through the JNI
# layer, every case and every refused configuration; the page-parity bundle byte for byte; the
# C interface's statuses as the JNI layer carries them; the loopback relay over the reference
# package's floor (presented byte for byte, a changed fragment answered 502 on every request,
# ranges, content codings, an unsigned playlist refused, nothing answered outside the session's
# token); and the observation loop's hidden and idle endings with its clock advanced by the test.
# No Android SDK is used there.
#
# On the macOS runner, the host where the platform SDK, its NDK and its emulator are installed:
# libwai and the JNI layer for each ABI with the pinned NDK, the AAR, and the instrumented tests in
# a headless emulator (the host's ABI, arm64-v8a): the corpus and the relay on the device, the
# platform's media player library playing the floor through the sink to its end, a changed
# fragment refused on every retry, a session nobody can see ended as hidden, and the attention
# signals read from a real activity. Nothing listens beyond loopback.
#
# Push filters match the push's delta, so every path the jobs read is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-sink-android/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/extensions/native-sink.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-sink-android.yml'
pull_request:
paths:
- 'wai/wai-sink-android/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/extensions/native-sink.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-sink-android.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-sink-android.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-sink-android: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
jvm-linux-arm64:
name: the JNI layer and the platform-free half on the host JVM · linux-arm64
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux-arm64
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# A JDK 17, from the image's packages when the image has none.
- name: JDK 17
shell: bash
run: |
set -euo pipefail
j="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
if [ ! -x "$j/bin/javac" ]; then
sudo=$(command -v sudo || true)
$sudo apt-get update -qq
$sudo apt-get install -y -qq --no-install-recommends openjdk-17-jdk-headless >/dev/null
fi
test -f "$j/include/jni.h" || { echo "::error::no JDK 17 with jni.h at $j"; exit 1; }
echo "JAVA_HOME=$j" >> "$GITHUB_ENV"
- name: libwai and the JNI layer for this runner
shell: bash
working-directory: wai/wai-sink-android
run: bash scripts/build-native.sh host
# The pinned Gradle and the build's dependencies are kept on the runner's cache volume when
# it has one.
- name: The unit tests
shell: bash
working-directory: wai/wai-sink-android
run: |
set -euo pipefail
cache=/ci-cache/wai-sink-android
mkdir -p "$cache" 2>/dev/null || cache="$RUNNER_TEMP/wai-sink-android"
export WAI_SINK_GRADLE_DISTS="$cache/dists" GRADLE_USER_HOME="$cache/gradle-home"
bash scripts/gradle.sh -Pwai.sink.jvmOnly=true :core:test
python3 -I - core/build/test-results/test <<'PY'
import sys, pathlib, xml.etree.ElementTree as ET
runs = {}
tests = fails = skips = 0
for f in pathlib.Path(sys.argv[1]).glob("*.xml"):
s = ET.parse(f).getroot()
tests += int(s.get("tests", 0)); fails += int(s.get("failures", 0)) + int(s.get("errors", 0)); skips += int(s.get("skipped", 0))
runs[s.get("name").split(".")[-1]] = int(s.get("tests", 0))
print(f"unit tests: {tests} run, {fails} failed, {skips} skipped: {runs}")
need = {"CorpusTest", "RelayTest", "SessionDriverTest", "JsonTest"}
missing = sorted(c for c in need if runs.get(c, 0) == 0)
if missing: print(f"::error::these classes ran no test: {missing}")
sys.exit(0 if tests > 0 and fails == 0 and skips == 0 and not missing else 1)
PY
android-macos:
name: every ABI, the AAR, and the instrumented tests in a headless emulator · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo on the macOS host runner.
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: The targets the ABIs need
shell: bash
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: libwai and the JNI layer for each ABI
shell: bash
working-directory: wai/wai-sink-android
run: nice -n 10 bash scripts/build-native.sh android
- name: The AAR
shell: bash
working-directory: wai/wai-sink-android
run: |
set -euo pipefail
nice -n 10 bash scripts/gradle.sh :sink:assembleRelease
aar=sink/build/outputs/aar/sink-release.aar
# The listing is read whole, then searched: under pipefail, `unzip -l | grep -q` fails
# whenever grep stops at its first match before unzip has written the rest (SIGPIPE).
list="$(unzip -l "$aar")"
for abi in arm64-v8a armeabi-v7a x86_64; do
for so in libwai.so libwai_sink_jni.so; do
grep -q " jni/$abi/$so\$" <<<"$list" || { echo "::error::the AAR has no jni/$abi/$so"; echo "$list"; exit 1; }
done
done
grep -q ' classes.jar$' <<<"$list" || { echo "::error::the AAR has no classes"; echo "$list"; exit 1; }
- name: The instrumented tests in a headless emulator
shell: bash
working-directory: wai/wai-sink-android
run: nice -n 10 bash scripts/test-emulator.sh

119
.github/workflows/wai-sink-swift.yml vendored Normal file
View file

@ -0,0 +1,119 @@
name: wai-sink-swift native sink package
# The native sink's Swift package (wai/wai-sink-swift, extensions/native-sink.md): on the macOS
# runner, the host with the platform SDKs and simulators, libwai is built for every slice of the
# package's binary target (desktop, phone, phone simulator, television, television simulator) and
# the package's tests run on the desktop, and in fresh phone and television simulators hosted in
# TestHost (a device runs no tool-hosted test bundle), every step bounded: the corpus
# live-conformance/native-sink through the Swift wrapper and the C interface; the loopback relay
# over the reference package's floor, presenting what verifies byte for byte and answering 502
# for a fragment changed at the origin; and the platform's player end to end, over a presentation
# made at run time with the platform's own encoder, played through the relay to its end, a changed
# fragment withheld, and a session nobody can see ended as hidden. The phone and television device
# slices are built and linked, not run. Nothing listens beyond loopback.
#
# Push filters match the push's delta, so every path the jobs read is listed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- 'wai/wai-sink-swift/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/pack-conformance/packs/video-base-01/**'
- 'wai/extensions/native-sink.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-sink-swift.yml'
pull_request:
paths:
- 'wai/wai-sink-swift/**'
- 'wai/wai-rs/**'
- 'wai/wai-quantum/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/native-sink/**'
- 'wai/pack-conformance/packs/video-base-01/**'
- 'wai/extensions/native-sink.md'
- 'rust-toolchain.toml'
- '.github/workflows/wai-sink-swift.yml'
jobs:
relevant:
name: does this change touch it
runs-on: linux-arm64
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-sink-swift.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-sink-swift: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
package-macos:
name: libwai slices, desktop tests, simulator tests · macos
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: macos
timeout-minutes: 90
steps:
- uses: actions/checkout@v5
# launchd's PATH omits cargo on the macOS host runner.
- name: Put cargo on PATH
shell: bash
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: The targets the slices need
shell: bash
run: |
set -euo pipefail
rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
rustup component list --toolchain nightly-2026-10-17 --installed | grep -q '^rust-src' \
|| rustup toolchain install nightly-2026-10-17 --profile minimal --component rust-src
- name: WaiFFI.xcframework, every slice
shell: bash
working-directory: wai/wai-sink-swift
run: nice -n 10 bash scripts/build-xcframework.sh
- name: The desktop tests
shell: bash
working-directory: wai/wai-sink-swift
timeout-minutes: 20
run: |
set -euo pipefail
nice -n 10 swift test 2>&1 | tee "$RUNNER_TEMP/swift-test.log"
grep -qE "Executed [1-9][0-9]* tests, with 0 failures" "$RUNNER_TEMP/swift-test.log" || { echo "::error::the desktop tests did not pass"; exit 1; }
# Each simulator run creates and boots a fresh simulator, builds the test host for it and runs
# the tests hosted in it, then shuts the simulator down and deletes it, however the run ends.
# Every step inside has a hard limit and fails, saying which, when it passes it; the step's
# own limit is the backstop, so no run holds the runner past it.
- name: The phone simulator tests, hosted in TestHost
shell: bash
working-directory: wai/wai-sink-swift
timeout-minutes: 30
run: nice -n 10 bash scripts/test-simulator.sh ios
- name: The television simulator tests, hosted in TestHost
shell: bash
working-directory: wai/wai-sink-swift
timeout-minutes: 30
run: nice -n 10 bash scripts/test-simulator.sh tvos
- name: The phone and television device slices link
shell: bash
working-directory: wai/wai-sink-swift
timeout-minutes: 20
run: |
set -euo pipefail
for d in "generic/platform=iOS" "generic/platform=tvOS"; do
xcodebuild build -scheme WaiSink -destination "$d" -derivedDataPath .build/xc-device 2>&1 | tee "$RUNNER_TEMP/device.log" | grep -E "error:|\*\* BUILD" || true
grep -q '\*\* BUILD SUCCEEDED \*\*' "$RUNNER_TEMP/device.log" || { echo "::error::the package does not build for $d"; exit 1; }
done

View file

@ -135,6 +135,19 @@ jobs:
cargo build --locked --release --target wasm32-unknown-unknown
wasm-bindgen --target nodejs --out-dir demo/pkg-node --out-name wai_web \
target/wasm32-unknown-unknown/release/wai_web.wasm
# The platform bundle (feature platform_decode: platform-decode's query, the answer in
# browser-sink's report, and browser-sink §4.7's choice), a bundle of its own that the
# main one does not carry, so nothing above builds it.
- name: Build wai-web's platform bundle for wasm32 (feature platform_decode)
shell: bash
working-directory: wai/wai-web
run: |
set -euo pipefail
cargo build --locked --release --target wasm32-unknown-unknown --features platform_decode
wasm-bindgen --target web --out-dir demo/pkg-platform --out-name wai_platform_decode \
target/wasm32-unknown-unknown/release/wai_web.wasm
test -s demo/pkg-platform/wai_platform_decode.js
grep -q "platformChoose" demo/pkg-platform/wai_platform_decode.js
# The JS surface itself (pick, companionsOf, verifyPin, rendition() and
# companion() refusing what is never presented or does not match its digest,
# the held-sets conversion, and the WAI2 parse at 32-bit usize) runs only on
@ -194,15 +207,16 @@ jobs:
# its import reads a program as the job service does and refuses one that
# measures and then acts, its replies stay valid JSON whatever an error says,
# and it signs with the page's own key once given one (quantum_toolchain::tests). The OTT demo seals a figure the page supplies as
# an estimate, never a reading (video_ott::tests). The count check turns a test that
# stopped being compiled into a failure instead of a silent "0 passed".
# an estimate, never a reading (video_ott::tests), and so does the shared world
# (world::tests). The count check turns a test that stopped being compiled into a
# failure instead of a silent "0 passed".
- name: wai-web native unit tests
shell: bash
working-directory: wai/wai-web
run: |
set -euo pipefail
cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log"
grep -q "test result: ok. 35 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log"
grep -q "test result: ok. 36 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log"
- name: wasm32 int_synth decode equals the frozen native corpus
shell: bash
working-directory: wai/wai-web

View file

@ -4,6 +4,54 @@ All notable changes to the Joule Code standard are recorded here. The format fol
## [Unreleased]
### Added — `joule-moq-relay`: relay-to-relay cascading
- **`[[upstream]]`**: relays this one dials, over native QUIC (`moqt://`), WebTransport
(`https://`) or the WebSocket binding (`wss://`, or `ws://` on a trusted path), each trusting a
CA file, a pinned certificate's SHA-256 or the platform's roots, presenting a token read from
a file and named by its SHA-256, and routing the namespace prefixes it lists. A link redials
after its session ends, backing off to 30 seconds.
- **Routing**: a SUBSCRIBE, FETCH or TRACK_STATUS for a track no session of the relay publishes
goes upstream once, on the first link of the configuration's order whose session is open; the
subscription it makes is the relay's own, cached and fanned out to every local subscriber; a
link that refuses or does not answer is passed over for the next, and when none answers the
request is refused `TIMEOUT` with `no_upstream`. Namespaces published to the relay, by
PUBLISH_NAMESPACE or by a track's PUBLISH, are published on every link that routes them, and
a SUBSCRIBE_NAMESPACE is forwarded on every link it overlaps, the upstream's NAMESPACE and
NAMESPACE_DONE merged with the relay's own.
- **Loops**: both ends of a link declare the Setup Option `RELAY_ID` (`0x3A4F`, the relay's
summary key), the dialer proving it over the listener's challenge; every request between
relays then carries `RELAY_PATH` (`0x3A51`, negotiated so, -22 §9.20), and no relay forwards
a request back where it came from, to a relay on its path, or past `limits.max_relay_hops`;
one that receives its own id is refused `relay_loop`.
- **GOAWAY** from an upstream moves the link's subscriptions to a session to the URI it names
(else the link's own) and closes the old one; it is never passed on downstream. The relay's
own GOAWAY on a session it dialed names no URI.
- **WAI evidence at every hop**: the door of every relay checks what a peer forwards; each
relay's per-hop record counts a link in its track entries and not as a subscriber session or
in its ledger; the floor and audience inputs are decided at the relay the performer or the
author publishes to, and read for their statements alone over a link; a FETCH or a fill of a
track the relay knows nothing of learns its live edge upstream first, so the seek power is
held at the viewer's relay.
- **`joule-moq`**: `Parameter::RelayPath` and `RELAY_ID_OPTION`; the bridges refuse the
parameter by name.
- `tests/cascade.rs`: two relays over each transport, a chain of three with a per-hop record
at each hop, a door refusing a tampered object another relay forwarded, a loop, failover and
`no_upstream`, GOAWAY, a DVR window fetched through an edge, and co-performance votes and the
floor across relays.
### Added — `joule-moq`: WAI envelopes on LOC objects
- **`joule-moq`**: module `loc` (WAI `extensions/loc-carriage.md`). An envelope whose payload
is one WebCodecs chunk (`wai.audio.opus.packet`, `wai.video.avc`, `wai.video.hevc`,
`wai.audio.aac`) is written as one object of a track of packaging `loc`
(`draft-ietf-moq-loc-04`): its payload as the LOC Payload, and in the Immutable Properties
the LOC properties, the manifest as `WAI_MANIFEST` (`0x3A61`) and the `WAI_RECEIPT`
property. `from_loc` reads an object back into the envelope, byte for byte, refusing with
the extension's codes. `examples/loc_vectors.rs` writes WAI's corpus `live-conformance/loc/`
and `tests/loc_vectors.rs` regenerates and checks it; WAI's `loc_verify.py` is its second
implementation.
### Added — `joule-moq`: `draft-ietf-moq-transport-18` and `-16` beside `-22`, and runs against other implementations' relays
- **`joule-moq`**: codec modules `draft18` and `draft16` (control messages, parameters,

View file

@ -1981,6 +1981,23 @@ dependencies = [
"thiserror",
]
[[package]]
name = "joule-contrib"
version = "0.1.0"
dependencies = [
"aes",
"ctr",
"hex",
"hmac 0.12.1",
"rand_core 0.6.4",
"serde",
"serde_json",
"sha1",
"sha2 0.10.9",
"tokio",
"tracing",
]
[[package]]
name = "joule-moq"
version = "0.3.0"
@ -2023,6 +2040,7 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"joule-contrib",
"joule-moq",
"joule-moq-relay",
"quinn",
@ -2034,6 +2052,7 @@ dependencies = [
"str0m",
"thiserror",
"tokio",
"tokio-rustls",
"toml",
"tracing",
"tracing-subscriber",
@ -2094,10 +2113,12 @@ dependencies = [
"rand_core 0.6.4",
"ring",
"rustls",
"rustls-native-certs",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"toml",
"tracing",
@ -4018,6 +4039,16 @@ dependencies = [
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"

View file

@ -21,6 +21,7 @@ members = [
"crates/joule-moq-relay",
"crates/joule-moq-interop",
"crates/joule-moq-ingest",
"crates/joule-contrib",
"crates/joule-moq-web",
"crates/lockstep-quic",
]

View file

@ -0,0 +1,49 @@
[package]
name = "joule-contrib"
version = "0.1.0"
description = "Contribution over UDP with retransmission: SRT (draft-sharabayko-srt-01) listener and caller with its latency window, ARQ and AES-CTR, and RIST (VSF TR-06-1 Simple and TR-06-2 Main profiles: RTP, RTCP retransmission requests, GRE-over-UDP tunnelling, PSK), with an MPEG-TS demultiplexer and an account of every sequence number's loss, recovery and latency."
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
repository = "https://github.com/transaction-science/open-standards"
[lib]
path = "src/lib.rs"
[features]
default = []
# RIST (VSF TR-06-1, TR-06-2). Off by default: both texts carry an IPR notice on their
# protocol sections with a licensing assurance to implementers, and whether a default build
# carries the code is the owner's decision (README, "Patents").
rist = []
[[bin]]
name = "joule-contrib-send"
path = "src/bin/send.rs"
[[bin]]
name = "joule-contrib-recv"
path = "src/bin/recv.rs"
[[bin]]
name = "joule-contrib-bench"
path = "src/bin/bench.rs"
[dependencies]
# AES (FIPS 197) on the CPU's AES instructions where it has them, CTR mode (SP 800-38A),
# HMAC (RFC 2104) with SHA-1 and SHA-256: RustCrypto, MIT OR Apache-2.0. PBKDF2 (RFC 8018)
# and AES key wrap (RFC 3394) are written here over them.
aes = "0.8"
ctr = "0.9"
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] }
rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
tracing = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

View file

@ -0,0 +1,84 @@
# joule-contrib
Contribution over UDP with retransmission, for WAI's ingest gateway: SRT
(`draft-sharabayko-srt-01`) and RIST (VSF TR-06-1:2020 Simple Profile, VSF TR-06-2:2024 Main
Profile), the MPEG transport stream both carry, and an account of what became of every
sequence number. WAI's `extensions/live-contribution.md` profiles them; `joule-moq-ingest`
uses this crate to bring a contribution link into a room's tracks.
## SRT
`srt::Listener` serves many callers on one UDP port, each connection found by the socket ID
its packets name; `srt::call` calls a listener. Both run the caller-listener handshake of the
draft's §4.3.1 (the SYN cookie, the HSv5 extensions, the stream ID, the key material), and
live mode (§7.1): every message one packet, TSBPD over the negotiated latency, too-late drop,
ACK/ACKACK with the RTT estimate, immediate and periodic NAKs, retransmission, AES-CTR with
the key wrapped (RFC 3394) under PBKDF2-HMAC-SHA1. A listener decides each caller through an
`Admission`, which sees the stream ID and gives the passphrase, the role (receive or send) and
the latency, or a rejection code.
## RIST (feature `rist`)
`rist::RistListener` receives one flow: the Simple Profile's RTP on an even port P and RTCP on
P + 1, or the Main Profile's GRE-over-UDP tunnel on one port, in reduced overhead mode with
the VSF EtherType or the legacy one, keep-alives, and PSK (PBKDF2-HMAC-SHA256 over the GRE
nonce, AES-CTR on the GRE sequence number). It takes the first flow whose CNAME is the one it
expects, and, under PSK, whose packets decrypt. It requests what is missing with range or
bitmask requests, measures the RTT with the RTT echo, and delivers each packet a buffer after
it was expected. `rist::send` is the sender of either profile.
## The account
`stats::Account` records what a receiver did with every sequence number, in order: delivered
at the first try, recovered within the window, dropped when its time passed, or outstanding
when the link ended; the loss runs, the requests sent and the numbers they named, the
retransmissions, duplicates and late arrivals received, the round trips, how long each
recovery took and its least margin. It is cut into parts, which the gateway signs as the link
record.
## The transport stream
`ts::Demux` reads the PAT and PMT (each section's CRC checked) and reassembles each stream's
PES packets, with the PTS extended past 33 bits; a PES missing a packet is dropped whole and
counted. `ts::Mux` writes a program of one video and one audio stream, and `ts::Pacer` times
a stream's chunks by its PCR.
## Tools
| binary | what |
|---|---|
| `joule-contrib-send` | sends a transport stream file (paced by its PCR) or a synthetic program over SRT (calling or listening) or RIST |
| `joule-contrib-recv` | receives over SRT or RIST into a file, and prints the link's account |
| `joule-contrib-bench` | one link on loopback, both ends in the process: the payload rate, CPU time and energy, with a lossy path if asked |
URLs: `srt://HOST:PORT?streamid=…&passphrase=…&pbkeylen=16&latency=MS` (calls; `srt://:PORT`
listens), `rist://HOST:PORT?profile=simple|main&cname=…&secret=…&aes-type=128|256&buffer=MS`.
## Tests
| test | what |
|---|---|
| unit tests | RFC 3394, RFC 6070 and TR-06-2 Annex B vectors, SP 800-38A's CTR vector; the packet formats; the TR-06-1 Appendix A requests; the TS multiplexer against the demultiplexer, a lost packet, the PTS wrap; the account |
| `srt_link` | SRT on loopback through a proxy that drops datagrams each way, every payload in order, the account of every sequence number; each key length, a wrong and a missing passphrase, the rejection codes; a packet held for the latency window; a listener that sends to a caller that receives |
| `rist_link` | the Simple Profile with range and bitmask requests through lossy proxies; the Main Profile in the clear, under PSK of each length and in the legacy format; another CNAME and another passphrase refused |
| `interop` (ignored) | against each transport's reference tools run locally, named by `WAI_SRT_LIVE_TRANSMIT`, `WAI_RISTSENDER` and `WAI_RISTRECEIVER`: their caller and senders into this crate and this crate into their listener and receivers, byte for byte, encrypted and through lossy paths. With `--ignored`, a tool not named fails the test |
The `lossy` module's proxy drops, delays and reorders datagrams from a fixed seed.
## Patents
VSF TR-06-1:2020 and TR-06-2:2024 each carry an intellectual property notice: a patent is
claimed to read on their protocol sections, and its holder has assured the forum that it will
license the claims to implementers. The RIST code is therefore built only with the feature
`rist`, which no default build turns on; whether a distributed build carries it is a licensing
decision for the distributor (`extensions/live-contribution.md` §10).
## Dependencies
AES, CTR, HMAC, SHA-1 and SHA-256 from RustCrypto (MIT OR Apache-2.0); PBKDF2 (RFC 8018) and
AES key wrap (RFC 3394) are written here over them. tokio, serde, serde_json, hex, tracing and
rand_core: MIT or Apache-2.0. No C library is linked.
## Licence
Apache-2.0.

View file

@ -0,0 +1,190 @@
//! `joule-contrib-bench`: one link's throughput and energy on loopback, sender and receiver
//! in this process.
//!
//! ```text
//! joule-contrib-bench [--proto srt|rist-simple|rist-main] [--seconds N] [--mbps M | --max]
//! [--encrypt] [--loss-ppm N] [--latency-ms L]
//! ```
//!
//! The sender sends 1316-byte payloads (seven TS packets) at `M` Mbit/s, or as fast as the
//! link takes them with `--max`; the receiver counts what it delivers. Through `--loss-ppm`
//! the datagrams pass a proxy that drops that share of them each way. It prints one JSON
//! line: the payload rate delivered, the link's account, the process's CPU time, and its
//! energy with the counter's profile, class and scope. Both ends are this process, so the
//! energy is the link's, sender and receiver together.
use joule_contrib::energy;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::srt::{self, Admitted, CallConfig, ListenerConfig, Role};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
let flag = |k: &str| args.iter().any(|a| a == k);
let proto = opt("--proto").unwrap_or_else(|| "srt".into());
let seconds: u64 = opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(10);
let mbps: f64 = opt("--mbps").and_then(|v| v.parse().ok()).unwrap_or(20.0);
let max = flag("--max");
let encrypt = flag("--encrypt");
let loss: u32 = opt("--loss-ppm").and_then(|v| v.parse().ok()).unwrap_or(0);
let latency: u16 = opt("--latency-ms").and_then(|v| v.parse().ok()).unwrap_or(200);
let pass = "a bench passphrase".to_string();
let (tx, mut rx, proxy, _keep) = match proto.as_str() {
"srt" => {
let p2 = encrypt.then(|| pass.clone());
let adm = move |_: &srt::ConnRequest| Ok(Admitted { passphrase: p2.clone(), role: Role::Receive, latency_ms: Some(latency), tag: 0 });
let mut l = srt::Listener::bind(ListenerConfig { latency_ms: latency, flow_window: 25_600, ..ListenerConfig::default() }, Arc::new(adm)).await.expect("the listener binds");
// The impairing proxy only where there is loss: it is a hop of its own.
let proxy = if loss > 0 {
let imp = Impair { loss_ppm: loss, ..Impair::default() };
Some(Proxy::start(l.addr, imp, imp, 7).await.expect("the proxy starts"))
} else {
None
};
let target = proxy.as_ref().map_or(l.addr, |p| p.addr);
let cfg = CallConfig { latency_ms: latency, passphrase: encrypt.then(|| pass.clone()), flow_window: 25_600, ..CallConfig::default() };
let tx = srt::call(target, cfg).await.expect("the call connects");
let rx = l.accept().await.expect("a link");
(tx, rx, proxy, Keep::Srt(l))
}
#[cfg(feature = "rist")]
p @ ("rist-simple" | "rist-main") => {
use joule_contrib::rist::{self, recv::RecvConfig, Profile, RistListener, RistListenerConfig, SendConfig};
let profile = if p == "rist-main" { Profile::Main } else { Profile::Simple };
let secret = (encrypt && profile == Profile::Main).then(|| pass.clone());
let cfg = RistListenerConfig { profile, bind_ip: "127.0.0.1".parse().unwrap(), port: 0, recv: RecvConfig { buffer_us: latency as u64 * 1000, ..RecvConfig::default() }, secret: secret.clone(), expect_cname: None, tag: 0 };
let mut l = RistListener::bind(cfg).await.expect("the receiver binds");
if loss > 0 && profile == Profile::Simple {
eprintln!("joule-contrib-bench: --loss-ppm needs two proxies for the Simple Profile's two ports; running without loss");
}
let (target, proxy) = if loss > 0 && profile == Profile::Main {
let imp = Impair { loss_ppm: loss, ..Impair::default() };
let px = Proxy::start(l.addr, imp, imp, 7).await.expect("the proxy starts");
(px.addr, Some(px))
} else {
(l.addr, None)
};
let tx = rist::send(target, SendConfig { profile, secret, buffer_ms: latency as u64 * 2, ..SendConfig::default() }).await.expect("the sender starts");
let rx = l.accept().await.expect("a flow");
(tx, rx, proxy, Keep::Rist(l))
}
p => {
eprintln!("joule-contrib-bench: no protocol {p}");
return ExitCode::from(2);
}
};
let payload = {
let mut p = vec![0u8; 1316];
for (i, c) in p.chunks_mut(188).enumerate() {
c[0] = 0x47;
for (j, b) in c.iter_mut().enumerate().skip(1) {
*b = ((i * 31 + j * 7) % 251) as u8;
}
}
p
};
let send = tx.send.clone().expect("a sending link");
// On a counter every user of the package shares (linux-rapl), the figure is a bracket:
// the run's reading less an idle reading of the same length taken just before it.
let idle = match energy::read() {
Some(r) if r.scope == "bracket" => {
let t = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(seconds)).await;
let r2 = energy::read().unwrap();
Some((r.to(&r2), t.elapsed().as_secs_f64()))
}
_ => None,
};
let e0 = energy::read();
let c0 = energy::cpu_us();
let start = Instant::now();
let pps = mbps * 1e6 / 8.0 / 1316.0;
let sender = tokio::spawn(async move {
let mut sent = 0u64;
let mut tick = tokio::time::interval(Duration::from_millis(1));
while start.elapsed() < Duration::from_secs(seconds) {
if max {
if send.send(payload.clone()).await.is_err() {
break;
}
sent += 1;
if sent.is_multiple_of(64) {
tokio::task::yield_now().await;
}
continue;
}
tick.tick().await;
let due = (start.elapsed().as_secs_f64() * pps) as u64;
while sent < due {
if send.send(payload.clone()).await.is_err() {
return sent;
}
sent += 1;
}
}
sent
});
let mut got = 0u64;
let mut bytes = 0u64;
let end = start + Duration::from_secs(seconds) + Duration::from_millis(latency as u64 * 3 + 500);
while let Ok(Some(p)) = tokio::time::timeout(end.saturating_duration_since(Instant::now()), rx.data.recv()).await {
got += 1;
bytes += p.len() as u64;
}
let sent = sender.await.unwrap_or(0);
let wall = start.elapsed().as_secs_f64();
let c1 = energy::cpu_us();
let e1 = energy::read();
let part = rx.account.lock().unwrap().cut(0, false);
let sent_counts = tx.sent.lock().unwrap().clone();
tx.close();
rx.close();
let raw = match (&e0, &e1) {
(Some(a), Some(b)) => Some(a.to(b) as f64 / 1e9),
_ => None,
};
// The idle reading scaled to the run's length, then taken off.
let idle_j = idle.map(|(nj, s)| nj as f64 / 1e9 * wall / s);
let joules = match (raw, idle_j) {
(Some(r), Some(i)) => Some(r - i),
(r, _) => r,
};
let gb = bytes as f64 / 1e9;
let out = serde_json::json!({
"proto": proto,
"encrypt": encrypt,
"seconds": seconds,
"target_mbps": if max { serde_json::Value::Null } else { mbps.into() },
"sent": sent,
"delivered": got,
"delivered_mbps": bytes as f64 * 8.0 / 1e6 / (seconds as f64),
"wall_s": wall,
"cpu_s": (c1 - c0) as f64 / 1e6,
"loss_ppm": loss,
"proxy": proxy.as_ref().map(|p| serde_json::json!({"up_dropped": p.up.dropped.load(std::sync::atomic::Ordering::Relaxed), "down_dropped": p.down.dropped.load(std::sync::atomic::Ordering::Relaxed)})),
"account": part,
"sender": sent_counts,
"rtt_us": rx.rtt(),
"energy": e1.as_ref().map(|e| serde_json::json!({
"profile": e.profile, "class": e.class, "scope": e.scope,
"joules": joules, "j_per_gb": joules.map(|j| if gb > 0.0 { j / gb } else { 0.0 }),
"raw_joules": raw, "idle_joules": idle_j,
"covers": if e.scope == "task" { "both ends of the link, one process" } else { "the package, less an idle bracket of the same length: both ends of the link and whatever else the package ran beyond its idle" },
})),
});
println!("{out}");
ExitCode::SUCCESS
}
#[allow(dead_code)]
enum Keep {
Srt(srt::Listener),
#[cfg(feature = "rist")]
Rist(joule_contrib::rist::RistListener),
}

View file

@ -0,0 +1,93 @@
//! `joule-contrib-recv`: receive a transport stream over SRT or RIST into a file.
//!
//! ```text
//! joule-contrib-recv <url> [--out FILE.ts] [--seconds N]
//! srt://:PORT?passphrase=P&latency=MS listen for a caller (srt://HOST:PORT calls)
//! rist://:PORT?profile=simple|main&cname=C&secret=P&buffer=MS
//! ```
//!
//! It writes every payload delivered, in order, and prints the link's account as one JSON
//! line when the link ends or the time is up.
use joule_contrib::cli::{self, Url};
use joule_contrib::srt::Role;
use std::io::Write;
use std::process::ExitCode;
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(url) = args.first() else {
eprintln!("usage: joule-contrib-recv <srt://…|rist://…> [--out FILE.ts] [--seconds N]");
return ExitCode::from(2);
};
let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
let u = match Url::parse(url) {
Ok(u) => u,
Err(e) => {
eprintln!("joule-contrib-recv: {e}");
return ExitCode::from(2);
}
};
let seconds: u64 = opt("--seconds").and_then(|s| s.parse().ok()).unwrap_or(3600);
let link = match u.scheme.as_str() {
"srt" => cli::srt_link(&u, Role::Receive).await,
#[cfg(feature = "rist")]
"rist" => rist_link(&u).await,
s => Err(format!("no receiver for {s}://")),
};
let mut link = match link {
Ok(l) => l,
Err(e) => {
eprintln!("joule-contrib-recv: {e}");
return ExitCode::FAILURE;
}
};
let mut out = opt("--out").map(|p| std::fs::File::create(p).expect("the output file opens"));
let deadline = Instant::now() + Duration::from_secs(seconds);
let mut bytes = 0u64;
loop {
let left = deadline.saturating_duration_since(Instant::now());
match tokio::time::timeout(left, link.data.recv()).await {
Ok(Some(p)) => {
bytes += p.len() as u64;
if let Some(f) = out.as_mut() {
let _ = f.write_all(&p);
}
}
Ok(None) | Err(_) => break,
}
}
link.close();
let _ = tokio::time::timeout(Duration::from_secs(2), link.wait_closed()).await;
while let Ok(p) = link.data.try_recv() {
bytes += p.len() as u64;
if let Some(f) = out.as_mut() {
let _ = f.write_all(&p);
}
}
let part = link.account.lock().unwrap().cut(0, true);
println!("{}", serde_json::json!({"protocol": link.info.protocol.name(), "bytes": bytes, "rtt_us": link.rtt(), "closed": link.closed_reason(), "account": part}));
ExitCode::SUCCESS
}
#[cfg(feature = "rist")]
async fn rist_link(u: &Url) -> Result<joule_contrib::link::Link, String> {
use joule_contrib::rist::{recv::RecvConfig, Profile, RistListener, RistListenerConfig};
let addr = u.addr()?;
let cfg = RistListenerConfig {
profile: if u.get("profile") == Some("main") { Profile::Main } else { Profile::Simple },
bind_ip: addr.ip(),
port: addr.port(),
recv: RecvConfig { buffer_us: u.num::<u64>("buffer", 1000) * 1000, ..RecvConfig::default() },
secret: u.get("secret").map(str::to_string),
expect_cname: u.get("cname").map(str::to_string),
tag: 0,
};
let mut l = RistListener::bind(cfg).await.map_err(|e| e.to_string())?;
eprintln!("listening on rist://{}", l.addr);
let link = l.accept().await.ok_or("the receiver stopped")?;
std::mem::forget(l);
Ok(link)
}

View file

@ -0,0 +1,84 @@
//! `joule-contrib-send`: send a transport stream over SRT or RIST.
//!
//! ```text
//! joule-contrib-send <url> [--input FILE.ts | --synthetic SECONDS [--kbps N]] [--linger MS]
//! srt://HOST:PORT?streamid=S&passphrase=P&pbkeylen=16&latency=MS call a listener
//! srt://:PORT?passphrase=P listen for a caller
//! rist://HOST:PORT?profile=simple|main&cname=C&secret=P&aes-type=128|256&buffer=MS&gre=vsf|legacy
//! ```
//!
//! A file is sent at the pace its PCR gives, seven TS packets a datagram; `--synthetic`
//! makes a program of H.264-shaped access units and AAC frames instead. It prints what it
//! sent as one JSON line.
use joule_contrib::cli::{self, Url};
use joule_contrib::srt::Role;
use std::process::ExitCode;
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(url) = args.first() else {
eprintln!("usage: joule-contrib-send <srt://…|rist://…> [--input FILE.ts | --synthetic SECONDS [--kbps N]] [--linger MS]");
return ExitCode::from(2);
};
let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
let u = match Url::parse(url) {
Ok(u) => u,
Err(e) => {
eprintln!("joule-contrib-send: {e}");
return ExitCode::from(2);
}
};
let chunks = match (opt("--input"), opt("--synthetic")) {
(Some(f), _) => match cli::from_file(&f) {
Ok(c) => c,
Err(e) => {
eprintln!("joule-contrib-send: {e}");
return ExitCode::FAILURE;
}
},
(None, s) => cli::synthetic(s.and_then(|s| s.parse().ok()).unwrap_or(5), opt("--kbps").and_then(|k| k.parse().ok()).unwrap_or(4000)),
};
let linger: u64 = opt("--linger").and_then(|v| v.parse().ok()).unwrap_or(1500);
let link = match u.scheme.as_str() {
"srt" => cli::srt_link(&u, Role::Send).await,
#[cfg(feature = "rist")]
"rist" => rist_link(&u).await,
s => Err(format!("no sender for {s}://")),
};
let mut link = match link {
Ok(l) => l,
Err(e) => {
eprintln!("joule-contrib-send: {e}");
return ExitCode::FAILURE;
}
};
let r = cli::pace(&link, &chunks, linger).await;
link.close();
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), link.wait_closed()).await;
let sent = link.sent.lock().unwrap().clone();
println!("{}", serde_json::json!({"protocol": link.info.protocol.name(), "chunks": chunks.len(), "sent": sent, "rtt_us": link.rtt()}));
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("joule-contrib-send: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(feature = "rist")]
async fn rist_link(u: &Url) -> Result<joule_contrib::link::Link, String> {
use joule_contrib::rist::{self, Profile, SendConfig};
let cfg = SendConfig {
profile: if u.get("profile") == Some("main") { Profile::Main } else { Profile::Simple },
cname: u.get("cname").unwrap_or("wai-contrib").to_string(),
secret: u.get("secret").map(str::to_string),
aes_256: u.get("aes-type") == Some("256"),
buffer_ms: u.num("buffer", 1000),
idle_ms: 10_000,
vsf: u.get("gre") != Some("legacy"),
};
rist::send(u.addr()?, cfg).await.map_err(|e| e.to_string())
}

View file

@ -0,0 +1,178 @@
//! What the command-line tools share: their URLs, their sources of transport stream, and
//! links opened from a URL.
//!
//! ```text
//! srt://HOST:PORT[?streamid=S&passphrase=P&pbkeylen=16|24|32&latency=MS&mode=caller|listener]
//! rist://HOST:PORT[?profile=simple|main&cname=C&secret=P&aes-type=128|256&buffer=MS]
//! ```
//!
//! An SRT URL calls by default; `mode=listener` (or an empty host, `srt://:PORT`) listens.
use crate::link::Link;
use crate::srt::{self, Admitted, CallConfig, ListenerConfig, Role};
use crate::ts::{Mux, Pacer, ST_H264};
use std::collections::BTreeMap;
use std::net::{SocketAddr, ToSocketAddrs};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct Url {
pub scheme: String,
pub host: String,
pub port: u16,
pub q: BTreeMap<String, String>,
}
impl Url {
pub fn parse(s: &str) -> Result<Url, String> {
let (scheme, rest) = s.split_once("://").ok_or("a URL is scheme://host:port")?;
let (hp, q) = rest.split_once('?').unwrap_or((rest, ""));
let (host, port) = hp.rsplit_once(':').ok_or("a URL names a port")?;
let port: u16 = port.trim_end_matches('/').parse().map_err(|_| "the port is a number")?;
let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
let mut m = BTreeMap::new();
for kv in q.split('&').filter(|x| !x.is_empty()) {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
m.insert(k.to_string(), percent_decode(v));
}
Ok(Url { scheme: scheme.to_ascii_lowercase(), host, port, q: m })
}
pub fn addr(&self) -> Result<SocketAddr, String> {
let h = if self.host.is_empty() { "0.0.0.0" } else { &self.host };
(h, self.port).to_socket_addrs().map_err(|e| e.to_string())?.next().ok_or_else(|| "the host does not resolve".into())
}
pub fn get(&self, k: &str) -> Option<&str> {
self.q.get(k).map(String::as_str)
}
pub fn num<T: std::str::FromStr>(&self, k: &str, d: T) -> T {
self.get(k).and_then(|v| v.parse().ok()).unwrap_or(d)
}
pub fn listens(&self) -> bool {
self.get("mode") == Some("listener") || self.host.is_empty()
}
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut o = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or(""), 16)
{
o.push(v);
i += 3;
} else {
o.push(b[i]);
i += 1;
}
}
String::from_utf8_lossy(&o).into_owned()
}
/// Open an SRT link from `u` in role `role`: call, or listen and take the first caller.
pub async fn srt_link(u: &Url, role: Role) -> Result<Link, String> {
let latency_ms: u16 = u.num("latency", 120);
let passphrase = u.get("passphrase").map(str::to_string);
let keylen: usize = u.num("pbkeylen", 16);
if u.listens() {
let p2 = passphrase.clone();
let adm = move |_: &srt::ConnRequest| Ok(Admitted { passphrase: p2.clone(), role, latency_ms: None, tag: 0 });
let cfg = ListenerConfig { bind: u.addr()?, latency_ms, advertise_keylen: if passphrase.is_some() { keylen } else { 0 }, ..ListenerConfig::default() };
let mut l = srt::Listener::bind(cfg, Arc::new(adm)).await.map_err(|e| e.to_string())?;
eprintln!("listening on srt://{}", l.addr);
let link = l.accept().await.ok_or("the listener stopped")?;
// The listener keeps serving the link's packets until it ends.
std::mem::forget(l);
Ok(link)
} else {
let cfg = CallConfig { latency_ms, passphrase, keylen, stream_id: u.get("streamid").map(str::to_string), role, ..CallConfig::default() };
srt::call(u.addr()?, cfg).await.map_err(|e| e.to_string())
}
}
/// A synthetic program for `seconds`: one H.264-shaped access unit every 1/30 s (a
/// keyframe each second) and two ADTS frames of AAC every 1/25 s, multiplexed and timed.
/// The bytes are not decodable media; they have the shape of it.
pub fn synthetic(seconds: u64, kbps: u64) -> Vec<(u64, Vec<u8>)> {
let mut m = Mux::new(ST_H264);
let mut ts = Vec::new();
let frames = seconds * 30;
let per_frame = (kbps * 1000 / 8 / 30).max(200) as usize;
let mut a = 0u64;
for i in 0..frames {
let key = i % 30 == 0;
let mut au = vec![0, 0, 0, 1, 0x09, 0xf0];
if key {
au.extend_from_slice(&[0, 0, 0, 1, 0x67, 0x64, 0x00, 0x28, 0xac, 0, 0, 0, 1, 0x68, 0xee, 0x3c, 0x80, 0, 0, 0, 1, 0x65]);
} else {
au.extend_from_slice(&[0, 0, 0, 1, 0x41]);
}
let n = if key { per_frame * 3 } else { per_frame };
au.extend((0..n).map(|j| ((i as usize * 131 + j * 7) % 251 + 1) as u8));
let pts = 126_000 + i * 3000;
ts.extend(m.pes(true, pts, &au, key));
// Audio up to this frame's time: two frames of 1024 samples at 48 kHz a PES, 3840
// ticks of 90 kHz.
while a * 3840 <= i * 3000 {
let mut f = Vec::new();
for k in 0..2 {
let body: Vec<u8> = (0..160).map(|j| ((a * 2 + k + j) % 200 + 9) as u8).collect();
let len = body.len() + 7;
f.extend_from_slice(&[0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) as u8 & 0x3), (len >> 3) as u8, ((len as u8 & 0x7) << 5) | 0x1f, 0xfc]);
f.extend(body);
}
ts.extend(m.pes(false, 126_000 + a * 3840, &f, false));
a += 1;
}
}
Pacer::new(&ts, 7).chunks().to_vec()
}
/// A transport stream file's chunks of seven packets, timed by its PCR.
pub fn from_file(path: &str) -> Result<Vec<(u64, Vec<u8>)>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
Ok(Pacer::new(&b, 7).chunks().to_vec())
}
/// Send `chunks` on `link`, each at its time from now; then wait `linger_ms` and close.
pub async fn pace(link: &Link, chunks: &[(u64, Vec<u8>)], linger_ms: u64) -> Result<(), String> {
let tx = link.send.clone().ok_or("the link does not send")?;
let start = tokio::time::Instant::now();
for (at, c) in chunks {
tokio::time::sleep_until(start + std::time::Duration::from_micros(*at)).await;
if tx.send(c.clone()).await.is_err() {
return Err(link.closed_reason().unwrap_or_else(|| "the link ended".into()));
}
}
tokio::time::sleep(std::time::Duration::from_millis(linger_ms)).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_read() {
let u = Url::parse("srt://127.0.0.1:9000?streamid=%23!%3A%3Ar%3Dx,m%3Dpublish&passphrase=0123456789&latency=200").unwrap();
assert_eq!((u.scheme.as_str(), u.port), ("srt", 9000));
assert_eq!(u.get("streamid"), Some("#!::r=x,m=publish"));
assert_eq!(u.num::<u16>("latency", 120), 200);
assert!(!u.listens());
assert!(Url::parse("srt://:9000").unwrap().listens());
assert!(Url::parse("rist://[::1]:5000?profile=main").unwrap().addr().unwrap().is_ipv6());
}
#[test]
fn a_synthetic_stream_is_timed() {
let c = synthetic(2, 2000);
assert!(c.len() > 100);
assert!(c.last().unwrap().0 > 1_800_000);
}
}

View file

@ -0,0 +1,225 @@
//! The cryptography both transports use, over RustCrypto's AES, CTR, HMAC, SHA-1 and
//! SHA-256:
//!
//! - **PBKDF2** (RFC 8018 §5.2) with HMAC-SHA1 (SRT's key-encrypting key,
//! `draft-sharabayko-srt-01` §6.1.4: 2048 iterations over the last 64 bits of the salt) and
//! HMAC-SHA256 (RIST's PSK key, VSF TR-06-2 §7.3: 1024 iterations over the GRE nonce).
//! - **AES key wrap** (RFC 3394 §2.2.1, §2.2.2, with the default initial value A6A6A6A6A6A6A6A6):
//! SRT's stream-encrypting keys travel wrapped under the key-encrypting key.
//! - **AES-CTR** (SP 800-38A §6.5) with a 128-bit big-endian counter: SRT's payload
//! (§6.2.2) and RIST's GRE payload (§7.2).
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, KeyIvInit, StreamCipher};
use hmac::{Hmac, Mac};
/// PBKDF2 (RFC 8018 §5.2) with the HMAC `$mac`: `len` bytes from `password`, `salt` and
/// `iterations`.
macro_rules! pbkdf2 {
($name:ident, $mac:ty) => {
pub fn $name(password: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec<u8> {
let prf = <$mac as Mac>::new_from_slice(password).expect("HMAC takes a key of any length");
let mut out = Vec::with_capacity(len);
let mut block = 1u32;
while out.len() < len {
let mut m = prf.clone();
m.update(salt);
m.update(&block.to_be_bytes());
let mut u = m.finalize().into_bytes();
let mut t = u;
for _ in 1..iterations.max(1) {
let mut m = prf.clone();
m.update(&u);
u = m.finalize().into_bytes();
for (a, b) in t.iter_mut().zip(u.iter()) {
*a ^= b;
}
}
out.extend_from_slice(&t);
block += 1;
}
out.truncate(len);
out
}
};
}
pbkdf2!(pbkdf2_hmac_sha1, Hmac<sha1::Sha1>);
pbkdf2!(pbkdf2_hmac_sha256, Hmac<sha2::Sha256>);
/// An AES key of 128, 192 or 256 bits, expanded.
#[derive(Clone)]
pub enum AesKey {
K128(aes::Aes128),
K192(aes::Aes192),
K256(aes::Aes256),
}
impl AesKey {
/// `None` unless `key` is 16, 24 or 32 bytes.
pub fn new(key: &[u8]) -> Option<AesKey> {
Some(match key.len() {
16 => AesKey::K128(aes::Aes128::new_from_slice(key).ok()?),
24 => AesKey::K192(aes::Aes192::new_from_slice(key).ok()?),
32 => AesKey::K256(aes::Aes256::new_from_slice(key).ok()?),
_ => return None,
})
}
fn encrypt(&self, b: &mut [u8; 16]) {
let g = aes::Block::from_mut_slice(b);
match self {
AesKey::K128(k) => k.encrypt_block(g),
AesKey::K192(k) => k.encrypt_block(g),
AesKey::K256(k) => k.encrypt_block(g),
}
}
fn decrypt(&self, b: &mut [u8; 16]) {
let g = aes::Block::from_mut_slice(b);
match self {
AesKey::K128(k) => k.decrypt_block(g),
AesKey::K192(k) => k.decrypt_block(g),
AesKey::K256(k) => k.decrypt_block(g),
}
}
}
/// RFC 3394's default initial value.
pub const KW_IV: [u8; 8] = [0xa6; 8];
/// AES key wrap (RFC 3394 §2.2.1) of `plain` (a multiple of 8 bytes, at least 16) under
/// `kek`: 8 bytes longer than `plain`.
pub fn key_wrap(kek: &AesKey, plain: &[u8]) -> Option<Vec<u8>> {
if plain.len() < 16 || !plain.len().is_multiple_of(8) {
return None;
}
let n = plain.len() / 8;
let mut a = KW_IV;
let mut r: Vec<[u8; 8]> = plain.chunks(8).map(|c| c.try_into().unwrap()).collect();
for j in 0..6u64 {
for (i, ri) in r.iter_mut().enumerate() {
let mut b = [0u8; 16];
b[..8].copy_from_slice(&a);
b[8..].copy_from_slice(ri);
kek.encrypt(&mut b);
let t = (n as u64) * j + i as u64 + 1;
a.copy_from_slice(&b[..8]);
for (x, y) in a.iter_mut().zip(t.to_be_bytes()) {
*x ^= y;
}
ri.copy_from_slice(&b[8..]);
}
}
let mut out = a.to_vec();
for ri in r {
out.extend_from_slice(&ri);
}
Some(out)
}
/// AES key unwrap (RFC 3394 §2.2.2): `None` when `wrapped` is not a wrap of the right
/// shape or its integrity check fails, that is, when `kek` is not the key it was wrapped
/// under.
pub fn key_unwrap(kek: &AesKey, wrapped: &[u8]) -> Option<Vec<u8>> {
if wrapped.len() < 24 || !wrapped.len().is_multiple_of(8) {
return None;
}
let n = wrapped.len() / 8 - 1;
let mut a: [u8; 8] = wrapped[..8].try_into().unwrap();
let mut r: Vec<[u8; 8]> = wrapped[8..].chunks(8).map(|c| c.try_into().unwrap()).collect();
for j in (0..6u64).rev() {
for i in (0..n).rev() {
let t = (n as u64) * j + i as u64 + 1;
let mut b = [0u8; 16];
for (k, y) in t.to_be_bytes().iter().enumerate() {
b[k] = a[k] ^ y;
}
b[8..].copy_from_slice(&r[i]);
kek.decrypt(&mut b);
a.copy_from_slice(&b[..8]);
r[i].copy_from_slice(&b[8..]);
}
}
// The check compares every byte, so it takes the same time whichever byte differs.
if a.iter().zip(KW_IV.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) != 0 {
return None;
}
Some(r.concat())
}
/// AES-CTR (SP 800-38A §6.5) in place over `data`, from the counter block `iv`, the
/// counter the whole 128-bit block incremented big-endian.
pub fn ctr_apply(key: &[u8], iv: &[u8; 16], data: &mut [u8]) -> bool {
match key.len() {
16 => ctr::Ctr128BE::<aes::Aes128>::new(key.into(), iv.into()).apply_keystream(data),
24 => ctr::Ctr128BE::<aes::Aes192>::new(key.into(), iv.into()).apply_keystream(data),
32 => ctr::Ctr128BE::<aes::Aes256>::new(key.into(), iv.into()).apply_keystream(data),
_ => return false,
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn h(s: &str) -> Vec<u8> {
hex::decode(s.replace(' ', "")).unwrap()
}
#[test]
fn pbkdf2_sha1_rfc6070() {
assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 1, 20), h("0c60c80f961f0e71f3a9b524af6012062fe037a6"));
assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 2, 20), h("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"));
assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 4096, 20), h("4b007901b765489abead49d926f721d065a429c1"));
assert_eq!(
pbkdf2_hmac_sha1(b"passwordPASSWORDpassword", b"saltSALTsaltSALTsaltSALTsaltSALTsalt", 4096, 25),
h("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038")
);
}
#[test]
fn pbkdf2_sha256_tr06_2_annex_b() {
let k = pbkdf2_hmac_sha256(b"Reliable Internet Stream Transport", &[0x52, 0x49, 0x53, 0x54], 1024, 32);
assert_eq!(k, h("1c2b0cfc90ae2638fea78c7fb297704718bff7f4052743001a9b7ebb51cc9f1c"));
assert_eq!(&k[..16], &h("1c2b0cfc90ae2638fea78c7fb2977047")[..]);
}
#[test]
fn key_wrap_rfc3394() {
// §4.1: a 128-bit key under a 128-bit KEK.
let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F")).unwrap();
let w = key_wrap(&kek, &h("00112233445566778899AABBCCDDEEFF")).unwrap();
assert_eq!(w, h("1FA68B0A8112B447 AEF34BD8FB5A7B82 9D3E862371D2CFE5"));
assert_eq!(key_unwrap(&kek, &w).unwrap(), h("00112233445566778899AABBCCDDEEFF"));
// §4.6: a 256-bit key under a 256-bit KEK.
let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")).unwrap();
let key = h("00112233445566778899AABBCCDDEEFF000102030405060708090A0B0C0D0E0F");
let w = key_wrap(&kek, &key).unwrap();
assert_eq!(w, h("28C9F404C4B810F4 CBCCB35CFB87F826 3F5786E2D80ED326 CBC7F0E71A99F43B FB988B9B7A02DD21"));
assert_eq!(key_unwrap(&kek, &w).unwrap(), key);
// §4.2: a 128-bit key under a 192-bit KEK.
let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F1011121314151617")).unwrap();
assert_eq!(key_wrap(&kek, &h("00112233445566778899AABBCCDDEEFF")).unwrap(), h("96778B25AE6CA435 F92B5B97C050AED2 468AB8A17AD84E5D"));
}
#[test]
fn unwrap_under_another_key_fails() {
let kek = AesKey::new(&[1; 16]).unwrap();
let w = key_wrap(&kek, &[7; 16]).unwrap();
assert!(key_unwrap(&AesKey::new(&[2; 16]).unwrap(), &w).is_none());
let mut bad = w.clone();
bad[9] ^= 1;
assert!(key_unwrap(&kek, &bad).is_none());
}
#[test]
fn ctr_sp800_38a_f5_1() {
// SP 800-38A F.5.1, CTR-AES128.Encrypt, the first two blocks.
let key = h("2b7e151628aed2a6abf7158809cf4f3c");
let iv: [u8; 16] = h("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff").try_into().unwrap();
let mut d = h("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51");
assert!(ctr_apply(&key, &iv, &mut d));
assert_eq!(d, h("874d6191b620e3261bef6864990db6ce9806f66b7970fdff8617187bb9fffdff"));
}
}

View file

@ -0,0 +1,121 @@
//! The energy counters the benches read, under the names of WAI's sink energy profiles
//! (PR #460, `energy-measurement` §9 there): what a reading covers is its profile's, and a
//! figure is never stated without its class and scope.
//!
//! - `macos-task`: `proc_pid_rusage` at `RUSAGE_INFO_V6` for this process,
//! `ri_energy_nj + ri_billed_energy − ri_serviced_energy`: the operating system's account of
//! the process's own energy (scope `task`), an on-chip counter.
//! - `linux-rapl`: the package zones of the powercap interface, summed: every user of the
//! package shares them, so a figure is a bracket (scope `bracket`), the run's reading less
//! an idle reading of the same length taken before it.
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct Reading {
pub profile: &'static str,
pub scope: &'static str,
pub class: &'static str,
pub nj: u64,
/// Where the counter wraps, nJ, if it does.
pub wrap_nj: Option<u64>,
}
impl Reading {
/// The energy from `self` to `later`, across one wrap of the counter.
pub fn to(&self, later: &Reading) -> u64 {
match (later.nj >= self.nj, self.wrap_nj) {
(true, _) => later.nj - self.nj,
(false, Some(w)) => w - self.nj + later.nj,
(false, None) => 0,
}
}
}
#[cfg(target_os = "macos")]
mod mac {
use std::ffi::{c_int, c_void};
unsafe extern "C" {
fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int;
fn getpid() -> c_int;
}
const RUSAGE_INFO_V6: c_int = 6;
// Byte offsets in `struct rusage_info_v6`.
const BILLED: usize = 264;
const SERVICED: usize = 272;
const ENERGY: usize = 336;
pub fn task_nj() -> Option<u64> {
let mut buf = [0u64; 128];
// SAFETY: the buffer is larger than `struct rusage_info_v6`.
let r = unsafe { proc_pid_rusage(getpid(), RUSAGE_INFO_V6, buf.as_mut_ptr().cast()) };
if r != 0 {
return None;
}
let at = |off: usize| buf[off / 8];
Some((at(ENERGY) + at(BILLED)).saturating_sub(at(SERVICED)))
}
}
/// The package zones of the powercap interface, in µJ, summed, and where the sum wraps;
/// `None` where none is readable (the files are root-only on most kernels). Only the first
/// zone's wrap is kept: one package is what the hosts here have.
#[cfg(target_os = "linux")]
fn rapl_uj() -> Option<(u64, Option<u64>)> {
let mut total = 0u64;
let mut wrap = None;
let mut any = false;
for i in 0..8 {
let p = format!("/sys/class/powercap/intel-rapl:{i}/energy_uj");
if let Ok(s) = std::fs::read_to_string(&p)
&& let Ok(v) = s.trim().parse::<u64>()
{
total += v;
any = true;
if wrap.is_none() {
wrap = std::fs::read_to_string(format!("/sys/class/powercap/intel-rapl:{i}/max_energy_range_uj")).ok().and_then(|s| s.trim().parse::<u64>().ok());
}
}
}
any.then_some((total, wrap))
}
/// One reading of the best counter this host has for this process.
pub fn read() -> Option<Reading> {
#[cfg(target_os = "macos")]
{
mac::task_nj().map(|nj| Reading { profile: "macos-task", scope: "task", class: "OnChipCounter", nj, wrap_nj: None })
}
#[cfg(target_os = "linux")]
{
rapl_uj().map(|(uj, w)| Reading { profile: "linux-rapl", scope: "bracket", class: "OnChipCounter", nj: uj * 1000, wrap_nj: w.map(|w| w * 1000) })
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
None
}
}
/// The process's CPU time so far, user and system, µs.
pub fn cpu_us() -> u64 {
#[repr(C)]
struct Timeval {
sec: i64,
usec: i64,
}
#[repr(C)]
struct Rusage {
utime: Timeval,
stime: Timeval,
rest: [i64; 14],
}
unsafe extern "C" {
fn getrusage(who: i32, usage: *mut Rusage) -> i32;
}
let mut r = Rusage { utime: Timeval { sec: 0, usec: 0 }, stime: Timeval { sec: 0, usec: 0 }, rest: [0; 14] };
// SAFETY: `Rusage` has the layout of `struct rusage` on 64-bit Linux and macOS.
if unsafe { getrusage(0, &mut r) } != 0 {
return 0;
}
(r.utime.sec * 1_000_000 + r.utime.usec + r.stime.sec * 1_000_000 + r.stime.usec).max(0) as u64
}

View file

@ -0,0 +1,61 @@
//! **joule-contrib**: contribution over UDP with retransmission.
//!
//! - **SRT** ([`srt`], `draft-sharabayko-srt-01`): a listener that demultiplexes many
//! callers on one UDP port by socket ID, and a caller; the caller-listener handshake
//! with its SYN cookie, the HSv5 extensions (`HSREQ`/`HSRSP`, the stream ID, the key
//! material), live mode: timestamp-based packet delivery (TSBPD) over the negotiated
//! latency window, too-late packet drop, ACK/ACKACK with the RTT estimate, immediate
//! and periodic NAKs and retransmission, and AES-CTR with the key wrapped under a
//! passphrase-derived key (PBKDF2-HMAC-SHA1, RFC 3394).
//! - **RIST** ([`rist`], feature `rist`; VSF TR-06-1:2020 and TR-06-2:2024): the Simple
//! Profile's RTP on port P and RTCP on P+1 with sender and receiver reports, CNAME,
//! bitmask (RFC 4585) and range retransmission requests, retransmissions marked by the
//! SSRC's low bit and the RTT echo; and the Main Profile's GRE-over-UDP tunnel (RFC
//! 8086) on one port in reduced overhead mode with the VSF EtherType, its keep-alive,
//! and PSK encryption (PBKDF2-HMAC-SHA256 over the GRE nonce, AES-CTR on the GRE
//! sequence number).
//! - **MPEG-TS** ([`ts`], ISO/IEC 13818-1): the payload both carry. A demultiplexer that
//! reads the PAT and PMT and hands over each PES's access unit with its PTS extended past
//! 33 bits, and a multiplexer and a PCR pacer for the senders and the tests.
//! - **The account** ([`stats`]): every sequence number a receiver passes is either
//! delivered (at the first try, or recovered after it was missed) or dropped when its
//! delivery time passed, or still outstanding when the link ended; with the retransmission
//! requests, the RTT samples and how long each recovery took. The gateway signs it as a
//! link record.
//! - **An impaired path** ([`lossy`]): a UDP proxy that drops, delays and reorders
//! datagrams from a fixed seed, for the tests and the benches.
//!
//! The crypto is RustCrypto's (AES on the CPU's AES instructions where present, CTR,
//! HMAC, SHA-1, SHA-256); PBKDF2 and AES key wrap are written in [`crypto`] over them.
pub mod cli;
pub mod crypto;
pub mod energy;
pub mod link;
pub mod lossy;
pub mod seq;
pub mod srt;
pub mod stats;
pub mod ts;
#[cfg(feature = "rist")]
pub mod rist;
/// Microseconds on a monotonic clock shared by a connection's parts.
pub fn micros(since: std::time::Instant) -> u64 {
since.elapsed().as_micros() as u64
}
/// A random 32-bit value from the operating system's generator.
pub fn random_u32() -> u32 {
use rand_core_06::RngCore;
rand_core_06::OsRng.next_u32()
}
/// `n` random bytes from the operating system's generator.
pub fn random_bytes(n: usize) -> Vec<u8> {
use rand_core_06::RngCore;
let mut b = vec![0u8; n];
rand_core_06::OsRng.fill_bytes(&mut b);
b
}

View file

@ -0,0 +1,163 @@
//! A link: one established SRT connection or RIST flow, as the application holds it.
//!
//! A receiving link hands over its payloads in sequence order at their delivery time on
//! [`Link::data`] and keeps its [`Account`]; a sending link takes payloads on
//! [`Link::send`]. Either ends with a reason on [`Link::closed`].
use crate::stats::Account;
use serde::Serialize;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, watch};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum Protocol {
Srt,
RistSimple,
RistMain,
}
impl Protocol {
pub fn code(self) -> u8 {
match self {
Protocol::Srt => 1,
Protocol::RistSimple => 2,
Protocol::RistMain => 3,
}
}
pub fn name(self) -> &'static str {
match self {
Protocol::Srt => "srt",
Protocol::RistSimple => "rist-simple",
Protocol::RistMain => "rist-main",
}
}
}
/// Which end opened the link: the local end listened, or it called.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum Mode {
Listener,
Caller,
}
impl Mode {
pub fn code(self) -> u8 {
match self {
Mode::Listener => 1,
Mode::Caller => 2,
}
}
}
/// What the link is.
#[derive(Clone, Debug, Serialize)]
pub struct LinkInfo {
pub protocol: Protocol,
pub mode: Mode,
pub peer: SocketAddr,
/// SRT: the caller's stream ID.
pub stream_id: Option<String>,
/// RIST: the sender's RTCP CNAME.
pub cname: Option<String>,
/// The receive window: SRT's negotiated TSBPD latency, RIST's receiver buffer.
pub latency_us: u64,
/// The AES key length in bits; 0 for a link in the clear.
pub cipher_bits: u16,
/// What the application's admission gave the link.
pub tag: u64,
}
/// What a sending link counted.
#[derive(Clone, Debug, Default, Serialize)]
pub struct SendCounts {
pub sent: u64,
pub bytes: u64,
pub retransmitted: u64,
pub requests: u64,
pub requested: u64,
/// Packets dropped from the send buffer before they were acknowledged, too old to be
/// of use.
pub expired: u64,
}
pub enum Cmd {
Close,
}
/// One link.
pub struct Link {
pub info: LinkInfo,
/// A receiving link's payloads, in order, at their delivery time.
pub data: mpsc::Receiver<Vec<u8>>,
/// A sending link's payloads to send, one packet each.
pub send: Option<mpsc::Sender<Vec<u8>>>,
pub account: Arc<Mutex<Account>>,
pub sent: Arc<Mutex<SendCounts>>,
/// The latest round-trip estimate, µs.
pub rtt_us: Arc<AtomicU64>,
pub closed: watch::Receiver<Option<String>>,
pub cmd: mpsc::UnboundedSender<Cmd>,
}
impl Link {
pub fn close(&self) {
let _ = self.cmd.send(Cmd::Close);
}
/// Why the link ended, once it has.
pub fn closed_reason(&self) -> Option<String> {
self.closed.borrow().clone()
}
/// Wait for the link to end; its reason.
pub async fn wait_closed(&mut self) -> String {
loop {
if let Some(r) = self.closed.borrow().clone() {
return r;
}
if self.closed.changed().await.is_err() {
return self.closed.borrow().clone().unwrap_or_else(|| "dropped".into());
}
}
}
pub fn rtt(&self) -> u64 {
self.rtt_us.load(Ordering::Relaxed)
}
}
/// The task side of a link: what its driver holds.
pub struct LinkEnds {
pub data_tx: mpsc::Sender<Vec<u8>>,
pub send_rx: Option<mpsc::Receiver<Vec<u8>>>,
pub account: Arc<Mutex<Account>>,
pub sent: Arc<Mutex<SendCounts>>,
pub rtt_us: Arc<AtomicU64>,
pub closed_tx: watch::Sender<Option<String>>,
pub cmd_rx: mpsc::UnboundedReceiver<Cmd>,
}
/// How many delivered payloads may wait for the application before more are dropped.
pub const DATA_QUEUE: usize = 1 << 16;
/// A link and its task's ends; `sending` for a link that sends.
pub fn pair(info: LinkInfo, sending: bool) -> (Link, LinkEnds) {
let (data_tx, data) = mpsc::channel(DATA_QUEUE);
let (send, send_rx) = if sending {
let (t, r) = mpsc::channel(4096);
(Some(t), Some(r))
} else {
(None, None)
};
let account = Arc::new(Mutex::new(Account::new()));
let sent = Arc::new(Mutex::new(SendCounts::default()));
let rtt_us = Arc::new(AtomicU64::new(0));
let (closed_tx, closed) = watch::channel(None);
let (cmd, cmd_rx) = mpsc::unbounded_channel();
(
Link { info, data, send, account: account.clone(), sent: sent.clone(), rtt_us: rtt_us.clone(), closed, cmd },
LinkEnds { data_tx, send_rx, account, sent, rtt_us, closed_tx, cmd_rx },
)
}

View file

@ -0,0 +1,176 @@
//! An impaired path for the tests and the benches: a UDP proxy between one client and one
//! server that drops, delays and reorders datagrams, each direction on its own, from a fixed
//! seed, so a run's losses are the same every time.
//!
//! The client sends to the proxy's address; the proxy forwards to the server from a socket
//! of its own, and forwards what the server sends back to the last address the client sent
//! from.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
/// How one direction is impaired.
#[derive(Clone, Copy, Debug, Default)]
pub struct Impair {
/// Each datagram is dropped with this probability, in parts per million.
pub loss_ppm: u32,
/// Once a datagram is dropped, each of the next `burst - 1` is dropped too.
pub burst: u32,
/// Each datagram is held this long before it goes on.
pub delay_ms: u64,
/// Each datagram is held, with this probability in parts per million, an extra
/// `reorder_ms`, so later ones overtake it.
pub reorder_ppm: u32,
pub reorder_ms: u64,
/// Drop the datagrams whose index in this direction (from 0) is listed.
pub drop_index: &'static [u64],
/// Drop none of the first `spare_first` at random: a RIST receiver cannot know of a
/// packet lost before the first one it receives.
pub spare_first: u64,
}
/// What the proxy did, per direction.
#[derive(Debug, Default)]
pub struct Counts {
pub forwarded: AtomicU64,
pub dropped: AtomicU64,
}
/// xorshift64*, from a seed: the same impairment on every run.
#[derive(Clone, Debug)]
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn ppm(&mut self, p: u32) -> bool {
p > 0 && (self.next() % 1_000_000) < p as u64
}
}
struct Dir {
imp: Impair,
rng: Rng,
index: u64,
burst_left: u32,
}
impl Dir {
/// Whether to drop the next datagram, and how long to hold it otherwise.
fn decide(&mut self) -> Option<Duration> {
let i = self.index;
self.index += 1;
if self.imp.drop_index.contains(&i) {
return None;
}
if self.burst_left > 0 {
self.burst_left -= 1;
return None;
}
if i >= self.imp.spare_first && self.rng.ppm(self.imp.loss_ppm) {
self.burst_left = self.imp.burst.saturating_sub(1);
return None;
}
let mut d = self.imp.delay_ms;
if self.rng.ppm(self.imp.reorder_ppm) {
d += self.imp.reorder_ms;
}
Some(Duration::from_millis(d))
}
}
/// A running proxy.
pub struct Proxy {
pub addr: SocketAddr,
pub up: Arc<Counts>,
pub down: Arc<Counts>,
task: tokio::task::JoinHandle<()>,
}
impl Proxy {
/// A proxy on loopback forwarding to `server`, impairing client→server datagrams by
/// `up` and server→client ones by `down`, from `seed`.
pub async fn start(server: SocketAddr, up: Impair, down: Impair, seed: u64) -> std::io::Result<Proxy> {
Proxy::start_on("127.0.0.1:0".parse().unwrap(), server, up, down, seed).await
}
/// As [`Proxy::start`], the proxy's own address `bind`.
pub async fn start_on(bind: SocketAddr, server: SocketAddr, up: Impair, down: Impair, seed: u64) -> std::io::Result<Proxy> {
let front = Arc::new(UdpSocket::bind(bind).await?);
let back = Arc::new(UdpSocket::bind("127.0.0.1:0").await?);
back.connect(server).await?;
let addr = front.local_addr()?;
let (cu, cd) = (Arc::new(Counts::default()), Arc::new(Counts::default()));
let (cu2, cd2) = (cu.clone(), cd.clone());
let task = tokio::spawn(async move {
let client: Arc<std::sync::Mutex<Option<SocketAddr>>> = Arc::default();
let mut du = Dir { imp: up, rng: Rng(seed | 1), index: 0, burst_left: 0 };
let mut dd = Dir { imp: down, rng: Rng(seed.rotate_left(17) | 1), index: 0, burst_left: 0 };
let mut a = vec![0u8; 65_536];
let mut b = vec![0u8; 65_536];
loop {
tokio::select! {
r = front.recv_from(&mut a) => {
let Ok((n, from)) = r else { continue };
*client.lock().unwrap() = Some(from);
match du.decide() {
None => { cu2.dropped.fetch_add(1, Ordering::Relaxed); }
Some(d) => {
cu2.forwarded.fetch_add(1, Ordering::Relaxed);
let (sock, data) = (back.clone(), a[..n].to_vec());
if d.is_zero() { let _ = sock.send(&data).await; } else {
tokio::spawn(async move { tokio::time::sleep(d).await; let _ = sock.send(&data).await; });
}
}
}
}
r = back.recv(&mut b) => {
let Ok(n) = r else { continue };
let Some(to) = *client.lock().unwrap() else { continue };
match dd.decide() {
None => { cd2.dropped.fetch_add(1, Ordering::Relaxed); }
Some(d) => {
cd2.forwarded.fetch_add(1, Ordering::Relaxed);
let (sock, data) = (front.clone(), b[..n].to_vec());
if d.is_zero() { let _ = sock.send_to(&data, to).await; } else {
tokio::spawn(async move { tokio::time::sleep(d).await; let _ = sock.send_to(&data, to).await; });
}
}
}
}
}
}
});
Ok(Proxy { addr, up: cu, down: cd, task })
}
pub fn stop(self) {
self.task.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_same_seed_drops_the_same_datagrams() {
let imp = Impair { loss_ppm: 50_000, burst: 2, ..Impair::default() };
let run = |seed: u64| {
let mut d = Dir { imp, rng: Rng(seed | 1), index: 0, burst_left: 0 };
(0..10_000).map(|_| d.decide().is_none()).collect::<Vec<_>>()
};
assert_eq!(run(7), run(7));
let dropped = run(7).iter().filter(|x| **x).count();
assert!((600..1400).contains(&dropped), "about 10% with bursts of 2: {dropped}");
}
}

View file

@ -0,0 +1,521 @@
//! The RIST receiver and sender, and the tasks that drive them.
use super::gre::{self, caps, Inner, KeyCache, Writer};
use super::recv::{RecvConfig, Receiver};
use super::rtp::{is_rtcp, read_compound, Rtcp, Rtp, PT_MP2T};
use super::send::{Out, Sender};
use crate::link::{pair, Cmd, Link, LinkEnds, LinkInfo, Mode, Protocol};
use std::collections::VecDeque;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Profile {
Simple,
Main,
}
/// The virtual ports a Main Profile sender's flow uses inside the tunnel: RTP from an even
/// port to an even one, RTCP each one above.
pub const VIRT_SRC: u16 = 1972;
pub const VIRT_DST: u16 = 1968;
const KEEPALIVE_EVERY: Duration = Duration::from_secs(1);
/// What a receiver holds of a flow before it knows the flow is the one it expects.
const PENDING: usize = 4096;
/// The MAC a keep-alive names (§5.6.3): a locally administered address, random per run.
fn mac() -> [u8; 6] {
let r = crate::random_bytes(6);
[0x02, r[1], r[2], r[3], r[4], r[5]]
}
#[derive(Clone, Debug)]
pub struct RistListenerConfig {
pub profile: Profile,
pub bind_ip: IpAddr,
/// P: even for the Simple Profile (RTCP on P + 1). 0 picks a free one.
pub port: u16,
pub recv: RecvConfig,
/// The Main Profile's PSK passphrase; `None` for a tunnel in the clear.
pub secret: Option<String>,
/// The CNAME the flow must give; `None` takes the first flow.
pub expect_cname: Option<String>,
pub tag: u64,
}
/// Why a receiver refused a packet that was not of its flow, counted.
#[derive(Debug, Default)]
pub struct Refusals {
pub cname: AtomicU64,
pub undecryptable: AtomicU64,
pub insecure_iv: AtomicU64,
pub malformed: AtomicU64,
pub other_source: AtomicU64,
}
pub struct RistListener {
pub addr: SocketAddr,
pub incoming: mpsc::Receiver<Link>,
pub refused: Arc<Refusals>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for RistListener {
fn drop(&mut self) {
self.task.abort();
}
}
async fn bind_pair(ip: IpAddr, port: u16) -> std::io::Result<(UdpSocket, UdpSocket)> {
if port != 0 {
if !port.is_multiple_of(2) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the Simple Profile's port P is even (TR-06-1 §5.1.1)"));
}
return Ok((UdpSocket::bind((ip, port)).await?, UdpSocket::bind((ip, port + 1)).await?));
}
for _ in 0..64 {
let a = UdpSocket::bind((ip, 0)).await?;
let p = a.local_addr()?.port();
let p = if p.is_multiple_of(2) { p } else { p + 1 };
if p == 65_534 || p < 1024 {
continue;
}
drop(a);
if let (Ok(a), Ok(b)) = (UdpSocket::bind((ip, p)).await, UdpSocket::bind((ip, p + 1)).await) {
return Ok((a, b));
}
}
Err(std::io::Error::new(std::io::ErrorKind::AddrInUse, "no free even port pair"))
}
impl RistListener {
pub async fn bind(cfg: RistListenerConfig) -> std::io::Result<RistListener> {
let (tx, incoming) = mpsc::channel(4);
let refused = Arc::new(Refusals::default());
let (addr, task) = match cfg.profile {
Profile::Simple => {
let (a, b) = bind_pair(cfg.bind_ip, cfg.port).await?;
let addr = a.local_addr()?;
(addr, tokio::spawn(simple_receiver(a, b, cfg, tx, refused.clone())))
}
Profile::Main => {
let s = UdpSocket::bind((cfg.bind_ip, cfg.port)).await?;
let addr = s.local_addr()?;
(addr, tokio::spawn(main_receiver(s, cfg, tx, refused.clone())))
}
};
Ok(RistListener { addr, incoming, refused, task })
}
pub async fn accept(&mut self) -> Option<Link> {
self.incoming.recv().await
}
}
fn new_flow(cfg: &RistListenerConfig, peer: SocketAddr, cname: Option<String>) -> (Link, LinkEnds, Receiver) {
let info = LinkInfo {
protocol: if cfg.profile == Profile::Simple { Protocol::RistSimple } else { Protocol::RistMain },
mode: Mode::Listener,
peer,
stream_id: None,
cname,
latency_us: cfg.recv.buffer_us,
cipher_bits: 0,
tag: cfg.tag,
};
let (link, ends) = pair(info, false);
let r = Receiver::new(cfg.recv.clone(), ends.account.clone(), ends.rtt_us.clone());
(link, ends, r)
}
/// The SDES CNAME and SSRC of a compound RTCP packet.
fn cname_of(b: &[u8]) -> Option<(u32, String)> {
read_compound(b)?.into_iter().find_map(|p| match p {
Rtcp::Sdes { ssrc, cname: Some(c) } => Some((ssrc, c)),
_ => None,
})
}
/// A data packet this receiver accepts: RTP version 2 of payload type 33 whose payload
/// begins with a transport stream's sync byte.
fn data_packet(b: &[u8]) -> Option<Rtp> {
let p = Rtp::read(b)?;
(p.pt == PT_MP2T && p.payload.first() == Some(&crate::ts::SYNC)).then_some(p)
}
struct Flow {
ip: IpAddr,
rtcp_to: Option<SocketAddr>,
r: Receiver,
ends: LinkEnds,
}
impl Flow {
fn flush(&mut self) -> (Vec<Vec<u8>>, Option<String>) {
for d in std::mem::take(&mut self.r.delivered) {
let _ = self.ends.data_tx.try_send(d);
}
(std::mem::take(&mut self.r.out), self.r.closed.clone())
}
fn end(self, why: String) {
let _ = self.ends.closed_tx.send(Some(why));
}
}
async fn simple_receiver(rtp: UdpSocket, rtcp: UdpSocket, cfg: RistListenerConfig, incoming: mpsc::Sender<Link>, refused: Arc<Refusals>) {
let mut pending: VecDeque<(IpAddr, Rtp)> = VecDeque::new();
let mut flow: Option<Flow> = None;
let (mut a, mut b) = (vec![0u8; 65_536], vec![0u8; 65_536]);
loop {
let wake = flow.as_ref().map(|f| tokio::time::Instant::from_std(f.r.t0() + Duration::from_micros(f.r.next_wake_us())));
tokio::select! {
r = rtp.recv_from(&mut a) => {
let Ok((n, from)) = r else { continue };
let Some(p) = data_packet(&a[..n]) else { refused.malformed.fetch_add(1, Ordering::Relaxed); continue };
match &mut flow {
Some(f) if f.ip == from.ip() => f.r.on_rtp(p),
Some(_) => { refused.other_source.fetch_add(1, Ordering::Relaxed); }
None => {
pending.push_back((from.ip(), p));
if pending.len() > PENDING { pending.pop_front(); }
}
}
}
r = rtcp.recv_from(&mut b) => {
let Ok((n, from)) = r else { continue };
let pkt = &b[..n];
match &mut flow {
Some(f) if f.ip == from.ip() => {
// RTCP goes back to the address of the last valid RTCP (§5.1.1).
if read_compound(pkt).is_some() { f.rtcp_to = Some(from); }
f.r.on_rtcp(pkt);
}
Some(_) => { refused.other_source.fetch_add(1, Ordering::Relaxed); }
None => {
let Some((ssrc, cname)) = cname_of(pkt) else { continue };
if cfg.expect_cname.as_ref().is_some_and(|e| *e != cname) {
refused.cname.fetch_add(1, Ordering::Relaxed);
continue;
}
let (link, ends, mut r) = new_flow(&cfg, from, Some(cname));
r.media = Some(ssrc & !1);
r.on_rtcp(pkt);
for (ip, p) in pending.drain(..) {
if ip == from.ip() && p.ssrc & !1 == ssrc & !1 { r.on_rtp(p); }
}
flow = Some(Flow { ip: from.ip(), rtcp_to: Some(from), r, ends });
if incoming.send(link).await.is_err() { return; }
}
}
}
_ = async { match wake { Some(w) => tokio::time::sleep_until(w).await, None => std::future::pending().await } } => {
if let Some(f) = &mut flow { f.r.on_timer(); }
}
c = async { match &mut flow { Some(f) => f.ends.cmd_rx.recv().await, None => std::future::pending().await } } => {
if let Some(f) = &mut flow && matches!(c, Some(Cmd::Close) | None) { f.r.close("closed"); }
}
}
if let Some(f) = &mut flow {
let (out, closed) = f.flush();
if let Some(to) = f.rtcp_to {
for o in out {
let _ = rtcp.send_to(&o, to).await;
}
}
if let Some(why) = closed {
flow.take().unwrap().end(why);
return;
}
}
}
}
async fn main_receiver(sock: UdpSocket, cfg: RistListenerConfig, incoming: mpsc::Sender<Link>, refused: Arc<Refusals>) {
struct Tunnel {
client: SocketAddr,
w: Writer,
flow: Flow,
link: Option<Link>,
pending: VecDeque<Rtp>,
/// (our virtual port, theirs) for RTCP.
rtcp_ports: Option<(u16, u16)>,
rtp_ports: Option<(u16, u16)>,
last_keepalive: Instant,
}
let mut cache = KeyCache::default();
let mac = mac();
let mut t: Option<Tunnel> = None;
let mut buf = vec![0u8; 65_536];
let mut ka = tokio::time::interval(KEEPALIVE_EVERY);
loop {
let wake = t.as_ref().map(|x| tokio::time::Instant::from_std(x.flow.r.t0() + Duration::from_micros(x.flow.r.next_wake_us())));
tokio::select! {
r = sock.recv_from(&mut buf) => {
let Ok((n, from)) = r else { continue };
if t.as_ref().is_some_and(|x| x.client != from) {
refused.other_source.fetch_add(1, Ordering::Relaxed);
continue;
}
let (h, inner) = match gre::read(&buf[..n], cfg.secret.as_deref(), &mut cache) {
Ok(x) => x,
Err(gre::GreError::InsecureIv) => { refused.insecure_iv.fetch_add(1, Ordering::Relaxed); continue }
Err(gre::GreError::Key) => { refused.undecryptable.fetch_add(1, Ordering::Relaxed); continue }
Err(_) => { refused.malformed.fetch_add(1, Ordering::Relaxed); continue }
};
// A packet that reads after decryption: one of this tunnel's, or the first of
// one.
let valid = match &inner {
Inner::KeepAlive { .. } | Inner::FutureNonce(_) => true,
Inner::Udp { payload, .. } => if is_rtcp(payload) { read_compound(payload).is_some() } else { data_packet(payload).is_some() },
Inner::Other(_) => t.is_some(),
};
if !valid {
refused.undecryptable.fetch_add(1, Ordering::Relaxed);
continue;
}
if t.is_none() {
let (link, ends, r) = new_flow(&cfg, from, None);
let vsf = h.proto == gre::ET_VSF;
let mut x = Tunnel { client: from, w: Writer::new(vsf, cfg.secret.clone().map(|s| (s, h.h))), flow: Flow { ip: from.ip(), rtcp_to: None, r, ends }, link: Some(link), pending: VecDeque::new(), rtcp_ports: None, rtp_ports: None, last_keepalive: Instant::now() };
// The server's keep-alives begin with the client's first message (§5.6.2).
let k = x.w.keepalive(mac, caps::V);
let _ = sock.send_to(&k, from).await;
t = Some(x);
}
let x = t.as_mut().unwrap();
x.flow.r.alive();
match inner {
Inner::Udp { src, dst, payload } => {
if is_rtcp(&payload) {
x.rtcp_ports = Some((dst, src));
x.flow.r.on_rtcp(&payload);
if x.link.is_some() {
let c = x.flow.r.sender_cname.clone();
match (&cfg.expect_cname, c) {
(Some(e), Some(c)) if *e != c => {
// Not the flow this tunnel is for: let it go.
refused.cname.fetch_add(1, Ordering::Relaxed);
t = None;
continue;
}
(Some(_), None) => {}
(_, c) => {
let mut link = x.link.take().unwrap();
link.info.cname = c;
link.info.cipher_bits = if cfg.secret.is_some() { if h.h { 256 } else { 128 } } else { 0 };
for p in x.pending.drain(..) { x.flow.r.on_rtp(p); }
if incoming.send(link).await.is_err() { return; }
}
}
}
} else if let Some(p) = data_packet(&payload) {
x.rtp_ports = Some((dst, src));
if x.link.is_some() {
x.pending.push_back(p);
if x.pending.len() > PENDING { x.pending.pop_front(); }
} else {
x.flow.r.on_rtp(p);
}
}
}
Inner::KeepAlive { caps: c, .. } if c & caps::D != 0 => x.flow.r.close("peer_disconnect"),
_ => {}
}
}
_ = async { match wake { Some(w) => tokio::time::sleep_until(w).await, None => std::future::pending().await } } => {
if let Some(x) = &mut t { x.flow.r.on_timer(); }
}
_ = ka.tick() => {
if let Some(x) = &mut t && x.last_keepalive.elapsed() >= KEEPALIVE_EVERY {
x.last_keepalive = Instant::now();
let k = x.w.keepalive(mac, caps::V);
let _ = sock.send_to(&k, x.client).await;
}
}
c = async { match &mut t { Some(x) => x.flow.ends.cmd_rx.recv().await, None => std::future::pending().await } } => {
if let Some(x) = &mut t && matches!(c, Some(Cmd::Close) | None) { x.flow.r.close("closed"); }
}
}
if let Some(x) = &mut t {
let (out, closed) = x.flow.flush();
let (src, dst) = x.rtcp_ports.or(x.rtp_ports.map(|(a, b)| (a + 1, b + 1))).unwrap_or((VIRT_DST + 1, VIRT_SRC + 1));
for o in out {
let b = x.w.udp(src, dst, &o);
let _ = sock.send_to(&b, x.client).await;
}
if let Some(why) = closed {
let x = t.take().unwrap();
x.flow.end(why);
return;
}
}
}
}
// ── The sender ─────────────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct SendConfig {
pub profile: Profile,
pub cname: String,
/// The Main Profile's PSK passphrase.
pub secret: Option<String>,
pub aes_256: bool,
pub buffer_ms: u64,
pub idle_ms: u64,
/// The Main Profile's packet formats: TR-06-2:2022's VSF EtherType, or TR-06-2:2021's.
pub vsf: bool,
}
impl Default for SendConfig {
fn default() -> Self {
SendConfig { profile: Profile::Simple, cname: "wai-contrib".into(), secret: None, aes_256: false, buffer_ms: 1000, idle_ms: 10_000, vsf: true }
}
}
/// A sending link to the receiver at `remote` (P).
pub async fn send(remote: SocketAddr, cfg: SendConfig) -> std::io::Result<Link> {
let any: SocketAddr = if remote.is_ipv4() { "0.0.0.0:0".parse().unwrap() } else { "[::]:0".parse().unwrap() };
let info = LinkInfo {
protocol: if cfg.profile == Profile::Simple { Protocol::RistSimple } else { Protocol::RistMain },
mode: Mode::Caller,
peer: remote,
stream_id: None,
cname: Some(cfg.cname.clone()),
latency_us: cfg.buffer_ms * 1000,
cipher_bits: if cfg.secret.is_some() { if cfg.aes_256 { 256 } else { 128 } } else { 0 },
tag: 0,
};
let (link, ends) = pair(info, true);
let mut s = Sender::new(cfg.cname.clone(), cfg.buffer_ms * 1000, cfg.idle_ms * 1000, ends.sent.clone(), ends.rtt_us.clone());
match cfg.profile {
Profile::Simple => {
let rtp = UdpSocket::bind(any).await?;
let rtcp = UdpSocket::bind(any).await?;
s.start();
tokio::spawn(simple_sender(s, rtp, rtcp, remote, ends));
}
Profile::Main => {
let sock = UdpSocket::bind(any).await?;
let mut w = Writer::new(cfg.vsf, cfg.secret.clone().map(|p| (p, cfg.aes_256)));
// The client starts with keep-alives, 3 to 10 of them back to back (§5.6.2).
let m = mac();
for _ in 0..5 {
sock.send_to(&w.keepalive(m, caps::V), remote).await?;
}
s.start();
tokio::spawn(main_sender(s, sock, w, m, cfg.secret.clone(), remote, ends));
}
}
Ok(link)
}
async fn sender_loop<F, Fut, R, RFut>(mut s: Sender, mut ends: LinkEnds, mut emit: F, mut recv: R)
where
F: FnMut(Out) -> Fut,
Fut: std::future::Future<Output = ()>,
R: FnMut() -> RFut,
RFut: std::future::Future<Output = Option<Vec<u8>>>,
{
let reason = loop {
for o in std::mem::take(&mut s.out) {
emit(o).await;
}
if let Some(r) = s.closed.clone() {
break r;
}
let wake = tokio::time::Instant::from_std(s.t0() + Duration::from_micros(s.next_wake_us()));
tokio::select! {
p = async { match &mut ends.send_rx { Some(rx) => rx.recv().await, None => std::future::pending().await } } => match p {
Some(p) => s.send(p),
None => s.close("closed"),
},
r = recv() => if let Some(b) = r { s.on_rtcp(&b) },
_ = tokio::time::sleep_until(wake) => s.on_timer(),
c = ends.cmd_rx.recv() => if matches!(c, Some(Cmd::Close) | None) { s.close("closed") },
}
};
for o in std::mem::take(&mut s.out) {
emit(o).await;
}
let _ = ends.closed_tx.send(Some(reason));
}
async fn simple_sender(s: Sender, rtp: UdpSocket, rtcp: UdpSocket, remote: SocketAddr, ends: LinkEnds) {
let rtp = Arc::new(rtp);
let rtcp = Arc::new(rtcp);
let rtcp_to = SocketAddr::new(remote.ip(), remote.port() + 1);
let (r1, r2) = (rtp.clone(), rtcp.clone());
let emit = move |o: Out| {
let (r1, r2) = (r1.clone(), r2.clone());
async move {
let _ = match o {
Out::Rtp(b) => r1.send_to(&b, remote).await,
Out::Rtcp(b) => r2.send_to(&b, rtcp_to).await,
};
}
};
let rr = rtcp.clone();
let recv = move || {
let rr = rr.clone();
async move {
let mut b = vec![0u8; 2048];
loop {
match rr.recv_from(&mut b).await {
Ok((n, from)) if from.ip() == remote.ip() => return Some(b[..n].to_vec()),
Ok(_) => continue,
Err(_) => return None,
}
}
}
};
sender_loop(s, ends, emit, recv).await;
}
async fn main_sender(s: Sender, sock: UdpSocket, w: Writer, mac: [u8; 6], secret: Option<String>, remote: SocketAddr, ends: LinkEnds) {
let sock = Arc::new(sock);
let w = Arc::new(tokio::sync::Mutex::new((w, Instant::now())));
let (s1, w1) = (sock.clone(), w.clone());
let emit = move |o: Out| {
let (s1, w1) = (s1.clone(), w1.clone());
async move {
let mut g = w1.lock().await;
let (src, dst, b) = match o {
Out::Rtp(b) => (VIRT_SRC, VIRT_DST, b),
Out::Rtcp(b) => (VIRT_SRC + 1, VIRT_DST + 1, b),
};
let p = g.0.udp(src, dst, &b);
let _ = s1.send_to(&p, remote).await;
// A keep-alive at least every second (§5.6.2).
if g.1.elapsed() >= KEEPALIVE_EVERY {
g.1 = Instant::now();
let k = g.0.keepalive(mac, caps::V);
let _ = s1.send_to(&k, remote).await;
}
}
};
let s2 = sock.clone();
let cache = Arc::new(std::sync::Mutex::new(KeyCache::default()));
let recv = move || {
let (s2, cache, secret) = (s2.clone(), cache.clone(), secret.clone());
async move {
let mut b = vec![0u8; 2048];
loop {
let Ok((n, from)) = s2.recv_from(&mut b).await else { return None };
if from != remote {
continue;
}
let r = gre::read(&b[..n], secret.as_deref(), &mut cache.lock().unwrap());
if let Ok((_, Inner::Udp { payload, .. })) = r
&& is_rtcp(&payload)
{
return Some(payload);
}
}
}
};
sender_loop(s, ends, emit, recv).await;
}

View file

@ -0,0 +1,351 @@
//! The RIST Main Profile tunnel (VSF TR-06-2:2024 §5, §7): GRE over UDP (RFC 8086) with
//! TR-06-2's use of the GRE header's reserved bits, the VSF EtherType and its packet
//! header, reduced overhead mode, the keep-alive message, and PSK encryption.
//!
//! ```text
//! flags word: C(0x8000) . K(0x2000) S(0x1000) Reserved0 H(0x0040) RV(0x0038) Ver(0x0007)
//! then, each when its bit is set: Checksum + Reserved1, Key (the PSK nonce), Sequence
//! ```
//!
//! - RV 000 is TR-06-2:2020, 001 TR-06-2:2021, 010 TR-06-2:2022 and later; 011 and 100 are
//! read as 010, 101 to 111 discarded (§5.1). H gives the PSK key length where RV ≥ 001.
//! - The Protocol Type 0xCCE0 is followed by the VSF packet header (§5.2): type 0x0000
//! (RIST), subtype 0x0000 a reduced overhead packet, 0x8000 a keep-alive, 0x8001 a future
//! nonce announcement. The legacy types 0x88B6 (reduced overhead) and 0x88B5 (keep-alive)
//! have no VSF header (§5.3.2, §5.6.3).
//! - Reduced overhead (§5.3.2): the UDP source and destination ports, then the UDP payload.
//! - PSK (§7.2, §7.3): with K set, the GRE payload (everything after the GRE header) is
//! AES-CTR under PBKDF2-HMAC-SHA256(passphrase, the nonce's four bytes, 1024 iterations),
//! 16 or 32 bytes by H, from the counter block `sequence (4 bytes) || 0^96`. RV 000's
//! counter block put the sequence number in the low bytes, which §7.2 calls insecure; it is
//! refused here.
use crate::crypto::{ctr_apply, pbkdf2_hmac_sha256};
pub const F_C: u16 = 0x8000;
pub const F_K: u16 = 0x2000;
pub const F_S: u16 = 0x1000;
pub const F_H: u16 = 0x0040;
pub const RV_SHIFT: u16 = 3;
pub const ET_VSF: u16 = 0xcce0;
pub const ET_LEGACY_REDUCED: u16 = 0x88b6;
pub const ET_LEGACY_KEEPALIVE: u16 = 0x88b5;
pub const ET_IPV4: u16 = 0x0800;
pub const ET_EAPOL: u16 = 0x888e;
pub const VSF_RIST: u16 = 0x0000;
pub const SUB_REDUCED: u16 = 0x0000;
pub const SUB_KEEPALIVE: u16 = 0x8000;
pub const SUB_FUTURE_NONCE: u16 = 0x8001;
pub const PSK_ITER: u32 = 1024;
/// The keep-alive capability flags (§5.6.3, Figure 8), as a 16-bit word after the MAC.
pub mod caps {
pub const X: u16 = 0x8000;
pub const R: u16 = 0x4000;
pub const B: u16 = 0x2000;
pub const A: u16 = 0x1000;
pub const P: u16 = 0x0800;
pub const E: u16 = 0x0400;
pub const L: u16 = 0x0200;
pub const N: u16 = 0x0100;
pub const D: u16 = 0x0080;
pub const T: u16 = 0x0040;
pub const V: u16 = 0x0020;
pub const J: u16 = 0x0010;
pub const F: u16 = 0x0008;
}
/// What a tunnel packet carries.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Inner {
/// A UDP payload between virtual ports (reduced overhead mode).
Udp { src: u16, dst: u16, payload: Vec<u8> },
KeepAlive { mac: [u8; 6], caps: u16, json: Vec<u8> },
FutureNonce(u32),
/// A packet this receiver does not process (full datagram mode, EAPOL, an unknown
/// subtype): discarded, though it keeps the tunnel alive (§5.1).
Other(u16),
}
/// The GRE header's fields.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Header {
pub rv: u8,
pub h: bool,
pub nonce: Option<u32>,
pub seq: Option<u32>,
pub proto: u16,
}
/// Why a tunnel packet is not read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GreError {
Short,
/// RV 101 to 111 (§5.1), or a GRE version other than 0.
UnknownVersion,
/// PSK under RV 000's insecure counter block (§7.2).
InsecureIv,
/// The packet is encrypted and no passphrase is held, or the reverse.
Key,
/// It decrypts to nothing this reader knows.
Malformed,
}
/// The PSK keys a receiver derives, one per nonce, the last few kept.
#[derive(Default)]
pub struct KeyCache {
keys: Vec<(u32, bool, Vec<u8>)>,
pub derived: u64,
}
impl KeyCache {
pub fn key(&mut self, passphrase: &str, nonce: u32, long: bool) -> &[u8] {
if let Some(i) = self.keys.iter().position(|(n, l, _)| *n == nonce && *l == long) {
return &self.keys[i].2;
}
let k = pbkdf2_hmac_sha256(passphrase.as_bytes(), &nonce.to_be_bytes(), PSK_ITER, if long { 32 } else { 16 });
self.derived += 1;
if self.keys.len() >= 4 {
self.keys.remove(0);
}
self.keys.push((nonce, long, k));
&self.keys.last().unwrap().2
}
}
fn iv(seq: u32) -> [u8; 16] {
let mut v = [0u8; 16];
v[..4].copy_from_slice(&seq.to_be_bytes());
v
}
/// Read one tunnel datagram: its header, and its inner packet decrypted under `psk` where
/// it is encrypted.
pub fn read(b: &[u8], psk: Option<&str>, cache: &mut KeyCache) -> Result<(Header, Inner), GreError> {
if b.len() < 4 {
return Err(GreError::Short);
}
let f = u16::from_be_bytes([b[0], b[1]]);
let proto = u16::from_be_bytes([b[2], b[3]]);
if f & 0x0007 != 0 {
return Err(GreError::UnknownVersion);
}
let rv = ((f >> RV_SHIFT) & 0x7) as u8;
if rv >= 5 {
return Err(GreError::UnknownVersion);
}
let mut off = 4;
if f & F_C != 0 {
off += 4;
}
let nonce = if f & F_K != 0 {
let n = b.get(off..off + 4).ok_or(GreError::Short)?;
off += 4;
Some(u32::from_be_bytes(n.try_into().unwrap()))
} else {
None
};
let seq = if f & F_S != 0 {
let n = b.get(off..off + 4).ok_or(GreError::Short)?;
off += 4;
Some(u32::from_be_bytes(n.try_into().unwrap()))
} else {
None
};
if off > b.len() {
return Err(GreError::Short);
}
let h = Header { rv, h: f & F_H != 0, nonce, seq, proto };
let mut body = b[off..].to_vec();
match (nonce.filter(|n| *n != 0), psk) {
(Some(n), Some(p)) => {
if rv == 0 {
return Err(GreError::InsecureIv);
}
let s = seq.ok_or(GreError::Malformed)?;
let key = cache.key(p, n, h.h).to_vec();
ctr_apply(&key, &iv(s), &mut body);
}
(None, None) => {}
_ => return Err(GreError::Key),
}
let inner = match proto {
ET_VSF => {
if body.len() < 4 {
return Err(GreError::Malformed);
}
let ty = u16::from_be_bytes([body[0], body[1]]);
let sub = u16::from_be_bytes([body[2], body[3]]);
let rest = &body[4..];
match (ty, sub) {
(VSF_RIST, SUB_REDUCED) => udp(rest)?,
(VSF_RIST, SUB_KEEPALIVE) => keepalive(rest)?,
(VSF_RIST, SUB_FUTURE_NONCE) if rest.len() >= 4 => Inner::FutureNonce(u32::from_be_bytes(rest[..4].try_into().unwrap())),
_ => Inner::Other(sub),
}
}
ET_LEGACY_REDUCED => udp(&body)?,
ET_LEGACY_KEEPALIVE => keepalive(&body)?,
p => Inner::Other(p),
};
Ok((h, inner))
}
fn udp(b: &[u8]) -> Result<Inner, GreError> {
if b.len() < 4 {
return Err(GreError::Malformed);
}
Ok(Inner::Udp { src: u16::from_be_bytes([b[0], b[1]]), dst: u16::from_be_bytes([b[2], b[3]]), payload: b[4..].to_vec() })
}
fn keepalive(b: &[u8]) -> Result<Inner, GreError> {
if b.len() < 8 {
return Err(GreError::Malformed);
}
Ok(Inner::KeepAlive { mac: b[..6].try_into().unwrap(), caps: u16::from_be_bytes([b[6], b[7]]), json: b[8..].to_vec() })
}
/// How a sender writes its tunnel packets.
#[derive(Clone, Debug)]
pub struct Writer {
/// TR-06-2:2022's VSF EtherType and RV 010; otherwise TR-06-2:2021's legacy types and RV
/// 001.
pub vsf: bool,
/// The passphrase and whether the key is 256 bits.
pub psk: Option<(String, bool)>,
pub nonce: u32,
pub seq: u32,
key: Option<Vec<u8>>,
}
impl Writer {
pub fn new(vsf: bool, psk: Option<(String, bool)>) -> Writer {
let nonce = loop {
let n = crate::random_u32();
if n != 0 {
break n;
}
};
let key = psk.as_ref().map(|(p, long)| pbkdf2_hmac_sha256(p.as_bytes(), &nonce.to_be_bytes(), PSK_ITER, if *long { 32 } else { 16 }));
Writer { vsf, psk, nonce, seq: crate::random_u32(), key }
}
fn header(&mut self, proto: u16) -> (Vec<u8>, u32) {
let rv: u16 = if self.vsf { 2 } else { 1 };
let mut f = F_S | (rv << RV_SHIFT);
if self.psk.is_some() {
f |= F_K;
if self.psk.as_ref().is_some_and(|p| p.1) {
f |= F_H;
}
}
let seq = self.seq;
self.seq = self.seq.wrapping_add(1);
// A new nonce whenever the sequence number wraps (§7.2).
if self.seq == 0
&& let Some((p, long)) = &self.psk
{
self.nonce = self.nonce.wrapping_add(1).max(1);
self.key = Some(pbkdf2_hmac_sha256(p.as_bytes(), &self.nonce.to_be_bytes(), PSK_ITER, if *long { 32 } else { 16 }));
}
let mut h = Vec::with_capacity(12);
h.extend_from_slice(&f.to_be_bytes());
h.extend_from_slice(&proto.to_be_bytes());
if self.psk.is_some() {
h.extend_from_slice(&self.nonce.to_be_bytes());
}
h.extend_from_slice(&seq.to_be_bytes());
(h, seq)
}
fn seal(&mut self, proto: u16, mut body: Vec<u8>) -> Vec<u8> {
let (mut h, seq) = self.header(proto);
if let Some(k) = &self.key {
ctr_apply(k, &iv(seq), &mut body);
}
h.extend_from_slice(&body);
h
}
/// A UDP payload from virtual port `src` to `dst`, in reduced overhead mode.
pub fn udp(&mut self, src: u16, dst: u16, payload: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + payload.len());
if self.vsf {
body.extend_from_slice(&VSF_RIST.to_be_bytes());
body.extend_from_slice(&SUB_REDUCED.to_be_bytes());
}
body.extend_from_slice(&src.to_be_bytes());
body.extend_from_slice(&dst.to_be_bytes());
body.extend_from_slice(payload);
let proto = if self.vsf { ET_VSF } else { ET_LEGACY_REDUCED };
self.seal(proto, body)
}
/// A keep-alive: `mac`, the capability flags, no JSON.
pub fn keepalive(&mut self, mac: [u8; 6], caps: u16) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
if self.vsf {
body.extend_from_slice(&VSF_RIST.to_be_bytes());
body.extend_from_slice(&SUB_KEEPALIVE.to_be_bytes());
}
body.extend_from_slice(&mac);
body.extend_from_slice(&caps.to_be_bytes());
let proto = if self.vsf { ET_VSF } else { ET_LEGACY_KEEPALIVE };
self.seal(proto, body)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reduced_overhead_round_trips_clear_and_under_psk() {
for vsf in [true, false] {
for psk in [None, Some(("Reliable Internet Stream Transport".to_string(), false)), Some(("x".repeat(20), true))] {
let mut w = Writer::new(vsf, psk.clone());
let b = w.udp(1972, 1968, b"\x80\x21hello");
let mut c = KeyCache::default();
let (h, i) = read(&b, psk.as_ref().map(|p| p.0.as_str()), &mut c).unwrap();
assert_eq!(i, Inner::Udp { src: 1972, dst: 1968, payload: b"\x80\x21hello".to_vec() });
assert_eq!(h.rv, if vsf { 2 } else { 1 });
assert_eq!(h.proto, if vsf { ET_VSF } else { ET_LEGACY_REDUCED });
assert_eq!(h.nonce.is_some(), psk.is_some());
assert_eq!(h.h, psk.as_ref().is_some_and(|p| p.1));
let ka = w.keepalive([2, 0, 0, 0, 0, 1], caps::V);
assert!(matches!(read(&ka, psk.as_ref().map(|p| p.0.as_str()), &mut c).unwrap().1, Inner::KeepAlive { caps: caps::V, .. }));
}
}
}
#[test]
fn the_wrong_passphrase_reads_nothing_of_use() {
let mut w = Writer::new(true, Some(("right one".into(), false)));
let b = w.udp(1972, 1968, &[0x80, 0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
let mut c = KeyCache::default();
if let Ok((_, Inner::Udp { payload, .. })) = read(&b, Some("wrong one"), &mut c) {
assert_ne!(payload[..2], [0x80, 0x21]);
}
assert_eq!(read(&b, None, &mut c).unwrap_err(), GreError::Key);
}
#[test]
fn rv_000_psk_is_refused() {
let mut w = Writer::new(false, Some(("p".into(), false)));
let mut b = w.udp(1, 2, b"x");
// Rewrite RV to 000.
let f = u16::from_be_bytes([b[0], b[1]]) & !(0x7 << RV_SHIFT);
b[..2].copy_from_slice(&f.to_be_bytes());
assert_eq!(read(&b, Some("p"), &mut KeyCache::default()).unwrap_err(), GreError::InsecureIv);
}
#[test]
fn annex_b_key_and_the_counter_block() {
let mut c = KeyCache::default();
let k = c.key("Reliable Internet Stream Transport", 0x5249_5354, false).to_vec();
assert_eq!(hex::encode(k), "1c2b0cfc90ae2638fea78c7fb2977047");
assert_eq!(iv(0x0102_0304)[..5], [1, 2, 3, 4, 0]);
}
}

View file

@ -0,0 +1,26 @@
//! RIST: VSF TR-06-1:2020 (Simple Profile) and TR-06-2:2024 (Main Profile).
//!
//! - [`rtp`]: RTP and the RTCP packets the Simple Profile uses.
//! - [`gre`]: the Main Profile's GRE-over-UDP tunnel, reduced overhead mode, keep-alives
//! and PSK.
//! - [`recv`], [`send`]: a flow's receiving and sending sides, without I/O.
//! - [`conn`]: the receiver ([`conn::RistListener`]: for the Simple Profile, RTP on an even
//! port P and RTCP on P+1; for the Main Profile, one tunnel port P) and the sender
//! ([`conn::send`]).
//!
//! A receiver serves one flow: the first whose RTCP CNAME is the one it expects (when it
//! expects one) and, in the Main Profile, whose tunnel packets decrypt under its passphrase.
//! From then on it takes the flow's packets from that address alone.
//!
//! **Not implemented**: bonding (§5.4), SMPTE 2022-1 FEC, multicast, the Main Profile's DTLS
//! (§6), full datagram mode (§5.3.1), EAP-SRP authentication (§7.5), null packet deletion
//! and extended sequence numbers (§8), and on-the-fly passphrase change (§7.4).
pub mod conn;
pub mod gre;
pub mod recv;
pub mod rtp;
pub mod send;
pub use conn::{send, Profile, RistListener, RistListenerConfig, SendConfig};
pub use recv::NackKind;

View file

@ -0,0 +1,397 @@
//! The receiving side of a RIST flow, without I/O (VSF TR-06-1 §5.3): RTP and RTCP in,
//! RTCP and delivered payloads out. The same machine serves the Simple Profile's two ports
//! and the Main Profile's tunnel.
//!
//! - **The buffer** (§5.3.1): each sequence number is delivered `buffer` after it was
//! expected (its arrival, or, for one found missing, the arrival that showed the gap),
//! in order; one still missing then is dropped.
//! - **Retransmission requests** (§5.3.2): a missing packet is first requested `reorder`
//! after it was found missing, then again every interval: 1.5 × the RTT where the RTT echo
//! (§5.2.6) has measured it, and otherwise (buffer − reorder) / the requests allowed, as
//! Appendix B derives it; at most that many times, and not once its time is too close to
//! be answered. Requests are range requests (§5.3.2.2) or bitmask ones (§5.3.2.1).
//! - **RTCP** (§5.2.1): every 100 ms a compound of an RR (with its report block once data
//! has arrived), an SDES CNAME, and the RTT echo request once a second; the echo
//! requests of the sender are answered (§5.2.6).
//! - **Retransmissions** are the packets whose SSRC has its low bit set (§5.3.3).
use super::rtp::{compound, ReportBlock, Rtcp, Rtp};
use crate::seq::Extender;
use crate::stats::{Account, Outcome};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
const RTCP_EVERY_US: u64 = 100_000;
const ECHO_EVERY_US: u64 = 1_000_000;
const MAX_IN_ONE_REQUEST: usize = 16;
const RECENT_DROPS: usize = 4096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NackKind {
Range,
Bitmask,
}
#[derive(Clone, Debug)]
pub struct RecvConfig {
pub buffer_us: u64,
pub reorder_us: u64,
pub max_requests: u32,
pub nack: NackKind,
pub idle_us: u64,
pub cname: String,
}
impl Default for RecvConfig {
fn default() -> Self {
// TR-06-1 Appendix B's suggested defaults, but a shorter reorder section for one path.
RecvConfig { buffer_us: 1_000_000, reorder_us: 20_000, max_requests: 7, nack: NackKind::Range, idle_us: 10_000_000, cname: "wai-ingest".into() }
}
}
struct Slot {
payload: Vec<u8>,
arrived_us: u64,
missed_at: Option<u64>,
}
struct Miss {
found_us: u64,
next_req_us: u64,
requests: u32,
}
pub struct Receiver {
pub cfg: RecvConfig,
t0: Instant,
pub ssrc: u32,
/// The sender's SSRC, its low bit clear.
pub media: Option<u32>,
seq_ext: Extender,
first_seq: Option<u64>,
next: Option<u64>,
highest: u64,
buf: BTreeMap<u64, Slot>,
missing: BTreeMap<u64, Miss>,
recent_drops: BTreeSet<u64>,
recent_order: VecDeque<u64>,
pub account: Arc<Mutex<Account>>,
pub rtt_shared: Arc<AtomicU64>,
rtt: Option<u64>,
last_rtcp_us: u64,
last_echo_us: u64,
last_recv_us: u64,
received: u64,
last_sr: Option<(u32, u64)>,
jitter: f64,
last_transit: Option<i64>,
expected_prior: u64,
received_prior: u64,
/// RTCP compound packets to send.
pub out: Vec<Vec<u8>>,
pub delivered: Vec<Vec<u8>>,
pub closed: Option<String>,
pub sender_cname: Option<String>,
}
impl Receiver {
pub fn new(cfg: RecvConfig, account: Arc<Mutex<Account>>, rtt_shared: Arc<AtomicU64>) -> Receiver {
Receiver {
cfg,
t0: Instant::now(),
ssrc: crate::random_u32() & !1,
media: None,
seq_ext: Extender::new(16),
first_seq: None,
next: None,
highest: 0,
buf: BTreeMap::new(),
missing: BTreeMap::new(),
recent_drops: BTreeSet::new(),
recent_order: VecDeque::new(),
account,
rtt_shared,
rtt: None,
last_rtcp_us: 0,
last_echo_us: 0,
last_recv_us: 0,
received: 0,
last_sr: None,
jitter: 0.0,
last_transit: None,
expected_prior: 0,
received_prior: 0,
out: Vec::new(),
delivered: Vec::new(),
closed: None,
sender_cname: None,
}
}
pub fn t0(&self) -> Instant {
self.t0
}
pub fn now_us(&self) -> u64 {
self.t0.elapsed().as_micros() as u64
}
/// Something of the flow arrived that is neither RTP nor RTCP (a tunnel keep-alive): it
/// keeps the flow alive.
pub fn alive(&mut self) {
self.last_recv_us = self.now_us();
}
fn interval(&self) -> u64 {
match self.rtt {
Some(r) => (r * 3 / 2).max(10_000),
None => (self.cfg.buffer_us.saturating_sub(self.cfg.reorder_us) / self.cfg.max_requests.max(1) as u64).max(10_000),
}
}
pub fn on_rtp(&mut self, p: Rtp) {
let now = self.now_us();
self.last_recv_us = now;
let base = p.ssrc & !1;
match self.media {
None => self.media = Some(base),
Some(m) if m != base => return,
_ => {}
}
if p.ssrc & 1 == 1 {
self.account.lock().unwrap().retransmitted();
}
let seq = self.seq_ext.extend(p.seq as u64);
// Interarrival jitter (RFC 3550 §6.4.1), 90 kHz, from originals only.
if p.ssrc & 1 == 0 {
let arrival = (now as i64) * 9 / 100;
let transit = arrival - p.ts as i64;
if let Some(lt) = self.last_transit {
let d = (transit - lt).abs() as f64;
self.jitter += (d - self.jitter) / 16.0;
}
self.last_transit = Some(transit);
}
let next = *self.next.get_or_insert(seq);
if self.received == 0 {
self.highest = seq - 1;
self.first_seq = Some(seq);
}
self.received += 1;
if seq < next {
let mut a = self.account.lock().unwrap();
if self.recent_drops.contains(&seq) { a.late() } else { a.duplicate() }
return;
}
if self.buf.contains_key(&seq) {
self.account.lock().unwrap().duplicate();
return;
}
let missed_at = self.missing.remove(&seq).map(|m| m.found_us);
if seq > self.highest {
for s in self.highest + 1..seq {
self.missing.insert(s, Miss { found_us: now, next_req_us: now + self.cfg.reorder_us, requests: 0 });
}
self.highest = seq;
}
self.buf.insert(seq, Slot { payload: p.payload, arrived_us: now, missed_at });
self.deliver(now);
}
pub fn on_rtcp(&mut self, b: &[u8]) {
let now = self.now_us();
self.last_recv_us = now;
let Some(parts) = super::rtp::read_compound(b) else { return };
let mut reply = Vec::new();
for p in parts {
match p {
Rtcp::Sdes { cname: Some(c), .. } => self.sender_cname = Some(c),
Rtcp::Sr { ntp, .. } => self.last_sr = Some(((ntp >> 16) as u32, now)),
Rtcp::EchoRequest { media, ts, padding } => reply.push(Rtcp::EchoResponse { media, ts, delay_us: 0, padding }),
Rtcp::EchoResponse { ts, delay_us, .. } => {
let rtt = now.saturating_sub(ts).saturating_sub(delay_us as u64);
if rtt < 10_000_000 {
let r = self.rtt.map_or(rtt, |r| (7 * r + rtt) / 8);
self.rtt = Some(r);
self.rtt_shared.store(r, Ordering::Relaxed);
self.account.lock().unwrap().rtt_sample(rtt as u32);
}
}
_ => {}
}
}
if !reply.is_empty() {
let mut parts = self.report();
parts.extend(reply);
self.out.push(compound(&parts));
}
}
/// The RR and the SDES every compound starts with.
fn report(&mut self) -> Vec<Rtcp> {
let block = self.media.filter(|_| self.received > 0).map(|m| {
// RFC 3550 §6.4.1 and §A.3: expected from the first sequence number received.
let expected = (self.highest + 1).saturating_sub(self.first_seq.unwrap_or(self.highest + 1));
let lost = expected.saturating_sub(self.received.min(expected));
let de = expected.saturating_sub(self.expected_prior);
let dr = self.received.saturating_sub(self.received_prior);
self.expected_prior = expected;
self.received_prior = self.received;
let fraction = if de == 0 || dr >= de { 0 } else { ((de - dr) * 256 / de).min(255) as u8 };
let (lsr, dlsr) = match self.last_sr {
Some((l, at)) => (l, ((self.now_us() - at) * 65_536 / 1_000_000) as u32),
None => (0, 0),
};
ReportBlock { source: m, fraction_lost: fraction, cumulative_lost: lost.min(0x00ff_ffff) as u32, highest: (self.highest & 0xffff_ffff) as u32, jitter: self.jitter as u32, lsr, dlsr }
});
vec![Rtcp::Rr { ssrc: self.ssrc, block }, Rtcp::Sdes { ssrc: self.ssrc, cname: Some(self.cfg.cname.clone()) }]
}
fn deliver(&mut self, now: u64) {
let Some(mut next) = self.next else { return };
loop {
if let Some(s) = self.buf.get(&next) {
let due = s.missed_at.unwrap_or(s.arrived_us) + self.cfg.buffer_us;
if now < due {
break;
}
let s = self.buf.remove(&next).unwrap();
let bytes = s.payload.len() as u32;
let o = match s.missed_at {
None => Outcome::FirstTry { bytes },
Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: due.saturating_sub(s.arrived_us).min(u32::MAX as u64) as u32 },
};
self.account.lock().unwrap().resolve(next, o);
self.delivered.push(s.payload);
next += 1;
continue;
}
let Some(m) = self.missing.get(&next) else { break };
if now < m.found_us + self.cfg.buffer_us {
break;
}
self.missing.remove(&next);
self.account.lock().unwrap().resolve(next, Outcome::Dropped);
self.recent_drops.insert(next);
self.recent_order.push_back(next);
if self.recent_order.len() > RECENT_DROPS
&& let Some(old) = self.recent_order.pop_front()
{
self.recent_drops.remove(&old);
}
next += 1;
}
self.next = Some(next);
}
pub fn on_timer(&mut self) {
let now = self.now_us();
self.deliver(now);
if self.closed.is_some() {
return;
}
if self.last_recv_us > 0 && now.saturating_sub(self.last_recv_us) > self.cfg.idle_us {
self.close("idle");
return;
}
// The requests due now.
let interval = self.interval();
let rtt = self.rtt.unwrap_or(0);
let mut due: Vec<u64> = Vec::new();
let buffer = self.cfg.buffer_us;
let max = self.cfg.max_requests;
for (&s, m) in self.missing.iter_mut() {
let deadline = m.found_us + buffer;
if now >= m.next_req_us && m.requests < max && now + rtt / 2 < deadline {
due.push(s);
m.requests += 1;
m.next_req_us = now + interval;
}
}
let mut parts = Vec::new();
if !due.is_empty() || now.saturating_sub(self.last_rtcp_us) >= RTCP_EVERY_US {
parts = self.report();
self.last_rtcp_us = now;
}
if !due.is_empty()
&& let Some(m) = self.media
{
match self.cfg.nack {
NackKind::Range => {
let mut ranges: Vec<(u64, u64)> = Vec::new();
for s in &due {
match ranges.last_mut() {
Some((a, b)) if *b + 1 == *s && *b - *a < 65_535 => *b = *s,
_ => ranges.push((*s, *s)),
}
}
for chunk in ranges.chunks(MAX_IN_ONE_REQUEST) {
let n: u64 = chunk.iter().map(|(a, b)| b - a + 1).sum();
self.account.lock().unwrap().request(n);
parts.push(Rtcp::Range { media: m, ranges: chunk.iter().map(|(a, b)| (*a as u16, (b - a) as u16)).collect() });
}
}
NackKind::Bitmask => {
let seqs: Vec<u16> = due.iter().map(|s| *s as u16).collect();
for chunk in seqs.chunks(MAX_IN_ONE_REQUEST * 17) {
self.account.lock().unwrap().request(chunk.len() as u64);
parts.push(Rtcp::Nack { media: m, lost: chunk.to_vec() });
}
}
}
}
if !parts.is_empty() {
if now.saturating_sub(self.last_echo_us) >= ECHO_EVERY_US
&& let Some(m) = self.media
{
self.last_echo_us = now;
parts.push(Rtcp::EchoRequest { media: m, ts: now, padding: Vec::new() });
}
self.out.push(compound(&parts));
}
}
pub fn next_wake_us(&self) -> u64 {
let now = self.now_us();
let mut w = now + 10_000;
if let Some(n) = self.next {
if let Some(s) = self.buf.get(&n) {
w = w.min(s.missed_at.unwrap_or(s.arrived_us) + self.cfg.buffer_us);
} else if let Some(m) = self.missing.get(&n) {
w = w.min(m.found_us + self.cfg.buffer_us);
}
}
if let Some(m) = self.missing.values().map(|m| m.next_req_us).min() {
w = w.min(m);
}
w.max(now + 200)
}
pub fn close(&mut self, why: &str) {
if self.closed.is_some() {
return;
}
self.closed = Some(why.into());
// What is held is delivered now; the missing among it are outstanding.
let Some(mut next) = self.next else { return };
let mut a = self.account.lock().unwrap();
while next <= self.highest {
match self.buf.remove(&next) {
Some(s) => {
let bytes = s.payload.len() as u32;
a.resolve(next, match s.missed_at {
None => Outcome::FirstTry { bytes },
Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: 0 },
});
self.delivered.push(s.payload);
}
None => a.resolve(next, Outcome::Outstanding),
}
next += 1;
}
self.next = Some(next);
self.missing.clear();
}
}

View file

@ -0,0 +1,312 @@
//! RTP (RFC 3550 §5.1) and the RTCP packets of VSF TR-06-1 §5.2 and §5.3: SR with no
//! report block, RR with none or one, SDES with one CNAME, the RFC 4585 generic NACK
//! (§5.3.2.1), and the APP packets named `RIST`: range retransmission requests (subtype 0,
//! §5.3.2.2) and the RTT echo request and response (subtypes 2 and 3, §5.2.6).
/// RTP payload type 33, MP2T (RFC 3551).
pub const PT_MP2T: u8 = 33;
pub const RTCP_SR: u8 = 200;
pub const RTCP_RR: u8 = 201;
pub const RTCP_SDES: u8 = 202;
pub const RTCP_APP: u8 = 204;
pub const RTCP_RTPFB: u8 = 205;
pub const RIST_NAME: [u8; 4] = *b"RIST";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rtp {
pub pt: u8,
pub marker: bool,
pub seq: u16,
pub ts: u32,
pub ssrc: u32,
pub payload: Vec<u8>,
}
impl Rtp {
pub fn read(b: &[u8]) -> Option<Rtp> {
if b.len() < 12 || b[0] >> 6 != 2 {
return None;
}
let padding = b[0] & 0x20 != 0;
let ext = b[0] & 0x10 != 0;
let cc = (b[0] & 0x0f) as usize;
let mut off = 12 + 4 * cc;
if ext {
if b.len() < off + 4 {
return None;
}
let words = u16::from_be_bytes([b[off + 2], b[off + 3]]) as usize;
off += 4 + 4 * words;
}
let mut end = b.len();
if padding {
end = end.checked_sub(*b.last()? as usize)?;
}
if off > end {
return None;
}
Some(Rtp { pt: b[1] & 0x7f, marker: b[1] & 0x80 != 0, seq: u16::from_be_bytes([b[2], b[3]]), ts: u32::from_be_bytes([b[4], b[5], b[6], b[7]]), ssrc: u32::from_be_bytes([b[8], b[9], b[10], b[11]]), payload: b[off..end].to_vec() })
}
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(12 + self.payload.len());
o.push(0x80);
o.push((self.marker as u8) << 7 | (self.pt & 0x7f));
o.extend_from_slice(&self.seq.to_be_bytes());
o.extend_from_slice(&self.ts.to_be_bytes());
o.extend_from_slice(&self.ssrc.to_be_bytes());
o.extend_from_slice(&self.payload);
o
}
}
/// Whether a datagram is RTCP rather than RTP: its second byte a type of 200 to 206 (RFC
/// 5761 §4).
pub fn is_rtcp(b: &[u8]) -> bool {
b.len() >= 4 && (200..=206).contains(&b[1])
}
/// One RTCP packet of a compound one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Rtcp {
Sr { ssrc: u32, ntp: u64, rtp_ts: u32, packets: u32, octets: u32 },
Rr { ssrc: u32, block: Option<ReportBlock> },
Sdes { ssrc: u32, cname: Option<String> },
/// Generic NACK: the media source, and the lost sequence numbers.
Nack { media: u32, lost: Vec<u16> },
/// A RIST range request: the media source, and (start, additional) pairs.
Range { media: u32, ranges: Vec<(u16, u16)> },
EchoRequest { media: u32, ts: u64, padding: Vec<u8> },
EchoResponse { media: u32, ts: u64, delay_us: u32, padding: Vec<u8> },
Other { pt: u8 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ReportBlock {
pub source: u32,
pub fraction_lost: u8,
pub cumulative_lost: u32,
pub highest: u32,
pub jitter: u32,
pub lsr: u32,
pub dlsr: u32,
}
fn be32(b: &[u8], i: usize) -> u32 {
u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]])
}
/// Every packet of a compound RTCP datagram; `None` when one's length runs past the end.
pub fn read_compound(b: &[u8]) -> Option<Vec<Rtcp>> {
let mut out = Vec::new();
let mut i = 0;
while i + 4 <= b.len() {
if b[i] >> 6 != 2 {
return None;
}
let count = b[i] & 0x1f;
let pt = b[i + 1];
let len = (u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize + 1) * 4;
if i + len > b.len() {
return None;
}
let p = &b[i..i + len];
out.push(match pt {
RTCP_SR if p.len() >= 28 => Rtcp::Sr { ssrc: be32(p, 4), ntp: (be32(p, 8) as u64) << 32 | be32(p, 12) as u64, rtp_ts: be32(p, 16), packets: be32(p, 20), octets: be32(p, 24) },
RTCP_RR if p.len() >= 8 => {
let block = (count >= 1 && p.len() >= 32).then(|| ReportBlock { source: be32(p, 8), fraction_lost: p[12], cumulative_lost: be32(p, 12) & 0x00ff_ffff, highest: be32(p, 16), jitter: be32(p, 20), lsr: be32(p, 24), dlsr: be32(p, 28) });
Rtcp::Rr { ssrc: be32(p, 4), block }
}
RTCP_SDES if p.len() >= 8 => {
let mut cname = None;
let mut j = 8;
while j + 2 <= p.len() && p[j] != 0 {
let (ty, n) = (p[j], p[j + 1] as usize);
if j + 2 + n > p.len() {
break;
}
if ty == 1 {
cname = Some(String::from_utf8_lossy(&p[j + 2..j + 2 + n]).into_owned());
}
j += 2 + n;
}
Rtcp::Sdes { ssrc: be32(p, 4), cname }
}
RTCP_RTPFB if count == 1 && p.len() >= 12 => {
let mut lost = Vec::new();
for f in p[12..].as_chunks::<4>().0 {
let pid = u16::from_be_bytes([f[0], f[1]]);
let blp = u16::from_be_bytes([f[2], f[3]]);
lost.push(pid);
for k in 0..16 {
if blp & (1 << k) != 0 {
lost.push(pid.wrapping_add(k + 1));
}
}
}
Rtcp::Nack { media: be32(p, 8), lost }
}
RTCP_APP if p.len() >= 12 && p[8..12] == RIST_NAME => match count {
0 => Rtcp::Range { media: be32(p, 4), ranges: p[12..].as_chunks::<4>().0.iter().map(|f| (u16::from_be_bytes([f[0], f[1]]), u16::from_be_bytes([f[2], f[3]]))).collect() },
2 if p.len() >= 24 => Rtcp::EchoRequest { media: be32(p, 4), ts: (be32(p, 12) as u64) << 32 | be32(p, 16) as u64, padding: p[24..].to_vec() },
3 if p.len() >= 24 => Rtcp::EchoResponse { media: be32(p, 4), ts: (be32(p, 12) as u64) << 32 | be32(p, 16) as u64, delay_us: be32(p, 20), padding: p[24..].to_vec() },
_ => Rtcp::Other { pt },
},
_ => Rtcp::Other { pt },
});
i += len;
}
Some(out)
}
fn header(count: u8, pt: u8, words_after: usize) -> [u8; 4] {
let len = words_after as u16;
[0x80 | (count & 0x1f), pt, (len >> 8) as u8, len as u8]
}
impl Rtcp {
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::new();
match self {
Rtcp::Sr { ssrc, ntp, rtp_ts, packets, octets } => {
o.extend_from_slice(&header(0, RTCP_SR, 6));
for w in [*ssrc, (*ntp >> 32) as u32, *ntp as u32, *rtp_ts, *packets, *octets] {
o.extend_from_slice(&w.to_be_bytes());
}
}
Rtcp::Rr { ssrc, block } => match block {
None => {
o.extend_from_slice(&header(0, RTCP_RR, 1));
o.extend_from_slice(&ssrc.to_be_bytes());
}
Some(b) => {
o.extend_from_slice(&header(1, RTCP_RR, 7));
o.extend_from_slice(&ssrc.to_be_bytes());
o.extend_from_slice(&b.source.to_be_bytes());
o.extend_from_slice(&((b.fraction_lost as u32) << 24 | (b.cumulative_lost & 0x00ff_ffff)).to_be_bytes());
for w in [b.highest, b.jitter, b.lsr, b.dlsr] {
o.extend_from_slice(&w.to_be_bytes());
}
}
},
Rtcp::Sdes { ssrc, cname } => {
let name = cname.as_deref().unwrap_or("").as_bytes();
let name = &name[..name.len().min(255)];
let mut body = ssrc.to_be_bytes().to_vec();
body.push(1);
body.push(name.len() as u8);
body.extend_from_slice(name);
// At least one zero byte ends the chunk, then to a word (§5.2.5).
body.push(0);
while !body.len().is_multiple_of(4) {
body.push(0);
}
o.extend_from_slice(&header(1, RTCP_SDES, body.len() / 4));
o.extend_from_slice(&body);
}
Rtcp::Nack { media, lost } => {
let fci = nack_fci(lost);
o.extend_from_slice(&header(1, RTCP_RTPFB, 2 + fci.len()));
o.extend_from_slice(&0u32.to_be_bytes());
o.extend_from_slice(&media.to_be_bytes());
for (pid, blp) in fci {
o.extend_from_slice(&pid.to_be_bytes());
o.extend_from_slice(&blp.to_be_bytes());
}
}
Rtcp::Range { media, ranges } => {
o.extend_from_slice(&header(0, RTCP_APP, 2 + ranges.len()));
o.extend_from_slice(&media.to_be_bytes());
o.extend_from_slice(&RIST_NAME);
for (s, a) in ranges {
o.extend_from_slice(&s.to_be_bytes());
o.extend_from_slice(&a.to_be_bytes());
}
}
Rtcp::EchoRequest { media, ts, padding } | Rtcp::EchoResponse { media, ts, padding, .. } => {
let (sub, delay) = match self {
Rtcp::EchoResponse { delay_us, .. } => (3, *delay_us),
_ => (2, 0),
};
let pad = &padding[..padding.len() / 4 * 4];
o.extend_from_slice(&header(sub, RTCP_APP, 5 + pad.len() / 4));
o.extend_from_slice(&media.to_be_bytes());
o.extend_from_slice(&RIST_NAME);
o.extend_from_slice(&((*ts >> 32) as u32).to_be_bytes());
o.extend_from_slice(&(*ts as u32).to_be_bytes());
o.extend_from_slice(&delay.to_be_bytes());
o.extend_from_slice(pad);
}
Rtcp::Other { .. } => {}
}
o
}
}
/// RFC 4585 §6.2.1 FCIs for `lost` (in any order): a PID and the bitmask of the 16 after it.
pub fn nack_fci(lost: &[u16]) -> Vec<(u16, u16)> {
let mut l = lost.to_vec();
l.sort_unstable();
l.dedup();
let mut out: Vec<(u16, u16)> = Vec::new();
for s in l {
match out.last_mut() {
Some((pid, blp)) if s.wrapping_sub(*pid) >= 1 && s.wrapping_sub(*pid) <= 16 => *blp |= 1 << (s.wrapping_sub(*pid) - 1),
_ => out.push((s, 0)),
}
}
out
}
/// A compound packet's bytes from its parts.
pub fn compound(parts: &[Rtcp]) -> Vec<u8> {
parts.iter().flat_map(|p| p.write()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_tr06_1_appendix_a_requests() {
// 100 lost, 101 and 102 received, 103 to 122 lost.
let lost: Vec<u16> = std::iter::once(100).chain(103..=122).collect();
let fci = nack_fci(&lost);
assert_eq!(fci, vec![(100, 0b1111_1111_1111_1100), (117, 0b0000_0000_0001_1111)]);
let n = Rtcp::Nack { media: 0xaabb_cc00, lost: lost.clone() }.write();
assert_eq!(&n[..4], &[0x81, 205, 0, 4]);
let back = read_compound(&n).unwrap();
let Rtcp::Nack { media, lost: mut got } = back[0].clone() else { panic!() };
got.sort_unstable();
assert_eq!((media, got), (0xaabb_cc00, lost));
let r = Rtcp::Range { media: 0xaabb_cc00, ranges: vec![(100, 0), (103, 19)] }.write();
assert_eq!(&r[..12], &[0x80, 204, 0, 4, 0xaa, 0xbb, 0xcc, 0x00, b'R', b'I', b'S', b'T']);
assert_eq!(&r[12..], &[0, 100, 0, 0, 0, 103, 0, 19]);
}
#[test]
fn a_compound_round_trips() {
let parts = vec![
Rtcp::Rr { ssrc: 7, block: Some(ReportBlock { source: 9, fraction_lost: 3, cumulative_lost: 77, highest: 65_600, jitter: 5, lsr: 1, dlsr: 2 }) },
Rtcp::Sdes { ssrc: 7, cname: Some("wai-ingest".into()) },
Rtcp::EchoRequest { media: 9, ts: 0x1122_3344_5566_7788, padding: vec![1, 2, 3, 4] },
Rtcp::EchoResponse { media: 9, ts: 5, delay_us: 40, padding: vec![] },
Rtcp::Sr { ssrc: 9, ntp: 1 << 40, rtp_ts: 90_000, packets: 10, octets: 13_160 },
];
let b = compound(&parts);
assert_eq!(read_compound(&b).unwrap(), parts);
// The SDES chunk ends in a zero byte and a word boundary.
let s = Rtcp::Sdes { ssrc: 1, cname: Some("abcd".into()) }.write();
assert_eq!(s.len() % 4, 0);
assert_eq!(&s[8..14], &[1, 4, b'a', b'b', b'c', b'd']);
}
#[test]
fn rtp_round_trips_and_is_told_from_rtcp() {
let p = Rtp { pt: PT_MP2T, marker: false, seq: 65_535, ts: 9, ssrc: 0x1234_5678, payload: vec![0x47; 1316] };
let b = p.write();
assert!(!is_rtcp(&b));
assert_eq!(Rtp::read(&b).unwrap(), p);
assert!(is_rtcp(&Rtcp::Rr { ssrc: 1, block: None }.write()));
}
}

View file

@ -0,0 +1,228 @@
//! The sending side of a RIST flow, without I/O (VSF TR-06-1 §5.2, §5.3): payloads in,
//! RTP out; RTCP requests in, retransmissions out.
//!
//! Each payload is one RTP packet of payload type 33 (MP2T) with a 90 kHz timestamp from
//! the flow's start, kept for `buffer` after it was sent. A bitmask or range request for a
//! packet still held sends it again, its sequence number and timestamp the same and its
//! SSRC's low bit set (§5.3.3). Every 100 ms the sender sends a compound of an SR and an
//! SDES CNAME (§5.2.1, §5.2.2), and the RTT echo request once a second; it answers the
//! receiver's echo requests at once.
use super::rtp::{compound, Rtcp, Rtp, PT_MP2T};
use crate::link::SendCounts;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
const RTCP_EVERY_US: u64 = 100_000;
const ECHO_EVERY_US: u64 = 1_000_000;
/// What the sender emits, by where it goes.
pub enum Out {
Rtp(Vec<u8>),
Rtcp(Vec<u8>),
}
pub struct Sender {
t0: Instant,
pub ssrc: u32,
pub cname: String,
buffer_us: u64,
seq: u16,
ts0: u32,
held: BTreeMap<u16, (u64, Vec<u8>)>,
order: std::collections::VecDeque<(u64, u16)>,
packets: u32,
octets: u32,
last_rtcp_us: u64,
last_echo_us: u64,
last_recv_us: u64,
idle_us: u64,
rtt: Option<u64>,
pub rtt_shared: Arc<AtomicU64>,
pub counts: Arc<Mutex<SendCounts>>,
pub out: Vec<Out>,
pub closed: Option<String>,
}
fn ntp_now() -> u64 {
let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = d.as_secs() + 2_208_988_800;
(secs << 32) | (((d.subsec_nanos() as u64) << 32) / 1_000_000_000)
}
impl Sender {
pub fn new(cname: String, buffer_us: u64, idle_us: u64, counts: Arc<Mutex<SendCounts>>, rtt_shared: Arc<AtomicU64>) -> Sender {
Sender {
t0: Instant::now(),
ssrc: crate::random_u32() & !1,
cname,
buffer_us,
seq: crate::random_u32() as u16,
ts0: crate::random_u32(),
held: BTreeMap::new(),
order: Default::default(),
packets: 0,
octets: 0,
last_rtcp_us: 0,
last_echo_us: 0,
last_recv_us: 0,
idle_us,
rtt: None,
rtt_shared,
counts,
out: Vec::new(),
closed: None,
}
}
pub fn t0(&self) -> Instant {
self.t0
}
pub fn now_us(&self) -> u64 {
self.t0.elapsed().as_micros() as u64
}
fn rtp_ts(&self, now_us: u64) -> u32 {
self.ts0.wrapping_add((now_us * 9 / 100) as u32)
}
/// The first RTCP, before any media: it opens the path through the firewalls (§5.2).
pub fn start(&mut self) {
let now = self.now_us();
self.rtcp(now, true);
}
pub fn send(&mut self, payload: Vec<u8>) {
let now = self.now_us();
let p = Rtp { pt: PT_MP2T, marker: false, seq: self.seq, ts: self.rtp_ts(now), ssrc: self.ssrc, payload };
let b = p.write();
self.packets = self.packets.wrapping_add(1);
self.octets = self.octets.wrapping_add(p.payload.len() as u32);
{
let mut c = self.counts.lock().unwrap();
c.sent += 1;
c.bytes += p.payload.len() as u64;
}
self.out.push(Out::Rtp(b.clone()));
self.held.insert(self.seq, (now, b));
self.order.push_back((now, self.seq));
self.seq = self.seq.wrapping_add(1);
self.expire(now);
}
fn expire(&mut self, now: u64) {
while let Some(&(at, s)) = self.order.front() {
if now.saturating_sub(at) <= self.buffer_us {
break;
}
self.order.pop_front();
// RIST acknowledges nothing: every packet ages out of the buffer, so none is
// counted as expired.
if self.held.get(&s).is_some_and(|(t, _)| *t == at) {
self.held.remove(&s);
}
}
}
fn resend(&mut self, s: u16) -> bool {
let Some((_, b)) = self.held.get(&s) else { return false };
let mut b = b.clone();
// The same packet, its SSRC's low bit set (§5.3.3).
b[11] |= 1;
self.out.push(Out::Rtp(b));
true
}
pub fn on_rtcp(&mut self, b: &[u8]) {
let now = self.now_us();
self.last_recv_us = now;
let Some(parts) = super::rtp::read_compound(b) else { return };
let mut reply = Vec::new();
for p in parts {
match p {
Rtcp::Nack { lost, .. } => {
let mut again = 0;
for s in &lost {
again += self.resend(*s) as u64;
}
let mut c = self.counts.lock().unwrap();
c.requests += 1;
c.requested += lost.len() as u64;
c.retransmitted += again;
}
Rtcp::Range { ranges, .. } => {
let mut named = 0u64;
let mut again = 0u64;
for (start, extra) in ranges {
for k in 0..=extra as u32 {
named += 1;
again += self.resend(start.wrapping_add(k as u16)) as u64;
}
}
let mut c = self.counts.lock().unwrap();
c.requests += 1;
c.requested += named;
c.retransmitted += again;
}
Rtcp::EchoRequest { media, ts, padding } => reply.push(Rtcp::EchoResponse { media, ts, delay_us: 0, padding }),
Rtcp::EchoResponse { ts, delay_us, .. } => {
let rtt = now.saturating_sub(ts).saturating_sub(delay_us as u64);
if rtt < 10_000_000 {
let r = self.rtt.map_or(rtt, |r| (7 * r + rtt) / 8);
self.rtt = Some(r);
self.rtt_shared.store(r, Ordering::Relaxed);
}
}
_ => {}
}
}
if !reply.is_empty() {
let mut parts = self.report(now);
parts.extend(reply);
self.out.push(Out::Rtcp(compound(&parts)));
}
}
fn report(&self, now: u64) -> Vec<Rtcp> {
vec![Rtcp::Sr { ssrc: self.ssrc, ntp: ntp_now(), rtp_ts: self.rtp_ts(now), packets: self.packets, octets: self.octets }, Rtcp::Sdes { ssrc: self.ssrc, cname: Some(self.cname.clone()) }]
}
fn rtcp(&mut self, now: u64, echo: bool) {
let mut parts = self.report(now);
if echo {
self.last_echo_us = now;
parts.push(Rtcp::EchoRequest { media: self.ssrc, ts: now, padding: Vec::new() });
}
self.out.push(Out::Rtcp(compound(&parts)));
self.last_rtcp_us = now;
}
pub fn on_timer(&mut self) {
let now = self.now_us();
if self.closed.is_some() {
return;
}
if self.last_recv_us > 0 && now.saturating_sub(self.last_recv_us) > self.idle_us {
self.closed = Some("idle".into());
return;
}
self.expire(now);
if now.saturating_sub(self.last_rtcp_us) >= RTCP_EVERY_US {
let echo = now.saturating_sub(self.last_echo_us) >= ECHO_EVERY_US;
self.rtcp(now, echo);
}
}
pub fn next_wake_us(&self) -> u64 {
self.now_us() + 20_000
}
pub fn close(&mut self, why: &str) {
if self.closed.is_none() {
self.closed = Some(why.into());
}
}
}

View file

@ -0,0 +1,101 @@
//! Sequence numbers and timestamps that wrap: SRT's 31-bit packet sequence numbers and
//! 32-bit microsecond timestamps, RTP's 16-bit sequence numbers, MPEG-TS's 33-bit PTS.
//! Each is extended past its width into a u64 by choosing, for every value read, the
//! extension nearest the last one, as RFC 3550 §A.1 extends RTP's sequence numbers.
/// Extends values of `bits` bits that wrap into a u64 sequence. The first value read is
/// placed at `base` wraps above zero so that a value read just before it (a reordered
/// packet) can still be placed below it.
#[derive(Clone, Debug)]
pub struct Extender {
bits: u32,
last: Option<u64>,
}
impl Extender {
pub fn new(bits: u32) -> Extender {
assert!(bits > 1 && bits < 63);
Extender { bits, last: None }
}
fn modulus(&self) -> u64 {
1u64 << self.bits
}
/// The extended value of `v`, the one nearest the last value read (a value exactly half
/// the range away is placed after it). The last value is the highest extended value
/// read so far.
pub fn extend(&mut self, v: u64) -> u64 {
let m = self.modulus();
let v = v & (m - 1);
let e = match self.last {
// One wrap above zero, so a value read before the first can lie below it.
None => m + v,
Some(last) => {
// `last` is at least `m`, so the wrap below its own is never negative.
let base = last & !(m - 1);
let mut best = base + v;
for c in [base - m + v, base + m + v] {
let (dc, db) = (c.abs_diff(last), best.abs_diff(last));
// A tie goes to the later value.
if dc < db || (dc == db && c > best) {
best = c;
}
}
best
}
};
self.last = Some(self.last.map_or(e, |l| l.max(e)));
e
}
/// The extended value `v` would get, without recording it.
pub fn peek(&self, v: u64) -> u64 {
self.clone().extend(v)
}
pub fn last(&self) -> Option<u64> {
self.last
}
}
/// `a` − `b` for values of `bits` bits that wrap, as a signed distance in (−2^(bits−1),
/// 2^(bits−1)].
pub fn wrapping_diff(a: u64, b: u64, bits: u32) -> i64 {
let m = 1u64 << bits;
let d = (a.wrapping_sub(b)) & (m - 1);
if d > m / 2 { d as i64 - m as i64 } else { d as i64 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtp_sequence_numbers_extend_across_the_wrap() {
let mut e = Extender::new(16);
let a = e.extend(65_534);
let b = e.extend(65_535);
let c = e.extend(0);
let d = e.extend(1);
assert_eq!((b - a, c - b, d - c), (1, 1, 1));
// A packet reordered from before the wrap lands below.
assert_eq!(e.extend(65_533), a - 1);
assert_eq!(e.last(), Some(d));
}
#[test]
fn a_value_read_before_the_first_lies_below_it() {
let mut e = Extender::new(31);
let first = e.extend(10);
assert_eq!(e.extend(9), first - 1);
assert_eq!(e.extend((1u64 << 31) - 1), first - 11);
}
#[test]
fn diffs_are_signed() {
assert_eq!(wrapping_diff(1, 65_535, 16), 2);
assert_eq!(wrapping_diff(65_535, 1, 16), -2);
assert_eq!(wrapping_diff(5, 5, 31), 0);
}
}

View file

@ -0,0 +1,611 @@
//! The SRT listener and caller, and the task that drives each connection.
use super::crypt::{KmError, Keys};
use super::packet::{ctrl, ext, flags, hs, rej, sid_read, sid_write, Control, Ext, Handshake, HsReq, KeyMaterial, Packet};
use super::recv::{RecvConfig, Receiver};
use super::send::Sender;
use super::SRT_VERSION;
use crate::link::{pair, Cmd, Link, LinkEnds, LinkInfo, Mode, Protocol};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
/// Whether the local end of a connection receives the stream or sends it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Receive,
Send,
}
/// What a caller asks for, as the listener's admission sees it.
#[derive(Clone, Debug)]
pub struct ConnRequest {
pub peer: SocketAddr,
pub stream_id: Option<String>,
/// The caller sent key material (it means to encrypt), of this key length in bytes.
pub keylen: Option<usize>,
/// The caller's proposed latency, ms: the larger of its two TSBPD delays.
pub latency_ms: u16,
}
/// What the admission gives a connection it accepts.
#[derive(Clone, Debug)]
pub struct Admitted {
/// The passphrase the connection is encrypted under; `None` for one in the clear.
pub passphrase: Option<String>,
pub role: Role,
/// The listener's latency for this connection, ms; `None` for its configured one.
pub latency_ms: Option<u16>,
/// The application's own handle, handed back on the link.
pub tag: u64,
}
/// The application's decision on each caller: admit it, or refuse it with a reason code
/// (`packet::rej`).
pub trait Admission: Send + Sync + 'static {
fn admit(&self, r: &ConnRequest) -> Result<Admitted, u32>;
}
impl<F> Admission for F
where
F: Fn(&ConnRequest) -> Result<Admitted, u32> + Send + Sync + 'static,
{
fn admit(&self, r: &ConnRequest) -> Result<Admitted, u32> {
self(r)
}
}
#[derive(Clone, Debug)]
pub struct ListenerConfig {
pub bind: SocketAddr,
/// The listener's latency, ms (§4.4: the connection's is the larger of the two ends').
pub latency_ms: u16,
/// A connection that receives nothing for this long is ended.
pub peer_idle_ms: u64,
pub mtu: u32,
pub flow_window: u32,
pub max_connections: usize,
/// The key length the INDUCTION reply advertises, bytes; 0 advertises none.
pub advertise_keylen: usize,
}
impl Default for ListenerConfig {
fn default() -> Self {
ListenerConfig { bind: "127.0.0.1:0".parse().unwrap(), latency_ms: 120, peer_idle_ms: 5_000, mtu: 1500, flow_window: 8192, max_connections: 64, advertise_keylen: 0 }
}
}
/// The Encryption Field of a key length (Table 2).
pub fn enc_field(keylen: usize) -> u16 {
match keylen {
16 => 2,
24 => 3,
32 => 4,
_ => 0,
}
}
fn random_id() -> u32 {
(crate::random_u32() & 0x3fff_ffff).max(1)
}
fn random_isn() -> u32 {
crate::random_u32() & 0x7fff_ffff
}
/// A running listener.
pub struct Listener {
pub addr: SocketAddr,
pub incoming: mpsc::Receiver<Link>,
/// Callers refused, by any reason.
pub refused: Arc<AtomicU64>,
task: tokio::task::JoinHandle<()>,
}
impl Listener {
pub async fn bind(cfg: ListenerConfig, admission: Arc<dyn Admission>) -> std::io::Result<Listener> {
let sock = Arc::new(UdpSocket::bind(cfg.bind).await?);
let addr = sock.local_addr()?;
let (tx, incoming) = mpsc::channel(64);
let refused = Arc::new(AtomicU64::new(0));
let task = tokio::spawn(listen(sock, cfg, admission, tx, refused.clone()));
Ok(Listener { addr, incoming, refused, task })
}
pub async fn accept(&mut self) -> Option<Link> {
self.incoming.recv().await
}
pub fn stop(&self) {
self.task.abort();
}
}
impl Drop for Listener {
fn drop(&mut self) {
self.task.abort();
}
}
/// The SYN cookie (§4.3.1.1): from the caller's address and port and the minute, under the
/// listener's secret.
fn cookie(secret: &[u8; 32], peer: SocketAddr, minute: u64) -> u32 {
let mut h = Sha256::new();
h.update(secret);
h.update(peer.ip().to_string().as_bytes());
h.update(peer.port().to_be_bytes());
h.update(minute.to_be_bytes());
let d = h.finalize();
u32::from_be_bytes([d[0], d[1], d[2], d[3]])
}
fn minute() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() / 60)
}
struct Accepted {
our_id: u32,
peer: SocketAddr,
reply: Vec<u8>,
tx: mpsc::Sender<Vec<u8>>,
}
#[allow(clippy::too_many_arguments)]
fn reply_to(hs_in: &Handshake, from: SocketAddr, hs_type: u32, socket_id: u32, cookie: u32, enc: u16, ext_field: u16, exts: Vec<Ext>, mtu: u32, fw: u32) -> Handshake {
Handshake { version: 5, enc_field: enc, ext_field, isn: hs_in.isn, mtu: hs_in.mtu.min(mtu), flow_window: hs_in.flow_window.min(fw), hs_type, socket_id, cookie, peer_ip: Handshake::ip_field(from.ip()), exts }
}
async fn listen(sock: Arc<UdpSocket>, cfg: ListenerConfig, admission: Arc<dyn Admission>, incoming: mpsc::Sender<Link>, refused: Arc<AtomicU64>) {
let secret: [u8; 32] = crate::random_bytes(32).try_into().unwrap();
let listener_id = random_id();
let started = Instant::now();
// Connections by the socket ID their packets name; and the CONCLUSION each answered,
// by (caller, caller's socket ID), sent again if the caller repeats it.
let mut by_id: HashMap<u32, Accepted> = HashMap::new();
let mut by_caller: HashMap<(SocketAddr, u32), u32> = HashMap::new();
let (done_tx, mut done_rx) = mpsc::unbounded_channel::<u32>();
let mut buf = vec![0u8; 65_536];
loop {
let (n, from) = tokio::select! {
r = sock.recv_from(&mut buf) => match r { Ok(x) => x, Err(_) => continue },
Some(id) = done_rx.recv() => {
if let Some(a) = by_id.remove(&id) {
by_caller.retain(|_, v| *v != a.our_id);
}
continue;
}
};
let b = &buf[..n];
let Some(dst) = Packet::dst_of(b) else { continue };
if dst != 0 && dst != listener_id {
if let Some(a) = by_id.get(&dst)
&& a.peer == from
{
let _ = a.tx.try_send(b.to_vec());
}
continue;
}
let Some(Packet::Control(c)) = Packet::read(b) else { continue };
if c.ctype != ctrl::HANDSHAKE {
continue;
}
let Some(h) = Handshake::read(&c.cif) else { continue };
let send_hs = |hs: &Handshake, dst: u32, ts: u32| Control::new(ctrl::HANDSHAKE, 0, ts, dst, hs.write()).write();
let now_ts = started.elapsed().as_micros() as u32;
match h.hs_type {
hs::INDUCTION => {
let ck = cookie(&secret, from, minute());
let r = reply_to(&h, from, hs::INDUCTION, listener_id, ck, enc_field(cfg.advertise_keylen), hs::MAGIC, Vec::new(), cfg.mtu, cfg.flow_window);
let _ = sock.send_to(&send_hs(&r, h.socket_id, now_ts), from).await;
}
hs::CONCLUSION => {
if let Some(id) = by_caller.get(&(from, h.socket_id))
&& let Some(a) = by_id.get(id)
{
let _ = sock.send_to(&a.reply, from).await;
continue;
}
let m = minute();
if h.cookie != cookie(&secret, from, m) && h.cookie != cookie(&secret, from, m.saturating_sub(1)) {
continue;
}
let reject = |code: u32| {
let r = reply_to(&h, from, hs::FAILURE_BASE + code, listener_id, 0, 0, 0, Vec::new(), cfg.mtu, cfg.flow_window);
send_hs(&r, h.socket_id, now_ts)
};
match conclude(&h, from, &cfg, admission.as_ref(), by_id.len()) {
Err(code) => {
refused.fetch_add(1, Ordering::Relaxed);
let _ = sock.send_to(&reject(code), from).await;
}
Ok(ok) => {
let our_id = loop {
let id = random_id();
if !by_id.contains_key(&id) && id != listener_id {
break id;
}
};
let reply = reply_to(&h, from, hs::CONCLUSION, our_id, 0, ok.enc, ok.ext_field, ok.exts.clone(), cfg.mtu, cfg.flow_window);
// The reply is stamped 0 on the new connection's clock, which its
// data packets' timestamps also count from.
let reply = send_hs(&reply, h.socket_id, 0);
let t0 = Instant::now();
let (dtx, drx) = mpsc::channel(4096);
let info = LinkInfo { protocol: Protocol::Srt, mode: Mode::Listener, peer: from, stream_id: ok.stream_id.clone(), cname: None, latency_us: ok.latency_ms as u64 * 1000, cipher_bits: (ok.keys.as_ref().map_or(0, |k| k.klen) * 8) as u16, tag: ok.tag };
let (link, ends) = pair(info, ok.role == Role::Send);
let machine = match ok.role {
Role::Receive => Machine::Recv(Box::new(Receiver::new(
RecvConfig { latency_us: ok.latency_ms as u64 * 1000, peer_idle_us: cfg.peer_idle_ms * 1000, flow_window: cfg.flow_window, require_crypto: ok.keys.is_some(), keys: ok.keys.clone(), passphrase: ok.passphrase.clone() },
h.socket_id,
h.isn,
c.ts,
t0,
ends.account.clone(),
ends.rtt_us.clone(),
))),
Role::Send => Machine::Send(Box::new(Sender::new(h.socket_id, h.isn, ok.latency_ms as u64 * 1000, cfg.peer_idle_ms * 1000, ok.keys.clone(), t0, ends.sent.clone(), ends.rtt_us.clone()))),
};
let _ = sock.send_to(&reply, from).await;
by_caller.insert((from, h.socket_id), our_id);
by_id.insert(our_id, Accepted { our_id, peer: from, reply, tx: dtx });
let done = done_tx.clone();
tokio::spawn(run(machine, sock.clone(), from, Source::Chan(drx), ends, Some((done, our_id))));
if incoming.send(link).await.is_err() {
return;
}
}
}
}
_ => {}
}
}
}
struct Concluded {
stream_id: Option<String>,
keys: Option<Keys>,
passphrase: Option<String>,
role: Role,
latency_ms: u16,
tag: u64,
enc: u16,
ext_field: u16,
exts: Vec<Ext>,
}
/// The listener's checks on a CONCLUSION, in order: its version and HSREQ, the stream ID,
/// the admission, then the keys.
fn conclude(h: &Handshake, from: SocketAddr, cfg: &ListenerConfig, admission: &dyn Admission, held: usize) -> Result<Concluded, u32> {
if h.version != 5 {
return Err(8); // REJ_VERSION: an HSv4 (UDT) caller
}
let req = h.ext(ext::HSREQ).and_then(|e| HsReq::read(&e.data)).ok_or(rej::ROGUE)?;
if req.flags & flags::STREAM != 0 {
return Err(rej::MESSAGEAPI);
}
let stream_id = match h.ext(ext::SID) {
Some(e) => Some(sid_read(&e.data).ok_or(rej::ROGUE)?),
None => None,
};
let km = match h.ext(ext::KMREQ) {
Some(e) => Some((KeyMaterial::read(&e.data).ok_or(rej::ROGUE)?, e.data.clone())),
None => None,
};
if held >= cfg.max_connections {
return Err(rej::OVERLOAD);
}
let r = ConnRequest { peer: from, stream_id: stream_id.clone(), keylen: km.as_ref().map(|k| k.0.klen), latency_ms: req.recv_delay_ms.max(req.send_delay_ms) };
let ok = admission.admit(&r)?;
let keys = match (&ok.passphrase, &km) {
(Some(p), Some((k, _))) => Some(Keys::from_km(k, p).map_err(|e| match e {
KmError::BadSecret => rej::BADSECRET,
KmError::Unsupported => rej::UNSECURE,
})?),
(None, None) => None,
// A passphrase the caller lacks, or one the listener does not have (§6.1.5;
// encryption is enforced both ways).
_ => return Err(rej::UNSECURE),
};
let ours = ok.latency_ms.unwrap_or(cfg.latency_ms);
// The caller's receive delay governs what the listener sends, its send delay what the
// listener receives (§4.4: the larger of the two ends').
let rcv = ours.max(req.send_delay_ms);
let snd = ours.max(req.recv_delay_ms);
let latency_ms = if ok.role == Role::Receive { rcv } else { snd };
let mut exts = vec![Ext { ty: ext::HSRSP, data: HsReq { version: SRT_VERSION, flags: flags::LIVE, recv_delay_ms: rcv, send_delay_ms: snd }.write() }];
let mut ext_field = ext::F_HSREQ;
let mut enc = 0;
if let Some((k, raw)) = &km {
exts.push(Ext { ty: ext::KMRSP, data: raw.clone() });
ext_field |= ext::F_KMREQ;
enc = enc_field(k.klen);
}
Ok(Concluded { stream_id, keys, passphrase: ok.passphrase, role: ok.role, latency_ms, tag: ok.tag, enc, ext_field, exts })
}
// ── The caller ─────────────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct CallConfig {
pub latency_ms: u16,
pub passphrase: Option<String>,
/// The key length, bytes: 16, 24 or 32.
pub keylen: usize,
pub stream_id: Option<String>,
pub peer_idle_ms: u64,
pub connect_timeout_ms: u64,
pub mtu: u32,
pub flow_window: u32,
pub role: Role,
/// The local address to call from; unspecified, any.
pub bind: Option<SocketAddr>,
}
impl Default for CallConfig {
fn default() -> Self {
CallConfig { latency_ms: 120, passphrase: None, keylen: 16, stream_id: None, peer_idle_ms: 5_000, connect_timeout_ms: 3_000, mtu: 1500, flow_window: 8192, role: Role::Send, bind: None }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallError {
Io(String),
Timeout,
/// The listener refused, with this reason code.
Rejected(u32),
/// The listener answered key material with a state, not the keys (a wrong or missing
/// passphrase on its side).
KeyMaterial(u32),
/// The listener's answer does not read.
Rogue,
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CallError::Io(e) => write!(f, "{e}"),
CallError::Timeout => write!(f, "the listener did not answer in time"),
CallError::Rejected(c) => write!(f, "rejected with reason {c}"),
CallError::KeyMaterial(s) => write!(f, "key material refused, state {s}"),
CallError::Rogue => write!(f, "the listener's answer does not read"),
}
}
}
/// Call the listener at `remote`, and return the link once the handshake completes.
pub async fn call(remote: SocketAddr, cfg: CallConfig) -> Result<Link, CallError> {
let bind = cfg.bind.unwrap_or_else(|| if remote.is_ipv4() { "0.0.0.0:0".parse().unwrap() } else { "[::]:0".parse().unwrap() });
let sock = Arc::new(UdpSocket::bind(bind).await.map_err(|e| CallError::Io(e.to_string()))?);
let our_id = random_id();
let isn = random_isn();
let t0 = Instant::now();
let deadline = t0 + Duration::from_millis(cfg.connect_timeout_ms);
let ts = || t0.elapsed().as_micros() as u32;
let base = Handshake { version: 4, enc_field: 0, ext_field: hs::UDT_DGRAM, isn, mtu: cfg.mtu, flow_window: cfg.flow_window, hs_type: hs::INDUCTION, socket_id: our_id, cookie: 0, peer_ip: Handshake::ip_field(remote.ip()), exts: Vec::new() };
let induction = Control::new(ctrl::HANDSHAKE, 0, 0, 0, base.write());
let mut buf = vec![0u8; 65_536];
// Each request is sent again every 250 ms until it is answered.
async fn exchange(sock: &UdpSocket, remote: SocketAddr, req: impl Fn() -> Vec<u8>, deadline: Instant, buf: &mut [u8], want: impl Fn(&Handshake) -> bool) -> Result<(Handshake, u32), CallError> {
loop {
if Instant::now() >= deadline {
return Err(CallError::Timeout);
}
sock.send_to(&req(), remote).await.map_err(|e| CallError::Io(e.to_string()))?;
let until = (Instant::now() + Duration::from_millis(250)).min(deadline);
while Instant::now() < until {
let r = tokio::time::timeout_at(tokio::time::Instant::from_std(until), sock.recv_from(buf)).await;
let Ok(Ok((n, from))) = r else { break };
if from != remote {
continue;
}
if let Some(Packet::Control(c)) = Packet::read(&buf[..n])
&& c.ctype == ctrl::HANDSHAKE
&& let Some(h) = Handshake::read(&c.cif)
&& want(&h)
{
return Ok((h, c.ts));
}
}
}
}
let (ind, _) = exchange(&sock, remote, || Control { ts: ts(), ..induction.clone() }.write(), deadline, &mut buf, |h| h.hs_type == hs::INDUCTION || (hs::FAILURE_BASE..hs::DONE).contains(&h.hs_type)).await?;
if (hs::FAILURE_BASE..hs::DONE).contains(&ind.hs_type) {
return Err(CallError::Rejected(ind.hs_type - hs::FAILURE_BASE));
}
if ind.version != 5 || ind.ext_field != hs::MAGIC {
return Err(CallError::Rogue);
}
// The CONCLUSION: HSREQ, the key material, the stream ID.
let mut exts = vec![Ext { ty: ext::HSREQ, data: HsReq { version: SRT_VERSION, flags: flags::LIVE, recv_delay_ms: cfg.latency_ms, send_delay_ms: cfg.latency_ms }.write() }];
let mut ext_field = ext::F_HSREQ;
let mut keys = None;
let mut km_sent = None;
if let Some(p) = &cfg.passphrase {
let (k, km) = Keys::generate(p, cfg.keylen);
let raw = km.write();
exts.push(Ext { ty: ext::KMREQ, data: raw.clone() });
ext_field |= ext::F_KMREQ;
keys = Some(k);
km_sent = Some(raw);
}
if let Some(s) = &cfg.stream_id {
exts.push(Ext { ty: ext::SID, data: sid_write(s) });
ext_field |= ext::F_CONFIG;
}
let conclusion = Handshake {
version: 5,
enc_field: if cfg.passphrase.is_some() { enc_field(cfg.keylen) } else { 0 },
ext_field,
hs_type: hs::CONCLUSION,
cookie: ind.cookie,
exts,
..base.clone()
};
let concl = Control::new(ctrl::HANDSHAKE, 0, 0, 0, conclusion.write());
let (rsp, rsp_ts) = exchange(&sock, remote, || Control { ts: ts(), ..concl.clone() }.write(), deadline, &mut buf, |h| h.hs_type == hs::CONCLUSION || (hs::FAILURE_BASE..hs::DONE).contains(&h.hs_type)).await?;
if (hs::FAILURE_BASE..hs::DONE).contains(&rsp.hs_type) {
return Err(CallError::Rejected(rsp.hs_type - hs::FAILURE_BASE));
}
let hsrsp = rsp.ext(ext::HSRSP).and_then(|e| HsReq::read(&e.data)).ok_or(CallError::Rogue)?;
if let Some(raw) = &km_sent {
match rsp.ext(ext::KMRSP) {
Some(e) if e.data == *raw => {}
Some(e) if e.data.len() == 4 => return Err(CallError::KeyMaterial(u32::from_be_bytes(e.data[..4].try_into().unwrap()))),
_ => return Err(CallError::Rogue),
}
}
let peer_id = rsp.socket_id;
// The connection's latency each way is the larger of the two ends' (§4.4).
let rcv = cfg.latency_ms.max(hsrsp.send_delay_ms);
let snd = cfg.latency_ms.max(hsrsp.recv_delay_ms);
let latency_ms = if cfg.role == Role::Receive { rcv } else { snd };
let info = LinkInfo { protocol: Protocol::Srt, mode: Mode::Caller, peer: remote, stream_id: cfg.stream_id.clone(), cname: None, latency_us: latency_ms as u64 * 1000, cipher_bits: keys.as_ref().map_or(0, |k: &Keys| k.klen as u16 * 8), tag: 0 };
let (link, ends) = pair(info, cfg.role == Role::Send);
let machine = match cfg.role {
Role::Receive => {
// The listener's data is stamped on the clock its CONCLUSION reply was.
let r = Receiver::new(
RecvConfig { latency_us: rcv as u64 * 1000, peer_idle_us: cfg.peer_idle_ms * 1000, flow_window: cfg.flow_window, require_crypto: keys.is_some(), keys: keys.clone(), passphrase: cfg.passphrase.clone() },
peer_id,
isn,
rsp_ts,
Instant::now(),
ends.account.clone(),
ends.rtt_us.clone(),
);
Machine::Recv(Box::new(r))
}
Role::Send => Machine::Send(Box::new(Sender::new(peer_id, isn, snd as u64 * 1000, cfg.peer_idle_ms * 1000, keys, t0, ends.sent.clone(), ends.rtt_us.clone()))),
};
tokio::spawn(run(machine, sock, remote, Source::Socket, ends, None));
Ok(link)
}
// ── The connection task ────────────────────────────────────────────────────────────
pub(crate) enum Machine {
Recv(Box<Receiver>),
Send(Box<Sender>),
}
impl Machine {
fn t0(&self) -> Instant {
match self {
Machine::Recv(r) => r.t0(),
Machine::Send(s) => s.t0(),
}
}
fn on_datagram(&mut self, b: &[u8]) {
match self {
Machine::Recv(r) => r.on_datagram(b),
Machine::Send(s) => s.on_datagram(b),
}
}
fn on_timer(&mut self) {
match self {
Machine::Recv(r) => r.on_timer(),
Machine::Send(s) => s.on_timer(),
}
}
fn next_wake_us(&self) -> u64 {
match self {
Machine::Recv(r) => r.next_wake_us(),
Machine::Send(s) => s.next_wake_us(),
}
}
fn closed(&self) -> Option<String> {
match self {
Machine::Recv(r) => r.closed.clone(),
Machine::Send(s) => s.closed.clone(),
}
}
fn close(&mut self, why: &str) {
match self {
Machine::Recv(r) => r.close(why),
Machine::Send(s) => s.close(why),
}
}
fn out(&mut self) -> Vec<Vec<u8>> {
match self {
Machine::Recv(r) => std::mem::take(&mut r.out),
Machine::Send(s) => std::mem::take(&mut s.out),
}
}
fn delivered(&mut self) -> Vec<Vec<u8>> {
match self {
Machine::Recv(r) => std::mem::take(&mut r.delivered),
Machine::Send(_) => Vec::new(),
}
}
}
pub(crate) enum Source {
Chan(mpsc::Receiver<Vec<u8>>),
Socket,
}
async fn run(mut m: Machine, sock: Arc<UdpSocket>, peer: SocketAddr, mut src: Source, mut ends: LinkEnds, done: Option<(mpsc::UnboundedSender<u32>, u32)>) {
let mut buf = vec![0u8; 65_536];
let t0 = m.t0();
let reason = loop {
for p in m.out() {
let _ = sock.send_to(&p, peer).await;
}
for d in m.delivered() {
let _ = ends.data_tx.try_send(d);
}
if let Some(r) = m.closed() {
break r;
}
let wake = tokio::time::Instant::from_std(t0 + Duration::from_micros(m.next_wake_us()));
tokio::select! {
d = async {
match &mut src {
Source::Chan(rx) => rx.recv().await,
Source::Socket => loop {
match sock.recv_from(&mut buf).await {
Ok((n, from)) if from == peer => break Some(buf[..n].to_vec()),
Ok(_) => continue,
Err(_) => continue,
}
},
}
} => match d {
Some(d) => m.on_datagram(&d),
None => m.close("listener_stopped"),
},
p = async {
match &mut ends.send_rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => match (p, &mut m) {
(Some(p), Machine::Send(s)) => s.send(p),
(None, _) => m.close("closed"),
_ => {}
},
_ = tokio::time::sleep_until(wake) => m.on_timer(),
c = ends.cmd_rx.recv() => match c {
Some(Cmd::Close) | None => m.close("closed"),
},
}
};
for p in m.out() {
let _ = sock.send_to(&p, peer).await;
}
for d in m.delivered() {
let _ = ends.data_tx.try_send(d);
}
let _ = ends.closed_tx.send(Some(reason));
if let Some((tx, id)) = done {
let _ = tx.send(id);
}
}

View file

@ -0,0 +1,140 @@
//! SRT's encryption (`draft-sharabayko-srt-01` §6): the stream-encrypting key (SEK)
//! wrapped (RFC 3394) under a key-encrypting key (KEK) derived from the passphrase by
//! PBKDF2-HMAC-SHA1 over the last 64 bits of the salt with 2048 iterations, the KEK as long
//! as the SEK; the payload in AES-CTR with the counter block
//!
//! ```text
//! bytes 0..14: the salt's first 112 bits, XOR the packet index (its sequence number,
//! big-endian) at bytes 10..14; bytes 14..16: the block counter, from 0.
//! ```
//!
//! (§6.1.2: "a block counter in the least significant 16 bits", "a packet index … in the
//! next 32 bits", XORed with the salt's most significant 112 bits.)
use super::packet::{KeyMaterial, CIPHER_AES_CTR, SE_MPEG_TS_SRT};
use crate::crypto::{ctr_apply, key_unwrap, key_wrap, pbkdf2_hmac_sha1, AesKey};
pub const PBKDF2_ITER: u32 = 2048;
pub const SALT_LEN: usize = 16;
/// The keys of one direction: the even and odd SEKs, and the salt.
#[derive(Clone, Debug)]
pub struct Keys {
pub sek: [Option<Vec<u8>>; 2],
pub salt: Vec<u8>,
pub klen: usize,
}
/// The KEK for `passphrase`, `salt` and a key of `klen` bytes.
pub fn kek(passphrase: &str, salt: &[u8], klen: usize) -> Vec<u8> {
let s = &salt[salt.len().saturating_sub(8)..];
pbkdf2_hmac_sha1(passphrase.as_bytes(), s, PBKDF2_ITER, klen)
}
/// Why a key material message is not accepted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KmError {
/// It does not read, or names a cipher, an authentication or a salt this
/// implementation does not use.
Unsupported,
/// The wrap's integrity check failed: the passphrase is not the sender's.
BadSecret,
}
impl Keys {
/// A fresh even SEK and salt of `klen` bytes, and the key material message wrapping it
/// under `passphrase`.
pub fn generate(passphrase: &str, klen: usize) -> (Keys, KeyMaterial) {
let sek = crate::random_bytes(klen);
let salt = crate::random_bytes(SALT_LEN);
let k = AesKey::new(&kek(passphrase, &salt, klen)).expect("a KEK of 16, 24 or 32 bytes");
let wrap = key_wrap(&k, &sek).expect("a key of 16, 24 or 32 bytes wraps");
let km = KeyMaterial { kk: 1, keki: 0, cipher: CIPHER_AES_CTR, auth: 0, se: SE_MPEG_TS_SRT, salt: salt.clone(), klen, wrap };
(Keys { sek: [Some(sek), None], salt, klen }, km)
}
/// The keys a key material message wraps under `passphrase`.
pub fn from_km(km: &KeyMaterial, passphrase: &str) -> Result<Keys, KmError> {
if km.cipher != CIPHER_AES_CTR || km.auth != 0 || km.salt.len() != SALT_LEN || km.keki != 0 {
return Err(KmError::Unsupported);
}
let k = AesKey::new(&kek(passphrase, &km.salt, km.klen)).ok_or(KmError::Unsupported)?;
let keys = key_unwrap(&k, &km.wrap).ok_or(KmError::BadSecret)?;
let mut sek = [None, None];
match km.kk {
1 => sek[0] = Some(keys),
2 => sek[1] = Some(keys),
3 if keys.len() == 2 * km.klen => {
sek[0] = Some(keys[..km.klen].to_vec());
sek[1] = Some(keys[km.klen..].to_vec());
}
_ => return Err(KmError::Unsupported),
}
Ok(Keys { sek, salt: km.salt.clone(), klen: km.klen })
}
/// Merge the keys of a refreshed key material message (§6.1.6): each key it carries
/// replaces the one of its parity.
pub fn merge(&mut self, other: Keys) {
for (i, k) in other.sek.into_iter().enumerate() {
if k.is_some() {
self.sek[i] = k;
}
}
self.salt = other.salt;
}
/// Encrypt or decrypt (the same operation) the payload of packet `seq` under the key of
/// parity `kk` (1 even, 2 odd). `false` when that key is not held.
pub fn apply(&self, kk: u8, seq: u32, payload: &mut [u8]) -> bool {
let Some(Some(sek)) = self.sek.get(match kk {
1 => 0,
2 => 1,
_ => return false,
}) else {
return false;
};
let mut iv = [0u8; 16];
iv[..14].copy_from_slice(&self.salt[..14]);
for (i, b) in (seq & 0x7fff_ffff).to_be_bytes().iter().enumerate() {
iv[10 + i] ^= b;
}
ctr_apply(sek, &iv, payload)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_wrapped_key_unwraps_under_the_passphrase_only() {
let (tx, km) = Keys::generate("a passphrase of ten+", 16);
let rx = Keys::from_km(&km, "a passphrase of ten+").unwrap();
assert_eq!(rx.sek[0], tx.sek[0]);
assert_eq!(Keys::from_km(&km, "another passphrase").unwrap_err(), KmError::BadSecret);
let mut p = vec![0x47u8; 1316];
assert!(tx.apply(1, 77, &mut p));
assert_ne!(p[0], 0x47);
assert!(rx.apply(1, 77, &mut p));
assert_eq!(p, vec![0x47u8; 1316]);
assert!(!rx.apply(2, 77, &mut p), "no odd key is held");
}
#[test]
fn the_counter_block_holds_the_packet_index() {
// With a zero key and salt, the first keystream block is AES_0(0^10 || seq || 0^2);
// two packets differ in their keystream.
let k = Keys { sek: [Some(vec![0; 16]), None], salt: vec![0; 16], klen: 16 };
let mut a = vec![0u8; 16];
let mut b = vec![0u8; 16];
k.apply(1, 1, &mut a);
k.apply(1, 2, &mut b);
assert_ne!(a, b);
let mut iv = [0u8; 16];
iv[13] = 1;
let mut c = vec![0u8; 16];
crate::crypto::ctr_apply(&[0; 16], &iv, &mut c);
assert_eq!(a, c);
}
}

View file

@ -0,0 +1,26 @@
//! SRT (`draft-sharabayko-srt-01`): a listener and a caller, live mode.
//!
//! - [`packet`]: the wire format.
//! - [`crypt`]: the key material and AES-CTR.
//! - [`recv`], [`send`]: the receiving and sending sides of a connection, without I/O.
//! - [`conn`]: the listener, which serves many callers on one UDP port, each found by the
//! socket ID its packets name, and admits each by its stream ID through the
//! application's [`conn::Admission`]; and [`conn::call`], the caller.
//!
//! The handshake is the caller-listener one of §4.3.1, HSv5: the INDUCTION exchange gives
//! the caller a SYN cookie made from its address, its port and the minute, with the
//! listener's secret; the CONCLUSION carries the HSREQ, the stream ID and, for an encrypted
//! connection, the key material, and its answer the HSRSP and the key material back, or a
//! rejection: a handshake type of 1000 plus the reason code.
pub mod conn;
pub mod crypt;
pub mod packet;
pub mod recv;
pub mod send;
pub use conn::{call, Admission, Admitted, CallConfig, ConnRequest, Listener, ListenerConfig, Role};
/// The SRT version this implementation states in its HSREQ and HSRSP: 1.5.0, the HSv5
/// handshake and live mode as the draft gives them.
pub const SRT_VERSION: u32 = 0x01_05_00;

View file

@ -0,0 +1,495 @@
//! SRT packets on the wire (`draft-sharabayko-srt-01` §3): the 16-byte header, data packets
//! (§3.1), control packets (§3.2) and the handshake's CIF and extensions (§3.2.1), the key
//! material message (§3.2.2), ACK (§3.2.4), NAK and its loss list (§3.2.5, Appendix A).
//!
//! Every field is big-endian, but the stream ID extension's contents, which go as 32-bit
//! words each written little-endian (§3.2.1.3).
pub const HEADER: usize = 16;
/// The handshake CIF before its extensions (Figure 5).
pub const HS_CIF: usize = 48;
pub mod ctrl {
pub const HANDSHAKE: u16 = 0x0000;
pub const KEEPALIVE: u16 = 0x0001;
pub const ACK: u16 = 0x0002;
pub const NAK: u16 = 0x0003;
pub const CONGESTION: u16 = 0x0004;
pub const SHUTDOWN: u16 = 0x0005;
pub const ACKACK: u16 = 0x0006;
pub const DROPREQ: u16 = 0x0007;
pub const PEERERROR: u16 = 0x0008;
pub const USER: u16 = 0x7fff;
}
/// Handshake types (Table 4); a rejection is 1000 plus its reason code (Table 7, and the
/// access-control codes 1400 and above).
pub mod hs {
pub const DONE: u32 = 0xffff_fffd;
pub const AGREEMENT: u32 = 0xffff_fffe;
pub const CONCLUSION: u32 = 0xffff_ffff;
pub const WAVEAHAND: u32 = 0;
pub const INDUCTION: u32 = 1;
pub const FAILURE_BASE: u32 = 1000;
/// The listener's INDUCTION reply's Extension Field (§4.3.1.1).
pub const MAGIC: u16 = 0x4a17;
/// The caller's INDUCTION Extension Field (§4.3.1.1).
pub const UDT_DGRAM: u16 = 2;
}
/// Handshake extension types (Table 5) and the Extension Field flags (Table 3).
pub mod ext {
pub const HSREQ: u16 = 1;
pub const HSRSP: u16 = 2;
pub const KMREQ: u16 = 3;
pub const KMRSP: u16 = 4;
pub const SID: u16 = 5;
pub const CONGESTION: u16 = 6;
pub const FILTER: u16 = 7;
pub const GROUP: u16 = 8;
pub const F_HSREQ: u16 = 0x1;
pub const F_KMREQ: u16 = 0x2;
pub const F_CONFIG: u16 = 0x4;
}
/// HSREQ/HSRSP flags (Table 6).
pub mod flags {
pub const TSBPDSND: u32 = 0x01;
pub const TSBPDRCV: u32 = 0x02;
pub const CRYPT: u32 = 0x04;
pub const TLPKTDROP: u32 = 0x08;
pub const PERIODICNAK: u32 = 0x10;
pub const REXMITFLG: u32 = 0x20;
pub const STREAM: u32 = 0x40;
pub const PACKET_FILTER: u32 = 0x80;
/// What live mode needs from a peer (§7.1).
pub const LIVE: u32 = TSBPDSND | TSBPDRCV | CRYPT | TLPKTDROP | PERIODICNAK | REXMITFLG;
}
/// Rejection reason codes: the protocol's (Table 7) and the access-control codes of the
/// stream ID convention, which a listener's callback gives.
pub mod rej {
pub const UNKNOWN: u32 = 0;
pub const ROGUE: u32 = 4;
pub const BACKLOG: u32 = 5;
pub const BADSECRET: u32 = 10;
pub const UNSECURE: u32 = 11;
pub const MESSAGEAPI: u32 = 12;
pub const BAD_REQUEST: u32 = 1400;
pub const UNAUTHORIZED: u32 = 1401;
pub const OVERLOAD: u32 = 1402;
pub const FORBIDDEN: u32 = 1403;
pub const NOTFOUND: u32 = 1404;
pub const BAD_MODE: u32 = 1405;
pub const UNACCEPTABLE: u32 = 1406;
pub const CONFLICT: u32 = 1409;
}
fn be32(b: &[u8], i: usize) -> u32 {
u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]])
}
/// A data packet (§3.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data {
/// 31 bits.
pub seq: u32,
/// Packet position: 0b11 a whole message.
pub pp: u8,
pub order: bool,
/// 0 clear, 1 even key, 2 odd key.
pub kk: u8,
pub rexmit: bool,
/// 26 bits.
pub msgno: u32,
pub ts: u32,
pub dst: u32,
pub payload: Vec<u8>,
}
/// A control packet (§3.2), its CIF unread.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Control {
pub ctype: u16,
pub subtype: u16,
pub info: u32,
pub ts: u32,
pub dst: u32,
pub cif: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Packet {
Data(Data),
Control(Control),
}
impl Packet {
pub fn read(b: &[u8]) -> Option<Packet> {
if b.len() < HEADER {
return None;
}
let w0 = be32(b, 0);
let w1 = be32(b, 4);
let ts = be32(b, 8);
let dst = be32(b, 12);
Some(if w0 & 0x8000_0000 == 0 {
Packet::Data(Data {
seq: w0 & 0x7fff_ffff,
pp: (w1 >> 30) as u8,
order: w1 & 0x2000_0000 != 0,
kk: ((w1 >> 27) & 0x3) as u8,
rexmit: w1 & 0x0400_0000 != 0,
msgno: w1 & 0x03ff_ffff,
ts,
dst,
payload: b[HEADER..].to_vec(),
})
} else {
Packet::Control(Control { ctype: ((w0 >> 16) & 0x7fff) as u16, subtype: w0 as u16, info: w1, ts, dst, cif: b[HEADER..].to_vec() })
})
}
/// The destination socket ID of a datagram, without reading the rest.
pub fn dst_of(b: &[u8]) -> Option<u32> {
(b.len() >= HEADER).then(|| be32(b, 12))
}
}
impl Data {
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(HEADER + self.payload.len());
o.extend_from_slice(&(self.seq & 0x7fff_ffff).to_be_bytes());
let w1 = ((self.pp as u32 & 0x3) << 30) | ((self.order as u32) << 29) | ((self.kk as u32 & 0x3) << 27) | ((self.rexmit as u32) << 26) | (self.msgno & 0x03ff_ffff);
o.extend_from_slice(&w1.to_be_bytes());
o.extend_from_slice(&self.ts.to_be_bytes());
o.extend_from_slice(&self.dst.to_be_bytes());
o.extend_from_slice(&self.payload);
o
}
}
impl Control {
pub fn new(ctype: u16, info: u32, ts: u32, dst: u32, cif: Vec<u8>) -> Control {
Control { ctype, subtype: 0, info, ts, dst, cif }
}
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(HEADER + self.cif.len());
let w0 = 0x8000_0000 | ((self.ctype as u32 & 0x7fff) << 16) | self.subtype as u32;
o.extend_from_slice(&w0.to_be_bytes());
o.extend_from_slice(&self.info.to_be_bytes());
o.extend_from_slice(&self.ts.to_be_bytes());
o.extend_from_slice(&self.dst.to_be_bytes());
o.extend_from_slice(&self.cif);
o
}
/// The CIF as 32-bit words.
pub fn words(&self) -> Vec<u32> {
self.cif.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()
}
}
/// One handshake extension block: its type and contents (a whole number of words).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ext {
pub ty: u16,
pub data: Vec<u8>,
}
/// The handshake CIF (Figure 5) and its extensions.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Handshake {
pub version: u32,
pub enc_field: u16,
pub ext_field: u16,
pub isn: u32,
pub mtu: u32,
pub flow_window: u32,
pub hs_type: u32,
pub socket_id: u32,
pub cookie: u32,
pub peer_ip: [u8; 16],
pub exts: Vec<Ext>,
}
impl Handshake {
pub fn read(cif: &[u8]) -> Option<Handshake> {
if cif.len() < HS_CIF {
return None;
}
let mut exts = Vec::new();
let mut i = HS_CIF;
while i + 4 <= cif.len() {
let ty = u16::from_be_bytes([cif[i], cif[i + 1]]);
let words = u16::from_be_bytes([cif[i + 2], cif[i + 3]]) as usize;
let end = i + 4 + 4 * words;
if end > cif.len() {
return None;
}
exts.push(Ext { ty, data: cif[i + 4..end].to_vec() });
i = end;
}
Some(Handshake {
version: be32(cif, 0),
enc_field: u16::from_be_bytes([cif[4], cif[5]]),
ext_field: u16::from_be_bytes([cif[6], cif[7]]),
isn: be32(cif, 8),
mtu: be32(cif, 12),
flow_window: be32(cif, 16),
hs_type: be32(cif, 20),
socket_id: be32(cif, 24),
cookie: be32(cif, 28),
peer_ip: cif[32..48].try_into().unwrap(),
exts,
})
}
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(HS_CIF + 64);
o.extend_from_slice(&self.version.to_be_bytes());
o.extend_from_slice(&self.enc_field.to_be_bytes());
o.extend_from_slice(&self.ext_field.to_be_bytes());
for w in [self.isn, self.mtu, self.flow_window, self.hs_type, self.socket_id, self.cookie] {
o.extend_from_slice(&w.to_be_bytes());
}
o.extend_from_slice(&self.peer_ip);
for e in &self.exts {
debug_assert!(e.data.len().is_multiple_of(4));
o.extend_from_slice(&e.ty.to_be_bytes());
o.extend_from_slice(&((e.data.len() / 4) as u16).to_be_bytes());
o.extend_from_slice(&e.data);
}
o
}
pub fn ext(&self, ty: u16) -> Option<&Ext> {
self.exts.iter().find(|e| e.ty == ty)
}
/// The Peer IP field as the reference implementation fills it: an IPv4 address in the
/// first word with its bytes reversed (it stores the address's bytes as a host-order
/// word, which the wire's byte order then reverses), the rest zero; an IPv6 address's
/// four words each reversed. A reader ignores the field.
pub fn ip_field(addr: std::net::IpAddr) -> [u8; 16] {
let mut f = [0u8; 16];
match addr {
std::net::IpAddr::V4(a) => {
let o = a.octets();
f[..4].copy_from_slice(&[o[3], o[2], o[1], o[0]]);
}
std::net::IpAddr::V6(a) => {
for (k, c) in a.octets().chunks(4).enumerate() {
f[4 * k..4 * k + 4].copy_from_slice(&[c[3], c[2], c[1], c[0]]);
}
}
}
f
}
}
/// HSREQ/HSRSP contents (Figure 6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HsReq {
pub version: u32,
pub flags: u32,
/// The sender of this message's receiver TSBPD delay, ms.
pub recv_delay_ms: u16,
/// Its sender TSBPD delay, ms.
pub send_delay_ms: u16,
}
impl HsReq {
pub fn read(b: &[u8]) -> Option<HsReq> {
(b.len() >= 12).then(|| HsReq { version: be32(b, 0), flags: be32(b, 4), recv_delay_ms: u16::from_be_bytes([b[8], b[9]]), send_delay_ms: u16::from_be_bytes([b[10], b[11]]) })
}
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(12);
o.extend_from_slice(&self.version.to_be_bytes());
o.extend_from_slice(&self.flags.to_be_bytes());
o.extend_from_slice(&self.recv_delay_ms.to_be_bytes());
o.extend_from_slice(&self.send_delay_ms.to_be_bytes());
o
}
}
/// The stream ID extension's contents for `s`: its bytes zero-padded to a whole number of
/// words, each word's four bytes reversed (§3.2.1.3: "stored as 32-bit little endian
/// words").
pub fn sid_write(s: &str) -> Vec<u8> {
let mut b = s.as_bytes().to_vec();
while !b.len().is_multiple_of(4) {
b.push(0);
}
for w in b.chunks_mut(4) {
w.reverse();
}
b
}
/// The stream ID back out of an extension's contents; `None` past 512 bytes or when it is
/// not UTF-8.
pub fn sid_read(b: &[u8]) -> Option<String> {
if b.len() > 512 {
return None;
}
let mut v = b.to_vec();
for w in v.chunks_mut(4) {
w.reverse();
}
while v.last() == Some(&0) {
v.pop();
}
String::from_utf8(v).ok()
}
/// A key material message (§3.2.2, Figure 10).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyMaterial {
/// Which keys it carries: 1 even, 2 odd, 3 both.
pub kk: u8,
pub keki: u32,
pub cipher: u8,
pub auth: u8,
pub se: u8,
pub salt: Vec<u8>,
/// The SEK length in bytes: 16, 24 or 32.
pub klen: usize,
pub wrap: Vec<u8>,
}
/// The KM message's fixed first word: S = 0, V = 1, PT = 2 (KMmsg), then the signature
/// 0x2029.
pub const KM_W0: [u8; 3] = [0x12, 0x20, 0x29];
pub const CIPHER_AES_CTR: u8 = 2;
pub const SE_MPEG_TS_SRT: u8 = 2;
impl KeyMaterial {
pub fn read(b: &[u8]) -> Option<KeyMaterial> {
if b.len() < 16 || b[..3] != KM_W0 {
return None;
}
let kk = b[3] & 0x3;
let slen = b[14] as usize * 4;
let klen = b[15] as usize * 4;
let n = if kk == 3 { 2 } else { 1 };
let wrap_len = 8 + n * klen;
if kk == 0 || !matches!(klen, 16 | 24 | 32) || b.len() < 16 + slen + wrap_len {
return None;
}
Some(KeyMaterial { kk, keki: be32(b, 4), cipher: b[8], auth: b[9], se: b[10], salt: b[16..16 + slen].to_vec(), klen, wrap: b[16 + slen..16 + slen + wrap_len].to_vec() })
}
pub fn write(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(16 + self.salt.len() + self.wrap.len());
o.extend_from_slice(&KM_W0);
o.push(self.kk & 0x3);
o.extend_from_slice(&self.keki.to_be_bytes());
o.extend_from_slice(&[self.cipher, self.auth, self.se, 0, 0, 0, (self.salt.len() / 4) as u8, (self.klen / 4) as u8]);
o.extend_from_slice(&self.salt);
o.extend_from_slice(&self.wrap);
while !o.len().is_multiple_of(4) {
o.push(0);
}
o
}
}
/// A NAK's loss list (§3.2.5, Appendix A) for the ascending, inclusive ranges `ranges` of
/// 31-bit sequence numbers: a range of one is its number, a longer one its first with the
/// top bit set, then its last.
pub fn loss_list_write(ranges: &[(u32, u32)]) -> Vec<u8> {
let mut o = Vec::with_capacity(ranges.len() * 8);
for &(a, b) in ranges {
if a == b {
o.extend_from_slice(&(a & 0x7fff_ffff).to_be_bytes());
} else {
o.extend_from_slice(&(0x8000_0000 | (a & 0x7fff_ffff)).to_be_bytes());
o.extend_from_slice(&(b & 0x7fff_ffff).to_be_bytes());
}
}
o
}
/// The ranges of a loss list; `None` when a range's start has no end.
pub fn loss_list_read(b: &[u8]) -> Option<Vec<(u32, u32)>> {
let w: Vec<u32> = b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect();
let mut out = Vec::new();
let mut i = 0;
while i < w.len() {
if w[i] & 0x8000_0000 != 0 {
let end = *w.get(i + 1)?;
out.push((w[i] & 0x7fff_ffff, end & 0x7fff_ffff));
i += 2;
} else {
out.push((w[i], w[i]));
i += 1;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_round_trips() {
let d = Data { seq: 0x7fff_fffe, pp: 3, order: false, kk: 1, rexmit: true, msgno: 0x03ff_ffff, ts: 123_456, dst: 0xdead_beef, payload: vec![0x47; 1316] };
let b = d.write();
assert_eq!(b[0] & 0x80, 0);
assert_eq!(Packet::read(&b), Some(Packet::Data(d)));
}
#[test]
fn a_handshake_round_trips_with_extensions() {
let h = Handshake {
version: 5,
enc_field: 2,
ext_field: ext::F_HSREQ | ext::F_KMREQ | ext::F_CONFIG,
isn: 0x1234_5678,
mtu: 1500,
flow_window: 8192,
hs_type: hs::CONCLUSION,
socket_id: 0x0101_0101,
cookie: 0x55aa_55aa,
peer_ip: Handshake::ip_field("127.0.0.1".parse().unwrap()),
exts: vec![
Ext { ty: ext::HSREQ, data: HsReq { version: 0x010500, flags: flags::LIVE, recv_delay_ms: 120, send_delay_ms: 120 }.write() },
Ext { ty: ext::SID, data: sid_write("#!::r=live/x,m=publish") },
],
};
let c = Control::new(ctrl::HANDSHAKE, 0, 7, 0, h.write());
let Packet::Control(c2) = Packet::read(&c.write()).unwrap() else { panic!() };
let h2 = Handshake::read(&c2.cif).unwrap();
assert_eq!(h2, h);
assert_eq!(h2.peer_ip[..4], [1, 0, 0, 127]);
assert_eq!(sid_read(&h2.ext(ext::SID).unwrap().data).as_deref(), Some("#!::r=live/x,m=publish"));
assert_eq!(HsReq::read(&h2.ext(ext::HSREQ).unwrap().data).unwrap().recv_delay_ms, 120);
}
#[test]
fn the_stream_id_goes_as_little_endian_words() {
assert_eq!(sid_write("abcde"), b"dcba\0\0\0e".to_vec());
assert_eq!(sid_read(b"dcba\0\0\0e").as_deref(), Some("abcde"));
}
#[test]
fn loss_lists() {
let r = vec![(5, 5), (10, 20), (0x7fff_fff0, 0x7fff_ffff)];
let b = loss_list_write(&r);
assert_eq!(b.len(), 4 + 8 + 8);
assert_eq!(loss_list_read(&b).unwrap(), r);
assert!(loss_list_read(&(0x8000_0001u32).to_be_bytes()).is_none());
}
#[test]
fn key_material_round_trips() {
let km = KeyMaterial { kk: 1, keki: 0, cipher: CIPHER_AES_CTR, auth: 0, se: SE_MPEG_TS_SRT, salt: vec![9; 16], klen: 16, wrap: vec![3; 24] };
let b = km.write();
assert_eq!(&b[..4], &[0x12, 0x20, 0x29, 0x01]);
assert_eq!(b.len(), 16 + 16 + 24);
assert_eq!(KeyMaterial::read(&b).unwrap(), km);
}
}

View file

@ -0,0 +1,489 @@
//! The receiving side of an SRT connection in live mode, without I/O: datagrams and the
//! time in, control packets and delivered payloads out.
//!
//! - **TSBPD** (§4.5): a packet is delivered at `base + timestamp + latency + drift` on the
//! receiver's clock, `base` taken when the handshake carrying the HSREQ arrived (§4.5.1.1),
//! the timestamp extended past its 32 bits, and `drift` the drift tracer's correction
//! (§4.7): the mean, over 1 000 ACKACK arrivals, of how far each arrived (less half the
//! RTT) from where `base` put it, applied once it passes 5 ms.
//! - **Too-late packet drop** (§4.6): when the next packet to deliver is missing and the
//! first packet after it is due, the missing ones are dropped.
//! - **ACK** (§3.2.4, §4.8.1): a full ACK every 10 ms while the acknowledged position moves
//! (and at least every 100 ms, so the RTT is measured), a light ACK every 64 packets; the
//! position is the first sequence number neither received nor resolved.
//! - **NAK** (§3.2.5, §4.8.2): at once for each gap found, and periodically, every
//! (RTT + 4 × RTTVar) / 2 with a 20 ms floor, for every packet still missing.
//! - **RTT** (§4.10) from ACK/ACKACK pairs: RTT = 7/8 RTT + 1/8 rtt, RTTVar = 3/4 RTTVar +
//! 1/4 |RTT − rtt|, from 100 ms and 50 ms.
//! - **Keys** (§6.3): payloads decrypted by the key of the parity their KK field names; a
//! refreshed key material message (§6.1.6, a user-defined control packet of subtype
//! KMREQ) is answered with the same message as KMRSP.
use super::crypt::Keys;
use super::packet::{ctrl, ext, loss_list_write, Control, Data, KeyMaterial, Packet};
use crate::seq::Extender;
use crate::stats::{Account, Outcome};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
const ACK_PERIOD_US: u64 = 10_000;
const ACK_MAX_SILENCE_US: u64 = 100_000;
const LIGHT_ACK_EVERY: u32 = 64;
const NAK_FLOOR_US: u64 = 20_000;
const KEEPALIVE_US: u64 = 1_000_000;
const DRIFT_SAMPLES: u64 = 1_000;
const DRIFT_MAX_US: i64 = 5_000;
/// The most loss-list bytes in one NAK: a datagram stays within 1500 bytes.
const NAK_MAX_CIF: usize = 1400;
/// How many dropped sequence numbers are remembered to tell a late arrival from a
/// duplicate.
const RECENT_DROPS: usize = 4096;
struct Slot {
payload: Vec<u8>,
ts: u64,
arrived_us: u64,
/// For a packet that was found missing first: when.
missed_at: Option<u64>,
}
struct Miss {
found_us: u64,
}
pub struct RecvConfig {
pub latency_us: u64,
pub peer_idle_us: u64,
pub flow_window: u32,
/// Payloads must arrive encrypted.
pub require_crypto: bool,
pub keys: Option<Keys>,
/// The passphrase, for refreshed key material.
pub passphrase: Option<String>,
}
pub struct Receiver {
cfg: RecvConfig,
pub peer_id: u32,
t0: Instant,
/// local µs = base + sender timestamp µs.
base: i64,
anchored: bool,
drift: i64,
drift_sum: i64,
drift_n: u64,
ts_ext: Extender,
seq_ext: Extender,
next: u64,
highest: u64,
buf: BTreeMap<u64, Slot>,
missing: BTreeMap<u64, Miss>,
recent_drops: BTreeSet<u64>,
recent_order: VecDeque<u64>,
pub account: Arc<Mutex<Account>>,
rtt: u64,
rtt_var: u64,
pub rtt_shared: Arc<AtomicU64>,
ack_no: u32,
acks: VecDeque<(u32, u64)>,
last_full_ack_us: u64,
last_ack_pos: u64,
since_light: u32,
last_nak_us: u64,
last_sent_us: u64,
last_recv_us: u64,
rate_since_us: u64,
rate_pkts: u64,
rate_bytes: u64,
pkt_rate: u32,
byte_rate: u32,
pub undecryptable: u64,
pub out: Vec<Vec<u8>>,
pub delivered: Vec<Vec<u8>>,
pub closed: Option<String>,
}
impl Receiver {
/// A receiver whose peer's first data packet is `isn`, its handshake carrying the HSREQ
/// (or HSRSP) stamped `hs_ts` on the peer's clock and arriving at `t0`.
pub fn new(cfg: RecvConfig, peer_id: u32, isn: u32, hs_ts: u32, t0: Instant, account: Arc<Mutex<Account>>, rtt_shared: Arc<AtomicU64>) -> Receiver {
let mut ts_ext = Extender::new(32);
let hs = ts_ext.extend(hs_ts as u64) as i64;
let mut seq_ext = Extender::new(31);
let next = seq_ext.extend(isn as u64);
Receiver {
cfg,
peer_id,
t0,
base: -hs,
anchored: false,
drift: 0,
drift_sum: 0,
drift_n: 0,
ts_ext,
seq_ext,
next,
highest: next - 1,
buf: BTreeMap::new(),
missing: BTreeMap::new(),
recent_drops: BTreeSet::new(),
recent_order: VecDeque::new(),
account,
rtt: 100_000,
rtt_var: 50_000,
rtt_shared,
ack_no: 0,
acks: VecDeque::new(),
last_full_ack_us: 0,
last_ack_pos: next,
since_light: 0,
last_nak_us: 0,
last_sent_us: 0,
last_recv_us: 0,
rate_since_us: 0,
rate_pkts: 0,
rate_bytes: 0,
pkt_rate: 0,
byte_rate: 0,
undecryptable: 0,
out: Vec::new(),
delivered: Vec::new(),
closed: None,
}
}
pub fn now_us(&self) -> u64 {
self.t0.elapsed().as_micros() as u64
}
/// The instant the connection's clock counts from.
pub fn t0(&self) -> Instant {
self.t0
}
fn due(&self, ts: u64) -> u64 {
(self.base + ts as i64 + self.cfg.latency_us as i64 + self.drift).max(0) as u64
}
fn ctrl(&mut self, ctype: u16, info: u32, cif: Vec<u8>) {
let now = self.now_us();
let c = Control::new(ctype, info, now as u32, self.peer_id, cif);
self.out.push(c.write());
self.last_sent_us = now;
}
fn nak_interval(&self) -> u64 {
((self.rtt + 4 * self.rtt_var) / 2).max(NAK_FLOOR_US)
}
/// The first sequence number neither received nor resolved.
fn ack_pos(&self) -> u64 {
match self.missing.keys().next() {
Some(&m) => m,
None => self.highest + 1,
}
.max(self.next)
}
pub fn on_datagram(&mut self, b: &[u8]) {
let now = self.now_us();
self.last_recv_us = now;
match Packet::read(b) {
Some(Packet::Data(d)) => self.on_data(d, now),
Some(Packet::Control(c)) => self.on_control(c, now),
None => {}
}
self.deliver(now);
}
fn on_data(&mut self, mut d: Data, now: u64) {
if d.kk != 0 {
match &self.cfg.keys {
Some(k) if k.apply(d.kk, d.seq, &mut d.payload) => {}
_ => {
self.undecryptable += 1;
return;
}
}
} else if self.cfg.require_crypto {
self.undecryptable += 1;
return;
}
let seq = self.seq_ext.extend(d.seq as u64);
let ts = self.ts_ext.extend(d.ts as u64);
if !self.anchored {
// The handshake's timestamp sets the time base (§4.5.1.1). A peer whose first
// data packet lands more than 2 s from where it puts it stamps its handshake on
// another clock than its data: the base is taken from that packet instead.
self.anchored = true;
if (self.base + ts as i64 - now as i64).abs() > 2_000_000 {
self.base = now as i64 - ts as i64;
}
}
if d.rexmit {
self.account.lock().unwrap().retransmitted();
}
self.rate_pkts += 1;
self.rate_bytes += d.payload.len() as u64;
if seq < self.next {
let mut a = self.account.lock().unwrap();
if self.recent_drops.contains(&seq) { a.late() } else { a.duplicate() }
return;
}
if self.buf.contains_key(&seq) {
self.account.lock().unwrap().duplicate();
return;
}
let missed_at = self.missing.remove(&seq).map(|m| m.found_us);
if seq > self.highest {
if seq > self.highest + 1 {
let (a, b) = (self.highest + 1, seq - 1);
for s in a..=b {
self.missing.insert(s, Miss { found_us: now });
}
// NAK at once for the gap (§4.8.2).
self.send_nak(&[(a, b)]);
}
self.highest = seq;
}
self.buf.insert(seq, Slot { payload: d.payload, ts, arrived_us: now, missed_at });
self.since_light += 1;
if self.since_light >= LIGHT_ACK_EVERY {
self.since_light = 0;
let pos = self.ack_pos();
if pos > self.last_ack_pos {
self.ctrl(ctrl::ACK, 0, ((pos & 0x7fff_ffff) as u32).to_be_bytes().to_vec());
}
}
}
fn send_nak(&mut self, ranges: &[(u64, u64)]) {
let mut chunk: Vec<(u32, u32)> = Vec::new();
let mut bytes = 0;
let mut named = 0u64;
for &(a, b) in ranges {
let w = if a == b { 4 } else { 8 };
if bytes + w > NAK_MAX_CIF {
break;
}
chunk.push(((a & 0x7fff_ffff) as u32, (b & 0x7fff_ffff) as u32));
bytes += w;
named += b - a + 1;
}
if chunk.is_empty() {
return;
}
self.account.lock().unwrap().request(named);
self.ctrl(ctrl::NAK, 0, loss_list_write(&chunk));
}
fn on_control(&mut self, c: Control, now: u64) {
match c.ctype {
ctrl::ACKACK => {
if let Some(i) = self.acks.iter().position(|(n, _)| *n == c.info) {
let (_, sent) = self.acks[i];
self.acks.drain(..=i);
let rtt = now.saturating_sub(sent);
self.rtt_var = (3 * self.rtt_var + self.rtt.abs_diff(rtt)) / 4;
self.rtt = (7 * self.rtt + rtt) / 8;
self.rtt_shared.store(self.rtt, Ordering::Relaxed);
self.account.lock().unwrap().rtt_sample(rtt.min(u32::MAX as u64) as u32);
// The drift tracer (§4.7).
let ts = self.ts_ext.peek(c.ts as u64) as i64;
let sample = (now as i64 - rtt as i64 / 2) - (self.base + ts);
self.drift_sum += sample;
self.drift_n += 1;
if self.drift_n >= DRIFT_SAMPLES {
let mean = self.drift_sum / self.drift_n as i64;
if mean.abs() > DRIFT_MAX_US {
self.drift += mean;
}
self.drift_sum = 0;
self.drift_n = 0;
}
}
}
ctrl::SHUTDOWN => self.close_now("peer_shutdown"),
ctrl::USER if c.subtype == ext::KMREQ => {
// A refreshed key (§6.1.6): take the keys it wraps, and answer with the
// same message.
if let (Some(km), Some(p)) = (KeyMaterial::read(&c.cif), self.cfg.passphrase.clone())
&& let Ok(k) = Keys::from_km(&km, &p)
{
match &mut self.cfg.keys {
Some(keys) => keys.merge(k),
None => self.cfg.keys = Some(k),
}
let mut r = Control::new(ctrl::USER, 0, now as u32, self.peer_id, c.cif.clone());
r.subtype = ext::KMRSP;
self.out.push(r.write());
}
}
// The peer's other control packets carry nothing a receiver acts on.
_ => {}
}
}
/// Deliver what is due, and drop what is too late (§4.6).
fn deliver(&mut self, now: u64) {
loop {
if let Some(slot) = self.buf.get(&self.next) {
if now < self.due(slot.ts) {
return;
}
let slot = self.buf.remove(&self.next).unwrap();
let bytes = slot.payload.len() as u32;
let o = match slot.missed_at {
None => Outcome::FirstTry { bytes },
// The margin is at most the window: a peer whose handshake reached
// the receiver later than its data does puts its packets' delivery times
// later than the window after their arrival.
Some(at) => Outcome::Recovered {
bytes,
recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32,
margin_us: self.due(slot.ts).saturating_sub(slot.arrived_us).min(self.cfg.latency_us).min(u32::MAX as u64) as u32,
},
};
self.account.lock().unwrap().resolve(self.next, o);
self.delivered.push(slot.payload);
self.next += 1;
continue;
}
// The next one is missing: drop it once the first packet after it is due.
let Some((&after, slot)) = self.buf.range(self.next..).next() else { return };
if now < self.due(slot.ts) {
return;
}
let mut a = self.account.lock().unwrap();
for s in self.next..after {
self.missing.remove(&s);
a.resolve(s, Outcome::Dropped);
self.recent_drops.insert(s);
self.recent_order.push_back(s);
if self.recent_order.len() > RECENT_DROPS
&& let Some(old) = self.recent_order.pop_front()
{
self.recent_drops.remove(&old);
}
}
drop(a);
self.next = after;
}
}
pub fn on_timer(&mut self) {
let now = self.now_us();
self.deliver(now);
if self.closed.is_some() {
return;
}
// `last_recv_us` is 0 at the handshake, the receiver's time origin.
if now.saturating_sub(self.last_recv_us) > self.cfg.peer_idle_us {
self.close_now("idle");
return;
}
// The rates over the last second.
if now.saturating_sub(self.rate_since_us) >= 1_000_000 {
let span = (now - self.rate_since_us).max(1);
self.pkt_rate = (self.rate_pkts * 1_000_000 / span) as u32;
self.byte_rate = (self.rate_bytes * 1_000_000 / span).min(u32::MAX as u64) as u32;
self.rate_since_us = now;
self.rate_pkts = 0;
self.rate_bytes = 0;
}
// A full ACK (§4.8.1).
let pos = self.ack_pos();
let since = now.saturating_sub(self.last_full_ack_us);
if (pos > self.last_ack_pos && since >= ACK_PERIOD_US) || since >= ACK_MAX_SILENCE_US {
self.ack_no = self.ack_no.wrapping_add(1).max(1);
let avail = self.cfg.flow_window.saturating_sub(self.buf.len() as u32).max(2);
let cap = self.pkt_rate.max(1).saturating_mul(2);
let mut cif = Vec::with_capacity(28);
for w in [(pos & 0x7fff_ffff) as u32, self.rtt as u32, self.rtt_var as u32, avail, self.pkt_rate, cap, self.byte_rate] {
cif.extend_from_slice(&w.to_be_bytes());
}
let n = self.ack_no;
self.ctrl(ctrl::ACK, n, cif);
self.acks.push_back((n, now));
while self.acks.len() > 64 {
self.acks.pop_front();
}
self.last_full_ack_us = now;
self.last_ack_pos = pos;
}
// The periodic NAK (§4.8.2).
if !self.missing.is_empty() && now.saturating_sub(self.last_nak_us) >= self.nak_interval() {
self.last_nak_us = now;
let mut ranges = Vec::new();
for &s in self.missing.keys() {
match ranges.last_mut() {
Some((_, b)) if *b + 1 == s => *b = s,
_ => ranges.push((s, s)),
}
}
self.send_nak(&ranges);
}
if now.saturating_sub(self.last_sent_us) >= KEEPALIVE_US {
self.ctrl(ctrl::KEEPALIVE, 0, Vec::new());
}
}
/// When the receiver next has something to do, µs on its clock.
pub fn next_wake_us(&self) -> u64 {
let now = self.now_us();
let mut w = now + ACK_PERIOD_US;
if let Some(s) = self.buf.get(&self.next) {
w = w.min(self.due(s.ts));
} else if let Some((_, s)) = self.buf.range(self.next..).next() {
w = w.min(self.due(s.ts));
}
w.max(now + 200)
}
fn close_now(&mut self, why: &str) {
if self.closed.is_some() {
return;
}
self.closed = Some(why.into());
self.flush();
}
/// The local end closes: SHUTDOWN to the peer, and what is held delivered.
pub fn close(&mut self, why: &str) {
if self.closed.is_none() {
// Three copies: SHUTDOWN is not acknowledged.
for _ in 0..3 {
self.ctrl(ctrl::SHUTDOWN, 0, Vec::new());
}
}
self.close_now(why);
}
/// Everything held delivered now, in order; the missing among it outstanding.
fn flush(&mut self) {
let mut a = self.account.lock().unwrap();
while self.next <= self.highest {
match self.buf.remove(&self.next) {
Some(slot) => {
let bytes = slot.payload.len() as u32;
let o = match slot.missed_at {
None => Outcome::FirstTry { bytes },
Some(at) => Outcome::Recovered { bytes, recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32, margin_us: 0 },
};
a.resolve(self.next, o);
self.delivered.push(slot.payload);
}
None => a.resolve(self.next, Outcome::Outstanding),
}
self.next += 1;
}
self.missing.clear();
}
/// The negotiated latency, µs.
pub fn latency_us(&self) -> u64 {
self.cfg.latency_us
}
}

View file

@ -0,0 +1,203 @@
//! The sending side of an SRT connection in live mode, without I/O.
//!
//! Each payload is one message of one packet (PP = 11, §7.1), stamped with the µs since
//! the connection began, encrypted once under the even key where the link is encrypted, and
//! kept until it is acknowledged or too old (§4.6: 1.25 × the latency, and at least 1 s).
//! A NAK's sequence numbers still held are sent again with the R flag set and their first
//! timestamp, ahead of new packets (§4.8.2); a full ACK is answered with an ACKACK at once
//! (§4.8.1), and its RTT taken (§4.10).
use super::crypt::Keys;
use super::packet::{ctrl, loss_list_read, Control, Data, Packet};
use crate::link::SendCounts;
use crate::seq::Extender;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
const KEEPALIVE_US: u64 = 1_000_000;
struct Held {
seq: u32,
ts: u32,
kk: u8,
msgno: u32,
/// As it went on the wire: encrypted where the link is.
payload: Vec<u8>,
origin_us: u64,
}
pub struct Sender {
pub peer_id: u32,
t0: Instant,
keep_us: u64,
peer_idle_us: u64,
next_seq: u64,
msgno: u32,
keys: Option<Keys>,
held: BTreeMap<u64, Held>,
seq_ext: Extender,
rtt: u64,
pub rtt_shared: Arc<AtomicU64>,
pub counts: Arc<Mutex<SendCounts>>,
last_sent_us: u64,
last_recv_us: u64,
pub out: Vec<Vec<u8>>,
pub closed: Option<String>,
}
impl Sender {
#[allow(clippy::too_many_arguments)]
pub fn new(peer_id: u32, isn: u32, latency_us: u64, peer_idle_us: u64, keys: Option<Keys>, t0: Instant, counts: Arc<Mutex<SendCounts>>, rtt_shared: Arc<AtomicU64>) -> Sender {
let mut seq_ext = Extender::new(31);
let next_seq = seq_ext.extend(isn as u64);
Sender {
peer_id,
t0,
keep_us: (latency_us * 5 / 4).max(1_000_000),
peer_idle_us,
next_seq,
msgno: 1,
keys,
held: BTreeMap::new(),
seq_ext,
rtt: 100_000,
rtt_shared,
counts,
last_sent_us: 0,
last_recv_us: 0,
out: Vec::new(),
closed: None,
}
}
pub fn now_us(&self) -> u64 {
self.t0.elapsed().as_micros() as u64
}
/// The instant the connection's clock counts from.
pub fn t0(&self) -> Instant {
self.t0
}
fn ctrl(&mut self, ctype: u16, info: u32, cif: Vec<u8>) {
let now = self.now_us();
self.out.push(Control::new(ctype, info, now as u32, self.peer_id, cif).write());
self.last_sent_us = now;
}
/// One payload, as one packet.
pub fn send(&mut self, mut payload: Vec<u8>) {
if self.closed.is_some() {
return;
}
let now = self.now_us();
let seq = (self.next_seq & 0x7fff_ffff) as u32;
let kk = match &self.keys {
Some(k) => {
k.apply(1, seq, &mut payload);
1
}
None => 0,
};
let msgno = self.msgno;
self.msgno = (self.msgno % 0x03ff_ffff) + 1;
let d = Data { seq, pp: 3, order: false, kk, rexmit: false, msgno, ts: now as u32, dst: self.peer_id, payload };
let bytes = d.payload.len() as u64;
self.out.push(d.write());
self.held.insert(self.next_seq, Held { seq, ts: d.ts, kk, msgno, payload: d.payload, origin_us: now });
self.next_seq += 1;
self.last_sent_us = now;
let mut c = self.counts.lock().unwrap();
c.sent += 1;
c.bytes += bytes;
}
pub fn on_datagram(&mut self, b: &[u8]) {
let now = self.now_us();
self.last_recv_us = now;
let Some(Packet::Control(c)) = Packet::read(b) else { return };
match c.ctype {
ctrl::ACK => {
let w = c.words();
let Some(&pos) = w.first() else { return };
let pos = self.seq_ext.extend(pos as u64);
// What is acknowledged leaves the buffer.
let keep = self.held.split_off(&pos);
self.held = keep;
if c.info != 0 {
// A full ACK: ACKACK at once, and its RTT.
self.ctrl(ctrl::ACKACK, c.info, Vec::new());
if let Some(&rtt) = w.get(1) {
self.rtt = (7 * self.rtt + rtt as u64) / 8;
self.rtt_shared.store(self.rtt, Ordering::Relaxed);
}
}
}
ctrl::NAK => {
let Some(ranges) = loss_list_read(&c.cif) else { return };
let mut named = 0u64;
let mut again = 0u64;
for (a, b) in ranges {
let a = self.seq_ext.peek(a as u64);
let b = self.seq_ext.peek(b as u64).max(a);
named += b - a + 1;
for (_, h) in self.held.range(a..=b) {
let d = Data { seq: h.seq, pp: 3, order: false, kk: h.kk, rexmit: true, msgno: h.msgno, ts: h.ts, dst: self.peer_id, payload: h.payload.clone() };
self.out.push(d.write());
again += 1;
}
}
let mut c = self.counts.lock().unwrap();
c.requests += 1;
c.requested += named;
c.retransmitted += again;
}
ctrl::SHUTDOWN => self.closed = Some("peer_shutdown".into()),
_ => {}
}
}
pub fn on_timer(&mut self) {
let now = self.now_us();
if self.closed.is_some() {
return;
}
if now.saturating_sub(self.last_recv_us) > self.peer_idle_us {
self.closed = Some("idle".into());
return;
}
// Too old to be of use (§4.6).
let old: Vec<u64> = self.held.iter().take_while(|(_, h)| now.saturating_sub(h.origin_us) > self.keep_us).map(|(k, _)| *k).collect();
if !old.is_empty() {
self.counts.lock().unwrap().expired += old.len() as u64;
for k in old {
self.held.remove(&k);
}
}
if now.saturating_sub(self.last_sent_us) >= KEEPALIVE_US {
self.ctrl(ctrl::KEEPALIVE, 0, Vec::new());
}
}
pub fn next_wake_us(&self) -> u64 {
self.now_us() + 20_000
}
pub fn close(&mut self, why: &str) {
if self.closed.is_none() {
// SHUTDOWN is not acknowledged: three copies, so one lost does not leave the
// peer to find the end by its idle timeout.
for _ in 0..3 {
self.ctrl(ctrl::SHUTDOWN, 0, Vec::new());
}
self.closed = Some(why.into());
}
}
/// Packets sent and not yet acknowledged or expired.
pub fn in_flight(&self) -> usize {
self.held.len()
}
}

View file

@ -0,0 +1,361 @@
//! The account of a receiving link: what became of every sequence number, and what it cost
//! to get there.
//!
//! A receiver resolves sequence numbers strictly in ascending order, at their delivery
//! time: each one is **delivered** (at the **first try**, or **recovered** after it was
//! found missing) or **dropped** when its delivery time passed without it. When the link
//! ends, what it holds is delivered at once, and the missing sequence numbers among it,
//! whose time had not come, are **outstanding**. So over any run of resolved sequence
//! numbers
//!
//! ```text
//! delivered + dropped + outstanding = count, first_try + recovered = delivered.
//! ```
//!
//! The account is cut into **parts**. A part covers the sequence numbers resolved while it
//! was open, and the events (retransmission requests sent, retransmissions received,
//! duplicates, late arrivals, round-trip samples) that happened while it was open; the
//! next part begins where it ends. A **loss run** is a maximal run of consecutive
//! sequence numbers of one part none of which arrived at the first try.
use serde::Serialize;
/// The most loss runs a part lists; past it the part says it is truncated.
pub const MAX_RUNS: usize = 512;
/// The most samples of one kind a part keeps for its summary; past it the samples of a
/// part are thinned to every other one, so the summary stays a summary of all of them.
const MAX_SAMPLES: usize = 65_536;
/// What became of one sequence number.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
/// It arrived before anything found it missing.
FirstTry { bytes: u32 },
/// It was found missing, then arrived before its delivery time: `recovery_us` after it
/// was found missing, `margin_us` before its delivery time.
Recovered { bytes: u32, recovery_us: u32, margin_us: u32 },
/// Its delivery time passed without it.
Dropped,
/// The link ended before it arrived and before its delivery time.
Outstanding,
}
/// One loss run.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Run {
/// Its first sequence number, extended.
pub first: u64,
pub count: u32,
pub recovered: u32,
pub dropped: u32,
/// `count` − `recovered` − `dropped`: outstanding when the link ended.
pub outstanding: u32,
}
/// The minimum, the median (the lower one of an even count) and the maximum of a set of
/// samples in µs, and their number.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Summary {
pub samples: u32,
pub min: u32,
pub median: u32,
pub max: u32,
}
impl Summary {
pub fn of(v: &mut [u32], count: u64) -> Summary {
if v.is_empty() {
return Summary::default();
}
v.sort_unstable();
Summary { samples: count.min(u32::MAX as u64) as u32, min: v[0], median: v[(v.len() - 1) / 2], max: v[v.len() - 1] }
}
}
#[derive(Clone, Debug, Default)]
struct Samples {
kept: Vec<u32>,
count: u64,
stride: u64,
}
impl Samples {
fn push(&mut self, v: u32) {
self.count += 1;
let stride = self.stride.max(1);
if !(self.count - 1).is_multiple_of(stride) {
return;
}
self.kept.push(v);
if self.kept.len() >= MAX_SAMPLES {
self.kept = self.kept.iter().step_by(2).copied().collect();
self.stride = stride * 2;
}
}
fn summary(&mut self) -> Summary {
let n = self.count;
Summary::of(&mut self.kept, n)
}
}
/// One part of a link's account, closed.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Part {
/// The sequence numbers it covers: [`seq_first`, `seq_first` + `seq_count`).
pub seq_first: u64,
pub seq_count: u64,
pub delivered: u64,
pub first_try: u64,
pub dropped: u64,
pub outstanding: u64,
/// Packets that arrived after their sequence number was dropped (an event of the
/// part's span, like the counts below).
pub late: u64,
/// The payload bytes delivered.
pub bytes: u64,
pub duplicates: u64,
/// Retransmission requests sent, and the sequence numbers they named, repeats counted.
pub requests: u64,
pub requested: u64,
/// Packets that arrived marked as retransmissions.
pub retransmitted: u64,
pub rtt: Summary,
/// From a recovered packet's being found missing to its arrival.
pub recovery: Summary,
/// The least time a recovered packet arrived before its delivery time (0 with none).
pub margin_min: u32,
pub runs: Vec<Run>,
pub runs_truncated: bool,
/// The part's span on the receiver's clock, µs since the link began.
pub start_us: u64,
pub end_us: u64,
/// Whether it is the link's last.
pub last: bool,
}
/// The open account of a link.
#[derive(Clone, Debug, Default)]
pub struct Account {
cur: Part,
begun: bool,
rtt: Samples,
recovery: Samples,
margin_min: Option<u32>,
/// Every sequence number resolved so far, over the whole link.
pub total: Totals,
}
/// The link's totals over every part.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Totals {
pub delivered: u64,
pub first_try: u64,
pub recovered: u64,
pub dropped: u64,
pub late: u64,
pub outstanding: u64,
pub bytes: u64,
pub duplicates: u64,
pub requests: u64,
pub requested: u64,
pub retransmitted: u64,
}
impl Account {
pub fn new() -> Account {
Account::default()
}
/// `seq` (extended) resolved, the next after the last one resolved.
pub fn resolve(&mut self, seq: u64, o: Outcome) {
if !self.begun {
self.begun = true;
self.cur.seq_first = seq;
}
debug_assert_eq!(seq, self.cur.seq_first + self.cur.seq_count, "sequence numbers resolve in order");
self.cur.seq_count += 1;
let missed = !matches!(o, Outcome::FirstTry { .. });
match o {
Outcome::FirstTry { bytes } => {
self.cur.delivered += 1;
self.cur.first_try += 1;
self.cur.bytes += bytes as u64;
self.total.delivered += 1;
self.total.first_try += 1;
self.total.bytes += bytes as u64;
}
Outcome::Recovered { bytes, recovery_us, margin_us } => {
self.cur.delivered += 1;
self.cur.bytes += bytes as u64;
self.recovery.push(recovery_us);
self.margin_min = Some(self.margin_min.map_or(margin_us, |m| m.min(margin_us)));
self.total.delivered += 1;
self.total.recovered += 1;
self.total.bytes += bytes as u64;
}
Outcome::Dropped => {
self.cur.dropped += 1;
self.total.dropped += 1;
}
Outcome::Outstanding => {
self.cur.outstanding += 1;
self.total.outstanding += 1;
}
}
if missed {
let recovered = matches!(o, Outcome::Recovered { .. }) as u32;
let dropped = matches!(o, Outcome::Dropped) as u32;
let outstanding = matches!(o, Outcome::Outstanding) as u32;
let extends = !self.cur.runs_truncated && self.cur.runs.last().is_some_and(|r| r.first + r.count as u64 == seq);
if extends {
let r = self.cur.runs.last_mut().unwrap();
r.count += 1;
r.recovered += recovered;
r.dropped += dropped;
r.outstanding += outstanding;
} else if self.cur.runs.len() >= MAX_RUNS {
self.cur.runs_truncated = true;
} else {
self.cur.runs.push(Run { first: seq, count: 1, recovered, dropped, outstanding });
}
}
}
/// The next sequence number to resolve, once one has been.
pub fn next_seq(&self) -> Option<u64> {
self.begun.then_some(self.cur.seq_first + self.cur.seq_count)
}
/// A packet arrived after its sequence number was dropped.
pub fn late(&mut self) {
self.cur.late += 1;
self.total.late += 1;
}
pub fn duplicate(&mut self) {
self.cur.duplicates += 1;
self.total.duplicates += 1;
}
/// A retransmission request sent, naming `n` sequence numbers.
pub fn request(&mut self, n: u64) {
self.cur.requests += 1;
self.cur.requested += n;
self.total.requests += 1;
self.total.requested += n;
}
pub fn retransmitted(&mut self) {
self.cur.retransmitted += 1;
self.total.retransmitted += 1;
}
pub fn rtt_sample(&mut self, us: u32) {
self.rtt.push(us);
}
/// Close the open part at `now_us` (µs since the link began) and open the next;
/// `last` for the link's last part.
pub fn cut(&mut self, now_us: u64, last: bool) -> Part {
let mut p = std::mem::take(&mut self.cur);
p.end_us = now_us.max(p.start_us + 1);
p.rtt = self.rtt.summary();
p.recovery = self.recovery.summary();
p.margin_min = self.margin_min.take().unwrap_or(0);
p.last = last;
self.rtt = Samples::default();
self.recovery = Samples::default();
self.cur.start_us = p.end_us;
self.cur.seq_first = p.seq_first + p.seq_count;
p
}
/// Whether the open part has nothing to cut yet: no sequence number resolved on the
/// link, or nothing in it since the last cut. A part is not cut before the first
/// sequence number resolves, so the parts' sequence numbers run on from the first: the
/// round trips measured before it fall into the first part.
pub fn open_is_empty(&self) -> bool {
!self.begun || (self.cur.seq_count == 0 && self.cur.requests == 0 && self.cur.duplicates == 0 && self.cur.retransmitted == 0 && self.cur.late == 0 && self.rtt.count == 0)
}
}
impl Part {
/// The rules every part keeps, as the link record's verifier checks them.
pub fn check(&self) -> Result<(), &'static str> {
if self.delivered + self.dropped + self.outstanding != self.seq_count {
return Err("delivered + dropped + outstanding != count");
}
if self.first_try > self.delivered {
return Err("first_try > delivered");
}
if self.outstanding > 0 && !self.last {
return Err("outstanding in a part that is not the last");
}
if self.recovery.samples as u64 != self.delivered - self.first_try {
return Err("recovery samples != recovered");
}
if !self.runs_truncated {
let n: u64 = self.runs.iter().map(|r| r.count as u64).sum();
let rec: u64 = self.runs.iter().map(|r| r.recovered as u64).sum();
let drop: u64 = self.runs.iter().map(|r| r.dropped as u64).sum();
let out: u64 = self.runs.iter().map(|r| r.outstanding as u64).sum();
if n != self.seq_count - self.first_try || rec != self.delivered - self.first_try || drop != self.dropped || out != self.outstanding {
return Err("runs do not sum to the part's losses");
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_part_accounts_for_every_sequence_number() {
let mut a = Account::new();
for s in 100..110u64 {
let o = match s {
103 | 104 => Outcome::Recovered { bytes: 1316, recovery_us: 20_000 + s as u32, margin_us: 50_000 },
107 => Outcome::Dropped,
_ => Outcome::FirstTry { bytes: 1316 },
};
a.resolve(s, o);
}
a.request(2);
a.request(1);
a.rtt_sample(10_000);
a.rtt_sample(12_000);
a.rtt_sample(11_000);
let p = a.cut(1_000_000, false);
assert_eq!((p.seq_first, p.seq_count, p.delivered, p.first_try, p.dropped), (100, 10, 9, 7, 1));
assert_eq!(p.runs, vec![Run { first: 103, count: 2, recovered: 2, dropped: 0, outstanding: 0 }, Run { first: 107, count: 1, recovered: 0, dropped: 1, outstanding: 0 }]);
assert_eq!(p.rtt, Summary { samples: 3, min: 10_000, median: 11_000, max: 12_000 });
assert_eq!(p.recovery.samples, 2);
assert_eq!((p.requests, p.requested), (2, 3));
p.check().unwrap();
// The next part begins where this one ended.
a.resolve(110, Outcome::FirstTry { bytes: 10 });
for s in 111..114 {
a.resolve(s, Outcome::Outstanding);
}
let q = a.cut(2_000_000, true);
assert_eq!((q.seq_first, q.seq_count, q.outstanding, q.start_us), (110, 4, 3, 1_000_000));
q.check().unwrap();
assert!(Part { outstanding: 1, seq_count: 1, last: false, runs: vec![Run { first: 0, count: 1, recovered: 0, dropped: 0, outstanding: 1 }], ..Part::default() }.check().is_err());
}
#[test]
fn runs_past_the_bound_truncate() {
let mut a = Account::new();
let mut s = 0;
for _ in 0..(MAX_RUNS + 5) {
a.resolve(s, Outcome::FirstTry { bytes: 1 });
a.resolve(s + 1, Outcome::Dropped);
s += 2;
}
let p = a.cut(10, false);
assert!(p.runs_truncated && p.runs.len() == MAX_RUNS);
p.check().unwrap();
}
}

View file

@ -0,0 +1,696 @@
//! MPEG-2 transport streams (ISO/IEC 13818-1), the payload SRT and RIST carry in live
//! contribution: a demultiplexer, a multiplexer, and a pacer.
//!
//! - [`Demux`] reads 188-byte packets (resynchronising on the sync byte), the PAT and the
//! first program's PMT (each section's CRC checked), and reassembles each elementary
//! stream's PES packets. It hands over, per PES, the stream's type and PID, the PTS
//! extended past its 33 bits, and the PES payload: for H.264 (stream type 0x1B) and HEVC
//! (0x24) one access unit in Annex B, for AAC in ADTS (0x0F) one or more ADTS frames,
//! which [`adts_frames`] splits. A PES whose packets did not all arrive (a gap in the
//! continuity counter) is dropped and counted, never handed over cut.
//! - [`Mux`] writes a program of one video and one audio stream: PAT and PMT, PES packets
//! with PTS (and DTS), the PCR on the video PID. For the senders and the tests.
//! - [`Pacer`] gives each 188 × n-byte chunk of a stream the time its PCR says it is due.
use crate::seq::Extender;
use std::collections::BTreeMap;
pub const PACKET: usize = 188;
pub const SYNC: u8 = 0x47;
pub const PID_PAT: u16 = 0;
pub const PID_NULL: u16 = 0x1fff;
pub const ST_AAC_ADTS: u8 = 0x0f;
pub const ST_H264: u8 = 0x1b;
pub const ST_HEVC: u8 = 0x24;
/// The MPEG-2 CRC-32 of PSI sections (ISO/IEC 13818-1 Annex A): polynomial 0x04C11DB7,
/// initial value 0xFFFFFFFF, not reflected, no final XOR.
pub fn crc32_mpeg(b: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &x in b {
crc ^= (x as u32) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04c1_1db7 } else { crc << 1 };
}
}
crc
}
/// One elementary stream the PMT declares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stream {
pub pid: u16,
pub stream_type: u8,
}
/// One PES packet, reassembled.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pes {
pub pid: u16,
pub stream_type: u8,
/// The PTS at 90 kHz, extended past 33 bits; the DTS where the header has one.
pub pts: Option<u64>,
pub dts: Option<u64>,
/// The payload after the PES header.
pub data: Vec<u8>,
/// Whether its first TS packet's adaptation field set `random_access_indicator`.
pub random_access: bool,
}
/// What the demultiplexer counted.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct DemuxCounts {
pub packets: u64,
/// Bytes skipped to find a sync byte.
pub resync_bytes: u64,
pub transport_errors: u64,
pub continuity_gaps: u64,
pub crc_errors: u64,
pub pes_out: u64,
pub pes_dropped: u64,
/// Packets of PIDs neither PSI nor a declared stream.
pub other_packets: u64,
}
struct Assembly {
stream_type: u8,
buf: Vec<u8>,
started: bool,
broken: bool,
random_access: bool,
cc: Option<u8>,
/// The PES_packet_length, where it is not 0.
expect: Option<usize>,
}
/// A transport stream demultiplexer for one program.
pub struct Demux {
carry: Vec<u8>,
pmt_pid: Option<u16>,
pub streams: Vec<Stream>,
pub pcr_pid: Option<u16>,
asm: BTreeMap<u16, Assembly>,
psi: BTreeMap<u16, (Vec<u8>, Option<u8>)>,
pub counts: DemuxCounts,
pts_extenders: BTreeMap<u16, Extender>,
}
impl Default for Demux {
fn default() -> Self {
Demux::new()
}
}
impl Demux {
pub fn new() -> Demux {
Demux { carry: Vec::new(), pmt_pid: None, streams: Vec::new(), pcr_pid: None, asm: BTreeMap::new(), psi: BTreeMap::new(), counts: DemuxCounts::default(), pts_extenders: BTreeMap::new() }
}
/// Feed bytes; returns every PES completed by them.
pub fn push(&mut self, bytes: &[u8]) -> Vec<Pes> {
let mut out = Vec::new();
let mut b = std::mem::take(&mut self.carry);
b.extend_from_slice(bytes);
let mut i = 0;
while i + PACKET <= b.len() {
if b[i] != SYNC || (i + PACKET < b.len() && b[i + PACKET] != SYNC && b.len() - i >= 2 * PACKET) {
i += 1;
self.counts.resync_bytes += 1;
continue;
}
let p: [u8; PACKET] = b[i..i + PACKET].try_into().unwrap();
self.packet(&p, &mut out);
i += PACKET;
}
self.carry = b[i..].to_vec();
out
}
/// The PES still being assembled, handed over where whole: at the end of a stream.
pub fn flush(&mut self) -> Vec<Pes> {
let mut out = Vec::new();
let pids: Vec<u16> = self.asm.keys().copied().collect();
for pid in pids {
self.finish(pid, &mut out);
}
out
}
fn packet(&mut self, p: &[u8; PACKET], out: &mut Vec<Pes>) {
self.counts.packets += 1;
let tei = p[1] & 0x80 != 0;
let pusi = p[1] & 0x40 != 0;
let pid = (((p[1] & 0x1f) as u16) << 8) | p[2] as u16;
let afc = (p[3] >> 4) & 0x3;
let cc = p[3] & 0x0f;
if tei {
self.counts.transport_errors += 1;
if let Some(a) = self.asm.get_mut(&pid) {
a.broken = true;
}
return;
}
if pid == PID_NULL {
return;
}
let mut off = 4;
let mut random_access = false;
let mut discontinuity = false;
if afc & 0x2 != 0 {
let len = p[4] as usize;
if len > 0 && 5 + len <= PACKET {
random_access = p[5] & 0x40 != 0;
discontinuity = p[5] & 0x80 != 0;
}
off = 5 + len;
}
let has_payload = afc & 0x1 != 0;
if off > PACKET {
return;
}
let payload = if has_payload { &p[off..] } else { &[][..] };
if pid == PID_PAT || Some(pid) == self.pmt_pid {
self.psi_packet(pid, pusi, cc, has_payload, payload);
return;
}
let Some(st) = self.streams.iter().find(|s| s.pid == pid).map(|s| s.stream_type) else {
self.counts.other_packets += 1;
return;
};
let a = self.asm.entry(pid).or_insert_with(|| Assembly { stream_type: st, buf: Vec::new(), started: false, broken: false, random_access: false, cc: None, expect: None });
// The continuity counter advances on packets with a payload; a repeat is a
// duplicate, a jump is a loss (§2.4.3.3), unless the adaptation field says so.
if has_payload {
if let Some(prev) = a.cc {
if cc == prev && !discontinuity {
return;
}
if cc != (prev + 1) & 0x0f && !discontinuity {
self.counts.continuity_gaps += 1;
a.broken = true;
}
}
a.cc = Some(cc);
}
if pusi {
self.finish(pid, out);
let a = self.asm.get_mut(&pid).unwrap();
a.buf.clear();
a.buf.extend_from_slice(payload);
a.started = true;
a.broken = false;
a.random_access = random_access;
a.expect = None;
if a.buf.len() >= 6 && a.buf[..3] == [0, 0, 1] {
let n = u16::from_be_bytes([a.buf[4], a.buf[5]]) as usize;
a.expect = (n > 0).then_some(6 + n);
}
} else if a.started {
a.buf.extend_from_slice(payload);
}
let a = self.asm.get(&pid).unwrap();
if a.expect.is_some_and(|n| a.buf.len() >= n) {
self.finish(pid, out);
}
}
fn finish(&mut self, pid: u16, out: &mut Vec<Pes>) {
let Some(a) = self.asm.get_mut(&pid) else { return };
if !a.started {
return;
}
a.started = false;
let buf = std::mem::take(&mut a.buf);
if a.broken {
a.broken = false;
self.counts.pes_dropped += 1;
return;
}
let buf = match a.expect {
Some(n) if buf.len() >= n => &buf[..n],
Some(_) => {
self.counts.pes_dropped += 1;
return;
}
None => &buf[..],
};
let Some((pts, dts, data)) = pes_payload(buf) else {
self.counts.pes_dropped += 1;
return;
};
let ext = self.pts_extenders.entry(pid).or_insert_with(|| Extender::new(33));
let pts = pts.map(|v| ext.extend(v));
let dts = dts.map(|v| ext.peek(v));
self.counts.pes_out += 1;
out.push(Pes { pid, stream_type: a.stream_type, pts, dts, data: data.to_vec(), random_access: a.random_access });
}
fn psi_packet(&mut self, pid: u16, pusi: bool, cc: u8, has_payload: bool, payload: &[u8]) {
if !has_payload {
return;
}
let e = self.psi.entry(pid).or_insert_with(|| (Vec::new(), None));
if let Some(prev) = e.1
&& cc != (prev + 1) & 0x0f
{
e.0.clear();
}
e.1 = Some(cc);
if pusi {
let ptr = payload.first().copied().unwrap_or(0) as usize;
if 1 + ptr > payload.len() {
e.0.clear();
return;
}
e.0.clear();
e.0.extend_from_slice(&payload[1 + ptr..]);
} else if !e.0.is_empty() {
e.0.extend_from_slice(payload);
}
let sec = e.0.clone();
if sec.len() < 3 || sec[0] == 0xff {
return;
}
let len = (((sec[1] & 0x0f) as usize) << 8) | sec[2] as usize;
if sec.len() < 3 + len {
return;
}
let section = &sec[..3 + len];
self.psi.get_mut(&pid).unwrap().0.clear();
if len < 9 || crc32_mpeg(section) != 0 {
self.counts.crc_errors += 1;
return;
}
let body = &section[8..section.len() - 4];
match section[0] {
0x00 if pid == PID_PAT => {
for e in body.as_chunks::<4>().0 {
let program = u16::from_be_bytes([e[0], e[1]]);
let p = (((e[2] & 0x1f) as u16) << 8) | e[3] as u16;
if program != 0 {
self.pmt_pid = Some(p);
break;
}
}
}
0x02 if Some(pid) == self.pmt_pid => {
if body.len() < 4 {
return;
}
self.pcr_pid = Some((((body[0] & 0x1f) as u16) << 8) | body[1] as u16);
let info = (((body[2] & 0x0f) as usize) << 8) | body[3] as usize;
let mut i = 4 + info;
let mut streams = Vec::new();
while i + 5 <= body.len() {
let st = body[i];
let p = (((body[i + 1] & 0x1f) as u16) << 8) | body[i + 2] as u16;
let es = (((body[i + 3] & 0x0f) as usize) << 8) | body[i + 4] as usize;
streams.push(Stream { pid: p, stream_type: st });
i += 5 + es;
}
self.streams = streams;
}
_ => {}
}
}
}
/// A PES packet's PTS, DTS and payload (§2.4.3.6, §2.4.3.7). `None` when the header does
/// not read.
pub fn pes_payload(b: &[u8]) -> Option<(Option<u64>, Option<u64>, &[u8])> {
if b.len() < 9 || b[..3] != [0, 0, 1] {
return None;
}
let sid = b[3];
// Streams without the optional header (padding, private stream 2, and the like).
if matches!(sid, 0xbc | 0xbe | 0xbf | 0xf0 | 0xf1 | 0xff | 0xf2 | 0xf8) {
return Some((None, None, &b[6..]));
}
if b[6] & 0xc0 != 0x80 {
return None;
}
let flags = b[7] >> 6;
let hl = b[8] as usize;
if b.len() < 9 + hl {
return None;
}
let ts = |p: &[u8]| -> u64 { (((p[0] >> 1) & 0x07) as u64) << 30 | (p[1] as u64) << 22 | ((p[2] >> 1) as u64) << 15 | (p[3] as u64) << 7 | (p[4] >> 1) as u64 };
let (mut pts, mut dts) = (None, None);
if flags & 0x2 != 0 && hl >= 5 {
pts = Some(ts(&b[9..14]));
}
if flags == 0x3 && hl >= 10 {
dts = Some(ts(&b[14..19]));
}
Some((pts, dts, &b[9 + hl..]))
}
/// The ADTS frames (ISO/IEC 13818-7 §6.2) of an AAC PES payload: each frame whole, its
/// header included. Bytes that do not form a whole frame are left out.
pub fn adts_frames(b: &[u8]) -> Vec<&[u8]> {
let mut out = Vec::new();
let mut i = 0;
while i + 7 <= b.len() {
if b[i] != 0xff || b[i + 1] & 0xf6 != 0xf0 {
break;
}
let len = (((b[i + 3] & 0x03) as usize) << 11) | (b[i + 4] as usize) << 3 | (b[i + 5] >> 5) as usize;
if len < 7 || i + len > b.len() {
break;
}
out.push(&b[i..i + len]);
i += len;
}
out
}
/// An ADTS header's audio object type (its profile plus one), sampling rate and channel
/// configuration.
pub fn adts_config(f: &[u8]) -> Option<(u8, u32, u8)> {
if f.len() < 7 || f[0] != 0xff || f[1] & 0xf6 != 0xf0 {
return None;
}
const RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350];
let profile = f[2] >> 6;
let sfi = ((f[2] >> 2) & 0x0f) as usize;
let ch = ((f[2] & 0x01) << 2) | (f[3] >> 6);
Some((profile + 1, *RATES.get(sfi)?, ch))
}
// ── The multiplexer ───────────────────────────────────────────────────────────────
/// One program of one video and one audio stream, for the senders and the tests.
pub struct Mux {
pub video_pid: u16,
pub audio_pid: u16,
pub video_type: u8,
pub pmt_pid: u16,
cc: BTreeMap<u16, u8>,
psi_every: u32,
since_psi: u32,
}
impl Mux {
pub fn new(video_type: u8) -> Mux {
Mux { video_pid: 0x100, audio_pid: 0x101, video_type, pmt_pid: 0x1000, cc: BTreeMap::new(), psi_every: 10, since_psi: u32::MAX }
}
fn next_cc(&mut self, pid: u16) -> u8 {
let c = self.cc.entry(pid).or_insert(0x0f);
*c = (*c + 1) & 0x0f;
*c
}
fn section_packets(&mut self, pid: u16, section: &[u8]) -> Vec<u8> {
let mut p = vec![SYNC, 0x40 | (pid >> 8) as u8, pid as u8, 0x10 | self.next_cc(pid), 0];
p.extend_from_slice(section);
p.resize(PACKET, 0xff);
p
}
/// The PAT and the PMT.
pub fn psi(&mut self) -> Vec<u8> {
let mut pat = vec![0x00, 0xb0, 0, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x01, 0xe0 | (self.pmt_pid >> 8) as u8, self.pmt_pid as u8];
let l = pat.len() - 3 + 4;
pat[1] = 0xb0 | (l >> 8) as u8;
pat[2] = l as u8;
let crc = crc32_mpeg(&pat);
pat.extend_from_slice(&crc.to_be_bytes());
let mut pmt = vec![0x02, 0xb0, 0, 0x00, 0x01, 0xc1, 0x00, 0x00, 0xe0 | (self.video_pid >> 8) as u8, self.video_pid as u8, 0xf0, 0x00];
for (st, pid) in [(self.video_type, self.video_pid), (ST_AAC_ADTS, self.audio_pid)] {
pmt.extend_from_slice(&[st, 0xe0 | (pid >> 8) as u8, pid as u8, 0xf0, 0x00]);
}
let l = pmt.len() - 3 + 4;
pmt[1] = 0xb0 | (l >> 8) as u8;
pmt[2] = l as u8;
let crc = crc32_mpeg(&pmt);
pmt.extend_from_slice(&crc.to_be_bytes());
let mut out = self.section_packets(PID_PAT, &pat);
let pmt_pid = self.pmt_pid;
out.extend(self.section_packets(pmt_pid, &pmt));
out
}
fn ts_field(prefix: u8, v: u64) -> [u8; 5] {
let v = v & ((1u64 << 33) - 1);
[(prefix << 4) | (((v >> 30) as u8 & 0x07) << 1) | 1, (v >> 22) as u8, (((v >> 15) as u8) << 1) | 1, (v >> 7) as u8, ((v as u8) << 1) | 1]
}
/// One access unit of video (`video`) or one or more ADTS frames of audio as a PES, in
/// TS packets; the PAT and PMT before it every so often, and the PCR on the video
/// PID's first packet of each PES (at `pts` less 100 ms).
pub fn pes(&mut self, video: bool, pts: u64, data: &[u8], key: bool) -> Vec<u8> {
let mut out = Vec::new();
if self.since_psi >= self.psi_every || key {
out.extend(self.psi());
self.since_psi = 0;
}
self.since_psi += 1;
let pid = if video { self.video_pid } else { self.audio_pid };
let mut pes = vec![0, 0, 1, if video { 0xe0 } else { 0xc0 }, 0, 0, 0x80, 0x80, 5];
pes.extend_from_slice(&Self::ts_field(0x2, pts));
pes.extend_from_slice(data);
let n = pes.len() - 6;
if !video || n <= 0xffff {
let n = if video { 0 } else { n.min(0xffff) };
pes[4..6].copy_from_slice(&(n as u16).to_be_bytes());
}
let mut first = true;
let mut i = 0;
while i < pes.len() {
let mut af = Vec::new();
if first && video {
let pcr = pts.saturating_sub(9_000);
let base = pcr & ((1u64 << 33) - 1);
af.push(if key { 0x50 } else { 0x10 });
af.extend_from_slice(&[(base >> 25) as u8, (base >> 17) as u8, (base >> 9) as u8, (base >> 1) as u8, (((base & 1) as u8) << 7) | 0x7e, 0x00]);
} else if first && key {
af.push(0x40);
}
let room = PACKET - 4 - if af.is_empty() { 0 } else { 1 + af.len() };
let left = pes.len() - i;
let take = left.min(room);
// Stuff the last packet through its adaptation field.
if take < room {
let pad = room - take;
if af.is_empty() {
// An adaptation field of `pad` bytes: its length byte, then flags and
// stuffing.
af = if pad == 1 { Vec::new() } else { std::iter::once(0x00u8).chain(std::iter::repeat_n(0xff, pad - 2)).collect() };
let mut h = vec![SYNC, (pid >> 8) as u8 | if first { 0x40 } else { 0 }, pid as u8, 0x30 | self.next_cc(pid), (pad - 1) as u8];
h.extend_from_slice(&af);
h.extend_from_slice(&pes[i..i + take]);
debug_assert_eq!(h.len(), PACKET);
out.extend(h);
i += take;
first = false;
continue;
}
af.extend(std::iter::repeat_n(0xff, pad));
}
let afc = if af.is_empty() { 0x10 } else { 0x30 };
let mut h = vec![SYNC, (pid >> 8) as u8 | if first { 0x40 } else { 0 }, pid as u8, afc | self.next_cc(pid)];
if !af.is_empty() {
h.push(af.len() as u8);
h.extend_from_slice(&af);
}
h.extend_from_slice(&pes[i..i + take]);
debug_assert_eq!(h.len(), PACKET, "a TS packet is 188 bytes");
out.extend(h);
i += take;
first = false;
}
out
}
}
/// The PCR of a TS packet, in 27 MHz units, where its adaptation field carries one.
pub fn pcr_of(p: &[u8]) -> Option<(u16, u64)> {
if p.len() < 12 || p[0] != SYNC || p[3] & 0x20 == 0 || p[4] < 7 || p[5] & 0x10 == 0 {
return None;
}
let pid = (((p[1] & 0x1f) as u16) << 8) | p[2] as u16;
let base = (p[6] as u64) << 25 | (p[7] as u64) << 17 | (p[8] as u64) << 9 | (p[9] as u64) << 1 | (p[10] >> 7) as u64;
let ext = (((p[10] & 1) as u64) << 8) | p[11] as u64;
Some((pid, base * 300 + ext))
}
/// Times each chunk of a transport stream by its PCR: a chunk is due when the stream's
/// clock, the first PCR's PID's PCRs interpolated by byte position, reaches its first byte.
pub struct Pacer {
chunks: Vec<(u64, Vec<u8>)>,
}
impl Pacer {
/// Cut `ts` into chunks of `n` packets and time them, in µs from the first.
pub fn new(ts: &[u8], n: usize) -> Pacer {
// The PCR wraps at 2^33 × 300 (27 MHz).
const WRAP: u64 = (1u64 << 33) * 300;
let mut pcrs: Vec<(usize, u64)> = Vec::new();
let mut pid = None;
let mut wraps = 0u64;
for (k, p) in ts.as_chunks::<PACKET>().0.iter().enumerate() {
if let Some((q, v)) = pcr_of(p)
&& pid.is_none_or(|x| x == q)
{
pid = Some(q);
if let Some(&(_, last)) = pcrs.last()
&& v + wraps * WRAP + WRAP / 2 < last
{
wraps += 1;
}
pcrs.push((k * PACKET, v + wraps * WRAP));
}
}
let n = n.max(1);
let at = |pos: usize| -> u64 {
if pcrs.len() < 2 {
return 0;
}
let v0 = pcrs[0].1;
let i = pcrs.partition_point(|x| x.0 <= pos).clamp(1, pcrs.len() - 1);
let (pa, va) = pcrs[i - 1];
let (pb, vb) = pcrs[i];
let v = if pb == pa { va as f64 } else { va as f64 + (vb as f64 - va as f64) * (pos as f64 - pa as f64) / (pb as f64 - pa as f64) };
((v - v0 as f64).max(0.0) / 27.0) as u64
};
let chunks = ts.chunks(PACKET * n).enumerate().map(|(k, c)| (at(k * PACKET * n), c.to_vec())).collect();
Pacer { chunks }
}
/// The chunks with their due times, µs from the first.
pub fn chunks(&self) -> &[(u64, Vec<u8>)] {
&self.chunks
}
}
#[cfg(test)]
mod tests {
use super::*;
fn au(i: u64, key: bool) -> Vec<u8> {
let mut f = vec![0, 0, 0, 1, 0x09, 0xf0];
if key {
f.extend_from_slice(&[0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0xac]);
f.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xee, 0x3c, 0x80]);
f.extend_from_slice(&[0, 0, 0, 1, 0x65]);
} else {
f.extend_from_slice(&[0, 0, 0, 1, 0x41]);
}
f.extend((0..(500 + i * 37 % 900)).map(|j| ((i * 13 + j) % 251 + 1) as u8));
f
}
fn adts(i: u64) -> Vec<u8> {
let body: Vec<u8> = (0..(120 + i % 30)).map(|j| ((i + j) % 200 + 3) as u8).collect();
let len = body.len() + 7;
// AAC-LC (profile 1), 48 kHz (index 3), stereo.
let mut f = vec![0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) as u8 & 0x3), (len >> 3) as u8, ((len as u8 & 0x7) << 5) | 0x1f, 0xfc];
f.extend(body);
f
}
#[test]
fn crc_of_a_known_pat() {
// A PAT of program 1 on PID 0x1000, as common muxers write it.
let pat = [0x00, 0xb0, 0x0d, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x00];
let crc = crc32_mpeg(&pat);
let mut all = pat.to_vec();
all.extend_from_slice(&crc.to_be_bytes());
assert_eq!(crc32_mpeg(&all), 0, "a section with its CRC appended checks to zero");
}
#[test]
fn mux_then_demux_gives_the_units_back() {
let mut m = Mux::new(ST_H264);
let mut ts = Vec::new();
let mut sent_v = Vec::new();
let mut sent_a = Vec::new();
for i in 0..60u64 {
let v = au(i, i % 30 == 0);
ts.extend(m.pes(true, 900_000 + i * 3000, &v, i % 30 == 0));
sent_v.push((900_000 + i * 3000, v));
let a = [adts(2 * i), adts(2 * i + 1)].concat();
ts.extend(m.pes(false, 900_000 + i * 3840, &a, false));
sent_a.push(a);
}
assert!(ts.len().is_multiple_of(PACKET));
let mut d = Demux::new();
let mut got = Vec::new();
// Fed in uneven pieces, as datagrams arrive.
for c in ts.chunks(1316 + 5) {
got.extend(d.push(c));
}
got.extend(d.flush());
let v: Vec<_> = got.iter().filter(|p| p.stream_type == ST_H264).collect();
let a: Vec<_> = got.iter().filter(|p| p.stream_type == ST_AAC_ADTS).collect();
assert_eq!(v.len(), 60);
for (p, (pts, data)) in v.iter().zip(&sent_v) {
assert_eq!(p.data, *data);
assert_eq!(p.pts.unwrap() % (1 << 33), *pts);
}
assert!(v[0].random_access && !v[1].random_access);
assert_eq!(a.len(), 60);
for (p, data) in a.iter().zip(&sent_a) {
assert_eq!(p.data, *data);
let f = adts_frames(&p.data);
assert_eq!(f.len(), 2);
assert_eq!(adts_config(f[0]), Some((2, 48_000, 2)));
}
assert_eq!(d.counts.pes_dropped, 0);
assert_eq!(d.counts.continuity_gaps, 0);
}
#[test]
fn a_lost_packet_drops_its_pes_and_no_other() {
let mut m = Mux::new(ST_HEVC);
let mut ts = Vec::new();
for i in 0..10u64 {
ts.extend(m.pes(true, i * 3000, &au(i, i == 0), i == 0));
}
// Drop a packet in the middle of some PES: one that does not start a PES, after the
// first few.
let k = (6..ts.len() / PACKET).find(|&k| ts[k * PACKET + 1] & 0x40 == 0 && ts[k * PACKET + 2] == 0x00 && ts[k * PACKET + 1] & 0x1f == 0x01).expect("a continuation packet");
let mut cut = ts.clone();
cut.drain(k * PACKET..(k + 1) * PACKET);
let mut d = Demux::new();
let mut got = d.push(&cut);
got.extend(d.flush());
assert_eq!(d.counts.continuity_gaps, 1);
assert_eq!(d.counts.pes_dropped, 1);
assert_eq!(got.len(), 9);
}
#[test]
fn the_pts_extends_past_33_bits() {
let mut m = Mux::new(ST_H264);
let mut ts = Vec::new();
let top = (1u64 << 33) - 6000;
for i in 0..4u64 {
ts.extend(m.pes(true, (top + i * 3000) & ((1 << 33) - 1), &au(i, i == 0), i == 0));
}
let mut d = Demux::new();
let mut got = d.push(&ts);
got.extend(d.flush());
let p: Vec<u64> = got.iter().map(|x| x.pts.unwrap()).collect();
assert!(p.windows(2).all(|w| w[1] == w[0] + 3000), "{p:?}");
}
#[test]
fn the_pacer_times_by_pcr() {
let mut m = Mux::new(ST_H264);
let mut ts = Vec::new();
for i in 0..90u64 {
ts.extend(m.pes(true, 90_000 + i * 3000, &au(i, i % 30 == 0), i % 30 == 0));
}
let p = Pacer::new(&ts, 7);
let c = p.chunks();
assert_eq!(c[0].0, 0);
let last = c.last().unwrap().0;
// 89 frames at 1/30 s apart, give or take one frame's bytes.
assert!((2_900_000..=3_000_000).contains(&last), "{last}");
assert!(c.windows(2).all(|w| w[1].0 >= w[0].0));
}
}

View file

@ -0,0 +1,292 @@
//! Interoperation with the reference tools of each transport, run locally as test peers:
//! their SRT caller and listener, their RIST sender and receiver of both profiles, against
//! this crate's listener, caller, receiver and sender, byte for byte, in the clear and
//! encrypted, and through a path that drops datagrams each way, so each side's
//! retransmission answers the other's requests.
//!
//! The tools are named by environment variables, and these tests are ignored unless asked
//! for: run them with `--ignored`, and then a tool that is not named is a failure, never a
//! skip.
//!
//! ```text
//! WAI_SRT_LIVE_TRANSMIT=…/srt-live-transmit WAI_RISTSENDER=…/ristsender \
//! WAI_RISTRECEIVER=…/ristreceiver cargo test -p joule-contrib --features rist --test interop -- --ignored
//! ```
use joule_contrib::link::Link;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::srt::{self, Admitted, CallConfig, ConnRequest, ListenerConfig, Role};
use std::net::{SocketAddr, UdpSocket};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
const STEP: Duration = Duration::from_secs(30);
fn tool(var: &str) -> String {
std::env::var(var).unwrap_or_else(|_| panic!("{var} names the reference tool this test runs; it is not set"))
}
/// A child process killed when dropped.
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn spawn(bin: &str, args: &[&str], stdout: bool) -> Child {
let mut c = Command::new(bin);
c.args(args).stdin(Stdio::null()).stderr(Stdio::null());
c.stdout(if stdout { Stdio::piped() } else { Stdio::null() });
Child(c.spawn().unwrap_or_else(|e| panic!("{bin}: {e}")))
}
/// A free UDP port on loopback (even, when `even`).
fn free_port(even: bool) -> u16 {
loop {
let s = UdpSocket::bind("127.0.0.1:0").unwrap();
let p = s.local_addr().unwrap().port();
drop(s);
if (!even || p.is_multiple_of(2)) && p < 65_000 && UdpSocket::bind(("127.0.0.1", p + 1)).is_ok() {
return p;
}
}
}
/// The program every test sends: four seconds of a synthetic transport stream, timed.
fn program() -> (Vec<(u64, Vec<u8>)>, Vec<u8>) {
let c = joule_contrib::cli::synthetic(4, 2500);
let all = c.iter().flat_map(|x| x.1.clone()).collect();
(c, all)
}
/// Send the chunks as UDP datagrams to `to`, each at its time.
async fn feed_udp(chunks: Vec<(u64, Vec<u8>)>, to: SocketAddr) {
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let start = tokio::time::Instant::now();
for (at, c) in chunks {
tokio::time::sleep_until(start + Duration::from_micros(at)).await;
s.send_to(&c, to).await.unwrap();
}
}
/// Every UDP datagram that reaches `port` until none has come for `idle`.
#[cfg_attr(not(feature = "rist"), allow(dead_code))]
async fn capture_udp(port: u16, idle: Duration) -> Vec<u8> {
let s = tokio::net::UdpSocket::bind(("127.0.0.1", port)).await.unwrap();
let mut out = Vec::new();
let mut b = vec![0u8; 65_536];
let mut wait = STEP;
while let Ok(Ok(n)) = timeout(wait, s.recv(&mut b)).await {
out.extend_from_slice(&b[..n]);
wait = idle;
}
out
}
async fn drain(link: &mut Link, quiet: Duration) -> Vec<u8> {
let mut out = Vec::new();
let mut wait = STEP;
while let Ok(Some(p)) = timeout(wait, link.data.recv()).await {
out.extend_from_slice(&p);
wait = quiet;
}
out
}
fn lossy() -> (Impair, Impair) {
(Impair { loss_ppm: 20_000, spare_first: 10, ..Impair::default() }, Impair { loss_ppm: 20_000, ..Impair::default() })
}
// ── SRT ────────────────────────────────────────────────────────────────────────────
async fn srt_reference_caller(passphrase: Option<&str>, pbkeylen: usize) {
let slt = tool("WAI_SRT_LIVE_TRANSMIT");
let pass = passphrase.map(str::to_string);
let adm = move |_: &ConnRequest| Ok(Admitted { passphrase: pass.clone(), role: Role::Receive, latency_ms: Some(300), tag: 0 });
let mut l = srt::Listener::bind(ListenerConfig { latency_ms: 300, ..ListenerConfig::default() }, Arc::new(adm)).await.unwrap();
let (up, down) = lossy();
let px = Proxy::start(l.addr, up, down, 21).await.unwrap();
let udp_in = free_port(false);
let mut url = format!("srt://127.0.0.1:{}?latency=300&streamid=%23!::r=room/owner,m=publish", px.addr.port());
if let Some(p) = passphrase {
url.push_str(&format!("&passphrase={p}&pbkeylen={pbkeylen}"));
}
let _c = spawn(&slt, &["-q", &format!("udp://127.0.0.1:{udp_in}"), &url], false);
let mut link = timeout(STEP, l.accept()).await.expect("the reference caller connects").unwrap();
assert_eq!(link.info.stream_id.as_deref(), Some("#!::r=room/owner,m=publish"));
assert_eq!(link.info.cipher_bits as usize, passphrase.map_or(0, |_| pbkeylen * 8));
let (chunks, all) = program();
tokio::spawn(feed_udp(chunks, format!("127.0.0.1:{udp_in}").parse().unwrap()));
let got = drain(&mut link, Duration::from_millis(1500)).await;
assert_eq!(got.len(), all.len(), "every byte");
assert!(got == all, "byte for byte");
let part = link.account.lock().unwrap().cut(1, false);
part.check().unwrap();
assert_eq!(part.dropped, 0, "{part:?}");
assert!(part.delivered > part.first_try && part.requests > 0, "losses found, requested and recovered: {part:?}");
assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"]
async fn srt_reference_caller_into_this_listener() {
srt_reference_caller(None, 16).await;
srt_reference_caller(Some("0123456789abcdef"), 16).await;
srt_reference_caller(Some("0123456789abcdef"), 32).await;
}
async fn srt_this_caller(passphrase: Option<&str>, pbkeylen: usize) {
let slt = tool("WAI_SRT_LIVE_TRANSMIT");
let port = free_port(false);
let mut url = format!("srt://:{port}?mode=listener&latency=300");
if let Some(p) = passphrase {
url.push_str(&format!("&passphrase={p}"));
}
// `-a no`: the reference listener exits when the caller ends the connection, and so
// flushes what it wrote to its standard output (killed, it would lose its buffer).
let mut c = spawn(&slt, &["-q", "-a", "no", &url, "file://con"], true);
let mut out = c.0.stdout.take().unwrap();
let reader = std::thread::spawn(move || {
let mut b = Vec::new();
std::io::Read::read_to_end(&mut out, &mut b).unwrap();
b
});
tokio::time::sleep(Duration::from_millis(500)).await;
let (up, down) = lossy();
let px = Proxy::start(format!("127.0.0.1:{port}").parse().unwrap(), up, down, 22).await.unwrap();
let cfg = CallConfig { latency_ms: 300, passphrase: passphrase.map(str::to_string), keylen: pbkeylen, ..CallConfig::default() };
let mut link = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().expect("the reference listener admits the call");
let (chunks, all) = program();
joule_contrib::cli::pace(&link, &chunks, 1500).await.unwrap();
let sent = link.sent.lock().unwrap().clone();
link.close();
let _ = timeout(Duration::from_secs(3), link.wait_closed()).await;
let got = tokio::task::spawn_blocking(move || reader.join().unwrap()).await.unwrap();
drop(c);
if got != all {
// Which of the chunks did not arrive, by content.
let missing: Vec<usize> = chunks.iter().enumerate().filter(|(_, c)| !got.windows(c.1.len()).step_by(4).any(|w| w == &c.1[..])).map(|(i, _)| i).collect();
eprintln!("missing chunks of {}: {missing:?}", chunks.len());
}
assert_eq!(got.len(), all.len(), "every byte reached the reference listener: {sent:?}");
assert!(got == all, "byte for byte");
assert!(sent.retransmitted > 0 && sent.requests > 0, "the reference listener's requests were answered: {sent:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"]
async fn this_caller_into_the_srt_reference_listener() {
srt_this_caller(None, 16).await;
srt_this_caller(Some("0123456789abcdef"), 24).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"]
async fn the_srt_reference_caller_with_a_wrong_passphrase_is_refused() {
let slt = tool("WAI_SRT_LIVE_TRANSMIT");
let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("the listener's passphrase".into()), role: Role::Receive, latency_ms: None, tag: 0 });
let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap();
let url = format!("srt://127.0.0.1:{}?passphrase=another-passphrase", l.addr.port());
let _c = spawn(&slt, &["-q", "-a", "no", &format!("udp://127.0.0.1:{}", free_port(false)), &url], false);
assert!(timeout(Duration::from_secs(4), l.accept()).await.is_err(), "no link");
assert!(l.refused.load(std::sync::atomic::Ordering::Relaxed) > 0, "the call was refused");
}
// ── RIST ───────────────────────────────────────────────────────────────────────────
#[cfg(feature = "rist")]
mod rist_tools {
use super::*;
use joule_contrib::rist::{self, recv::RecvConfig, Profile, RistListener, RistListenerConfig, SendConfig};
async fn reference_sender(profile: Profile, secret: Option<&str>, aes: u16, loss: bool) {
let rs = tool("WAI_RISTSENDER");
let mut l = RistListener::bind(RistListenerConfig {
profile,
bind_ip: "127.0.0.1".parse().unwrap(),
port: 0,
recv: RecvConfig { buffer_us: 1_000_000, ..RecvConfig::default() },
secret: secret.map(str::to_string),
expect_cname: Some("tok".into()),
tag: 0,
})
.await
.unwrap();
let (up, down) = lossy();
let px = if loss { Some(Proxy::start(l.addr, up, down, 31).await.unwrap()) } else { None };
let target = px.as_ref().map_or(l.addr, |p| p.addr);
let udp_in = free_port(false);
let mut url = format!("rist://127.0.0.1:{}?cname=tok&buffer=1000", target.port());
if let Some(s) = secret {
url.push_str(&format!("&secret={s}&aes-type={aes}"));
}
let p = if profile == Profile::Main { "1" } else { "0" };
let _c = spawn(&rs, &["-i", &format!("udp://@127.0.0.1:{udp_in}"), "-o", &url, "-p", p, "-v", "2"], false);
tokio::time::sleep(Duration::from_millis(1000)).await;
let (chunks, all) = program();
tokio::spawn(feed_udp(chunks, format!("127.0.0.1:{udp_in}").parse().unwrap()));
let mut link = timeout(STEP, l.accept()).await.expect("the reference sender's flow").unwrap();
assert_eq!(link.info.cname.as_deref(), Some("tok"));
let got = drain(&mut link, Duration::from_millis(2500)).await;
assert_eq!(got.len(), all.len());
assert!(got == all, "byte for byte");
let part = link.account.lock().unwrap().cut(1, false);
part.check().unwrap();
if loss {
assert_eq!(part.dropped, 0, "{part:?}");
assert!(part.delivered > part.first_try, "losses recovered by the reference sender's retransmissions: {part:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference RIST tools: set WAI_RISTSENDER"]
async fn rist_reference_sender_into_this_receiver() {
reference_sender(Profile::Simple, None, 0, false).await;
reference_sender(Profile::Main, None, 0, true).await;
reference_sender(Profile::Main, Some("0123456789abcdef"), 128, true).await;
reference_sender(Profile::Main, Some("0123456789abcdef"), 256, false).await;
}
async fn this_sender(profile: Profile, secret: Option<&str>, aes: u16, loss: bool) {
let rr = tool("WAI_RISTRECEIVER");
let port = free_port(true);
let out_port = free_port(false);
let mut url = format!("rist://@127.0.0.1:{port}?buffer=1000");
if let Some(s) = secret {
url.push_str(&format!("&secret={s}&aes-type={aes}"));
}
let p = if profile == Profile::Main { "1" } else { "0" };
let cap = tokio::spawn(capture_udp(out_port, Duration::from_millis(3000)));
let _c = spawn(&rr, &["-i", &url, "-o", &format!("udp://127.0.0.1:{out_port}"), "-p", p, "-v", "2"], false);
tokio::time::sleep(Duration::from_millis(700)).await;
let cfg = SendConfig { profile, cname: "tok".into(), secret: secret.map(str::to_string), aes_256: aes == 256, buffer_ms: 1000, ..SendConfig::default() };
// The Main Profile's one port can be reached through a lossy path.
let (up, down) = lossy();
let px = if loss { Some(Proxy::start(format!("127.0.0.1:{port}").parse().unwrap(), up, down, 32).await.unwrap()) } else { None };
let target = px.as_ref().map_or_else(|| format!("127.0.0.1:{port}").parse().unwrap(), |p| p.addr);
let link = rist::send(target, cfg).await.unwrap();
let (chunks, all) = program();
joule_contrib::cli::pace(&link, &chunks, 2000).await.unwrap();
let sent = link.sent.lock().unwrap().clone();
link.close();
let got = cap.await.unwrap();
assert_eq!(got.len(), all.len(), "every byte reached the reference receiver: {sent:?}");
assert!(got == all, "byte for byte");
if loss {
assert!(sent.requests > 0 && sent.retransmitted > 0, "the reference receiver's requests were answered: {sent:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference RIST tools: set WAI_RISTRECEIVER"]
async fn this_sender_into_the_rist_reference_receiver() {
this_sender(Profile::Simple, None, 0, false).await;
this_sender(Profile::Main, None, 0, true).await;
this_sender(Profile::Main, Some("0123456789abcdef"), 256, true).await;
}
}

View file

@ -0,0 +1,178 @@
//! RIST on loopback, this crate's sender against its receiver: the Simple Profile on P and
//! P+1 with range and bitmask requests through proxies that drop datagrams; the Main
//! Profile's tunnel in the clear and under PSK (128 and 256), in the VSF and the legacy
//! formats, with loss; a CNAME that is not the expected one; a wrong passphrase.
#![cfg(feature = "rist")]
use joule_contrib::link::Link;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::rist::recv::RecvConfig;
use joule_contrib::rist::{self, NackKind, Profile, RistListener, RistListenerConfig, SendConfig};
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::time::timeout;
const STEP: Duration = Duration::from_secs(20);
fn payload(i: u64) -> Vec<u8> {
let mut p = vec![0u8; 1316];
for (k, c) in p.chunks_mut(188).enumerate() {
c[0] = 0x47;
for (j, b) in c.iter_mut().enumerate().skip(1) {
*b = ((i * 11 + k as u64 * 3 + j as u64) % 251) as u8;
}
}
p
}
async fn carry(tx: &Link, rx: &mut Link, n: u64, pps: u64) -> Vec<Vec<u8>> {
let send = tx.send.clone().unwrap();
let t = tokio::spawn(async move {
let start = tokio::time::Instant::now();
// Ten more than are counted: a receiver cannot know of a packet lost after the
// last it receives.
for i in 0..n + 10 {
tokio::time::sleep_until(start + Duration::from_micros(i * 1_000_000 / pps)).await;
if send.send(payload(i)).await.is_err() {
return;
}
}
});
let mut got = Vec::new();
while got.len() < n as usize + 10 {
match timeout(Duration::from_secs(5), rx.data.recv()).await {
Ok(Some(p)) => got.push(p),
_ => break,
}
}
t.await.unwrap();
got.truncate(n as usize);
got
}
fn listener_cfg(profile: Profile, secret: Option<&str>, cname: Option<&str>, nack: NackKind) -> RistListenerConfig {
RistListenerConfig {
profile,
bind_ip: "127.0.0.1".parse().unwrap(),
port: 0,
recv: RecvConfig { buffer_us: 600_000, nack, ..RecvConfig::default() },
secret: secret.map(str::to_string),
expect_cname: cname.map(str::to_string),
tag: 0,
}
}
async fn simple(nack: NackKind, seed: u64) {
let mut l = RistListener::bind(listener_cfg(Profile::Simple, None, Some("token-1"), nack)).await.unwrap();
let p = l.addr;
// Two proxies, one per port. The sender's RTCP must reach P + 1, so the RTCP proxy
// listens on an address whose port is the RTP proxy's plus one.
let imp = Impair { loss_ppm: 40_000, drop_index: &[30, 31, 32, 33], spare_first: 8, ..Impair::default() };
let (rtp_px, rtcp_px) = proxy_pair(p, imp, Impair { loss_ppm: 40_000, ..Impair::default() }, seed).await;
let tx = rist::send(rtp_px.addr, SendConfig { cname: "token-1".into(), buffer_ms: 1000, ..SendConfig::default() }).await.unwrap();
let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap();
assert_eq!(rx.info.cname.as_deref(), Some("token-1"));
let got = carry(&tx, &mut rx, 800, 400).await;
assert!(rtp_px.up.dropped.load(Ordering::Relaxed) > 10);
tokio::time::sleep(Duration::from_millis(900)).await;
let part = rx.account.lock().unwrap().cut(1, false);
part.check().unwrap();
assert_eq!(got.len(), 800, "{part:?}");
assert!(got.iter().enumerate().all(|(i, q)| *q == payload(i as u64)));
assert!(part.delivered - part.first_try >= 4 && part.requests > 0 && part.retransmitted > 0, "{part:?}");
assert!(rx.rtt() > 0, "the RTT echo measured the round trip");
let s = tx.sent.lock().unwrap().clone();
assert!(s.retransmitted > 0);
rtp_px.stop();
rtcp_px.stop();
}
/// Proxies to P and P + 1 on two ports one apart, the first even, as a Simple Profile
/// sender addresses them.
async fn proxy_pair(p: SocketAddr, up: Impair, down: Impair, seed: u64) -> (Proxy, Proxy) {
for _ in 0..100 {
let probe = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let q = probe.local_addr().unwrap().port();
drop(probe);
let q = if q.is_multiple_of(2) { q } else { q + 1 };
if q >= 65_534 {
continue;
}
let Ok(a) = Proxy::start_on(SocketAddr::new(p.ip(), q), p, up, down, seed).await else { continue };
match Proxy::start_on(SocketAddr::new(p.ip(), q + 1), SocketAddr::new(p.ip(), p.port() + 1), down, down, seed + 1).await {
Ok(b) => return (a, b),
Err(_) => a.stop(),
}
}
panic!("no adjacent proxy ports");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_simple_profile_recovers_with_range_requests() {
simple(NackKind::Range, 3).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_simple_profile_recovers_with_bitmask_requests() {
simple(NackKind::Bitmask, 4).await;
}
async fn main_profile(secret: Option<&str>, aes_256: bool, vsf: bool, loss: u32) {
let mut l = RistListener::bind(listener_cfg(Profile::Main, secret, Some("tunnel-token"), NackKind::Range)).await.unwrap();
let imp = Impair { loss_ppm: loss, spare_first: 16, ..Impair::default() };
let px = Proxy::start(l.addr, imp, imp, 17).await.unwrap();
let cfg = SendConfig { profile: Profile::Main, cname: "tunnel-token".into(), secret: secret.map(str::to_string), aes_256, vsf, buffer_ms: 1000, ..SendConfig::default() };
let tx = rist::send(px.addr, cfg).await.unwrap();
let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap();
assert_eq!(rx.info.cipher_bits, if secret.is_some() { if aes_256 { 256 } else { 128 } } else { 0 });
let got = carry(&tx, &mut rx, 600, 400).await;
tokio::time::sleep(Duration::from_millis(900)).await;
let part = rx.account.lock().unwrap().cut(1, false);
part.check().unwrap();
assert_eq!(got.len(), 600, "{part:?}");
assert!(got.iter().enumerate().all(|(i, q)| *q == payload(i as u64)));
if loss > 0 {
assert!(part.delivered > part.first_try, "losses were recovered: {part:?}");
}
px.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_main_profile_in_the_clear() {
main_profile(None, false, true, 30_000).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_main_profile_under_psk_128_and_256() {
main_profile(Some("Reliable Internet Stream Transport"), false, true, 30_000).await;
main_profile(Some("another tunnel passphrase"), true, true, 0).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_main_profile_in_the_legacy_format() {
main_profile(Some("legacy formats"), false, false, 20_000).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_wrong_cname_and_the_wrong_passphrase_are_refused() {
let mut l = RistListener::bind(listener_cfg(Profile::Main, Some("the tunnel's passphrase"), Some("tok"), NackKind::Range)).await.unwrap();
let bad = rist::send(l.addr, SendConfig { profile: Profile::Main, cname: "tok".into(), secret: Some("not the passphrase".into()), ..SendConfig::default() }).await.unwrap();
let send = bad.send.clone().unwrap();
for i in 0..50 {
send.send(payload(i)).await.unwrap();
}
assert!(timeout(Duration::from_millis(1500), l.accept()).await.is_err(), "no flow under another passphrase");
assert!(l.refused.undecryptable.load(Ordering::Relaxed) > 0);
bad.close();
let mut s = RistListener::bind(listener_cfg(Profile::Simple, None, Some("tok"), NackKind::Range)).await.unwrap();
let other = rist::send(s.addr, SendConfig { cname: "another".into(), ..SendConfig::default() }).await.unwrap();
let send = other.send.clone().unwrap();
for i in 0..20 {
send.send(payload(i)).await.unwrap();
}
assert!(timeout(Duration::from_millis(1500), s.accept()).await.is_err(), "no flow under another CNAME");
assert!(s.refused.cname.load(Ordering::Relaxed) > 0);
}

View file

@ -0,0 +1,161 @@
//! SRT on loopback, this crate's caller against its listener: every payload delivered in
//! order and whole, with no loss and through a proxy that drops datagrams each way; the
//! account of every sequence number; encryption, a wrong passphrase, a missing one, the
//! stream ID reaching the admission and its refusal codes; and the latency window: a packet
//! is not delivered before it.
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::srt::packet::rej;
use joule_contrib::srt::{self, conn::CallError, Admitted, CallConfig, ConnRequest, ListenerConfig, Role};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::time::timeout;
const STEP: Duration = Duration::from_secs(20);
fn payload(i: u64) -> Vec<u8> {
let mut p = vec![0u8; 1316];
for (k, c) in p.chunks_mut(188).enumerate() {
c[0] = 0x47;
for (j, b) in c.iter_mut().enumerate().skip(1) {
*b = ((i * 7 + k as u64 * 13 + j as u64) % 251) as u8;
}
}
p
}
async fn listener(pass: Option<&str>, latency: u16, seen: Arc<Mutex<Vec<ConnRequest>>>) -> srt::Listener {
let pass = pass.map(str::to_string);
let adm = move |r: &ConnRequest| {
seen.lock().unwrap().push(r.clone());
match r.stream_id.as_deref() {
Some("#!::m=request") => Err(rej::BAD_MODE),
Some("#!::r=nobody,m=publish") => Err(rej::NOTFOUND),
_ => Ok(Admitted { passphrase: pass.clone(), role: Role::Receive, latency_ms: Some(latency), tag: 42 }),
}
};
srt::Listener::bind(ListenerConfig { latency_ms: latency, ..ListenerConfig::default() }, Arc::new(adm)).await.unwrap()
}
/// Send `n` payloads at `pps` packets a second; receive them all; return what arrived.
async fn carry(tx: &joule_contrib::link::Link, rx: &mut joule_contrib::link::Link, n: u64, pps: u64) -> Vec<Vec<u8>> {
let send = tx.send.clone().unwrap();
let t = tokio::spawn(async move {
let start = tokio::time::Instant::now();
// Ten more than are counted: a receiver cannot know of a packet lost after the
// last it receives.
for i in 0..n + 10 {
tokio::time::sleep_until(start + Duration::from_micros(i * 1_000_000 / pps)).await;
send.send(payload(i)).await.unwrap();
}
});
let mut got = Vec::new();
while got.len() < n as usize + 10 {
match timeout(Duration::from_secs(5), rx.data.recv()).await {
Ok(Some(p)) => got.push(p),
_ => break,
}
}
t.await.unwrap();
got.truncate(n as usize);
got
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_payload_arrives_in_order_and_is_accounted_for() {
let seen = Arc::new(Mutex::new(Vec::new()));
let mut l = listener(None, 120, seen.clone()).await;
let cfg = CallConfig { stream_id: Some("#!::r=room/owner,m=publish,s=abc".into()), latency_ms: 80, ..CallConfig::default() };
let tx = timeout(STEP, srt::call(l.addr, cfg)).await.unwrap().unwrap();
let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap();
assert_eq!(rx.info.stream_id.as_deref(), Some("#!::r=room/owner,m=publish,s=abc"));
assert_eq!(rx.info.tag, 42);
assert_eq!(rx.info.latency_us, 120_000, "the larger of the two ends' latencies");
assert_eq!(seen.lock().unwrap()[0].latency_ms, 80);
let got = carry(&tx, &mut rx, 400, 500).await;
assert_eq!(got.len(), 400);
assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)), "every payload whole and in order");
tokio::time::sleep(Duration::from_millis(300)).await;
let part = rx.account.lock().unwrap().cut(1, false);
part.check().unwrap();
assert_eq!((part.seq_count, part.first_try, part.dropped), (410, 410, 0), "the 400 and the ten after them");
assert!(part.rtt.samples > 0, "ACK/ACKACK pairs measured the RTT");
tx.close();
let mut rx = rx;
assert_eq!(timeout(STEP, rx.wait_closed()).await.unwrap(), "peer_shutdown");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn losses_each_way_are_recovered_within_the_window() {
let seen = Arc::new(Mutex::new(Vec::new()));
let mut l = listener(Some("correct horse battery"), 300, seen).await;
// 3% of datagrams dropped toward the listener, 3% back (NAKs and ACKs among them), and
// datagrams 50 to 54 in a row.
let up = Impair { loss_ppm: 30_000, drop_index: &[50, 51, 52, 53, 54], ..Impair::default() };
let down = Impair { loss_ppm: 30_000, ..Impair::default() };
let px = Proxy::start(l.addr, up, down, 0x5eed).await.unwrap();
let cfg = CallConfig { passphrase: Some("correct horse battery".into()), keylen: 32, latency_ms: 300, ..CallConfig::default() };
let tx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap();
let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap();
assert_eq!(rx.info.cipher_bits, 256);
let got = carry(&tx, &mut rx, 1000, 500).await;
assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 20, "the proxy dropped datagrams");
tokio::time::sleep(Duration::from_millis(500)).await;
let part = rx.account.lock().unwrap().cut(1, false);
part.check().unwrap();
assert!(part.delivered >= got.len() as u64);
// Everything was recovered: the window is many round trips on loopback.
assert_eq!(part.dropped, 0, "{part:?}");
assert_eq!(got.len(), 1000);
assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)));
assert!(part.first_try < 1000 && part.delivered - part.first_try >= 5, "losses were found and recovered: {part:?}");
assert!(part.requests > 0 && part.retransmitted > 0);
assert!(part.runs.iter().any(|r| r.count >= 5), "the burst of five is one run");
assert!(part.recovery.max < 300_000 && part.margin_min > 0);
px.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_packet_is_held_for_the_latency_window() {
let seen = Arc::new(Mutex::new(Vec::new()));
let mut l = listener(None, 400, seen).await;
let tx = srt::call(l.addr, CallConfig { latency_ms: 400, ..CallConfig::default() }).await.unwrap();
let mut rx = l.accept().await.unwrap();
let t = Instant::now();
tx.send.clone().unwrap().send(payload(0)).await.unwrap();
let p = timeout(STEP, rx.data.recv()).await.unwrap().unwrap();
let held = t.elapsed();
assert_eq!(p, payload(0));
assert!(held >= Duration::from_millis(380) && held < Duration::from_millis(900), "delivered at its TSBPD time: {held:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn refusals_carry_their_codes() {
let seen = Arc::new(Mutex::new(Vec::new()));
let l = listener(Some("the right passphrase"), 120, seen.clone()).await;
let call = |pass: Option<&str>, sid: Option<&str>| {
let cfg = CallConfig { passphrase: pass.map(str::to_string), stream_id: sid.map(str::to_string), connect_timeout_ms: 3_000, ..CallConfig::default() };
srt::call(l.addr, cfg)
};
assert_eq!(call(Some("a wrong passphrase"), None).await.err(), Some(CallError::Rejected(rej::BADSECRET)));
assert_eq!(call(None, None).await.err(), Some(CallError::Rejected(rej::UNSECURE)));
assert_eq!(call(Some("the right passphrase"), Some("#!::m=request")).await.err(), Some(CallError::Rejected(rej::BAD_MODE)));
assert_eq!(call(Some("the right passphrase"), Some("#!::r=nobody,m=publish")).await.err(), Some(CallError::Rejected(rej::NOTFOUND)));
assert!(call(Some("the right passphrase"), None).await.is_ok());
assert_eq!(l.refused.load(std::sync::atomic::Ordering::Relaxed), 4);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_listener_that_sends_and_a_caller_that_receives() {
let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("both ways round".into()), role: Role::Send, latency_ms: None, tag: 0 });
let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap();
let px = Proxy::start(l.addr, Impair { loss_ppm: 20_000, ..Impair::default() }, Impair { loss_ppm: 20_000, ..Impair::default() }, 99).await.unwrap();
let cfg = CallConfig { passphrase: Some("both ways round".into()), role: Role::Receive, latency_ms: 250, ..CallConfig::default() };
let mut rx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap();
let tx = timeout(STEP, l.accept()).await.unwrap().unwrap();
let got = carry(&tx, &mut rx, 600, 400).await;
assert_eq!(got.len(), 600);
assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)));
let s = tx.sent.lock().unwrap().clone();
assert!(s.retransmitted > 0 && s.requests > 0, "{s:?}");
}

View file

@ -1,7 +1,7 @@
[package]
name = "joule-moq-ingest"
version = "0.1.0"
description = "A WebRTC ingest and egress gateway for WAI rooms: WHIP (RFC 9725) into MoQ room tracks, where it is the track's publisher and signs every group's receipts as the first hop, and WHEP (draft-ietf-wish-whep-05) out of them, verifying every object's receipt before it forwards it."
description = "An ingest and egress gateway for WAI rooms: WHIP (RFC 9725) and Enhanced RTMP v2 into MoQ room tracks, where it is the tracks' publisher and signs every group's receipts as the first hop; WHEP (draft-ietf-wish-whep-05) out of them, verifying every object's receipt before it forwards it; and SCTE 104 automation translated to SCTE 35 and signed as the room's cues."
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
@ -14,9 +14,22 @@ path = "src/lib.rs"
name = "joule-moq-ingest"
path = "src/main.rs"
# The link records' conformance corpus, wai/live-conformance/links/.
[[bin]]
name = "joule-moq-ingest-link-vectors"
path = "src/bin/link_vectors.rs"
[features]
default = []
# RIST (VSF TR-06-1, TR-06-2) contribution, from joule-contrib. Off by default: see
# joule-contrib's README, "Patents".
rist = ["joule-contrib/rist"]
[dependencies]
joule-moq = { path = "../joule-moq" }
joule-moq-relay = { path = "../joule-moq-relay" }
# SRT and RIST, and the MPEG-TS demultiplexer (this workspace, Apache-2.0).
joule-contrib = { path = "../joule-contrib" }
# WebRTC, sans-IO (MIT OR Apache-2.0), with its pure-Rust crypto backend: DTLS by its own
# `dimpl`, SRTP and the rest on RustCrypto. No C library is linked.
str0m = { version = "=0.24.1", default-features = false, features = ["rust-crypto"] }
@ -26,6 +39,8 @@ hyper-util = { version = "=0.1.20", default-features = false, features =
http-body-util = "=0.1.5"
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# RTMPS: the Enhanced RTMP listener over TLS, on the same rustls and ring (MIT OR Apache-2.0).
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
bytes = "1"
futures = "0.3"
@ -43,6 +58,14 @@ base64 = "0.22"
thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# `process`: tests/ertmp_ffmpeg.rs runs a local encoder as a test client.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process"] }
# The tests' HTTP client.
hyper = { version = "=1.11.1", features = ["client", "http1"] }
# Contribution over RIST needs the feature that carries it.
[[test]]
name = "contrib_rist"
path = "tests/contrib_rist.rs"
required-features = ["rist"]

View file

@ -1,12 +1,17 @@
# joule-moq-ingest
A gateway between WebRTC and the MoQ tracks of a WAI room. A standard WebRTC encoder
publishes into a room over **WHIP** (RFC 9725); the gateway becomes its tracks' MoQ
publisher at the relay and signs every group's receipts as the first hop. A standard
WebRTC player watches a room's tracks over **WHEP** (`draft-ietf-wish-whep-05`); the
gateway subscribes at the relay and verifies every object's receipt before it hands the
frame on. The mapping it implements is WAI's `extensions/live-ingest.md`, which also maps
Enhanced RTMP v2 multitrack and SRT (specified, not implemented here).
A gateway between contribution protocols and the MoQ tracks of a WAI room. A standard
WebRTC encoder publishes into a room over **WHIP** (RFC 9725), and an RTMP encoder over
**Enhanced RTMP v2** (`v2-2026-01-31-r2`), single-track or multitrack, over TCP or TLS; the
gateway becomes their tracks' MoQ publisher at the relay and signs every group's receipts as
the first hop. A standard WebRTC player watches a room's tracks over **WHEP**
(`draft-ietf-wish-whep-05`); the gateway subscribes at the relay and verifies every object's
receipt before it hands the frame on. A broadcast automation system drives the gateway's
**SCTE 104** injector, which translates its requests to SCTE 35 and signs them as the room's
cues. It also takes contribution over **SRT** and **RIST** into the same tracks
(`extensions/live-contribution.md`, below), and signs each such link's record of loss,
recovery and latency. The mappings it implements are WAI's `extensions/live-ingest.md`,
`extensions/cue-injection.md` and `extensions/live-contribution.md`.
It is a library (`GatewayServer`, `Gateway`) and a binary, `joule-moq-ingest`. The relay
is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`.
@ -19,6 +24,7 @@ is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport
| `/whip/s/<id>` | `PATCH` an `application/trickle-ice-sdpfrag`: trickle ICE (`204`) or an ICE restart (`200`, a fragment, a new `ETag`); `DELETE` (`200`) ends the session and its tracks |
| `/whep/<room>/<owner>[?layer=<n>]` | `POST` an offer for the owner's tracks: `201`, or `409` with `Retry-After` while the owner is not live. `HEAD` answers with `Link: <urn:ietf:params:whep>; rel="profile"` (WHEP §4.2) |
| `/whep/s/<id>` | `PATCH`, `DELETE`, as for WHIP |
| `/rtmp` | `POST`: `201`, `{"expires_in", "stream_key", "url"}`: a one-time stream key for an Enhanced RTMP publish under the grant |
Every request but `OPTIONS` carries `Authorization: Bearer <token>`. **The token is a room
grant**: a WAI `WRG1` record (`live-rooms.md` §4), base64url without padding. A grant is
@ -109,6 +115,53 @@ WebTransport or WebSocket paths, and verifies every object itself.
A verified group is released when its receipts arrive, at the group's end, so a WHEP
player is up to a video group behind the publisher.
## Enhanced RTMP into the room
- **Admission.** An encoder has a server URL and a stream key, and cannot answer a
challenge, so the publisher first POSTs to `/rtmp` with its grant and a proof of
possession, admitted as a WHIP request is, and gets a stream key: 128 random bits, good for
one `publish` within `stream_key_ttl_s` (60 s). The `publish` command's stream name is the
key; the grant is admitted again when it is used. A refusal is `onStatus`
`NetStream.Publish.BadName` with the reason as its description.
- **The connection** (`rtmp::chunk`, `rtmp::amf0`, `rtmp::session`): the simple handshake,
the chunk stream with its extended timestamps, Set Chunk Size, Abort, acknowledgements and
pings, and the commands. The `connect` result declares `capsEx` 0x0F and the FourCCs it
carries.
- **Tracks** (`rtmp::tracks`, `rtmp::flv`): legacy AVC and AAC, and `avc1`, `hvc1`, `av01`,
`vp09`, `vp08`, `Opus`, `fLaC` and `mp4a`, through ModEx and multitrack bodies and aggregate
messages. Each RTMP track is bound to a room track at its first packet: under a grant with a
layered video profile, the layer its `onMetaData` entry's size and frame rate match; audio
track 0 only. Each frame is one object: Annex B with the record's parameter sets before a
key access unit that carries none, the AV1 framing with the sequence header, IVF for VP8 and
VP9, ADTS for AAC, a FLAC stream. `live.rtp` is the decode time in µs (`rate` 1 000 000) and
`live.cto` the composition offset; §7's mapping gives `t`.
- **RTMPS** on `rtmp.tls_listen` with a PEM chain and PKCS #8 key (tokio-rustls on the
workspace's rustls and ring).
- **Shutdown.** A publisher that declared `Reconnect` is sent
`NetConnection.Connect.ReconnectRequest` (with `rtmp.reconnect_url` as its `tcUrl`, if set),
its key is good once more, and the session ends when it leaves or after
`reconnect_grace_ms`.
- **Records.** Each publish ends with a session record (`protocol` `ertmp`) whose `detail`
counts what was read and not carried, by reason.
## SCTE 104 into the room's cues
`scte` listens on `injector.listen` (SCTE 104's port is 5167) for one automation system at a
time: `init_request`, `alive_request`, and `multiple_operation_message()`s of splice,
time-signal and splice-null requests with avail, DTMF, segmentation, time, tier and private
descriptor requests. Each is answered (`inject_response`, then `inject_complete_response`
once its cues are signed) with SCTE 104's result codes. `scte::scte104::translate` builds each
SCTE 35 section (`scte::scte35`) on the room's 90 kHz clock and the cues it yields;
`scte::Engine` holds each cue until its splice instant, applies cancels and early returns,
drops what live-ads' break rules would refuse, and signs the cue as a `cue` event of the
injector's principal after its injection record (`WIJ1`, a `meta` event carrying the message,
its arrival and the translation's parameters). The events go out on the principal's events
track at the relay, published from the injector's start. The injector's grant must name its
key and permit `cue` and `meta`.
A relay whose door checks receipts names the room's media kinds in its rules
(`[room, "*", "audio"]`, `[room, "*", "video"]`): an events track carries no receipts.
## Bounds
Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the configuration:
@ -123,6 +176,12 @@ Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the confi
| `audio_group_ms`, `max_video_group_ms` | 200, 2 000 | — |
| `max_skew_ms` | 250 | the track is re-anchored |
| `whep.hold_ms` | 5 000 | the group is dropped (`receipt_missing`) |
| `rtmp.max_sessions` | 64 | one more connection is closed at accept |
| `rtmp.max_message_bytes` | 8 MiB | the connection ends (`message_too_large`) |
| `rtmp.max_bitrate_kbps` over `bitrate_window_ms` | 40 000 over 2 000 ms | the publish ends (`bitrate_exceeded`) |
| `rtmp.connect_timeout_ms`, `rtmp.idle_timeout_ms` | 15 000, 20 000 | the connection ends |
| `rtmp.stream_key_ttl_s` | 60 | the key is refused (`stream_key_expired`) |
| `injector.max_pending` | 256 | a message is answered 124 |
`gateway.example.toml` shows every field. It holds no secret: the relay token and the
signing key are files, and a room is its public, signed descriptor.
@ -146,6 +205,33 @@ crate's tests take AV1 through it end to end), supports ICE lite and full ICE, I
restarts, NACK and keyframe requests. Every crate new to the lockfile is MIT, Apache-2.0
or ISC.
## SRT and RIST
| resource | methods |
|---|---|
| `/srt` | `POST` with the grant and its proof: `201`, a one-time session as JSON: its `stream_id`, `passphrase`, `pbkeylen`, the listener's `latency_ms`, `host` and `port`. With `{"call": "ip:port", "passphrase": …}`, the gateway calls the publisher's SRT listener, where `[contrib.srt] call_allow` lists it |
| `/rist` | `POST` with the grant and its proof, `{"profile": "simple"|"main"}`: `201`, the session's `port` (Simple: an even port and the one above), its `cname`, its `buffer_ms`, and for the Main Profile the PSK `passphrase` and its `aes` |
| `/srt/s/<id>`, `/rist/s/<id>` | `DELETE` ends the session, or one not yet connected; `GET`, `HEAD` |
The SRT listener (`[contrib.srt] listen`) checks each caller's stream ID
(`#!::r=<room>/<owner>,m=publish,s=<session>`) against the sessions it gave, and refuses with
the codes of live-ingest §13 (1401, 1402, 1403, 1404, 1405, 1409) and the draft's (1010 for a
wrong passphrase, 1011 for none). A RIST session's port takes the first flow whose RTCP CNAME
is the session's. The link's transport stream is demultiplexed: the first H.264 or HEVC
stream and the first AAC stream go to the video and audio tracks as WHIP's frames do, one
access unit (one ADTS frame) an object, the PTS on the room clock; a PES missing a packet is
dropped whole. The catalog declares the owner's `link` track.
**The link record** (`linkrec`, `WCL1`): every `[contrib] record_interval_ms` and when the
session ends, the gateway signs a part: the sequence numbers passed, each delivered at the
first try, recovered, dropped or outstanding, the loss runs, requests, retransmissions, round
trips and recovery times, chained to the part before and naming each media track's receipt
chain head. Each part goes out on the `link` track with its receipts, is kept
(`Gateway::link_records`) and is appended to `[summary] link_out_path`. The corpus
`wai/live-conformance/links/` is written by `joule-moq-ingest-link-vectors`.
RIST is built only with the feature `rist` (`joule-contrib`'s README, "Patents").
## The session record, and live metering
Each session ends with a record of what it carried, signed by the gateway's key
@ -171,10 +257,21 @@ All on loopback, every wait bounded, the relay checking every receipt at its doo
| `auth_and_ice` | No token `401`; a token that is not a grant `401 invalid_token`; a viewer's grant `403 insufficient_scope`; a grant past its window, one of another room; `415`; `422`; WHEP without subscribe or above `max_layer` `403`; discovery and CORS. `PATCH` without `If-Match` `428`, stale `412`, trickle `204`, another principal's grant, its own key proved, `403`; a `PATCH` or `DELETE` presenting the grant without a proof `401 pop_missing`; an ICE restart (`If-Match: *`) `200` with new credentials and a new `ETag`, ICE connecting again, and every frame before and after it at the relay. |
| `codecs` | VP8, VP9 and AV1 each through WHIP: every frame whole, one group per keyframe, the size each framing states. |
| `room_corpus` | The room reader against `wai/live-conformance/rooms/`, and its six HTTP proofs of possession through the gateway's proof step, each accepted proof also refused as a replay. |
| `ertmp` | A publisher of our own over RTMP and RTMPS: legacy AVC and AAC; enhanced HEVC with composition offsets and Opus; AV1 with FLAC and VP9 with AAC, a ModEx nanosecond offset; multitrack layers bound by the grant's profile; every refusal of a stream key; the reconnect request at shutdown. Every frame reaches a relay subscriber in its payload framing, at its mapped time, every receipt verified. |
| `scte104_injection` | An automation system of our own over TCP: init, alive, a second connection refused 110, splices with and without pre-roll, a cancel, time signals, refusals with their result codes; the chain verifies and every cue is its message's translation; a relay subscriber reads the chain from the events track. |
| `conformance_corpus` | `wai/live-conformance/ertmp/` and `wai/live-conformance/injection/` regenerate byte for byte; the texts' reason tables list the codes reported. |
| `ertmp_ffmpeg` (ignored; `--ignored` where a local encoder is present) | A local encoder as the test client: AVC with B-frames and AAC, HEVC and AVC on the platform's media engine, HEVC with Opus, AV1 with FLAC, VP9 with Opus; every frame decoded from what the relay carries. |
| `contrib_srt` | An SRT publisher, through a path that drops datagrams each way, into the gateway: a relay subscriber gets every H.264 access unit and AAC frame, every receipt verified; the link record's parts verify as a series, show the losses recovered and name each track's receipt-chain head as the subscriber found it, and are on the link track. Every listener refusal with its code; the gateway calling an encoder's listener with HEVC; 20 Mbit/s with groups of megabytes through the relay's door. |
| `contrib_rist` (feature `rist`) | A RIST Simple Profile flow and a Main Profile tunnel under PSK into the gateway, received whole; another CNAME opens no session. |
| `link_corpus` | Every vector of `wai/live-conformance/links/` with its stated outcome. |
| `contrib_interop` (ignored) | The reference SRT caller and RIST sender into the gateway, with an encoder's H.264, HEVC and AAC through lossy paths (tools named by `WAI_SRT_LIVE_TRANSMIT`, `WAI_RISTSENDER`, `WAI_FFMPEG`, `WAI_FFPROBE`). |
| `contrib_bench` (ignored) | The gateway's throughput and energy on an SRT link, the sender in a process of its own. |
## Not implemented
E-RTMP v2 and SRT ingest (`live-ingest.md` §12, §13: mapped only); a WHEP counter-offer;
`vvc1` over Enhanced RTMP; a stream key another gateway accepts after a reconnect request;
SCTE 104 in a video signal's ancillary data (`cue-injection.md` §10); SRT and RIST egress; a
WHEP counter-offer;
a player's keyframe request carried upstream; publishing into a slot; simulcast within one
WHIP session; STUN and TURN in `Link` headers; reading the key log itself (revocations are
the list a deployment configures).

View file

@ -0,0 +1,88 @@
//! The two processes an ingest energy measurement reads: a relay, and a gateway that
//! publishes to it, each its own process so that a reader of per-process energy (WAI
//! energy-measurement §9.2, `macos-task`) reads one hop alone.
//!
//! ```text
//! ingest_energy relay # prints RELAY <addr> <certificate, DER, hex>
//! ingest_energy gateway <addr> <cert-hex> # prints PID, then a stream key URL per line read
//! ```
//!
//! The room, its authority, the publisher and the gateway's key are fixed seeds, so both
//! processes hold the same room. The gateway issues each stream key itself (the `/rtmp`
//! resource's admission is not what is measured) and prints `RTMP rtmp://…/live/<key>` for
//! each line on its standard input; it stops at the end of its input. Every listener is
//! loopback.
use ed25519_dalek::SigningKey;
use joule_moq_ingest::room::{build, Room};
use joule_moq_ingest::{GatewayConfig, GatewayServer};
use joule_moq_relay::{RelayConfig, RelayServer};
use std::io::BufRead;
use std::sync::Arc;
const T0: u64 = 1_790_000_000_000_000;
fn keys() -> (SigningKey, SigningKey, SigningKey) {
(SigningKey::from_bytes(&[0x51; 32]), SigningKey::from_bytes(&[0x52; 32]), SigningKey::from_bytes(&[0x53; 32]))
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_writer(std::io::stderr).with_max_level(tracing::Level::INFO).with_target(false).init();
let args: Vec<String> = std::env::args().skip(1).collect();
let (authority, publisher, gateway_key) = keys();
let descriptor = build::room(&authority, T0);
let room = descriptor.room_id();
match args.first().map(String::as_str) {
Some("relay") => {
let mut rc = RelayConfig::loopback();
// The door holds the room's media tracks to their receipts under the gateway's key.
rc.receipts.door = ["audio", "video"]
.iter()
.map(|k| joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room), "*".into(), k.to_string()], trust: vec![hex::encode(gateway_key.verifying_key().to_bytes())], trust_field: None, hold_ms: 10_000 })
.collect();
let relay = RelayServer::start(rc).await.expect("the relay starts");
println!("RELAY {} {}", relay.quic_addr.unwrap(), hex::encode(&relay.identity.chain[0]));
println!("PID {}", std::process::id());
let _ = tokio::task::spawn_blocking(|| std::io::stdin().lock().lines().count()).await;
relay.shutdown().await;
}
Some("gateway") => {
let addr = args.get(1).expect("the relay's address").parse().expect("an address");
let cert = hex::decode(args.get(2).expect("the relay's certificate")).expect("hex");
let mut gc = GatewayConfig::loopback(addr);
gc.relay.trust_der = vec![cert];
gc.signing.key_seed = Some(gateway_key.to_bytes());
gc.rooms = vec![joule_moq_ingest::config::RoomConfig { descriptor_path: None, revoked: Vec::new(), descriptor: Some(descriptor.to_bytes()) }];
gc.rtmp.listen = Some("127.0.0.1:0".parse().unwrap());
gc.rtmp.stream_key_ttl_s = 3600;
let server = GatewayServer::start(gc).await.expect("the gateway starts");
let rtmp = server.rtmp_addr.unwrap();
println!("PID {}", std::process::id());
let gw = server.gateway.clone();
let room_arc: Arc<Room> = gw.rooms.get(&room).unwrap().clone();
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
std::thread::spawn(move || {
for _ in std::io::stdin().lock().lines() {
if tx.send(()).is_err() {
break;
}
}
});
while rx.recv().await.is_some() {
let g = build::grant(&descriptor, &authority, &publisher.verifying_key().to_bytes(), 0, now - 60, now + 86_000);
let key = gw.issue_stream_key(room_arc.clone(), g.to_bytes());
println!("RTMP rtmp://{rtmp}/live/{key}");
}
for r in gw.records() {
eprintln!("RECORD {} {} {}", r.record.ended, r.record.tracks.iter().filter(|t| t.dir == 2).map(|t| t.objects).sum::<u64>(), r.record.detail["counts"]);
}
server.shutdown().await;
}
_ => {
eprintln!("usage: ingest_energy relay | ingest_energy gateway <relay addr> <certificate hex>");
std::process::exit(2);
}
}
}

View file

@ -58,3 +58,57 @@ retry_after_s = 2 # what a 409 asks a player to wait
[summary]
# out_path = "sessions.jsonl" # session records, one JSON line each; absent: kept in memory only
# link_out_path = "links.hex" # contribution link records (WCL1), one hexadecimal line each
[rtmp]
# Enhanced RTMP ingest (live-ingest §12); off unless a listener is set.
listen = "127.0.0.1:1935" # RTMP over TCP
# tls_listen = "127.0.0.1:1936" # RTMPS, with the two files below
# cert_path = "rtmps-chain.pem"
# key_path = "rtmps-key.pem" # PKCS #8
url = "" # what a stream key's response names; empty: rtmp://<listen>/live
stream_key_ttl_s = 60 # a stream key from POST /rtmp is good this long for its first use
max_sessions = 64
max_message_bytes = 8388608 # the largest message the chunk stream takes
max_bitrate_kbps = 40000 # per publisher, every track, over limits.bitrate_window_ms
connect_timeout_ms = 15000 # a connection that has not published by then is closed
idle_timeout_ms = 20000
chunk_size = 4096 # the chunk size the gateway sends with
reconnect_url = "" # the tcUrl a reconnect request names; empty: none
reconnect_grace_ms = 5000 # how long a session stays after a reconnect request
[injector]
# SCTE 104 automation into signed cues (cue-injection); off unless listen is set.
# listen = "127.0.0.1:5167"
room = "" # the room id, 64 hexadecimal digits, one of [[rooms]]
# key_path = "injector.key" # 32 bytes, the injector principal's Ed25519 seed
# grant_path = "injector.wrg1" # the principal's room grant, permitting cue and meta
leap_seconds = 18 # GPS - UTC, for a UTC timestamp()
fps_num = 30000 # the frame rate duration_extension_frames counts in
fps_den = 1001
dpi_pid_index = [] # the DPI_PID_index values answered; empty: any
max_pending = 256 # cues held for splices still to come
# Contribution over SRT and RIST (WAI extensions/live-contribution.md). A publisher POSTs
# /srt or /rist with its room grant and proof, and gets a one-time session.
[contrib]
host = "" # the host name stated in a session's answer
session_ttl_s = 60 # a session not connected by then is dropped
max_sessions = 16
max_bitrate_kbps = 80000 # per session, all streams, over limits.bitrate_window_ms
record_interval_ms = 10000 # how often a session signs a part of its link record
idle_ms = 5000 # a link that receives nothing this long is ended
[contrib.srt]
# listen = "127.0.0.1:9000" # the SRT listener; absent: /srt is 404
latency_ms = 120 # the connection's is the larger of the two ends'
pbkeylen = 16 # AES key bytes: 16, 24 or 32
call_allow = [] # publishers' listeners the gateway may call: "ip:port" or "ip"
[contrib.rist]
enabled = false # needs a build with the feature `rist`
bind_ip = "127.0.0.1"
port_min = 0 # the ports sessions are given; 0 and 0: any free port
port_max = 0
buffer_ms = 1000
aes = 128 # the Main Profile's PSK key bits: 128 or 256

View file

@ -0,0 +1,456 @@
//! `joule-moq-ingest-link-vectors`: the conformance corpus of the contribution link record,
//! `WCL1` (WAI `extensions/live-contribution.md` §6), written from fixed seeds, so the
//! same bytes every run.
//!
//! ```text
//! joule-moq-ingest-link-vectors --out <wai/live-conformance/links> write the corpus
//! joule-moq-ingest-link-vectors --check <wai/live-conformance/links> regenerate and compare
//! ```
//!
//! Each vector is a part (one `.wcl1` file) or a series (several, in order), with the
//! outcome §6.5 and §6.6 give it: accepted, or refused with its reason (and, in a series,
//! the index of the part refused). Each refused vector breaks one rule and keeps every
//! other: where a rule after the signature is broken, the record is signed again.
use ed25519_dalek::SigningKey;
use joule_contrib::stats::{Part, Run, Summary};
use joule_moq_ingest::linkrec::{self, ended, LinkRecord, Reason, TrackHead, MAX_LEN, MAX_RUNS};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::process::ExitCode;
fn key() -> SigningKey {
SigningKey::from_bytes(&[0x5c; 32])
}
fn h32(tag: u8) -> [u8; 32] {
linkrec_sha(&[b"wai:links-vector".as_slice(), &[tag]])
}
fn linkrec_sha(parts: &[&[u8]]) -> [u8; 32] {
joule_moq_ingest::room::sha256(parts)
}
fn tracks(groups: u64) -> Vec<TrackHead> {
let mut t = vec![
TrackHead { track: h32(1), receipt_alias: 0x0001_2345_6789_abcd, groups, head: if groups == 0 { [0; 32] } else { h32(100 + groups as u8) } },
TrackHead { track: h32(2), receipt_alias: 0x000f_edcb_a987_6543, groups: groups * 5, head: if groups == 0 { [0; 32] } else { h32(150 + groups as u8) } },
];
t.sort_by_key(|x| x.track);
t
}
/// A part's account from its outcomes in order: `F` first try, `R` recovered, `D` dropped,
/// `O` outstanding.
fn account(seq_first: u64, outcomes: &str, start: u64, end: u64, last: bool) -> Part {
let mut a = joule_contrib::stats::Account::new();
for (i, c) in outcomes.chars().enumerate() {
let s = seq_first + i as u64;
a.resolve(
s,
match c {
'F' => joule_contrib::stats::Outcome::FirstTry { bytes: 1316 },
'R' => joule_contrib::stats::Outcome::Recovered { bytes: 1316, recovery_us: 20_000 + 1_000 * (i as u32 % 7), margin_us: 80_000 + 3_000 * (i as u32 % 5) },
'D' => joule_contrib::stats::Outcome::Dropped,
_ => joule_contrib::stats::Outcome::Outstanding,
},
);
}
let n = outcomes.matches(|c| c != 'F').count() as u64;
if n > 0 {
a.request(n + 1);
a.request(1);
a.rtt_sample(900);
a.rtt_sample(1_100);
a.rtt_sample(1_000);
for _ in 0..outcomes.matches('R').count() {
a.retransmitted();
}
}
let mut p = a.cut(end, last);
p.start_us = start;
p.end_us = end;
p
}
#[allow(clippy::too_many_arguments)]
fn record(protocol: u8, mode: u8, cipher: u16, part: u32, parent: [u8; 32], last: bool, why: u8, start: u64, end: u64, window: u64, account: Part, tracks: Vec<TrackHead>) -> LinkRecord {
LinkRecord {
room: h32(10),
hop: key().verifying_key().to_bytes(),
principal: h32(11),
grant: h32(12),
session: h32(13)[..16].try_into().unwrap(),
part,
parent,
protocol,
mode,
cipher,
last,
ended: why,
start,
end,
window,
account,
tracks,
}
}
const T0: u64 = 3_600_000_000;
const PART: u64 = 10_000_000;
fn base() -> LinkRecord {
let outcomes = "FFFRRFFFFDFFFFRFFFFFFFFFFRRRFFFF";
record(1, 1, 128, 0, [0; 32], false, ended::NOT_LAST, T0, T0 + PART, 120_000, account(1_000_000, outcomes, T0, T0 + PART, false), tracks(3))
}
struct Out {
files: BTreeMap<String, Vec<u8>>,
vectors: Vec<Value>,
}
impl Out {
fn part(&mut self, name: &str, bytes: Vec<u8>, outcome: Result<(), Reason>, note: &str) {
let file = format!("{name}.wcl1");
let mut v = json!({"name": name, "kind": "part", "files": [file], "note": note});
match outcome {
Ok(()) => {
let r = LinkRecord::verify(&bytes).unwrap_or_else(|e| panic!("{name}: an accepted vector verifies: {e:?}"));
v["outcome"] = json!("accept");
v["digest"] = json!(hex::encode(linkrec::digest(&bytes)));
v["summary"] = summary(&r);
}
Err(reason) => {
let got = LinkRecord::verify(&bytes).err();
assert_eq!(got, Some(reason), "{name}: refused for the reason it is meant to show");
v["outcome"] = json!("refuse");
v["reason"] = json!(reason.code());
}
}
self.files.insert(file, bytes);
self.vectors.push(v);
}
fn series(&mut self, name: &str, parts: Vec<Vec<u8>>, outcome: Result<(), (usize, Reason)>, note: &str) {
let files: Vec<String> = (0..parts.len()).map(|i| format!("{name}.{i}.wcl1")).collect();
let mut v = json!({"name": name, "kind": "series", "files": files, "note": note});
let got = linkrec::verify_series(&parts);
match outcome {
Ok(()) => {
let rs = got.unwrap_or_else(|e| panic!("{name}: an accepted series verifies: {e:?}"));
v["outcome"] = json!("accept");
v["digests"] = json!(parts.iter().map(|b| hex::encode(linkrec::digest(b))).collect::<Vec<_>>());
v["summaries"] = json!(rs.iter().map(summary).collect::<Vec<_>>());
}
Err((at, reason)) => {
assert_eq!(got.err(), Some((at, reason)), "{name}: refused where and why it is meant to be");
v["outcome"] = json!("refuse");
v["reason"] = json!(reason.code());
v["at"] = json!(at);
}
}
for (f, b) in files.iter().zip(parts) {
self.files.insert(f.clone(), b);
}
self.vectors.push(v);
}
}
fn summary(r: &LinkRecord) -> Value {
let a = &r.account;
json!({
"part": r.part, "protocol": r.protocol, "mode": r.mode, "cipher": r.cipher, "last": r.last, "ended": r.ended,
"seq_first": a.seq_first, "seq_count": a.seq_count, "delivered": a.delivered, "first_try": a.first_try,
"recovered": a.delivered - a.first_try, "dropped": a.dropped, "outstanding": a.outstanding, "runs": a.runs.len(),
"bytes": a.bytes, "size": r.body().len() + 64,
})
}
fn signed(r: &LinkRecord) -> Vec<u8> {
r.sign(&key())
}
fn corpus() -> Out {
let mut o = Out { files: BTreeMap::new(), vectors: Vec::new() };
let k = key();
// ── Accepted parts ──────────────────────────────────────────────────────────────
let clean = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, &"F".repeat(40), T0, T0 + PART, false), tracks(2));
o.part("srt_listener_clean", signed(&clean), Ok(()), "SRT, the gateway listening, AES-128: every packet at the first try");
o.part("srt_recovered", signed(&base()), Ok(()), "SRT: losses found and recovered within the window, one dropped");
let out = record(1, 1, 0, 0, [0; 32], true, ended::PEER_SHUTDOWN, T0, T0 + 4_000_000, 200_000, account(7, "FFFRFFOOFO", T0, T0 + 4_000_000, true), tracks(1));
o.part("srt_last_outstanding", signed(&out), Ok(()), "a session of one part, its last: three sequence numbers outstanding when the caller shut down");
let simple = record(2, 1, 0, 0, [0; 32], false, 0, T0, T0 + PART, 1_000_000, account(65_536, "FFFFRRRRFFFFFFFD", T0, T0 + PART, false), tracks(4));
o.part("rist_simple", signed(&simple), Ok(()), "RIST Simple Profile, in the clear (the profile has no encryption)");
let main = record(3, 1, 256, 0, [0; 32], false, 0, T0, T0 + PART, 1_000_000, account(70_000, "FFFRFFRFFF", T0, T0 + PART, false), tracks(2));
o.part("rist_main_psk256", signed(&main), Ok(()), "RIST Main Profile under PSK with a 256-bit key");
let caller = record(1, 2, 192, 0, [0; 32], false, 0, T0, T0 + PART, 300_000, account(9, "FFFFFFFFRF", T0, T0 + PART, false), tracks(1));
o.part("srt_caller_aes192", signed(&caller), Ok(()), "SRT, the gateway calling the publisher's listener, AES-192");
let none = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + 1_000_000, 120_000, account(5, "FFF", T0, T0 + 1_000_000, false), tracks(0));
o.part("tracks_not_begun", signed(&none), Ok(()), "no group signed yet on either track: each head zero");
let mut many = String::new();
for _ in 0..(MAX_RUNS + 40) {
many.push_str("FR");
}
let trunc = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 500_000, account(100, &many, T0, T0 + PART, false), tracks(2));
assert!(trunc.account.runs_truncated);
o.part("runs_truncated", signed(&trunc), Ok(()), "more loss runs than a part lists: the first 512, marked truncated, the sums at most the part's");
// ── Refused parts ───────────────────────────────────────────────────────────────
let good = signed(&base());
let mut b = good.clone();
b.resize(MAX_LEN + 1, 0);
o.part("too_large", b, Err(Reason::RecordTooLarge), "one byte past the largest record");
o.part("truncated", good[..good.len() - 1].to_vec(), Err(Reason::Truncated), "the signature's last byte missing");
let mut b = good.clone();
b[0] = b'X';
o.part("bad_magic", b, Err(Reason::Malformed), "not WCL1");
let mut b = good.clone();
b.push(0);
o.part("trailing_byte", b, Err(Reason::Malformed), "a byte after the signature");
let mut b = good.clone();
b[4] = 2;
o.part("unknown_version", b, Err(Reason::UnknownVersion), "version 2");
let mut r = base();
let mut small = [0u8; 32];
small[0] = 1;
r.hop = small;
o.part("bad_key", r.sign(&k), Err(Reason::BadKey), "the hop's key is the identity point, of small order");
let mut b = good.clone();
let n = b.len();
b[n - 10] ^= 0x40;
o.part("bad_signature", b, Err(Reason::BadSignature), "a bit of the signature flipped");
let mut b = good.clone();
b[300] ^= 1;
o.part("altered_count", b, Err(Reason::BadSignature), "a count changed after signing");
let link_bad = |o: &mut Out, name: &str, f: &dyn Fn(&mut LinkRecord), note: &str, reason: Reason| {
let mut r = base();
f(&mut r);
o.part(name, r.sign(&key()), Err(reason), note);
};
use Reason::*;
link_bad(&mut o, "protocol_unknown", &|r| r.protocol = 4, "protocol 4", LinkInvalid);
link_bad(&mut o, "mode_unknown", &|r| r.mode = 3, "mode 3", LinkInvalid);
link_bad(&mut o, "rist_simple_encrypted", &|r| r.protocol = 2, "the Simple Profile has no encryption, yet the cipher is 128", LinkInvalid);
link_bad(&mut o, "rist_main_aes192", &|r| { r.protocol = 3; r.cipher = 192 }, "PSK keys are 128 or 256 bits", LinkInvalid);
link_bad(&mut o, "srt_cipher_100", &|r| r.cipher = 100, "no such key length", LinkInvalid);
link_bad(&mut o, "last_without_reason", &|r| { r.last = true; r.account.last = true }, "the last part names no reason the session ended", LinkInvalid);
link_bad(&mut o, "reason_without_last", &|r| r.ended = ended::IDLE, "a reason on a part that is not the last", LinkInvalid);
link_bad(&mut o, "reason_unknown", &|r| { r.last = true; r.account.last = true; r.ended = 9 }, "reason 9", LinkInvalid);
link_bad(&mut o, "empty_span", &|r| { r.end = r.start; r.account.end_us = r.start }, "start equals end", LinkInvalid);
link_bad(&mut o, "no_window", &|r| r.window = 0, "a window of 0 µs", LinkInvalid);
link_bad(&mut o, "first_part_with_parent", &|r| r.parent = h32(77), "part 0 names a parent", LinkInvalid);
link_bad(&mut o, "later_part_without_parent", &|r| r.part = 3, "part 3 names none", LinkInvalid);
link_bad(&mut o, "tracks_unsorted", &|r| r.tracks.reverse(), "the tracks out of order", LinkInvalid);
link_bad(&mut o, "track_head_without_groups", &|r| r.tracks[0].groups = 0, "a head with no group signed", LinkInvalid);
link_bad(&mut o, "track_zero_digest", &|r| r.tracks[0].track = [0; 32], "a track digest of zero", LinkInvalid);
link_bad(&mut o, "count_mismatch", &|r| r.account.seq_count += 1, "delivered + dropped + outstanding is one short of the count", AccountInvalid);
link_bad(&mut o, "first_try_over_delivered", &|r| { r.account.first_try = r.account.delivered + 1 }, "more at the first try than delivered", AccountInvalid);
link_bad(&mut o, "outstanding_not_last", &|r| { r.account.outstanding = 1; r.account.seq_count += 1 }, "outstanding in a part that is not the last", AccountInvalid);
link_bad(&mut o, "recovery_samples", &|r| r.account.recovery.samples += 1, "one recovery sample more than recovered", AccountInvalid);
link_bad(&mut o, "rtt_disordered", &|r| r.account.rtt = Summary { samples: 3, min: 2_000, median: 1_000, max: 3_000 }, "the RTT's minimum above its median", AccountInvalid);
link_bad(&mut o, "rtt_empty_with_figures", &|r| r.account.rtt = Summary { samples: 0, min: 0, median: 5, max: 5 }, "no RTT sample, yet figures", AccountInvalid);
link_bad(&mut o, "margin_past_window", &|r| r.account.margin_min = 120_001, "a recovered packet's margin larger than the window", AccountInvalid);
link_bad(&mut o, "requested_below_requests", &|r| r.account.requested = r.account.requests - 1, "requests naming fewer sequence numbers than there are requests", AccountInvalid);
link_bad(&mut o, "runs_overlap", &|r| r.account.runs[1].first = r.account.runs[0].first + r.account.runs[0].count as u64, "two runs with no first-try packet between", RunsInvalid);
link_bad(&mut o, "run_outside", &|r| { let n = r.account.runs.len(); r.account.runs[n - 1].first = r.account.seq_first + r.account.seq_count }, "a run past the part's sequence numbers", RunsInvalid);
link_bad(&mut o, "run_empty", &|r| r.account.runs.push(Run { first: r.account.seq_first + r.account.seq_count - 1, count: 0, recovered: 0, dropped: 0, outstanding: 0 }), "a run of no sequence number", RunsInvalid);
link_bad(&mut o, "run_overfull", &|r| r.account.runs[0].dropped = r.account.runs[0].count, "recovered and dropped more than the run holds", RunsInvalid);
link_bad(&mut o, "runs_short", &|r| { r.account.runs.remove(0); }, "the runs do not sum to the part's losses", RunsInvalid);
link_bad(&mut o, "truncated_not_full", &|r| r.account.runs_truncated = true, "marked truncated with fewer runs than a part lists", RunsInvalid);
link_bad(&mut o, "run_overfull_alone", &|r| { r.account.runs[0].dropped = 1; r.account.runs[1].dropped = 0 }, "a run's recovered and dropped past its count, every sum still right", RunsInvalid);
link_bad(&mut o, "recovery_disordered", &|r| { let n = r.account.recovery.samples; r.account.recovery = Summary { samples: n, min: 30_000, median: 20_000, max: 40_000 } }, "the recoveries' minimum above their median", AccountInvalid);
link_bad(&mut o, "recovery_empty_with_figures", &|r| { r.account.first_try = r.account.delivered; r.account.recovery = Summary { samples: 0, min: 0, median: 0, max: 9 }; r.account.margin_min = 0; for x in r.account.runs.iter_mut() { x.recovered = 0; } r.account.runs.retain(|x| x.dropped > 0); r.account.seq_count = r.account.delivered + r.account.dropped; }, "no recovery sample, yet a maximum", AccountInvalid);
// Counts past their bounds, flags out of their range, a signature's R of small order.
let mut r = base();
for k in 0..9u8 {
r.tracks.push(TrackHead { track: h32(40 + k), receipt_alias: 9, groups: 1, head: h32(60 + k) });
}
r.tracks.sort_by_key(|t| t.track);
r.tracks.dedup_by_key(|t| t.track);
assert!(r.tracks.len() > 8);
o.part("tracks_past_bound", r.sign(&k), Err(Malformed), "more track heads than a record holds");
let mut many = String::new();
for _ in 0..(MAX_RUNS + 1) {
many.push_str("FR");
}
let mut r = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 500_000, account(100, &many, T0, T0 + PART, false), tracks(2));
// The account lists 512 runs and truncates; write all 513 as if it did not.
let mut runs = Vec::new();
for i in 0..(MAX_RUNS as u64 + 1) {
runs.push(Run { first: 101 + 2 * i, count: 1, recovered: 1, dropped: 0, outstanding: 0 });
}
r.account.runs = runs;
r.account.runs_truncated = false;
o.part("runs_past_bound", r.sign(&k), Err(Malformed), "513 runs listed");
let good_one = base();
let b = good_one.sign(&k);
let at_last = 4 + 1 + 4 * 32 + 16 + 4 + 32 + 1 + 1 + 2;
let mut x = b.clone();
x[at_last] = 2;
o.part("last_byte_two", x, Err(Malformed), "a last flag of 2");
let at_trunc = 347 + 1 + 80 * good_one.tracks.len() + 2;
let mut x = b.clone();
x[at_trunc] = 2;
o.part("truncated_byte_two", x, Err(Malformed), "a truncated flag of 2");
let mut x = b.clone();
let n = x.len();
x[n - 64..n - 32].copy_from_slice(&{
let mut id = [0u8; 32];
id[0] = 1;
id
});
o.part("small_order_r", x, Err(BadSignature), "the signature's R is the identity point, of small order");
let margin = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, &"F".repeat(40), T0, T0 + PART, false), tracks(2));
let mut m = margin.clone();
m.account.margin_min = 5;
o.part("margin_without_recovery", m.sign(&k), Err(AccountInvalid), "a recovery margin with nothing recovered");
let lost = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, "FFDD", T0, T0 + PART, false), tracks(2));
o.part("two_dropped", lost.sign(&k), Ok(()), "two of four dropped, none recovered");
let all_lost = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, "DD", T0, T0 + PART, false), tracks(2));
let mut m = all_lost.clone();
m.account.bytes = 10;
o.part("bytes_without_delivery", m.sign(&k), Err(AccountInvalid), "bytes delivered with nothing delivered");
// ── Series ──────────────────────────────────────────────────────────────────────
let p0 = base();
let b0 = signed(&p0);
let p1 = {
let a = account(p0.account.seq_first + p0.account.seq_count, "FFFFFFRFFFFFFFFF", T0 + PART, T0 + 2 * PART, false);
record(1, 1, 128, 1, linkrec::digest(&b0), false, 0, T0 + PART, T0 + 2 * PART, 120_000, a, tracks(5))
};
let b1 = signed(&p1);
let p2 = {
let a = account(p1.account.seq_first + p1.account.seq_count, "FFFFFFFFFO", T0 + 2 * PART, T0 + 2 * PART + 3_000_000, true);
record(1, 1, 128, 2, linkrec::digest(&b1), true, ended::DELETED, T0 + 2 * PART, T0 + 2 * PART + 3_000_000, 120_000, a, tracks(6))
};
let b2 = signed(&p2);
o.series("series_three_parts", vec![b0.clone(), b1.clone(), b2.clone()], Ok(()), "a session of three parts, each chained to the one before, the last ended by DELETE");
o.series("series_starts_late", vec![b1.clone(), b2.clone()], Err((0, ChainBroken)), "a series whose first part is not part 0");
let mut q = p1.clone();
q.parent = h32(99);
o.series("series_wrong_parent", vec![b0.clone(), signed(&q), b2.clone()], Err((1, ChainBroken)), "part 1 names another parent");
let mut q = p2.clone();
q.part = 3;
o.series("series_skips_a_part", vec![b0.clone(), b1.clone(), signed(&q)], Err((2, ChainBroken)), "part 1 then part 3");
let mut q = p1.clone();
q.session = h32(55)[..16].try_into().unwrap();
o.series("series_other_session", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "part 1 of another session");
let mut q = p1.clone();
q.tracks[0].head = h32(200);
q.tracks[0].groups = p0.tracks[0].groups;
o.series("series_head_moved_without_groups", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "a track's head changed with no group signed since");
let mut q = p1.clone();
q.tracks.remove(0);
o.series("series_track_dropped", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "a track of part 0 missing from part 1");
let mut last0 = p0.clone();
last0.last = true;
last0.account.last = true;
last0.ended = ended::IDLE;
let bl = signed(&last0);
let mut q = p1.clone();
q.parent = linkrec::digest(&bl);
o.series("series_part_after_last", vec![bl, signed(&q)], Err((1, ChainBroken)), "a part after the session's last");
let mut q = p1.clone();
q.account.seq_first += 1;
q.account.runs.iter_mut().for_each(|r| r.first += 1);
o.series("series_sequence_gap", vec![b0.clone(), signed(&q)], Err((1, SequenceGap)), "a sequence number between part 0 and part 1 accounted for by neither");
let mut q = p1.clone();
q.start += 1;
q.account.start_us += 1;
o.series("series_time_gap", vec![b0.clone(), signed(&q)], Err((1, SequenceGap)), "part 1 begins a microsecond after part 0 ended");
let other = SigningKey::from_bytes(&[0x5d; 32]);
let mut q = p1.clone();
q.hop = other.verifying_key().to_bytes();
o.series("series_other_hop", vec![b0.clone(), q.sign(&other)], Err((1, ChainBroken)), "part 1 signed by another gateway");
let same_rest = |f: &dyn Fn(&mut LinkRecord)| {
let mut q = p1.clone();
f(&mut q);
signed(&q)
};
o.series("series_other_room", vec![b0.clone(), same_rest(&|q| q.room = h32(56))], Err((1, ChainBroken)), "part 1 of another room");
o.series("series_other_principal", vec![b0.clone(), same_rest(&|q| q.principal = h32(57))], Err((1, ChainBroken)), "part 1 of another owner");
o.series("series_other_grant", vec![b0.clone(), same_rest(&|q| q.grant = h32(58))], Err((1, ChainBroken)), "part 1 under another grant");
o.series("series_other_protocol", vec![b0.clone(), same_rest(&|q| { q.protocol = 3; q.cipher = 128 })], Err((1, ChainBroken)), "part 1 of another protocol");
o.series("series_other_mode", vec![b0.clone(), same_rest(&|q| q.mode = 2)], Err((1, ChainBroken)), "part 1 says the gateway called");
o.series("series_other_cipher", vec![b0.clone(), same_rest(&|q| q.cipher = 256)], Err((1, ChainBroken)), "part 1 under another key length");
o.series("series_other_window", vec![b0.clone(), same_rest(&|q| q.window = 200_000)], Err((1, ChainBroken)), "part 1 with another window");
o.series("series_track_alias_changed", vec![b0.clone(), same_rest(&|q| q.tracks[0].receipt_alias ^= 1)], Err((1, ChainBroken)), "a track's receipt chain changed");
let mut q = p1.clone();
q.tracks.push(TrackHead { track: h32(3), receipt_alias: 0x00ab_cdef, groups: 1, head: h32(201) });
q.tracks.sort_by_key(|t| t.track);
let bq = signed(&q);
let mut q2 = p2.clone();
q2.parent = linkrec::digest(&bq);
q2.tracks.push(TrackHead { track: h32(3), receipt_alias: 0x00ab_cdef, groups: 2, head: h32(202) });
q2.tracks.sort_by_key(|t| t.track);
o.series("series_track_added", vec![b0.clone(), bq, signed(&q2)], Ok(()), "a track that begins in part 1, named again in part 2");
o
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let (mode, dir) = match args.as_slice() {
[m, d] if m == "--out" || m == "--check" => (m.clone(), std::path::PathBuf::from(d)),
_ => {
eprintln!("usage: joule-moq-ingest-link-vectors --out|--check <dir>");
return ExitCode::from(2);
}
};
let o = corpus();
let reasons: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
for r in &reasons {
assert!(o.vectors.iter().any(|v| v["reason"] == *r), "every reason refuses a vector: {r}");
}
let index = json!({
"format": "wai-live-contribution-links/1",
"hop_seed": hex::encode([0x5c; 32]),
"hop": hex::encode(key().verifying_key().to_bytes()),
"reasons": reasons,
"vectors": o.vectors,
});
let mut files = o.files;
files.insert("vectors.json".into(), (serde_json::to_string_pretty(&index).unwrap() + "\n").into_bytes());
if mode == "--out" {
std::fs::create_dir_all(&dir).expect("the corpus directory");
for (name, b) in &files {
std::fs::write(dir.join(name), b).expect("a corpus file writes");
}
println!("wrote {} files to {}", files.len(), dir.display());
ExitCode::SUCCESS
} else {
let mut bad = 0;
for (name, b) in &files {
match std::fs::read(dir.join(name)) {
Ok(have) if have == *b => {}
Ok(_) => {
eprintln!("differs: {name}");
bad += 1;
}
Err(_) => {
eprintln!("missing: {name}");
bad += 1;
}
}
}
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if (n.ends_with(".wcl1") || n == "vectors.json") && !files.contains_key(&n) {
eprintln!("not generated: {n}");
bad += 1;
}
}
}
if bad == 0 {
println!("the corpus is what the generator writes: {} files", files.len());
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
}

View file

@ -66,6 +66,8 @@ pub fn receipt_alias(session: &[u8; 16], ns: &TrackNamespace, name: &str) -> u64
pub struct Entry {
pub media: u8,
pub ns: TrackNamespace,
/// The Track Name: the layer, `0` for a WHIP session's tracks (live-rooms §6.3).
pub name: String,
pub codec: Codec,
pub header: Option<Header>,
pub receipt_alias: u64,
@ -78,8 +80,10 @@ pub fn catalog(entries: &[Entry]) -> Vec<u8> {
let mut tracks = Vec::new();
for e in entries {
let namespace = render(&e.ns);
let receipts = format!("{}{RECEIPTS_SUFFIX}", e.name);
let mut t = serde_json::Map::new();
if e.codec == Codec::Opus {
let audio = !e.codec.is_video();
if audio {
let ch = e.header.as_ref().map_or(2, |h| h.channels.max(1));
t.insert("channelConfig".into(), json!(ch.to_string()));
}
@ -90,28 +94,40 @@ pub fn catalog(entries: &[Entry]) -> Vec<u8> {
t.insert("height".into(), json!(h.height));
}
t.insert("isLive".into(), json!(true));
t.insert("name".into(), json!(LAYER0));
t.insert("name".into(), json!(e.name));
t.insert("namespace".into(), json!(namespace));
t.insert("packaging".into(), json!("wai"));
t.insert("renderGroup".into(), json!(1));
t.insert("role".into(), json!(if e.codec.is_video() { "video" } else { "audio" }));
if e.codec == Codec::Opus {
t.insert("samplerate".into(), json!(48_000));
if audio {
let rate = e.header.as_ref().map_or(0, |h| h.rate);
t.insert("samplerate".into(), json!(if rate > 0 { rate } else { 48_000 }));
}
t.insert("wai_capabilities".into(), json!([e.codec.capability()]));
t.insert("wai_receipt_alias".into(), json!(e.receipt_alias));
t.insert("wai_receipts".into(), json!(true));
t.insert("wai_receipts_track".into(), json!(format!("{LAYER0}{RECEIPTS_SUFFIX}")));
t.insert("wai_receipts_track".into(), json!(receipts));
if let Some(h) = e.header.as_ref().filter(|h| h.width > 0) {
t.insert("width".into(), json!(h.width));
}
tracks.push(sorted(Value::Object(t)));
tracks.push(json!({"isLive": true, "name": format!("{LAYER0}{RECEIPTS_SUFFIX}"), "namespace": namespace, "packaging": "wai-receipts"}));
tracks.push(json!({"isLive": true, "name": receipts, "namespace": namespace, "packaging": "wai-receipts"}));
}
let v = json!({"tracks": tracks, "version": 1});
serde_json::to_vec(&sorted(v)).expect("a catalog serialises")
}
/// [`catalog`], and a contribution session's link track (`extensions/live-contribution.md`
/// §6.4): `name` in the catalog's namespace `namespace`, packaging `wai-link`, its receipts
/// on `<name>_receipts` under the chain `chain`.
pub fn catalog_with_link(entries: &[Entry], namespace: &str, name: &str, chain: u64) -> Vec<u8> {
let mut v: Value = serde_json::from_slice(&catalog(entries)).expect("a catalog reads");
let tracks = v["tracks"].as_array_mut().expect("a catalog has tracks");
tracks.push(json!({"isLive": true, "name": name, "namespace": namespace, "packaging": "wai-link", "wai_receipt_alias": chain, "wai_receipts": true, "wai_receipts_track": format!("{name}{RECEIPTS_SUFFIX}")}));
tracks.push(json!({"isLive": true, "name": format!("{name}{RECEIPTS_SUFFIX}"), "namespace": namespace, "packaging": "wai-receipts"}));
serde_json::to_vec(&sorted(v)).expect("a catalog serialises")
}
/// A value with every object's members in ascending order of name, whatever order the
/// map type keeps.
fn sorted(v: Value) -> Value {
@ -173,8 +189,8 @@ mod tests {
let v = media_namespace(&room, &owner, crate::room::media::VIDEO);
let a = media_namespace(&room, &owner, crate::room::media::AUDIO);
let entries = vec![
Entry { media: 2, ns: v.clone(), codec: Codec::Avc, header: Some(Header { width: 0, height: 0, codec: Some("avc1.42E01F".into()), channels: 0 }), receipt_alias: 11 },
Entry { media: 1, ns: a.clone(), codec: Codec::Opus, header: Some(Header { width: 0, height: 0, codec: Some("opus".into()), channels: 2 }), receipt_alias: 12 },
Entry { media: 2, ns: v.clone(), name: LAYER0.into(), codec: Codec::Avc, header: Some(Header { width: 0, height: 0, codec: Some("avc1.42E01F".into()), channels: 0, rate: 0 }), receipt_alias: 11 },
Entry { media: 1, ns: a.clone(), name: LAYER0.into(), codec: Codec::Opus, header: Some(Header { width: 0, height: 0, codec: Some("opus".into()), channels: 2, rate: 48_000 }), receipt_alias: 12 },
];
let b = catalog(&entries);
let s = String::from_utf8(b.clone()).unwrap();

View file

@ -30,6 +30,188 @@ pub struct GatewayConfig {
pub whep: WhepConfig,
#[serde(default)]
pub summary: SummaryConfig,
#[serde(default)]
pub rtmp: RtmpConfig,
#[serde(default)]
pub injector: InjectorConfig,
#[serde(default)]
pub contrib: ContribConfig,
}
/// The Enhanced RTMP ingest (`extensions/live-ingest.md` §12). Off unless `listen` or
/// `tls_listen` is set.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RtmpConfig {
/// Where RTMP over TCP listens.
#[serde(default)]
pub listen: Option<SocketAddr>,
/// Where RTMP over TLS listens (RTMPS), with `cert_path` and `key_path`.
#[serde(default)]
pub tls_listen: Option<SocketAddr>,
/// The certificate chain, PEM, and its PKCS #8 private key, PEM.
#[serde(default)]
pub cert_path: Option<PathBuf>,
#[serde(default)]
pub key_path: Option<PathBuf>,
/// The URL a stream key's response names, for an encoder's server field, e.g.
/// `rtmps://ingest.example/live`. Empty: `rtmp://<listen>/live`.
#[serde(default)]
pub url: String,
/// How long a stream key the `/rtmp` resource issues stays good for its first use.
#[serde(default = "d_key_ttl")]
pub stream_key_ttl_s: u64,
/// The most RTMP connections at once.
#[serde(default = "d_rtmp_sessions")]
pub max_sessions: usize,
/// The largest message the chunk stream takes, in bytes: a frame larger is refused,
/// and ends the connection.
#[serde(default = "d_rtmp_message")]
pub max_message_bytes: usize,
/// What a publisher may send, over every track, in kilobits per second, over the
/// limits' `bitrate_window_ms`; past it the session ends (`bitrate_exceeded`).
#[serde(default = "d_rtmp_kbps")]
pub max_bitrate_kbps: u32,
/// A connection that has not published by then is closed.
#[serde(default = "d_rtmp_connect")]
pub connect_timeout_ms: u64,
/// A publishing session that has carried no media for this long ends (`idle`).
#[serde(default = "d_rtmp_idle")]
pub idle_timeout_ms: u64,
/// The chunk size the gateway sends with.
#[serde(default = "d_rtmp_chunk")]
pub chunk_size: u32,
/// The `tcUrl` a reconnect request names (E-RTMP *Reconnect Request*); empty: none, so
/// the publisher reconnects to the URL it used.
#[serde(default)]
pub reconnect_url: String,
/// How long the gateway keeps a session after it sends a reconnect request, for the
/// publisher to reach its next keyframe and leave.
#[serde(default = "d_rtmp_grace")]
pub reconnect_grace_ms: u64,
/// TLS identity given in code (not read from TOML): the chain, DER, and the PKCS #8
/// key, DER.
#[serde(skip)]
pub tls_der: Option<(Vec<Vec<u8>>, Vec<u8>)>,
}
fn d_key_ttl() -> u64 {
60
}
fn d_rtmp_sessions() -> usize {
64
}
fn d_rtmp_message() -> usize {
8 << 20
}
fn d_rtmp_kbps() -> u32 {
40_000
}
fn d_rtmp_connect() -> u64 {
15_000
}
fn d_rtmp_idle() -> u64 {
20_000
}
fn d_rtmp_chunk() -> u32 {
4096
}
fn d_rtmp_grace() -> u64 {
5_000
}
impl Default for RtmpConfig {
fn default() -> Self {
RtmpConfig {
listen: None,
tls_listen: None,
cert_path: None,
key_path: None,
url: String::new(),
stream_key_ttl_s: d_key_ttl(),
max_sessions: d_rtmp_sessions(),
max_message_bytes: d_rtmp_message(),
max_bitrate_kbps: d_rtmp_kbps(),
connect_timeout_ms: d_rtmp_connect(),
idle_timeout_ms: d_rtmp_idle(),
chunk_size: d_rtmp_chunk(),
reconnect_url: String::new(),
reconnect_grace_ms: d_rtmp_grace(),
tls_der: None,
}
}
}
/// The SCTE 104 injector (WAI `extensions/cue-injection.md`). Off unless `listen` is set.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InjectorConfig {
/// Where the automation system's API connection is accepted (SCTE 104's port is 5167).
#[serde(default)]
pub listen: Option<SocketAddr>,
/// The room the injector's cues are for: its id, 64 hexadecimal digits, a room the
/// gateway holds.
#[serde(default)]
pub room: String,
/// A file of 32 bytes, the Ed25519 seed of the injector's principal, which signs its
/// events.
#[serde(default)]
pub key_path: Option<PathBuf>,
/// The principal's room grant (`WRG1`), which permits `cue` and `meta`.
#[serde(default)]
pub grant_path: Option<PathBuf>,
/// The GPS − UTC leap seconds a UTC `timestamp()` is read with (SCTE 104 §12.4: its
/// seconds count from 1980-01-06 with the leap seconds since).
#[serde(default = "d_leap")]
pub leap_seconds: u8,
/// The video frame rate a segmentation descriptor's `duration_extension_frames` is
/// counted in, as a fraction.
#[serde(default = "d_fps_num")]
pub fps_num: u32,
#[serde(default = "d_fps_den")]
pub fps_den: u32,
/// The DPI_PID_index values the injector answers; empty: any.
#[serde(default)]
pub dpi_pid_index: Vec<u16>,
/// The most cues held for a splice that has not come yet.
#[serde(default = "d_pending")]
pub max_pending: usize,
/// A seed and a grant given in code (not read from TOML).
#[serde(skip)]
pub key_seed: Option<[u8; 32]>,
#[serde(skip)]
pub grant: Option<Vec<u8>>,
}
fn d_leap() -> u8 {
18
}
fn d_fps_num() -> u32 {
30_000
}
fn d_fps_den() -> u32 {
1_001
}
fn d_pending() -> usize {
256
}
impl Default for InjectorConfig {
fn default() -> Self {
InjectorConfig {
listen: None,
room: String::new(),
key_path: None,
grant_path: None,
leap_seconds: d_leap(),
fps_num: d_fps_num(),
fps_den: d_fps_den(),
dpi_pid_index: Vec::new(),
max_pending: d_pending(),
key_seed: None,
grant: None,
}
}
}
#[derive(Clone, Debug, Deserialize)]
@ -362,6 +544,128 @@ pub struct SummaryConfig {
/// Each session's record is appended here as one JSON line, when set.
#[serde(default)]
pub out_path: Option<PathBuf>,
/// Each contribution link record part (`WCL1`) is appended here, as one line of
/// hexadecimal, when set.
#[serde(default)]
pub link_out_path: Option<PathBuf>,
}
/// Contribution over SRT and RIST (`extensions/live-contribution.md`).
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContribConfig {
/// The host name callers connect to, stated in a session's answer; empty: none stated.
#[serde(default)]
pub host: String,
/// How long a session a POST opened waits for its connection before it is dropped.
#[serde(default = "d_ttl")]
pub session_ttl_s: u64,
/// SRT and RIST sessions at once.
#[serde(default = "d_contrib_sessions")]
pub max_sessions: usize,
/// The most a session may carry, all streams, over `limits.bitrate_window_ms`; past
/// it the session is ended (`bitrate_exceeded`).
#[serde(default = "d_contrib_kbps")]
pub max_bitrate_kbps: u32,
/// How often a session signs a part of its link record.
#[serde(default = "d_record_interval")]
pub record_interval_ms: u64,
/// A link that receives nothing for this long is ended.
#[serde(default = "d_contrib_idle")]
pub idle_ms: u64,
#[serde(default)]
pub srt: SrtConfig,
#[serde(default)]
pub rist: RistConfig,
}
fn d_ttl() -> u64 {
60
}
fn d_contrib_sessions() -> usize {
16
}
fn d_contrib_kbps() -> u32 {
80_000
}
fn d_record_interval() -> u64 {
10_000
}
fn d_contrib_idle() -> u64 {
5_000
}
impl Default for ContribConfig {
fn default() -> Self {
ContribConfig { host: String::new(), session_ttl_s: d_ttl(), max_sessions: d_contrib_sessions(), max_bitrate_kbps: d_contrib_kbps(), record_interval_ms: d_record_interval(), idle_ms: d_contrib_idle(), srt: SrtConfig::default(), rist: RistConfig::default() }
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SrtConfig {
/// The SRT listener's UDP address; absent: no listener (and `/srt` is `404`).
#[serde(default)]
pub listen: Option<SocketAddr>,
/// The listener's latency, ms: the connection's is the larger of the two ends'.
#[serde(default = "d_latency")]
pub latency_ms: u16,
/// The key length, bytes (16, 24 or 32), a session's answer states.
#[serde(default = "d_pbkeylen")]
pub pbkeylen: usize,
/// The publishers' listeners the gateway may call (`call` in a POST's body): each
/// `ip:port`, or `ip` for any port of it. Empty: the gateway calls none.
#[serde(default)]
pub call_allow: Vec<String>,
}
fn d_latency() -> u16 {
120
}
fn d_pbkeylen() -> usize {
16
}
impl Default for SrtConfig {
fn default() -> Self {
SrtConfig { listen: None, latency_ms: d_latency(), pbkeylen: d_pbkeylen(), call_allow: Vec::new() }
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RistConfig {
/// Whether `/rist` opens sessions (and the build carries RIST: feature `rist`).
#[serde(default)]
pub enabled: bool,
/// The address each session's port is bound on.
#[serde(default = "d_bind_ip")]
pub bind_ip: IpAddr,
/// The ports sessions are given, `port_min` to `port_max` (the Simple Profile takes an
/// even port and the one above). 0 and 0: any free port.
#[serde(default)]
pub port_min: u16,
#[serde(default)]
pub port_max: u16,
/// The receiver's buffer, ms (VSF TR-06-1 Appendix B suggests 1 000).
#[serde(default = "d_rist_buffer")]
pub buffer_ms: u64,
/// The Main Profile's PSK key length, bits: 128 or 256.
#[serde(default = "d_rist_aes")]
pub aes: u16,
}
fn d_rist_buffer() -> u64 {
1_000
}
fn d_rist_aes() -> u16 {
128
}
impl Default for RistConfig {
fn default() -> Self {
RistConfig { enabled: false, bind_ip: d_bind_ip(), port_min: 0, port_max: 0, buffer_ms: d_rist_buffer(), aes: d_rist_aes() }
}
}
impl GatewayConfig {
@ -382,6 +686,9 @@ impl GatewayConfig {
timeline: Timeline::default(),
whep: WhepConfig::default(),
summary: SummaryConfig::default(),
rtmp: RtmpConfig::default(),
injector: InjectorConfig::default(),
contrib: ContribConfig::default(),
}
}
}

View file

@ -0,0 +1,656 @@
//! Contribution over UDP into a room's tracks: SRT and RIST sessions (WAI
//! `extensions/live-ingest.md` §13 and `extensions/live-contribution.md`).
//!
//! - **Admission.** A publisher first POSTs `/srt` or `/rist` with its room grant and the
//! proof of its key, admitted as WHIP is (live-ingest §3). The gateway answers with a
//! one-time session: for SRT the stream ID `#!::r=<room>/<owner>,m=publish,s=<session>`
//! and the passphrase the handshake must carry (the gateway's listener checks both); for
//! RIST a port of its own, the CNAME the flow's RTCP must give, and, in the Main Profile,
//! the PSK passphrase. With `call` in its body, a POST to `/srt` has the gateway call the
//! publisher's SRT listener instead, where the configuration lets it.
//! - **The stream.** The link delivers the transport stream at its delivery time (SRT's TSBPD
//! over its latency, RIST's buffer); the gateway demultiplexes it, takes the first video
//! stream (H.264, HEVC) and the first AAC stream, and gives each access unit (each ADTS
//! frame) to the track's packager with its PTS as the media time on the room clock
//! (live-ingest §7) and its arrival from the link as its arrival: the same objects,
//! groups, catalog and receipts as a WHIP session's (live-ingest §4 to §8).
//! - **The link record.** Every `record_interval_ms`, and when the session ends, the gateway
//! signs a part of the link's `WCL1` record ([`crate::linkrec`]) and publishes it on the
//! owner's `link` track with its receipts: every sequence number delivered, recovered,
//! dropped or outstanding, the requests and the round trips, and the head of every media
//! track's receipt chain at the part's end.
use crate::catalog::{self, Entry};
use crate::frame::{Codec, GrantRef};
use crate::gateway::{random_hex, Cmd, Gateway, Handle};
use crate::linkrec::{self, LinkRecord, TrackHead};
use crate::package::{sign_group, Emit, Packager, TrackOut};
use crate::record::{Energy, LedgerEntry, SessionRecord, SignedRecord, TrackEntry, FORMAT};
use crate::relay::{Published, Publisher};
use crate::room::{media, Admitted, Room, NO_CEILING};
use joule_contrib::link::{Link, Protocol};
use joule_contrib::srt::packet::rej;
use joule_contrib::ts::{self, Demux};
use joule_moq::TrackName;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
const CATALOG_ALIAS: u64 = 1;
const CATALOG_RECEIPTS_ALIAS: u64 = 6;
const LINK_ALIAS: u64 = 7;
const LINK_RECEIPTS_ALIAS: u64 = 8;
pub const LINK_NAME: &str = "link";
const CATALOG_HOLD: Duration = Duration::from_millis(1_000);
const END_GRACE: Duration = Duration::from_millis(250);
fn aliases(m: u8) -> (u64, u64) {
if m == media::AUDIO { (2, 3) } else { (4, 5) }
}
fn priority(m: u8) -> u8 {
if m == media::AUDIO { 0 } else { 1 }
}
/// A session a POST opened, not yet connected.
pub struct Pending {
pub id: String,
pub session: [u8; 16],
pub room: Arc<Room>,
pub ad: Admitted,
pub token_hash: [u8; 32],
pub passphrase: Option<String>,
pub protocol: Protocol,
pub created: Instant,
}
impl Pending {
pub fn tag(&self) -> u64 {
u64::from_le_bytes(self.session[..8].try_into().unwrap())
}
}
/// The gateway's contribution sessions.
#[derive(Default)]
pub struct Contrib {
/// Opened, not yet connected, by tag.
pub pending: Mutex<HashMap<u64, Pending>>,
/// Admitted by the SRT listener, the handshake under way, by tag.
pub admitted: Mutex<HashMap<u64, Pending>>,
pub sessions: Mutex<HashMap<String, Handle>>,
pub active: AtomicUsize,
/// (room, owner) of every running session.
pub owners: Mutex<HashSet<([u8; 32], [u8; 32])>>,
/// Every link record part signed so far, as bytes.
pub records: Mutex<Vec<Vec<u8>>>,
pub refused: AtomicU64,
}
impl Contrib {
pub fn tag_of(id: &str) -> Option<u64> {
let b = hex::decode(id).ok()?;
(b.len() == 16).then(|| u64::from_le_bytes(b[..8].try_into().unwrap()))
}
/// Drop pending sessions older than `ttl`.
pub fn prune(&self, ttl: Duration) {
self.pending.lock().unwrap().retain(|_, p| p.created.elapsed() <= ttl);
self.admitted.lock().unwrap().retain(|_, p| p.created.elapsed() <= ttl * 2);
}
}
/// A fresh one-time session for `ad`.
pub fn open(room: Arc<Room>, ad: Admitted, token_hash: [u8; 32], protocol: Protocol, passphrase: bool) -> Pending {
let id = random_hex(16);
let session: [u8; 16] = hex::decode(&id).unwrap().try_into().unwrap();
// 24 random bytes in base64url: 32 characters, within SRT's 10 to 79.
let passphrase = passphrase.then(|| {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(joule_contrib::random_bytes(24))
});
Pending { id, session, room, ad, token_hash, passphrase, protocol, created: Instant::now() }
}
/// The stream ID a caller presents for `p` (live-ingest §13).
pub fn stream_id(p: &Pending) -> String {
format!("#!::r={}/{},m=publish,s={}", hex::encode(p.room.id), hex::encode(p.ad.grant.principal), p.id)
}
/// The keys of a stream ID in the `#!::` key-value syntax.
fn sid_keys(s: &str) -> Option<HashMap<String, String>> {
let body = s.strip_prefix("#!::")?;
let mut m = HashMap::new();
for kv in body.split(',') {
let (k, v) = kv.split_once('=')?;
m.insert(k.trim().to_string(), v.trim().to_string());
}
Some(m)
}
/// The SRT listener's decision on a caller (live-ingest §13, its rejection codes).
pub fn admit_srt(gw: &Gateway, stream_id: Option<&str>) -> Result<u64, u32> {
let refuse = |c: u32| {
gw.contrib.refused.fetch_add(1, Ordering::Relaxed);
Err(c)
};
let Some(keys) = stream_id.and_then(sid_keys) else { return refuse(rej::UNAUTHORIZED) };
match keys.get("m").map(String::as_str) {
Some("publish") => {}
// `m=request` is the mapping's play mode, which this gateway does not serve.
_ => return refuse(rej::BAD_MODE),
}
let Some(tag) = keys.get("s").and_then(|s| Contrib::tag_of(s)) else { return refuse(rej::UNAUTHORIZED) };
gw.contrib.prune(Duration::from_secs(gw.cfg.contrib.session_ttl_s));
let mut pending = gw.contrib.pending.lock().unwrap();
let Some(p) = pending.get(&tag).filter(|p| p.protocol == Protocol::Srt && keys.get("s") == Some(&p.id)) else { return refuse(rej::UNAUTHORIZED) };
let (room_hex, owner_hex) = match keys.get("r").and_then(|r| r.split_once('/')) {
Some((a, b)) => (a.to_string(), b.to_string()),
None => return refuse(rej::NOTFOUND),
};
if !gw.rooms.contains_key(&<[u8; 32]>::try_from(hex::decode(&room_hex).unwrap_or_default().as_slice()).unwrap_or([0; 32])) {
return refuse(rej::NOTFOUND);
}
if room_hex != hex::encode(p.room.id) || owner_hex != hex::encode(p.ad.grant.principal) {
return refuse(rej::FORBIDDEN);
}
if !p.ad.grant.good_at(gw.clock.unix_us()) {
return refuse(rej::UNAUTHORIZED);
}
if gw.contrib.active.load(Ordering::SeqCst) >= gw.cfg.contrib.max_sessions {
return refuse(rej::OVERLOAD);
}
if gw.contrib.owners.lock().unwrap().contains(&(p.room.id, p.ad.grant.principal)) {
return refuse(rej::CONFLICT);
}
// The session ID is spent by its first use.
let p = pending.remove(&tag).unwrap();
gw.contrib.admitted.lock().unwrap().insert(tag, p);
Ok(tag)
}
/// Run a session over `link` for `p` until it ends.
pub async fn run(gw: Arc<Gateway>, p: Pending, link: Link) {
let key = (p.room.id, p.ad.grant.principal);
if !gw.contrib.owners.lock().unwrap().insert(key) {
link.close();
return;
}
gw.contrib.active.fetch_add(1, Ordering::SeqCst);
let id = p.id.clone();
let r = Session::start(gw.clone(), p, link).await;
match r {
Ok(s) => s.run().await,
Err(e) => {
let (why, link) = *e;
tracing::warn!(session = %id, "a contribution session could not start: {why}");
link.close();
}
}
gw.contrib.sessions.lock().unwrap().remove(&id);
gw.contrib.owners.lock().unwrap().remove(&key);
gw.contrib.active.fetch_sub(1, Ordering::SeqCst);
}
struct LinkTrack {
chain: u64,
group: Option<u64>,
prev: Option<[u8; 32]>,
part: u32,
parent: [u8; 32],
part_start: u64,
}
struct Session {
gw: Arc<Gateway>,
p: Pending,
owner: String,
link: Link,
publisher: Publisher,
packagers: BTreeMap<u8, Packager>,
demux: Demux,
/// PID → (media kind, codec) of the streams carried.
streams: BTreeMap<u16, (u8, Codec)>,
unsupported_streams: HashSet<u16>,
catalog_group: Option<u64>,
catalog_chain: u64,
catalog_link: Option<[u8; 32]>,
catalog_dirty: bool,
first_frame: Option<Instant>,
lt: LinkTrack,
start_us: u64,
window_start: Instant,
window_bytes: u64,
bytes_in: u64,
frames_in: Arc<AtomicU64>,
last_pts: HashMap<u16, u64>,
cmd: mpsc::Receiver<Cmd>,
delete_reply: Option<tokio::sync::oneshot::Sender<()>>,
}
impl Session {
/// The session's tracks published; or why not, with the link handed back.
async fn start(gw: Arc<Gateway>, p: Pending, link: Link) -> Result<Session, Box<(String, Link)>> {
let owner = hex::encode(p.ad.grant.principal);
let kinds: Vec<u8> = [media::VIDEO, media::AUDIO].into_iter().filter(|k| p.ad.grant.caps.publishes(*k)).collect();
let (client, endpoint) = match gw.link.connect().await {
Ok(x) => x,
Err(e) => return Err(Box::new((format!("relay_unreachable: {e}"), link))),
};
let cat_ns = catalog::catalog_namespace(&p.room.id, &owner);
let mut published = vec![
Published { ns: cat_ns.clone(), name: TrackName::from_str(catalog::CATALOG_NAME), alias: CATALOG_ALIAS, priority: 0 },
Published { ns: cat_ns.clone(), name: catalog::receipts_name(catalog::CATALOG_NAME), alias: CATALOG_RECEIPTS_ALIAS, priority: 0 },
Published { ns: cat_ns.clone(), name: TrackName::from_str(LINK_NAME), alias: LINK_ALIAS, priority: 0 },
Published { ns: cat_ns.clone(), name: catalog::receipts_name(LINK_NAME), alias: LINK_RECEIPTS_ALIAS, priority: 0 },
];
let grant_ref = GrantRef { grant_hash: p.ad.digest, joule_ceiling_micro: p.ad.grant.joules_ceiling, funds_ceiling: (p.ad.grant.funds_ceiling != NO_CEILING).then_some(p.ad.grant.funds_ceiling) };
let t0 = p.room.descriptor.t0_us;
let mut packagers = BTreeMap::new();
for &k in &kinds {
let ns = catalog::media_namespace(&p.room.id, &owner, k);
let (ma, ra) = aliases(k);
let out = TrackOut { media: k, media_alias: ma, receipts_alias: ra, receipt_alias: catalog::receipt_alias(&p.session, &ns, catalog::LAYER0), priority: priority(k) };
published.push(Published { ns: ns.clone(), name: TrackName::from_str(catalog::LAYER0), alias: ma, priority: priority(k) });
published.push(Published { ns: ns.clone(), name: catalog::receipts_name(catalog::LAYER0), alias: ra, priority: priority(k) });
let g = &gw.cfg.groups;
// A transport stream's PTS is a 90 kHz clock for every stream.
packagers.insert(k, Packager::new(out, 90_000, gw.cfg.timeline.max_skew_ms * 1000, grant_ref.clone(), gw.signer.clone(), t0, g.audio_group_ms * 1000, g.max_video_group_ms * 1000, gw.cfg.limits.max_frame_bytes));
}
let publisher = match Publisher::start(client, endpoint, published, gw.cfg.limits.max_queue_bytes).await {
Ok(p) => p,
Err(e) => return Err(Box::new((format!("relay_refused: {e}"), link))),
};
let (tx, cmd) = mpsc::channel(8);
let frames_in = Arc::new(AtomicU64::new(0));
gw.contrib.sessions.lock().unwrap().insert(p.id.clone(), Handle { tx, token_hash: p.token_hash, frames: frames_in.clone() });
let now = gw.clock.unix_us().saturating_sub(t0);
let catalog_chain = catalog::receipt_alias(&p.session, &cat_ns, catalog::CATALOG_NAME);
let link_chain = catalog::receipt_alias(&p.session, &cat_ns, LINK_NAME);
Ok(Session {
owner,
publisher,
packagers,
demux: Demux::new(),
streams: BTreeMap::new(),
unsupported_streams: HashSet::new(),
catalog_group: None,
catalog_chain,
catalog_link: None,
catalog_dirty: false,
first_frame: None,
lt: LinkTrack { chain: link_chain, group: None, prev: None, part: 0, parent: [0; 32], part_start: now },
start_us: now,
window_start: Instant::now(),
window_bytes: 0,
bytes_in: 0,
frames_in,
last_pts: HashMap::new(),
cmd,
delete_reply: None,
link,
p,
gw,
})
}
fn room_now(&self) -> u64 {
self.gw.clock.unix_us().saturating_sub(self.p.room.descriptor.t0_us)
}
async fn run(mut self) {
let mut tick = tokio::time::interval(Duration::from_millis(100));
let mut last_part = Instant::now();
let interval = Duration::from_millis(self.gw.cfg.contrib.record_interval_ms.max(100));
let mut ending: Option<String> = None;
let why = loop {
tokio::select! {
d = self.link.data.recv() => match d {
Some(payload) => {
if let Some(w) = self.on_payload(&payload) {
ending.get_or_insert(w);
self.link.close();
}
}
None => break ending.clone().or_else(|| self.link.closed_reason()).unwrap_or_else(|| "closed".into()),
},
c = self.cmd.recv() => match c {
Some(Cmd::Delete { reply }) => {
self.delete_reply = Some(reply);
ending.get_or_insert_with(|| "deleted".into());
self.link.close();
}
Some(Cmd::Patch { reply, .. }) => {
let _ = reply.send(crate::gateway::problem(hyper::StatusCode::METHOD_NOT_ALLOWED, "not_found", "a contribution session has no ICE to patch"));
}
None => {
ending.get_or_insert_with(|| "gateway_stopped".into());
self.link.close();
}
},
_ = tick.tick() => {
if self.publisher.failed.load(Ordering::Relaxed) {
ending.get_or_insert_with(|| "relay_lost".into());
self.link.close();
}
if self.window_start.elapsed() >= Duration::from_millis(self.gw.cfg.limits.bitrate_window_ms) {
self.window_start = Instant::now();
self.window_bytes = 0;
}
self.publish_catalog(false);
if last_part.elapsed() >= interval && !self.link.account.lock().unwrap().open_is_empty() {
last_part = Instant::now();
self.part(false, "");
}
}
}
};
self.end(&why).await;
}
/// One delivered payload of the link: seven TS packets, as a rule.
fn on_payload(&mut self, payload: &[u8]) -> Option<String> {
self.bytes_in += payload.len() as u64;
self.window_bytes += payload.len() as u64;
let window_ms = self.gw.cfg.limits.bitrate_window_ms.max(1);
if self.window_bytes * 8 > self.max_kbps() * window_ms {
return Some("bitrate_exceeded".into());
}
for pes in self.demux.push(payload) {
if let Some(why) = self.on_pes(pes) {
return Some(why);
}
}
None
}
fn max_kbps(&self) -> u64 {
rate_bound_kbps(self.link.info.protocol, self.gw.cfg.contrib.max_bitrate_kbps as u64, self.link.info.latency_us)
}
/// The stream a PES belongs to: the first video stream and the first AAC stream the PMT
/// declares are carried, each to its track.
fn stream_of(&mut self, pid: u16, st: u8) -> Option<(u8, Codec)> {
if let Some(s) = self.streams.get(&pid) {
return Some(*s);
}
let (kind, codec) = match st {
ts::ST_H264 => (media::VIDEO, Codec::Avc),
ts::ST_HEVC => (media::VIDEO, Codec::Hevc),
ts::ST_AAC_ADTS => (media::AUDIO, Codec::Aac),
_ => {
self.unsupported_streams.insert(pid);
return None;
}
};
if self.streams.values().any(|(k, _)| *k == kind) || !self.packagers.contains_key(&kind) {
self.unsupported_streams.insert(pid);
return None;
}
self.streams.insert(pid, (kind, codec));
Some((kind, codec))
}
fn on_pes(&mut self, pes: ts::Pes) -> Option<String> {
let (kind, codec) = self.stream_of(pes.pid, pes.stream_type)?;
let arrival = self.room_now();
// A PES with no PTS continues from the one before.
let pts = match pes.pts {
Some(v) => v,
None => self.last_pts.get(&pes.pid).map_or(0, |v| v + 3000),
};
self.last_pts.insert(pes.pid, pts);
self.first_frame.get_or_insert_with(Instant::now);
let mut emits = Vec::new();
let p = self.packagers.get_mut(&kind)?;
let mut catalog = false;
if codec == Codec::Aac {
// One ADTS frame an object, each at its own time: 1024 samples a frame.
let rate = ts::adts_frames(&pes.data).first().and_then(|f| ts::adts_config(f)).map_or(48_000, |c| c.1.max(1)) as u64;
for (k, f) in ts::adts_frames(&pes.data).iter().enumerate() {
self.frames_in.fetch_add(1, Ordering::Relaxed);
let t = pts + k as u64 * 1024 * 90_000 / rate;
catalog |= p.on_frame(codec, f, t, arrival, false, &mut emits).1;
}
} else {
self.frames_in.fetch_add(1, Ordering::Relaxed);
catalog |= p.on_frame(codec, &pes.data, pts, arrival, pes.random_access, &mut emits).1;
}
self.catalog_dirty |= catalog;
for e in emits {
if !self.publisher.send(e) {
return Some("relay_backpressure".into());
}
}
None
}
fn publish_catalog(&mut self, force: bool) {
if !self.catalog_dirty {
return;
}
let all = self.packagers.values().all(|p| p.codec.is_some());
let held = self.first_frame.is_some_and(|t| t.elapsed() >= CATALOG_HOLD);
if !all && !held && !force {
return;
}
let entries: Vec<Entry> = self
.packagers
.values()
.filter_map(|p| Some(Entry { media: p.out.media, ns: catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media), name: catalog::LAYER0.into(), codec: p.codec?, header: p.header.clone(), receipt_alias: p.out.receipt_alias }))
.collect();
if entries.is_empty() && !force {
return;
}
let g = match self.catalog_group {
Some(g) => g + 1,
None => self.room_now(),
};
self.catalog_group = Some(g);
self.catalog_dirty = false;
let ns = catalog::catalog_namespace(&self.p.room.id, &self.owner);
let bytes = catalog::catalog_with_link(&entries, &catalog::render(&ns), LINK_NAME, self.lt.chain);
let ts = crate::package::rfc3339(self.gw.clock.unix_us());
let hash = joule_moq::receipt::ObjectReceipt::hash_payload(&bytes);
let (bundle, link) = sign_group(&self.gw.signer, self.catalog_chain, g, &[hash], &ts, &ts, self.catalog_link);
self.catalog_link = Some(link);
let _ = self.publisher.send(Emit::Single { alias: CATALOG_ALIAS, group: g, priority: 0, bytes });
let _ = self.publisher.send(Emit::Single { alias: CATALOG_RECEIPTS_ALIAS, group: g, priority: 0, bytes: bundle });
}
fn heads(&self) -> Vec<TrackHead> {
let mut v: Vec<TrackHead> = self
.packagers
.values()
.map(|p| {
let ns = catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media);
let (groups, head) = p.chain_head();
TrackHead { track: catalog::track_digest(&ns, catalog::LAYER0), receipt_alias: p.out.receipt_alias, groups, head: head.unwrap_or([0; 32]) }
})
.collect();
v.sort_by_key(|t| t.track);
v
}
/// Sign the next part of the link record, publish it on the link track, and keep it.
fn part(&mut self, last: bool, why: &str) {
let end = self.room_now().max(self.lt.part_start + 1);
let mut account = self.link.account.lock().unwrap().cut(end, last);
account.start_us = self.lt.part_start;
account.end_us = end;
let info = &self.link.info;
let rec = LinkRecord {
room: self.p.room.id,
hop: self.gw.public_key(),
principal: self.p.ad.grant.principal,
grant: self.p.ad.digest,
session: self.p.session,
part: self.lt.part,
parent: self.lt.parent,
protocol: info.protocol.code(),
mode: info.mode.code(),
cipher: info.cipher_bits,
last,
ended: if last { linkrec::ended::code(why) } else { 0 },
start: self.lt.part_start,
end,
window: info.latency_us.max(1),
account,
tracks: self.heads(),
};
let bytes = rec.sign(&self.gw.signer.key);
debug_assert!(LinkRecord::verify(&bytes).is_ok(), "the gateway signs records that verify: {:?}: window {} account {:?}", LinkRecord::verify(&bytes), rec.window, rec.account);
self.lt.parent = linkrec::digest(&bytes);
self.lt.part += 1;
self.lt.part_start = end;
let g = match self.lt.group {
Some(g) => g + 1,
None => end,
};
self.lt.group = Some(g);
let ts = crate::package::rfc3339(self.gw.clock.unix_us());
let hash = joule_moq::receipt::ObjectReceipt::hash_payload(&bytes);
let (bundle, link) = sign_group(&self.gw.signer, self.lt.chain, g, &[hash], &ts, &ts, self.lt.prev);
self.lt.prev = Some(link);
let _ = self.publisher.send(Emit::Single { alias: LINK_ALIAS, group: g, priority: 0, bytes: bytes.clone() });
let _ = self.publisher.send(Emit::Single { alias: LINK_RECEIPTS_ALIAS, group: g, priority: 0, bytes: bundle });
self.gw.sink_link(&bytes);
}
async fn end(mut self, why: &str) {
// What the demultiplexer still holds.
let rest = self.demux.flush();
for pes in rest {
let _ = self.on_pes(pes);
}
let mut emits = Vec::new();
for p in self.packagers.values_mut() {
p.end_group(&mut emits);
}
for e in emits {
let _ = self.publisher.send(e);
}
if self.catalog_group.is_none() {
self.catalog_dirty = true;
}
self.publish_catalog(true);
self.part(true, why);
self.publisher.end(END_GRACE).await;
let end_us = self.room_now();
let record = self.record(why, end_us);
self.gw.sink.record(&SignedRecord::sign(record, &self.gw.signer.key));
if let Some(r) = self.delete_reply.take() {
let _ = r.send(());
}
tracing::info!(session = %self.p.id, "{} session ended: {why}", self.link.info.protocol.name());
}
fn record(&self, why: &str, end: u64) -> SessionRecord {
let mut tracks = Vec::new();
let mut detail = serde_json::Map::new();
let mut bytes_out = 0;
for p in self.packagers.values() {
let ns = catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media);
let digest = hex::encode(catalog::track_digest(&ns, catalog::LAYER0));
let base = TrackEntry { kind: 1, track: digest, namespace: catalog::render(&ns), name: catalog::LAYER0.into(), ..Default::default() };
let c = &p.counts;
let dropped = c.dropped_before_keyframe + c.dropped_too_large + c.dropped_unreadable;
tracks.push(TrackEntry { dir: 1, objects: c.frames_in, bytes: c.bytes_in, dropped_refused: dropped, ..base.clone() });
tracks.push(TrackEntry { dir: 2, objects: c.objects_out, bytes: c.bytes_out, ..base });
bytes_out += c.bytes_out;
detail.insert(crate::room::media_name(p.out.media).unwrap_or("media").into(), serde_json::to_value(c).unwrap());
}
tracks.sort_by(|a, b| (a.kind, &a.track, a.dir).cmp(&(b.kind, &b.track, b.dir)));
detail.insert("demux".into(), serde_json::to_value(&self.demux.counts).unwrap());
detail.insert("unsupported_streams".into(), (self.unsupported_streams.len() as u64).into());
detail.insert("link".into(), serde_json::to_value(&self.link.account.lock().unwrap().total).unwrap());
detail.insert("link_parts".into(), self.lt.part.into());
let g = &self.p.ad.grant;
let ledger = vec![LedgerEntry {
grant: hex::encode(self.p.ad.digest),
joules_ceiling: g.joules_ceiling,
funds_ceiling: g.funds_ceiling,
currency: String::from_utf8_lossy(&g.currency).trim_matches('\0').to_string(),
sessions: 1,
bytes_in: self.bytes_in,
bytes_out,
subscriber_us: [0; 9],
}];
SessionRecord {
format: FORMAT.into(),
hop: hex::encode(self.gw.public_key()),
role: 3,
protocol: self.link.info.protocol.name().into(),
session: self.p.id.clone(),
room: hex::encode(self.p.room.id),
principal: self.owner.clone(),
start: self.start_us,
end: end.max(self.start_us + 1),
energy: Energy::unmetered(),
tracks,
sessions: 0,
subscriber_us: [0; 9],
ledger,
ended: why.into(),
detail: serde_json::Value::Object(detail),
}
}
}
/// A session's rate bound, kbit/s: the configured one; for RIST, at most what keeps the
/// receiver's buffer (`buffer_us`) under 32 768 packets of 1 316 bytes, half the space of
/// RTP's 16-bit sequence numbers (live-contribution §8).
pub fn rate_bound_kbps(protocol: Protocol, configured: u64, buffer_us: u64) -> u64 {
match protocol {
Protocol::Srt => configured,
Protocol::RistSimple | Protocol::RistMain => configured.min(32_768 * 1_316 * 8 / (buffer_us / 1000).max(1)),
}
}
/// What a POST answers: the session, and how to connect to it.
pub fn answer(gw: &Gateway, p: &Pending, port: Option<u16>) -> serde_json::Value {
let host = gw.cfg.contrib.host.clone();
match p.protocol {
Protocol::Srt => serde_json::json!({
"session": p.id,
"protocol": "srt",
"stream_id": stream_id(p),
"passphrase": p.passphrase,
"pbkeylen": gw.cfg.contrib.srt.pbkeylen,
"latency_ms": gw.cfg.contrib.srt.latency_ms,
"host": host,
"port": port,
"expires_s": gw.cfg.contrib.session_ttl_s,
}),
Protocol::RistSimple | Protocol::RistMain => serde_json::json!({
"session": p.id,
"protocol": p.protocol.name(),
"cname": p.id,
"passphrase": p.passphrase,
"aes": if p.passphrase.is_some() { serde_json::json!(gw.cfg.contrib.rist.aes) } else { serde_json::Value::Null },
"buffer_ms": gw.cfg.contrib.rist.buffer_ms,
"host": host,
"port": port,
"expires_s": gw.cfg.contrib.session_ttl_s,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rist_sessions_rate_is_bounded_by_its_sequence_space() {
// §8's figure: 32 768 × 1 316 × 8 bits within a buffer of 1 000 ms.
assert_eq!(rate_bound_kbps(Protocol::RistMain, 1_000_000, 1_000_000), 344_981);
assert_eq!(rate_bound_kbps(Protocol::RistSimple, u64::MAX, 100_000), 3_449_815);
// Under the configured bound, the configured one holds; SRT's 32-bit space is not
// bounded so.
assert_eq!(rate_bound_kbps(Protocol::RistMain, 80_000, 1_000_000), 80_000);
assert_eq!(rate_bound_kbps(Protocol::Srt, 1_000_000, 10_000_000), 1_000_000);
// A buffer under a millisecond is taken as one.
assert_eq!(rate_bound_kbps(Protocol::RistMain, u64::MAX, 0), 32_768 * 1_316 * 8);
}
}

View file

@ -11,6 +11,18 @@
//! | AV1 | the AV1 RTP payload format, v1.0.0 | `wai.video.av1` | one temporal unit in SPEC §5's AV1 framing |
//! | Opus | RFC 7587 | `wai.audio.opus.packet` | one Opus packet (RFC 6716 §3) |
//!
//! Enhanced RTMP ([`crate::rtmp`]) carries four more, whose payloads its reader builds
//! (`extensions/live-ingest.md` §12): HEVC (`wai.video.hevc`, an Annex B access unit), VVC
//! (`wai.video.vvc`, mapped and not carried in this revision), AAC (`wai.audio.aac`, one
//! ADTS frame) and FLAC (`wai.audio.flac`, a FLAC stream). An MPEG transport stream over SRT
//! or RIST (`extensions/live-ingest.md` §13, `extensions/live-contribution.md`) carries H.264
//! as above, and two of those:
//!
//! | codec | stream type | capability | one object's payload |
//! |---|---|---|---|
//! | HEVC | 0x24 | `wai.video.hevc` | one access unit, Annex B byte stream, as the PES carries it |
//! | AAC | 0x0F | `wai.audio.aac` | one ADTS frame (ISO/IEC 13818-7) |
//!
//! The RTP depacketisation itself is the WebRTC stack's (str0m), which hands over each
//! frame whole: an H.264 access unit with a four-byte start code before every NAL unit,
//! a VP8 or VP9 frame without its payload descriptor, the OBUs of an AV1 temporal unit
@ -31,10 +43,23 @@ pub enum Codec {
Vp9,
Av1,
Opus,
/// From Enhanced RTMP or a transport stream only: the WebRTC stack here carries none of
/// these four.
Hevc,
Vvc,
Aac,
Flac,
}
impl Codec {
pub const ALL: [Codec; 5] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus];
/// The codecs a WebRTC session carries (WHIP and WHEP).
pub const WEBRTC: [Codec; 5] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus];
/// Every codec an object of this gateway may carry.
pub const ALL: [Codec; 9] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus, Codec::Hevc, Codec::Vvc, Codec::Aac, Codec::Flac];
pub fn webrtc(self) -> bool {
Codec::WEBRTC.contains(&self)
}
pub fn from_str0m(c: str0m::format::Codec) -> Option<Codec> {
use str0m::format::Codec as C;
@ -48,14 +73,16 @@ impl Codec {
}
}
pub fn to_str0m(self) -> str0m::format::Codec {
/// The WebRTC stack's codec; `None` for a codec WebRTC does not carry here.
pub fn to_str0m(self) -> Option<str0m::format::Codec> {
use str0m::format::Codec as C;
match self {
Codec::Avc => C::H264,
Codec::Vp8 => C::Vp8,
Codec::Vp9 => C::Vp9,
Codec::Av1 => C::Av1,
Codec::Opus => C::Opus,
Codec::Avc => Some(C::H264),
Codec::Vp8 => Some(C::Vp8),
Codec::Vp9 => Some(C::Vp9),
Codec::Av1 => Some(C::Av1),
Codec::Opus => Some(C::Opus),
Codec::Hevc | Codec::Vvc | Codec::Aac | Codec::Flac => None,
}
}
@ -67,6 +94,10 @@ impl Codec {
Codec::Vp9 => "wai.video.vp9",
Codec::Av1 => "wai.video.av1",
Codec::Opus => "wai.audio.opus.packet",
Codec::Hevc => "wai.video.hevc",
Codec::Vvc => "wai.video.vvc",
Codec::Aac => "wai.audio.aac",
Codec::Flac => "wai.audio.flac",
}
}
@ -82,11 +113,15 @@ impl Codec {
Codec::Vp9 => "vp9",
Codec::Av1 => "av1",
Codec::Opus => "opus",
Codec::Hevc => "hevc",
Codec::Vvc => "vvc",
Codec::Aac => "aac",
Codec::Flac => "flac",
}
}
pub fn is_video(self) -> bool {
self != Codec::Opus
!matches!(self, Codec::Opus | Codec::Aac | Codec::Flac)
}
/// The manifest's `media`.
@ -94,7 +129,7 @@ impl Codec {
if self.is_video() { "video" } else { "audio" }
}
/// The RTP clock rate every one of these payload formats uses.
/// The RTP clock rate every one of the WebRTC payload formats uses.
pub fn clock_rate(self) -> u64 {
if self.is_video() { 90_000 } else { 48_000 }
}
@ -107,6 +142,10 @@ impl Codec {
Codec::Vp9 => "vp09",
Codec::Av1 => "av01",
Codec::Opus => "opus",
Codec::Hevc => "hvc1",
Codec::Vvc => "vvc1",
Codec::Aac => "mp4a",
Codec::Flac => "flac",
}
}
}
@ -153,6 +192,30 @@ pub struct Header {
pub codec: Option<String>,
/// Audio: the channel count the first packet states.
pub channels: u8,
/// Audio: the sampling rate its configuration states, in Hz; 0 where it states none.
pub rate: u32,
}
impl Header {
/// This header, with what it leaves unstated taken from `hint`: a size of 0, no codec
/// string, no channel count or rate.
pub fn or_hint(mut self, hint: Option<&Header>) -> Header {
if let Some(h) = hint {
if self.width == 0 || self.height == 0 {
(self.width, self.height) = (h.width, h.height);
}
if self.codec.is_none() {
self.codec = h.codec.clone();
}
if self.channels == 0 {
self.channels = h.channels;
}
if self.rate == 0 {
self.rate = h.rate;
}
}
self
}
}
/// H.264: the codec string from the first SPS of an access unit (`avc1.PPCCLL`: its
@ -160,7 +223,7 @@ pub struct Header {
pub fn avc_header(au: &[u8]) -> Option<Header> {
for nal in annexb_nals(au) {
if nal.first().map(|h| h & 0x1f) == Some(7) && nal.len() >= 4 {
return Some(Header { width: 0, height: 0, codec: Some(format!("avc1.{:02X}{:02X}{:02X}", nal[1], nal[2], nal[3])), channels: 0 });
return Some(Header { width: 0, height: 0, codec: Some(format!("avc1.{:02X}{:02X}{:02X}", nal[1], nal[2], nal[3])), channels: 0, rate: 0 });
}
}
None
@ -195,6 +258,19 @@ pub fn avc_is_idr(au: &[u8]) -> bool {
annexb_nals(au).iter().any(|n| n.first().map(|h| h & 0x1f) == Some(5))
}
/// Whether an H.265 access unit holds an IRAP picture (NAL unit types 16 to 23, ITU-T
/// H.265 §7.4.2.2): the first byte of its two-byte header holds the type in bits 1 to 6.
pub fn hevc_is_irap(au: &[u8]) -> bool {
annexb_nals(au).iter().any(|n| n.first().is_some_and(|h| (16..=23).contains(&((h >> 1) & 0x3f))))
}
/// AAC: the codec string (`mp4a.40.<audio object type>`), the channel count and the
/// sampling rate an ADTS header states (ISO/IEC 13818-7 §6.2.1).
pub fn aac_header(frame: &[u8]) -> Option<Header> {
let (aot, rate, ch) = joule_contrib::ts::adts_config(frame)?;
Some(Header { width: 0, height: 0, codec: Some(format!("mp4a.40.{aot}")), channels: ch.max(1), rate })
}
/// VP8: a keyframe's size (RFC 6386 §9.1: the 3-byte frame tag, the start code
/// `9d 01 2a`, then 14-bit width and height, each with a 2-bit scale above it).
pub fn vp8_header(frame: &[u8]) -> Option<Header> {
@ -203,7 +279,7 @@ pub fn vp8_header(frame: &[u8]) -> Option<Header> {
}
let w = u16::from_le_bytes([frame[6], frame[7]]) & 0x3fff;
let h = u16::from_le_bytes([frame[8], frame[9]]) & 0x3fff;
Some(Header { width: w as u32, height: h as u32, codec: Some("vp8".into()), channels: 0 })
Some(Header { width: w as u32, height: h as u32, codec: Some("vp8".into()), channels: 0, rate: 0 })
}
/// VP9: a keyframe's size from its uncompressed header (VP9 Bitstream & Decoding Process
@ -247,7 +323,7 @@ pub fn vp9_header(frame: &[u8]) -> Option<Header> {
}
let w = r.f(16)? + 1;
let h = r.f(16)? + 1;
Some(Header { width: w as u32, height: h as u32, codec: None, channels: 0 })
Some(Header { width: w as u32, height: h as u32, codec: None, channels: 0, rate: 0 })
}
/// An OBU: its type, its bytes whole, its payload.
@ -344,7 +420,7 @@ pub fn av1_header(tu: &[u8]) -> Option<Header> {
let w = r.f(wbits)? + 1;
let h = r.f(hbits)? + 1;
let _ = seq_profile;
Some(Header { width: w as u32, height: h as u32, codec: None, channels: 0 })
Some(Header { width: w as u32, height: h as u32, codec: None, channels: 0, rate: 0 })
}
/// Opus: the channel count a packet's TOC byte states (RFC 6716 §3.1, the `s` bit).
@ -368,7 +444,9 @@ pub struct FrameInfo {
/// The payload of one object: the frame in its capability's framing.
pub fn payload(codec: Codec, frame: &[u8], info: FrameInfo) -> Vec<u8> {
match codec {
Codec::Avc | Codec::Opus => frame.to_vec(),
// The reader that took the frame built its payload already (an Annex B access unit,
// an ADTS frame, a FLAC stream), or the frame is its own payload.
Codec::Avc | Codec::Opus | Codec::Hevc | Codec::Vvc | Codec::Aac | Codec::Flac => frame.to_vec(),
Codec::Vp8 | Codec::Vp9 => {
let mut o = Vec::with_capacity(44 + frame.len());
o.extend_from_slice(IVF_MAGIC);
@ -406,7 +484,9 @@ pub fn payload(codec: Codec, frame: &[u8], info: FrameInfo) -> Vec<u8> {
/// when the payload is not one frame in its capability's framing.
pub fn unframe(codec: Codec, p: &[u8]) -> Option<Vec<u8>> {
match codec {
Codec::Avc | Codec::Opus => (!p.is_empty()).then(|| p.to_vec()),
Codec::Avc | Codec::Opus | Codec::Hevc | Codec::Vvc | Codec::Flac => (!p.is_empty()).then(|| p.to_vec()),
// One ADTS frame, whole.
Codec::Aac => joule_contrib::ts::adts_frames(p).first().filter(|f| f.len() == p.len()).map(|f| f.to_vec()),
Codec::Vp8 | Codec::Vp9 => {
let fourcc: &[u8; 4] = if codec == Codec::Vp8 { b"VP80" } else { b"VP90" };
if p.len() < 44 || &p[..4] != IVF_MAGIC || u16::from_le_bytes([p[6], p[7]]) != 32 || &p[8..12] != fourcc {
@ -438,9 +518,14 @@ pub fn unframe(codec: Codec, p: &[u8]) -> Option<Vec<u8>> {
pub struct Live {
/// The room-clock instant of the frame, µs since the room's `t0` (§7).
pub t: u64,
/// The frame's RTP timestamp, extended past its 32 bits as the WebRTC stack extends it.
/// The frame's RTP timestamp, extended past its 32 bits as the WebRTC stack extends it;
/// for an Enhanced RTMP track, its decode time in µs on the publisher's clock; for a frame
/// from a transport stream, its PES's PTS extended past its 33 bits.
pub rtp: u64,
pub rate: u64,
/// Enhanced RTMP video only: the composition time offset, in µs, when it is not 0
/// (`extensions/live-ingest.md` §6): the frame is presented at `t` plus it.
pub cto: Option<i64>,
/// Video: whether the frame is a keyframe, the first of its group.
pub key: Option<bool>,
/// On the first object of a group: the grant reference (live-rooms §4.5), as JSON
@ -463,6 +548,9 @@ pub fn manifest(codec: Codec, live: &Live) -> String {
if let Some(k) = live.key {
l.push_str(&format!(",\"key\":{k}"));
}
if let Some(c) = live.cto {
l.push_str(&format!(",\"cto\":{c}"));
}
if let Some(g) = &live.grant {
let funds = g.funds_ceiling.map_or("null".to_string(), |f| f.to_string());
l.push_str(&format!(
@ -545,6 +633,13 @@ pub fn open(b: &[u8]) -> Result<Opened, OpenError> {
mod tests {
use super::*;
#[test]
fn an_hevc_irap_is_found() {
// NAL unit header: type in bits 1..7 of the first byte. 19 is IDR_W_RADL, 1 TRAIL_R.
assert!(hevc_is_irap(&[0, 0, 0, 1, 0x40, 0x01, 1, 0, 0, 1, 19 << 1, 0x01, 9]));
assert!(!hevc_is_irap(&[0, 0, 0, 1, 1 << 1, 0x01, 9]));
}
#[test]
fn annexb_units_and_the_idr_flag() {
let au = [0, 0, 0, 1, 0x67, 0x42, 0xe0, 0x1f, 9, 0, 0, 0, 1, 0x68, 1, 2, 0, 0, 1, 0x65, 3, 4, 5];
@ -569,10 +664,21 @@ mod tests {
fn framing_round_trips() {
let frame: Vec<u8> = (1..=200u8).collect();
let info = FrameInfo { width: 640, height: 360, ticks_90k: 123_456, delta_90k: 3000 };
for c in Codec::ALL {
for c in Codec::ALL.into_iter().filter(|c| *c != Codec::Aac) {
let p = payload(c, &frame, info);
assert_eq!(unframe(c, &p).as_deref(), Some(&frame[..]), "{c:?}");
}
// AAC: one ADTS frame, whole, and nothing else.
let mut adts = vec![0xff, 0xf1, (1 << 6) | (3 << 2), 2 << 6, 0, 0, 0xfc];
let len = adts.len() + 50;
adts[3] |= (len >> 11) as u8 & 0x3;
adts[4] = (len >> 3) as u8;
adts[5] = ((len as u8 & 0x7) << 5) | 0x1f;
adts.extend([5u8; 50]);
assert_eq!(unframe(Codec::Aac, &payload(Codec::Aac, &adts, info)).as_deref(), Some(&adts[..]));
assert_eq!(unframe(Codec::Aac, &adts[..20]), None);
let h = aac_header(&adts).unwrap();
assert_eq!((h.codec.as_deref(), h.channels, h.rate), (Some("mp4a.40.2"), 2, 48_000));
let p = payload(Codec::Vp8, &frame, info);
assert_eq!(p.len(), 44 + frame.len());
assert_eq!(&p[8..12], b"VP80");
@ -580,7 +686,7 @@ mod tests {
#[test]
fn an_envelope_opens() {
let live = Live { t: 42, rtp: 9000, rate: 90_000, key: Some(true), grant: Some(GrantRef { grant_hash: [7; 32], joule_ceiling_micro: u64::MAX, funds_ceiling: None }) };
let live = Live { t: 42, rtp: 9000, rate: 90_000, key: Some(true), cto: None, grant: Some(GrantRef { grant_hash: [7; 32], joule_ceiling_micro: u64::MAX, funds_ceiling: None }) };
let m = manifest(Codec::Avc, &live);
assert!(m.starts_with("{\"wai\":\"1.0\",\"media\":\"video\",\"intent\":\"replicate\",\"model_requirement\":{\"capability\":\"wai.video.avc\""));
let v: Value = serde_json::from_str(&m).unwrap();

View file

@ -7,6 +7,9 @@
//! | `/whip/s/<id>` | `PATCH` trickle ICE or an ICE restart; `DELETE`; `GET`, `HEAD`; `OPTIONS` |
//! | `/whep/<room>/<owner>[?layer=<n>]` | `POST` an offer for the owner's tracks; `HEAD` (discovery); `OPTIONS`; `GET` |
//! | `/whep/s/<id>` | `PATCH`; `DELETE`; `GET`, `HEAD`; `OPTIONS` |
//! | `/rtmp` | `POST`: `201` with a one-time stream key for an Enhanced RTMP publish (`extensions/live-ingest.md` §12); `OPTIONS` |
//! | `/srt`, `/rist` | `POST`: a one-time contribution session (`extensions/live-contribution.md` §3), `201` with its JSON; `OPTIONS` |
//! | `/srt/s/<id>`, `/rist/s/<id>` | `DELETE` ends the session (or the one waiting to connect); `GET`, `HEAD` |
//!
//! Every request but `OPTIONS` carries `Authorization: Bearer <token>`, the token a room
//! grant (`WRG1`) in base64url without padding, and beside it `WAI-Room-Proof`, a proof of
@ -233,6 +236,17 @@ pub struct Handle {
pub frames: Arc<AtomicU64>,
}
/// A one-time stream key the `/rtmp` resource issued: the grant it admitted, the room,
/// and when it was issued.
pub struct StreamKey {
pub room: Arc<Room>,
pub token: Vec<u8>,
pub issued: Instant,
}
/// The most stream keys held at once; past it the oldest go first.
const MAX_STREAM_KEYS: usize = 4096;
/// The gateway's counters.
#[derive(Debug, Default)]
pub struct Stats {
@ -256,8 +270,17 @@ pub struct Gateway {
pub stats: Stats,
/// The proof-of-possession challenges (live-rooms §10.5.3).
pub challenges: Challenges,
/// The SRT listener's address, once it listens.
pub srt_addr: Mutex<Option<SocketAddr>>,
kept: Arc<KeepAndAppend>,
pub sink: Arc<dyn HopSink>,
/// The stream keys issued and not used (Enhanced RTMP admission).
pub stream_keys: Mutex<HashMap<String, StreamKey>>,
/// The RTMP connections, and how many there are.
pub rtmp: Mutex<HashMap<String, crate::rtmp::session::Handle>>,
pub rtmp_active: AtomicUsize,
/// The SRT and RIST sessions (`crate::contrib`).
pub contrib: crate::contrib::Contrib,
}
/// `did:key` of an Ed25519 key (WAI jwp-receipts §7.1): `did:key:z`, then base58btc of
@ -344,12 +367,77 @@ impl Gateway {
whep_active: AtomicUsize::new(0),
stats: Stats::default(),
challenges: Challenges::new(Duration::from_secs(cfg.http.challenge_ttl_s), cfg.http.max_challenges),
srt_addr: Mutex::new(None),
sink: kept.clone(),
kept,
stream_keys: Mutex::new(HashMap::new()),
rtmp: Mutex::new(HashMap::new()),
rtmp_active: AtomicUsize::new(0),
contrib: crate::contrib::Contrib::default(),
cfg,
})
}
fn stream_key_ttl(&self) -> Duration {
Duration::from_secs(self.cfg.rtmp.stream_key_ttl_s)
}
/// Hold a stream key for `room` and `token`; the key, 128 random bits.
pub fn issue_stream_key(&self, room: Arc<Room>, token: Vec<u8>) -> String {
let key = random_hex(16);
let ttl = self.stream_key_ttl();
let mut m = self.stream_keys.lock().unwrap();
m.retain(|_, k| k.issued.elapsed() <= ttl);
while m.len() >= MAX_STREAM_KEYS {
let Some(oldest) = m.iter().min_by_key(|(_, k)| k.issued).map(|(k, _)| k.clone()) else { break };
m.remove(&oldest);
}
m.insert(key.clone(), StreamKey { room, token, issued: Instant::now() });
key
}
/// Take a stream key for its one use: `stream_key_unknown` when the gateway holds no
/// such key, `stream_key_expired` when it is past its time.
pub fn take_stream_key(&self, key: &str) -> Result<StreamKey, &'static str> {
let k = self.stream_keys.lock().unwrap().remove(key).ok_or("stream_key_unknown")?;
if k.issued.elapsed() > self.stream_key_ttl() {
return Err("stream_key_expired");
}
Ok(k)
}
/// A key whose session the gateway asked to reconnect, good once more for its time.
pub fn rearm_stream_key(&self, key: String, mut k: StreamKey) {
k.issued = Instant::now();
self.stream_keys.lock().unwrap().insert(key, k);
}
/// Whether a running RTMP connection has its publish admitted and its tracks published.
pub fn rtmp_publishing(&self) -> bool {
self.rtmp.lock().unwrap().values().any(|h| h.publishing.load(Ordering::Relaxed))
}
/// Frames a running RTMP connection has carried.
pub fn rtmp_frames(&self) -> Vec<(String, u64)> {
self.rtmp.lock().unwrap().iter().map(|(k, h)| (k.clone(), h.frames.load(Ordering::Relaxed))).collect()
}
/// Every contribution link record part (`WCL1`) signed so far.
pub fn link_records(&self) -> Vec<Vec<u8>> {
self.contrib.records.lock().unwrap().clone()
}
/// Keep a link record part, and append it to the configured file as a hexadecimal line.
pub fn sink_link(&self, bytes: &[u8]) {
if let Some(p) = &self.cfg.summary.link_out_path {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(p) {
let _ = writeln!(f, "{}", hex::encode(bytes));
}
}
self.contrib.records.lock().unwrap().push(bytes.to_vec());
}
pub fn public_key(&self) -> [u8; 32] {
self.signer.key.verifying_key().to_bytes()
}
@ -498,7 +586,7 @@ impl Gateway {
let m = req.method().clone();
let presented = req.headers().contains_key(hyper::header::AUTHORIZATION) && m != Method::OPTIONS;
let scope = match seg.as_slice() {
["whip" | "whep", "s", id] => Scope::Session(id.to_string()),
["whip" | "whep" | "srt" | "rist", "s", id] => Scope::Session(id.to_string()),
_ => Scope::Endpoint,
};
// A request that carries a grant on an endpoint's GET or HEAD proves it too; one
@ -516,7 +604,18 @@ impl Gateway {
let id = id.to_string();
self.on_session(req, whep, &id).await
}
(["whip"], &Method::OPTIONS) | (["whep", _, _], &Method::OPTIONS) => self.options(true),
(["srt" | "rist", "s", _], &Method::OPTIONS) => self.options(false),
(["srt" | "rist", "s", id], _) => {
let id = id.to_string();
self.on_contrib_session(req, &id).await
}
(["srt" | "rist"], &Method::OPTIONS) => self.options(true),
(["srt"], &Method::POST) => self.post_srt(req).await,
(["rist"], &Method::POST) => self.post_rist(req).await,
(["srt" | "rist"], _) => Reply::new(StatusCode::METHOD_NOT_ALLOWED).header(hyper::header::ALLOW, "POST, OPTIONS"),
(["whip"], &Method::OPTIONS) | (["whep", _, _], &Method::OPTIONS) | (["rtmp"], &Method::OPTIONS) => self.options(true),
(["rtmp"], &Method::POST) => self.post_rtmp(req).await,
(["rtmp"], _) => Reply::new(StatusCode::METHOD_NOT_ALLOWED).header(hyper::header::ALLOW, "POST, OPTIONS"),
(["whip"], &Method::POST) => self.post_whip(req).await,
(["whip"], &Method::GET) | (["whip"], &Method::HEAD) => Reply::new(StatusCode::NO_CONTENT),
(["whep", room, owner], &Method::POST) => {
@ -573,6 +672,227 @@ impl Gateway {
crate::whip::create(self.clone(), room, ad, crate::room::sha256(&[&token]), offer).await
}
/// A stream key for an Enhanced RTMP publish (`extensions/live-ingest.md` §12): the
/// request admitted as a WHIP request is, the grant's publish power for audio or
/// video, a single-use grant's nonce spent; the key is good for one `publish` within
/// `stream_key_ttl_s`.
async fn post_rtmp(self: &Arc<Self>, req: Request<Incoming>) -> Reply {
if self.cfg.rtmp.listen.is_none() && self.cfg.rtmp.tls_listen.is_none() {
return problem(StatusCode::NOT_FOUND, "not_found", "this gateway runs no RTMP ingest");
}
let (room, ad, token) = match self.authorize(&req, &Scope::Endpoint, true) {
Ok(x) => x,
Err(r) => return r,
};
if !ad.grant.caps.publishes(crate::room::media::AUDIO) && !ad.grant.caps.publishes(crate::room::media::VIDEO) {
return forbidden("publish_not_permitted", "the grant does not let its principal publish audio or video");
}
if !room.spend(&ad) {
return unauthorized(Some("grant_reused"));
}
let key = self.issue_stream_key(room, token);
let url = if !self.cfg.rtmp.url.is_empty() {
self.cfg.rtmp.url.clone()
} else if let Some(a) = self.cfg.rtmp.listen {
format!("rtmp://{a}/live")
} else {
format!("rtmps://{}/live", self.cfg.rtmp.tls_listen.unwrap())
};
let body = serde_json::json!({"expires_in": self.cfg.rtmp.stream_key_ttl_s, "stream_key": key, "url": url});
Reply::new(StatusCode::CREATED).header(hyper::header::CACHE_CONTROL, "no-store").body("application/json", serde_json::to_vec(&body).unwrap())
}
/// A contribution session's grant: admitted as WHIP's is (live-ingest §3.2, §3.3), its
/// publish power for at least one media kind; the request's JSON body, if any.
async fn contrib_admit(self: &Arc<Self>, req: Request<Incoming>) -> Result<(Arc<Room>, Admitted, [u8; 32], serde_json::Value), Reply> {
let (room, ad, token) = self.authorize(&req, &Scope::Endpoint, true)?;
if !ad.grant.caps.publishes(crate::room::media::VIDEO) && !ad.grant.caps.publishes(crate::room::media::AUDIO) {
return Err(forbidden("publish_not_permitted", "the grant does not let its principal publish audio or video"));
}
let body = self.body(req).await?;
let v = if body.iter().all(|b| b.is_ascii_whitespace()) {
serde_json::json!({})
} else {
serde_json::from_slice(&body).map_err(|_| problem(StatusCode::BAD_REQUEST, "body_malformed", "the body is a JSON object"))?
};
if !v.is_object() {
return Err(problem(StatusCode::BAD_REQUEST, "body_malformed", "the body is a JSON object"));
}
if !room.spend(&ad) {
return Err(unauthorized(Some("grant_reused")));
}
Ok((room, ad, crate::room::sha256(&[&token]), v))
}
fn contrib_full(&self) -> bool {
self.contrib.active.load(Ordering::SeqCst) + self.contrib.pending.lock().unwrap().len() >= self.cfg.contrib.max_sessions
}
fn contrib_created(&self, p: &crate::contrib::Pending, kind: &str, port: Option<u16>) -> Reply {
let body = crate::contrib::answer(self, p, port);
Reply::new(StatusCode::CREATED).header(hyper::header::LOCATION, format!("/{kind}/s/{}", p.id)).body("application/json", serde_json::to_vec(&body).unwrap())
}
async fn post_srt(self: &Arc<Self>, req: Request<Incoming>) -> Reply {
let srt_addr = *self.srt_addr.lock().unwrap();
let Some(port) = srt_addr.map(|a| a.port()) else { return problem(StatusCode::NOT_FOUND, "not_found", "this gateway has no SRT listener") };
let (room, ad, token_hash, body) = match self.contrib_admit(req).await {
Ok(x) => x,
Err(r) => return r,
};
self.contrib.prune(Duration::from_secs(self.cfg.contrib.session_ttl_s));
if self.contrib_full() {
return self.busy();
}
let Some(call) = body.get("call").and_then(serde_json::Value::as_str) else {
// The gateway listens: the caller presents the stream ID and the passphrase.
let p = crate::contrib::open(room, ad, token_hash, joule_contrib::link::Protocol::Srt, true);
let r = self.contrib_created(&p, "srt", Some(port));
self.contrib.pending.lock().unwrap().insert(p.tag(), p);
return r;
};
// The gateway calls the publisher's listener, where the configuration lets it.
let Ok(addr) = call.parse::<SocketAddr>() else { return problem(StatusCode::BAD_REQUEST, "body_malformed", "call is ip:port") };
let allowed = self.cfg.contrib.srt.call_allow.iter().any(|a| *a == addr.to_string() || *a == addr.ip().to_string());
if !allowed {
return forbidden("call_not_permitted", "this gateway does not call that address");
}
let mut p = crate::contrib::open(room, ad, token_hash, joule_contrib::link::Protocol::Srt, false);
p.passphrase = body.get("passphrase").and_then(serde_json::Value::as_str).map(str::to_string);
let cfg = joule_contrib::srt::CallConfig {
latency_ms: body.get("latency_ms").and_then(serde_json::Value::as_u64).map_or(self.cfg.contrib.srt.latency_ms, |v| v.min(u16::MAX as u64) as u16),
passphrase: p.passphrase.clone(),
keylen: body.get("pbkeylen").and_then(serde_json::Value::as_u64).map_or(16, |v| v as usize),
stream_id: body.get("stream_id").and_then(serde_json::Value::as_str).map(str::to_string),
peer_idle_ms: self.cfg.contrib.idle_ms,
role: joule_contrib::srt::Role::Receive,
..joule_contrib::srt::CallConfig::default()
};
match joule_contrib::srt::call(addr, cfg).await {
Ok(link) => {
let r = self.contrib_created(&p, "srt", None);
tokio::spawn(crate::contrib::run(self.clone(), p, link));
r
}
Err(joule_contrib::srt::conn::CallError::Rejected(c)) => problem(StatusCode::BAD_GATEWAY, "call_rejected", &format!("the publisher's listener rejected the call with reason {c}")),
Err(e) => problem(StatusCode::BAD_GATEWAY, "call_failed", &e.to_string()),
}
}
#[cfg(not(feature = "rist"))]
async fn post_rist(self: &Arc<Self>, _req: Request<Incoming>) -> Reply {
problem(StatusCode::NOT_FOUND, "not_found", "this gateway is built without RIST")
}
#[cfg(feature = "rist")]
async fn post_rist(self: &Arc<Self>, req: Request<Incoming>) -> Reply {
use joule_contrib::link::Protocol;
use joule_contrib::rist::{recv::RecvConfig, Profile, RistListener, RistListenerConfig};
let c = &self.cfg.contrib.rist;
if !c.enabled {
return problem(StatusCode::NOT_FOUND, "not_found", "this gateway serves no RIST");
}
let (room, ad, token_hash, body) = match self.contrib_admit(req).await {
Ok(x) => x,
Err(r) => return r,
};
self.contrib.prune(Duration::from_secs(self.cfg.contrib.session_ttl_s));
if self.contrib_full() {
return self.busy();
}
let profile = match body.get("profile").and_then(serde_json::Value::as_str) {
None | Some("simple") => Profile::Simple,
Some("main") => Profile::Main,
Some(_) => return problem(StatusCode::BAD_REQUEST, "body_malformed", "profile is simple or main"),
};
let proto = if profile == Profile::Main { Protocol::RistMain } else { Protocol::RistSimple };
let p = crate::contrib::open(room, ad, token_hash, proto, profile == Profile::Main);
let mut listener = None;
let ports: Vec<u16> = if c.port_min == 0 { vec![0] } else { (c.port_min..=c.port_max.max(c.port_min)).step_by(if profile == Profile::Simple { 2 } else { 1 }).collect() };
for port in ports {
let cfg = RistListenerConfig {
profile,
bind_ip: c.bind_ip,
port,
recv: RecvConfig { buffer_us: c.buffer_ms * 1000, idle_us: self.cfg.contrib.idle_ms * 1000, ..RecvConfig::default() },
secret: p.passphrase.clone(),
expect_cname: Some(p.id.clone()),
tag: p.tag(),
};
if let Ok(l) = RistListener::bind(cfg).await {
listener = Some(l);
break;
}
}
let Some(mut listener) = listener else { return problem(StatusCode::SERVICE_UNAVAILABLE, "media_socket", "no free port for the session") };
let r = self.contrib_created(&p, "rist", Some(listener.addr.port()));
let tag = p.tag();
self.contrib.pending.lock().unwrap().insert(tag, p);
let gw = self.clone();
let ttl = Duration::from_secs(self.cfg.contrib.session_ttl_s);
tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + ttl;
let link = loop {
tokio::select! {
l = listener.accept() => break l,
_ = tokio::time::sleep(Duration::from_millis(250)) => {
// A DELETE, or the session's time, ends the wait.
if !gw.contrib.pending.lock().unwrap().contains_key(&tag) || tokio::time::Instant::now() >= deadline {
break None;
}
}
}
};
let p = gw.contrib.pending.lock().unwrap().remove(&tag);
match (p, link) {
// The listener drives the flow: it is held for the session's life.
(Some(p), Some(link)) => crate::contrib::run(gw, p, link).await,
(_, Some(link)) => link.close(),
_ => {}
}
drop(listener);
});
r
}
async fn on_contrib_session(self: &Arc<Self>, req: Request<Incoming>, id: &str) -> Reply {
let running = self.contrib.sessions.lock().unwrap().get(id).cloned();
let tag = crate::contrib::Contrib::tag_of(id);
let pending_hash = tag.and_then(|t| self.contrib.pending.lock().unwrap().get(&t).filter(|p| p.id == id).map(|p| p.token_hash));
let token_hash = match (&running, pending_hash) {
(Some(h), _) => h.token_hash,
(None, Some(h)) => h,
(None, None) => return problem(StatusCode::NOT_FOUND, "session_unknown", "no such session"),
};
let token = match self.authorize(&req, &Scope::Session(id.to_string()), false) {
Ok((_, _, t)) => t,
Err(r) => return r,
};
if crate::room::sha256(&[&token]) != token_hash {
return forbidden("not_the_session_token", "the session answers the token it was opened with");
}
match *req.method() {
Method::GET | Method::HEAD => Reply::new(StatusCode::NO_CONTENT),
Method::DELETE => {
match running {
Some(h) => {
let (tx, rx) = oneshot::channel();
if h.tx.send(Cmd::Delete { reply: tx }).await.is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(30), rx).await;
}
}
None => {
if let Some(t) = tag {
self.contrib.pending.lock().unwrap().remove(&t);
}
}
}
Reply::new(StatusCode::OK)
}
_ => Reply::new(StatusCode::METHOD_NOT_ALLOWED).header(hyper::header::ALLOW, "GET, HEAD, DELETE, OPTIONS"),
}
}
async fn post_whep(self: &Arc<Self>, req: Request<Incoming>, room_hex: String, owner: String, query: &str) -> Reply {
let (room, ad, token) = match self.authorize(&req, &Scope::Endpoint, true) {
Ok(x) => x,
@ -664,11 +984,75 @@ pub fn check_if_match(if_match: Option<&str>, etag: &str) -> Result<bool, Reply>
}
}
/// The HTTP server, bound and serving.
/// The HTTP server, bound and serving, and the RTMP and SCTE 104 listeners where they are
/// configured.
pub struct GatewayServer {
pub addr: SocketAddr,
pub gateway: Arc<Gateway>,
/// The SRT listener's UDP address, where one is configured.
pub srt_addr: Option<SocketAddr>,
task: tokio::task::JoinHandle<()>,
/// RTMP over TCP, RTMPS, and the SCTE 104 injector, where each listens.
pub rtmp_addr: Option<SocketAddr>,
pub rtmps_addr: Option<SocketAddr>,
pub injector_addr: Option<SocketAddr>,
pub injector: Option<Arc<crate::scte::Injector>>,
listeners: Vec<tokio::task::JoinHandle<()>>,
srt_task: Option<tokio::task::JoinHandle<()>>,
}
/// The RTMPS identity: a chain and its PKCS #8 key, given in code or read from PEM files.
fn tls_acceptor(c: &crate::config::RtmpConfig) -> Result<tokio_rustls::TlsAcceptor, String> {
let (chain, key) = match (&c.tls_der, &c.cert_path, &c.key_path) {
(Some((chain, key)), _, _) => (chain.clone(), key.clone()),
(None, Some(cp), Some(kp)) => {
let chain = crate::relay::certificates(&std::fs::read(cp).map_err(|e| format!("{}: {e}", cp.display()))?);
let pem = std::fs::read_to_string(kp).map_err(|e| format!("{}: {e}", kp.display()))?;
let b64: String = pem.lines().skip_while(|l| l.trim() != "-----BEGIN PRIVATE KEY-----").skip(1).take_while(|l| l.trim() != "-----END PRIVATE KEY-----").collect();
let key = base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|_| format!("{}: not a PKCS #8 PEM key", kp.display()))?;
(chain, key)
}
_ => return Err("rtmp.tls_listen needs rtmp.cert_path and rtmp.key_path".into()),
};
let certs = chain.into_iter().map(rustls::pki_types::CertificateDer::from).collect();
let key = rustls::pki_types::PrivateKeyDer::Pkcs8(rustls::pki_types::PrivatePkcs8KeyDer::from(key));
let cfg = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| format!("the RTMPS identity: {e}"))?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(cfg)))
}
/// Accept RTMP connections at `listener`, over TLS where `tls` is given, each counted
/// against `max_sessions`.
fn accept_rtmp(gw: Arc<Gateway>, listener: tokio::net::TcpListener, tls: Option<tokio_rustls::TlsAcceptor>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else { continue };
if gw.rtmp_active.fetch_add(1, Ordering::SeqCst) >= gw.cfg.rtmp.max_sessions {
gw.rtmp_active.fetch_sub(1, Ordering::SeqCst);
drop(stream);
continue;
}
let _ = stream.set_nodelay(true);
let gw = gw.clone();
let tls = tls.clone();
tokio::spawn(async move {
match tls {
None => crate::rtmp::session::serve(gw.clone(), stream, false).await,
Some(acc) => {
let t = Duration::from_millis(gw.cfg.rtmp.connect_timeout_ms);
if let Ok(Ok(s)) = tokio::time::timeout(t, acc.accept(stream)).await {
crate::rtmp::session::serve(gw.clone(), s, true).await;
}
}
}
gw.rtmp_active.fetch_sub(1, Ordering::SeqCst);
});
}
})
}
impl GatewayServer {
@ -676,6 +1060,36 @@ impl GatewayServer {
let listener = tokio::net::TcpListener::bind(cfg.http.listen).await.map_err(|e| format!("{}: {e}", cfg.http.listen))?;
let addr = listener.local_addr().map_err(|e| e.to_string())?;
let gateway = Arc::new(Gateway::new(cfg)?);
// The SRT listener, admitting each caller by its stream ID (live-ingest §13).
let (srt_addr, srt_task) = match gateway.cfg.contrib.srt.listen {
None => (None, None),
Some(bind) => {
let c = &gateway.cfg.contrib;
let lc = joule_contrib::srt::ListenerConfig { bind, latency_ms: c.srt.latency_ms, peer_idle_ms: c.idle_ms, max_connections: c.max_sessions, advertise_keylen: c.srt.pbkeylen, ..joule_contrib::srt::ListenerConfig::default() };
let g = gateway.clone();
let adm = move |r: &joule_contrib::srt::ConnRequest| {
let tag = crate::contrib::admit_srt(&g, r.stream_id.as_deref())?;
let passphrase = g.contrib.admitted.lock().unwrap().get(&tag).and_then(|p| p.passphrase.clone());
Ok(joule_contrib::srt::Admitted { passphrase, role: joule_contrib::srt::Role::Receive, latency_ms: Some(g.cfg.contrib.srt.latency_ms), tag })
};
let mut l = joule_contrib::srt::Listener::bind(lc, Arc::new(adm)).await.map_err(|e| format!("{bind}: {e}"))?;
let addr = l.addr;
*gateway.srt_addr.lock().unwrap() = Some(addr);
let g = gateway.clone();
let t = tokio::spawn(async move {
while let Some(link) = l.accept().await {
let p = g.contrib.admitted.lock().unwrap().remove(&link.info.tag);
match p {
Some(p) => {
tokio::spawn(crate::contrib::run(g.clone(), p, link));
}
None => link.close(),
}
}
});
(Some(addr), Some(t))
}
};
let gw = gateway.clone();
let task = tokio::spawn(async move {
loop {
@ -692,18 +1106,59 @@ impl GatewayServer {
});
}
});
Ok(GatewayServer { addr, gateway, task })
let mut listeners = Vec::new();
let rtmp_cfg = gateway.cfg.rtmp.clone();
let mut rtmp_addr = None;
if let Some(a) = rtmp_cfg.listen {
let l = tokio::net::TcpListener::bind(a).await.map_err(|e| format!("{a}: {e}"))?;
rtmp_addr = Some(l.local_addr().map_err(|e| e.to_string())?);
listeners.push(accept_rtmp(gateway.clone(), l, None));
}
let mut rtmps_addr = None;
if let Some(a) = rtmp_cfg.tls_listen {
let acc = tls_acceptor(&rtmp_cfg)?;
let l = tokio::net::TcpListener::bind(a).await.map_err(|e| format!("{a}: {e}"))?;
rtmps_addr = Some(l.local_addr().map_err(|e| e.to_string())?);
listeners.push(accept_rtmp(gateway.clone(), l, Some(acc)));
}
let (injector, injector_addr) = match crate::scte::Injector::start(gateway.clone()).await? {
Some((i, a)) => (Some(i), Some(a)),
None => (None, None),
};
Ok(GatewayServer { addr, gateway, srt_addr, task, srt_task, rtmp_addr, rtmps_addr, injector_addr, injector, listeners })
}
/// End every session, then stop listening.
/// End every session, then stop listening. A publisher over RTMP that declared
/// `Reconnect` is asked to reconnect, and given `reconnect_grace_ms` to leave.
pub async fn shutdown(self) {
let handles: Vec<Handle> = self.gateway.whip.lock().unwrap().values().cloned().chain(self.gateway.whep.lock().unwrap().values().cloned()).collect();
if let Some(t) = &self.srt_task {
t.abort();
}
let handles: Vec<Handle> = self.gateway.whip.lock().unwrap().values().cloned().chain(self.gateway.whep.lock().unwrap().values().cloned()).chain(self.gateway.contrib.sessions.lock().unwrap().values().cloned()).collect();
for h in handles {
let (tx, rx) = oneshot::channel();
if h.tx.send(Cmd::Delete { reply: tx }).await.is_ok() {
let _ = tokio::time::timeout(Duration::from_secs(30), rx).await;
}
}
let rtmp: Vec<crate::rtmp::session::Handle> = self.gateway.rtmp.lock().unwrap().values().cloned().collect();
let mut waits = Vec::new();
for h in rtmp {
let (tx, rx) = oneshot::channel();
if h.tx.send(crate::rtmp::session::Cmd::Stop { reply: tx }).await.is_ok() {
waits.push(rx);
}
}
let grace = Duration::from_millis(self.gateway.cfg.rtmp.reconnect_grace_ms) + Duration::from_secs(15);
for rx in waits {
let _ = tokio::time::timeout(grace, rx).await;
}
if let Some(i) = &self.injector {
i.stop().await;
}
for l in &self.listeners {
l.abort();
}
self.task.abort();
}
}

View file

@ -11,23 +11,42 @@
//! publisher's tracks. The gateway subscribes at the relay, verifies every object
//! against its receipt, drops and counts what fails, and packetises the rest back to RTP.
//! - **Bounds** ([`config::Limits`]): sessions, bitrate, tracks per session.
//! - **Enhanced RTMP** ([`rtmp`], `extensions/live-ingest.md` §12): an RTMP or RTMPS
//! encoder publishes into a room with a one-time stream key the `/rtmp` resource issues
//! under its grant; legacy AVC and AAC, and the enhanced FourCCs for HEVC, AV1, VP9,
//! VP8, Opus, FLAC and AAC, single-track or multitrack, each track a room track whose
//! receipts the gateway signs as the first hop.
//! - **SCTE 104** ([`scte`], `extensions/cue-injection.md`): an automation system's
//! splice and time-signal requests, translated to SCTE 35 sections and signed as the
//! room's `cue` events, each preceded by the injection record that lets a verifier
//! recompute it.
//! - **Records** ([`record`]): each session ends with a record of what it carried, signed
//! by the gateway: the seam for live metering's per-hop record.
//! - **SRT and RIST** ([`contrib`]): contribution over UDP with retransmission, from
//! `joule-contrib`, into the same tracks: a one-time session from `/srt` or `/rist`
//! under the room grant, the transport stream demultiplexed, H.264, HEVC and AAC
//! carried; and the link's own record, `WCL1` ([`linkrec`]), signed part by part on the
//! owner's `link` track.
//!
//! The WebRTC stack is `str0m` (sans-IO, MIT OR Apache-2.0) with its pure-Rust crypto
//! backend; the relay protocol is `joule-moq`'s draft-22 codec and `joule-moq-relay`'s
//! client. The mapping this crate implements, and the ones it specifies only (E-RTMP v2
//! multitrack and SRT), are WAI's `extensions/live-ingest.md`.
//! client. The mappings this crate implements, and the one it specifies only (SRT), are
//! WAI's `extensions/live-ingest.md`; SRT and RIST as it profiles them for contribution are
//! `extensions/live-contribution.md`.
pub mod catalog;
pub mod config;
pub mod contrib;
pub mod frame;
pub mod gateway;
pub mod linkrec;
pub mod package;
pub mod record;
pub mod relay;
pub mod room;
pub mod rtc;
pub mod rtmp;
pub mod scte;
pub mod sdp;
pub mod whep;
pub mod whip;

View file

@ -0,0 +1,516 @@
//! The contribution link record, `WCL1` (WAI `extensions/live-contribution.md` §6): what a
//! contribution link over SRT or RIST carried, lost, recovered and dropped, and how long it
//! took, signed by the gateway that received it, part by part, each part chained to the one
//! before and naming the head of every media track's receipt chain at its end.
//!
//! Every integer is little-endian. The record's digest is SHA-256 over its bytes before the
//! signature; the signature is Ed25519 by `hop` over `wai:contrib-link` 0x01 and those
//! bytes, verified by WAI's strict rule (its SPEC, *Signatures*).
use crate::room::{sha256, verify_strict, Reason as RoomReason};
use ed25519_dalek::{Signer, SigningKey};
use joule_contrib::stats::{Part, Run, Summary};
pub const MAGIC: &[u8; 4] = b"WCL1";
pub const VERSION: u8 = 1;
pub const DOMAIN: &[u8] = b"wai:contrib-link\x01";
pub const MAX_TRACKS: usize = 8;
pub const MAX_RUNS: usize = joule_contrib::stats::MAX_RUNS;
/// The fixed fields, with no track and no run, and the signature.
pub const FIXED: usize = 415;
pub const TRACK: usize = 80;
pub const RUN: usize = 20;
/// The largest record: every track and every run.
pub const MAX_LEN: usize = FIXED + MAX_TRACKS * TRACK + MAX_RUNS * RUN;
/// Why a session ended, on its last part (§6.2).
pub mod ended {
pub const NOT_LAST: u8 = 0;
pub const PEER_SHUTDOWN: u8 = 1;
pub const IDLE: u8 = 2;
pub const DELETED: u8 = 3;
pub const GATEWAY_STOPPED: u8 = 4;
pub const RELAY_LOST: u8 = 5;
pub const RELAY_BACKPRESSURE: u8 = 6;
pub const CLOSED: u8 = 7;
pub fn code(why: &str) -> u8 {
match why {
"peer_shutdown" | "peer_disconnect" => PEER_SHUTDOWN,
"idle" => IDLE,
"deleted" => DELETED,
"gateway_stopped" | "listener_stopped" => GATEWAY_STOPPED,
"relay_lost" => RELAY_LOST,
"relay_backpressure" => RELAY_BACKPRESSURE,
_ => CLOSED,
}
}
}
/// The head of one media track's receipt chain at a part's end.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TrackHead {
/// The track digest (live-events §3.1).
pub track: [u8; 32],
/// The `track_alias` its receipts sign (live-ingest §8.2).
pub receipt_alias: u64,
/// The groups whose receipts the gateway signed on it so far.
pub groups: u64,
/// The link hash of the latest of them; zero with none.
pub head: [u8; 32],
}
/// One part of a link's record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkRecord {
pub room: [u8; 32],
pub hop: [u8; 32],
pub principal: [u8; 32],
pub grant: [u8; 32],
pub session: [u8; 16],
pub part: u32,
pub parent: [u8; 32],
/// 1 SRT, 2 RIST Simple Profile, 3 RIST Main Profile.
pub protocol: u8,
/// 1 the gateway listened, 2 it called.
pub mode: u8,
/// The AES key length in bits; 0 in the clear.
pub cipher: u16,
pub last: bool,
pub ended: u8,
pub start: u64,
pub end: u64,
/// The receive window, µs.
pub window: u64,
pub account: Part,
pub tracks: Vec<TrackHead>,
}
/// The refusals of §6.5, each with its code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reason {
RecordTooLarge,
Truncated,
Malformed,
UnknownVersion,
BadKey,
BadSignature,
LinkInvalid,
AccountInvalid,
RunsInvalid,
ChainBroken,
SequenceGap,
}
impl Reason {
pub fn code(self) -> &'static str {
match self {
Reason::RecordTooLarge => "record_too_large",
Reason::Truncated => "truncated",
Reason::Malformed => "malformed",
Reason::UnknownVersion => "unknown_version",
Reason::BadKey => "bad_key",
Reason::BadSignature => "bad_signature",
Reason::LinkInvalid => "link_invalid",
Reason::AccountInvalid => "account_invalid",
Reason::RunsInvalid => "runs_invalid",
Reason::ChainBroken => "chain_broken",
Reason::SequenceGap => "sequence_gap",
}
}
pub const ALL: [Reason; 11] = [
Reason::RecordTooLarge,
Reason::Truncated,
Reason::Malformed,
Reason::UnknownVersion,
Reason::BadKey,
Reason::BadSignature,
Reason::LinkInvalid,
Reason::AccountInvalid,
Reason::RunsInvalid,
Reason::ChainBroken,
Reason::SequenceGap,
];
}
fn put_summary(o: &mut Vec<u8>, s: &Summary) {
for v in [s.samples, s.min, s.median, s.max] {
o.extend_from_slice(&v.to_le_bytes());
}
}
impl LinkRecord {
/// The bytes the digest and the signature cover.
pub fn body(&self) -> Vec<u8> {
let a = &self.account;
let mut o = Vec::with_capacity(FIXED + self.tracks.len() * TRACK + a.runs.len() * RUN);
o.extend_from_slice(MAGIC);
o.push(VERSION);
for k in [&self.room, &self.hop, &self.principal, &self.grant] {
o.extend_from_slice(k);
}
o.extend_from_slice(&self.session);
o.extend_from_slice(&self.part.to_le_bytes());
o.extend_from_slice(&self.parent);
o.push(self.protocol);
o.push(self.mode);
o.extend_from_slice(&self.cipher.to_le_bytes());
o.push(self.last as u8);
o.push(self.ended);
for v in [self.start, self.end, self.window, a.seq_first, a.seq_count, a.delivered, a.first_try, a.dropped, a.outstanding, a.late, a.duplicates, a.bytes, a.requests, a.requested, a.retransmitted] {
o.extend_from_slice(&v.to_le_bytes());
}
put_summary(&mut o, &a.rtt);
put_summary(&mut o, &a.recovery);
o.extend_from_slice(&a.margin_min.to_le_bytes());
o.push(self.tracks.len() as u8);
for t in &self.tracks {
o.extend_from_slice(&t.track);
o.extend_from_slice(&t.receipt_alias.to_le_bytes());
o.extend_from_slice(&t.groups.to_le_bytes());
o.extend_from_slice(&t.head);
}
o.extend_from_slice(&(a.runs.len() as u16).to_le_bytes());
o.push(a.runs_truncated as u8);
for r in &a.runs {
o.extend_from_slice(&r.first.to_le_bytes());
o.extend_from_slice(&r.count.to_le_bytes());
o.extend_from_slice(&r.recovered.to_le_bytes());
o.extend_from_slice(&r.dropped.to_le_bytes());
}
o
}
/// The record signed by `key` (whose public half must be `hop`).
pub fn sign(&self, key: &SigningKey) -> Vec<u8> {
let mut b = self.body();
let sig = key.sign(&[DOMAIN, &b].concat());
b.extend_from_slice(&sig.to_bytes());
b
}
/// Read a record's bytes (§6.5 steps 1 and 2), without checking its rules or its
/// signature.
pub fn read(b: &[u8]) -> Result<LinkRecord, Reason> {
if b.len() > MAX_LEN {
return Err(Reason::RecordTooLarge);
}
let mut r = Rd { b, i: 0 };
if r.take(4)? != MAGIC {
return Err(Reason::Malformed);
}
if r.u8()? != VERSION {
return Err(Reason::UnknownVersion);
}
let room = r.arr32()?;
let hop = r.arr32()?;
let principal = r.arr32()?;
let grant = r.arr32()?;
let session: [u8; 16] = r.take(16)?.try_into().unwrap();
let part = r.u32()?;
let parent = r.arr32()?;
let protocol = r.u8()?;
let mode = r.u8()?;
let cipher = u16::from_le_bytes(r.take(2)?.try_into().unwrap());
let last = r.u8()?;
let ended = r.u8()?;
let mut v = [0u64; 15];
for x in v.iter_mut() {
*x = r.u64()?;
}
let rd = |r: &mut Rd| -> Result<Summary, Reason> { Ok(Summary { samples: r.u32()?, min: r.u32()?, median: r.u32()?, max: r.u32()? }) };
let rtt = rd(&mut r)?;
let recovery = rd(&mut r)?;
let margin_min = r.u32()?;
let nt = r.u8()? as usize;
if nt > MAX_TRACKS {
return Err(Reason::Malformed);
}
let mut tracks = Vec::with_capacity(nt);
for _ in 0..nt {
tracks.push(TrackHead { track: r.arr32()?, receipt_alias: r.u64()?, groups: r.u64()?, head: r.arr32()? });
}
let nr = u16::from_le_bytes(r.take(2)?.try_into().unwrap()) as usize;
let truncated = r.u8()?;
if nr > MAX_RUNS {
return Err(Reason::Malformed);
}
let mut runs = Vec::with_capacity(nr);
for _ in 0..nr {
let first = r.u64()?;
let (count, recovered, dropped) = (r.u32()?, r.u32()?, r.u32()?);
runs.push(Run { first, count, recovered, dropped, outstanding: count.saturating_sub(recovered).saturating_sub(dropped) });
}
r.take(64)?;
if r.i != b.len() {
return Err(Reason::Malformed);
}
if last > 1 || truncated > 1 {
return Err(Reason::Malformed);
}
let account = Part {
seq_first: v[3],
seq_count: v[4],
delivered: v[5],
first_try: v[6],
dropped: v[7],
outstanding: v[8],
late: v[9],
duplicates: v[10],
bytes: v[11],
requests: v[12],
requested: v[13],
retransmitted: v[14],
rtt,
recovery,
margin_min,
runs,
runs_truncated: truncated == 1,
start_us: v[0],
end_us: v[1],
last: last == 1,
};
Ok(LinkRecord { room, hop, principal, grant, session, part, parent, protocol, mode, cipher, last: last == 1, ended, start: v[0], end: v[1], window: v[2], account, tracks })
}
/// The whole of §6.5: read, the signature, then the rules.
pub fn verify(b: &[u8]) -> Result<LinkRecord, Reason> {
let rec = LinkRecord::read(b)?;
let sig: [u8; 64] = b[b.len() - 64..].try_into().unwrap();
verify_strict(&rec.hop, &[DOMAIN, &b[..b.len() - 64]].concat(), &sig).map_err(|e| if e == RoomReason::BadKey { Reason::BadKey } else { Reason::BadSignature })?;
rec.check()?;
Ok(rec)
}
/// §6.5 steps 4 to 6.
pub fn check(&self) -> Result<(), Reason> {
// The link.
let cipher_ok = match self.protocol {
1 => matches!(self.cipher, 0 | 128 | 192 | 256),
2 => self.cipher == 0,
3 => matches!(self.cipher, 0 | 128 | 256),
_ => false,
};
if !cipher_ok || !matches!(self.mode, 1 | 2) {
return Err(Reason::LinkInvalid);
}
if self.last != (self.ended != ended::NOT_LAST) || self.ended > ended::CLOSED {
return Err(Reason::LinkInvalid);
}
if self.start >= self.end || self.window == 0 || (self.part == 0) != (self.parent == [0; 32]) {
return Err(Reason::LinkInvalid);
}
if self.tracks.windows(2).any(|w| w[0].track >= w[1].track) || self.tracks.iter().any(|t| t.track == [0; 32] || (t.groups == 0) != (t.head == [0; 32])) {
return Err(Reason::LinkInvalid);
}
// The account.
let a = &self.account;
let s = |x: &Summary| (x.samples == 0 && (x.min, x.median, x.max) == (0, 0, 0)) || (x.samples > 0 && x.min <= x.median && x.median <= x.max);
let recovered = a.delivered.checked_sub(a.first_try).ok_or(Reason::AccountInvalid)?;
if a.delivered.checked_add(a.dropped).and_then(|x| x.checked_add(a.outstanding)) != Some(a.seq_count)
|| (a.outstanding > 0 && !self.last)
|| a.recovery.samples as u64 != recovered
|| !s(&a.rtt)
|| !s(&a.recovery)
|| (recovered == 0 && a.margin_min != 0)
|| a.margin_min as u64 > self.window
|| a.requested < a.requests
|| (a.delivered == 0 && a.bytes != 0)
{
return Err(Reason::AccountInvalid);
}
// The runs.
if a.runs_truncated && a.runs.len() != MAX_RUNS {
return Err(Reason::RunsInvalid);
}
let end = a.seq_first.checked_add(a.seq_count).ok_or(Reason::RunsInvalid)?;
let mut prev_end: Option<u64> = None;
let (mut n, mut rec, mut drop, mut out) = (0u64, 0u64, 0u64, 0u64);
for r in &a.runs {
let r_end = r.first.checked_add(r.count as u64).ok_or(Reason::RunsInvalid)?;
// Maximal runs: one arrived at the first try lies between two.
if r.count == 0 || r.first < a.seq_first || r_end > end || prev_end.is_some_and(|p| r.first <= p) || (r.recovered as u64 + r.dropped as u64) > r.count as u64 {
return Err(Reason::RunsInvalid);
}
prev_end = Some(r_end);
n += r.count as u64;
rec += r.recovered as u64;
drop += r.dropped as u64;
out += (r.count - r.recovered - r.dropped) as u64;
}
let lost = a.seq_count - a.first_try;
let ok = if a.runs_truncated { n <= lost && rec <= recovered && drop <= a.dropped && out <= a.outstanding } else { n == lost && rec == recovered && drop == a.dropped && out == a.outstanding };
if !ok {
return Err(Reason::RunsInvalid);
}
Ok(())
}
}
/// The digest of a record's bytes.
pub fn digest(b: &[u8]) -> [u8; 32] {
sha256(&[&b[..b.len().saturating_sub(64)]])
}
/// A session's parts in order (§6.6): each verifies, and each continues the one before.
/// The index of the first that fails, and why.
pub fn verify_series(parts: &[Vec<u8>]) -> Result<Vec<LinkRecord>, (usize, Reason)> {
let mut out: Vec<LinkRecord> = Vec::with_capacity(parts.len());
for (i, b) in parts.iter().enumerate() {
let r = LinkRecord::verify(b).map_err(|e| (i, e))?;
match out.last() {
None => {
if r.part != 0 {
return Err((i, Reason::ChainBroken));
}
}
Some(p) => {
let same = (p.room, p.hop, p.principal, p.grant, p.session, p.protocol, p.mode, p.cipher, p.window) == (r.room, r.hop, r.principal, r.grant, r.session, r.protocol, r.mode, r.cipher, r.window);
if !same || p.last || r.part != p.part + 1 || r.parent != digest(&parts[i - 1]) {
return Err((i, Reason::ChainBroken));
}
// Every track of the part before is in this one, its chain the same or longer.
for t in &p.tracks {
match r.tracks.iter().find(|x| x.track == t.track) {
Some(x) if x.receipt_alias == t.receipt_alias && (x.groups > t.groups || (x.groups == t.groups && x.head == t.head)) => {}
_ => return Err((i, Reason::ChainBroken)),
}
}
if r.start != p.end || r.account.seq_first != p.account.seq_first + p.account.seq_count {
return Err((i, Reason::SequenceGap));
}
}
}
out.push(r);
}
Ok(out)
}
struct Rd<'a> {
b: &'a [u8],
i: usize,
}
impl<'a> Rd<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], Reason> {
let s = self.b.get(self.i..self.i + n).ok_or(Reason::Truncated)?;
self.i += n;
Ok(s)
}
fn u8(&mut self) -> Result<u8, Reason> {
Ok(self.take(1)?[0])
}
fn u32(&mut self) -> Result<u32, Reason> {
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn u64(&mut self) -> Result<u64, Reason> {
Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn arr32(&mut self) -> Result<[u8; 32], Reason> {
Ok(self.take(32)?.try_into().unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
pub fn sample(key: &SigningKey) -> LinkRecord {
let account = Part {
seq_first: 1000,
seq_count: 10,
delivered: 9,
first_try: 7,
dropped: 1,
outstanding: 0,
late: 1,
duplicates: 0,
bytes: 9 * 1316,
requests: 3,
requested: 4,
retransmitted: 3,
rtt: Summary { samples: 5, min: 900, median: 1000, max: 1500 },
recovery: Summary { samples: 2, min: 20_000, median: 20_000, max: 30_000 },
margin_min: 90_000,
runs: vec![Run { first: 1003, count: 2, recovered: 2, dropped: 0, outstanding: 0 }, Run { first: 1007, count: 1, recovered: 0, dropped: 1, outstanding: 0 }],
runs_truncated: false,
start_us: 5,
end_us: 10_000_005,
last: false,
};
LinkRecord {
room: [1; 32],
hop: key.verifying_key().to_bytes(),
principal: [2; 32],
grant: [3; 32],
session: [4; 16],
part: 0,
parent: [0; 32],
protocol: 1,
mode: 1,
cipher: 128,
last: false,
ended: 0,
start: 5,
end: 10_000_005,
window: 120_000,
account,
tracks: vec![TrackHead { track: [5; 32], receipt_alias: 77, groups: 3, head: [6; 32] }],
}
}
#[test]
fn a_record_round_trips_and_verifies() {
let key = SigningKey::from_bytes(&[9; 32]);
let r = sample(&key);
let b = r.sign(&key);
assert_eq!(b.len(), FIXED + TRACK + 2 * RUN);
assert_eq!(LinkRecord::verify(&b).unwrap(), r);
let mut bad = b.clone();
bad[200] ^= 1;
assert_eq!(LinkRecord::verify(&bad).unwrap_err(), Reason::BadSignature);
assert_eq!(LinkRecord::verify(&b[..b.len() - 1]).unwrap_err(), Reason::Truncated);
}
#[test]
fn the_account_must_add_up() {
let key = SigningKey::from_bytes(&[9; 32]);
let mut r = sample(&key);
r.account.dropped = 2;
assert_eq!(LinkRecord::verify(&r.sign(&key)).unwrap_err(), Reason::AccountInvalid);
let mut r = sample(&key);
r.account.runs[0].count = 3;
assert_eq!(LinkRecord::verify(&r.sign(&key)).unwrap_err(), Reason::RunsInvalid);
}
#[test]
fn a_series_chains() {
let key = SigningKey::from_bytes(&[9; 32]);
let a = sample(&key);
let ab = a.sign(&key);
let mut b = sample(&key);
b.part = 1;
b.parent = digest(&ab);
b.start = a.end;
b.end = a.end + 1_000_000;
b.account.seq_first = 1010;
b.account.runs = vec![];
b.account.first_try = 9;
b.account.dropped = 0;
b.account.delivered = 9;
b.account.seq_count = 9;
b.account.recovery = Summary::default();
b.account.margin_min = 0;
b.tracks[0].groups = 4;
b.tracks[0].head = [7; 32];
let bb = b.sign(&key);
assert!(verify_series(&[ab.clone(), bb.clone()]).is_ok());
let mut c = b.clone();
c.account.seq_first = 1011;
assert_eq!(verify_series(&[ab.clone(), c.sign(&key)]).unwrap_err(), (1, Reason::SequenceGap));
let mut c = b.clone();
c.parent = [8; 32];
assert_eq!(verify_series(&[ab, c.sign(&key)]).unwrap_err(), (1, Reason::ChainBroken));
}
}

View file

@ -143,11 +143,17 @@ pub struct Packager {
pub out: TrackOut,
pub codec: Option<Codec>,
pub header: Option<Header>,
/// What the track's carriage states beside the bitstream (Enhanced RTMP: its
/// configuration record and its `onMetaData` entry): the fields a keyframe's header
/// leaves unstated are taken from it.
pub hint: Option<Header>,
pub timeline: Timeline,
pub counts: TrackCounts,
group: Option<OpenGroup>,
next_group: Option<u64>,
prev_link: Option<[u8; 32]>,
/// The groups whose receipts were signed.
signed: u64,
last_ticks: Option<u64>,
grant: GrantRef,
signer: Signer,
@ -164,11 +170,13 @@ impl Packager {
out,
codec: None,
header: None,
hint: None,
timeline: Timeline::new(rate, max_skew_us),
counts: TrackCounts::default(),
group: None,
next_group: None,
prev_link: None,
signed: 0,
last_ticks: None,
grant,
signer,
@ -183,10 +191,24 @@ impl Packager {
self.out.media != crate::room::media::AUDIO
}
/// The groups whose receipts this track has signed, and the link hash of the latest:
/// the head of its receipt chain.
pub fn chain_head(&self) -> (u64, Option<[u8; 32]>) {
(self.signed, self.prev_link)
}
/// One frame, depacketised: its codec, bytes, extended RTP timestamp and room-clock
/// arrival, and whether the WebRTC stack read it as a keyframe. Returns whether the
/// catalog must be published again (the track's codec or picture size changed).
pub fn on_frame(&mut self, codec: Codec, data: &[u8], rtp: u64, arrival: u64, keyframe: bool, emits: &mut Vec<Emit>) -> (Verdict, bool) {
self.on_frame_cto(codec, data, rtp, arrival, keyframe, None, emits)
}
/// As [`Packager::on_frame`], with the media time `rtp` at the timeline's rate, and a
/// video frame's composition time offset in µs (Enhanced RTMP), written as `live.cto`
/// when it is not 0.
#[allow(clippy::too_many_arguments)]
pub fn on_frame_cto(&mut self, codec: Codec, data: &[u8], rtp: u64, arrival: u64, keyframe: bool, cto: Option<i64>, emits: &mut Vec<Emit>) -> (Verdict, bool) {
self.counts.frames_in += 1;
self.counts.bytes_in += data.len() as u64;
if data.len() > self.max_frame_bytes {
@ -205,7 +227,8 @@ impl Packager {
self.counts.reanchors = self.timeline.reanchors;
let key = match codec {
Codec::Avc => keyframe || frame::avc_is_idr(data),
Codec::Opus => false,
Codec::Hevc => keyframe || frame::hevc_is_irap(data),
c if !c.is_video() => false,
_ => keyframe,
};
@ -217,8 +240,14 @@ impl Packager {
Codec::Vp8 => frame::vp8_header(data),
Codec::Vp9 => frame::vp9_header(data),
Codec::Av1 => frame::av1_header(data).or_else(|| self.header.clone()),
Codec::Opus => None,
// HEVC and VVC: an Enhanced RTMP track's configuration record is its hint.
// From a transport stream there is none, and HEVC states its codec string in
// its parameter sets, which this reader does not walk: the catalog names the
// capability alone.
Codec::Hevc | Codec::Vvc => self.hint.clone().or_else(|| self.header.clone()).or_else(|| Some(Header::default())),
Codec::Opus | Codec::Aac | Codec::Flac => None,
};
let h = h.map(|h| h.or_hint(self.hint.as_ref()));
match h {
Some(h) => {
if self.header.as_ref() != Some(&h) {
@ -226,7 +255,7 @@ impl Packager {
self.header = Some(h);
}
}
None if codec != Codec::Avc => {
None if !matches!(codec, Codec::Avc | Codec::Hevc | Codec::Vvc) => {
self.counts.dropped_unreadable += 1;
return (Verdict::Unreadable, catalog);
}
@ -240,8 +269,14 @@ impl Packager {
}
} else {
if self.header.is_none() {
let ch = frame::opus_channels(data).unwrap_or(2);
self.header = Some(Header { width: 0, height: 0, codec: Some("opus".into()), channels: ch });
let h = match codec {
Codec::Opus => Header { width: 0, height: 0, codec: Some("opus".into()), channels: frame::opus_channels(data).unwrap_or(2), rate: 48_000 },
// AAC from a transport stream: what its ADTS header states. An Enhanced RTMP
// track's configuration record (its hint) states it there.
Codec::Aac if self.hint.is_none() => frame::aac_header(data).unwrap_or_default(),
_ => Header::default(),
};
self.header = Some(h.or_hint(self.hint.as_ref()));
catalog = true;
}
let window_ends = self.group.as_ref().map(|g| g.start_t + self.audio_group_us);
@ -269,8 +304,9 @@ impl Packager {
let live = Live {
t,
rtp,
rate: codec.clock_rate(),
rate: self.timeline.rate,
key: codec.is_video().then_some(key),
cto: cto.filter(|c| *c != 0 && codec.is_video()),
grant: first.then(|| self.grant.clone()),
};
let envelope = frame::envelope(&frame::manifest(codec, &live), &payload);
@ -307,6 +343,7 @@ impl Packager {
let end = rfc3339(self.t0_us.saturating_add(g.last_t));
let (bundle, link) = sign_group(&self.signer, self.out.receipt_alias, g.id, &g.hashes, &start, &end, self.prev_link);
self.prev_link = Some(link);
self.signed += 1;
bundle
}
}

View file

@ -20,6 +20,8 @@ pub fn config(ice_lite: bool, codecs: &[Codec]) -> RtcConfig {
Codec::Vp9 => c.enable_vp9(true),
Codec::Av1 => c.enable_av1(true),
Codec::Opus => c.enable_opus(true, false),
// Not a WebRTC codec here: never offered or accepted.
Codec::Hevc | Codec::Vvc | Codec::Aac | Codec::Flac => c,
};
}
c

View file

@ -0,0 +1,282 @@
//! AMF0, the serialisation of RTMP's command and data messages and of FLV's script data
//! (the `[AMF0]` reference of Enhanced RTMP v2, `v2-2026-01-31-r2`). Multi-byte integers
//! and numbers are big-endian.
//!
//! | marker | type | here |
//! |---|---|---|
//! | 0x00 | number, an IEEE 754 double | [`Value::Number`] |
//! | 0x01 | boolean | [`Value::Bool`] |
//! | 0x02 | string, a u16 length | [`Value::String`] |
//! | 0x03 | object: (u16 name, value) pairs up to an empty name and 0x09 | [`Value::Object`] |
//! | 0x05, 0x06 | null, undefined | [`Value::Null`], [`Value::Undefined`] |
//! | 0x08 | ECMA array: a u32 count hint, then pairs as an object's | [`Value::EcmaArray`] |
//! | 0x0A | strict array: a u32 count, then values | [`Value::StrictArray`] |
//! | 0x0B | date: a double and a s16 time zone | [`Value::Date`] |
//! | 0x0C | long string, a u32 length | [`Value::String`] |
//!
//! Every other marker (movie clip, reference, unsupported, record set, XML document, typed
//! object, and 0x11, the switch to AMF3) is refused: a command that needs one is not a
//! command this gateway answers. Nesting is bounded, and so is every count, by the bytes
//! that remain.
/// One AMF0 value.
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Number(f64),
Bool(bool),
String(String),
Object(Vec<(String, Value)>),
Null,
Undefined,
EcmaArray(Vec<(String, Value)>),
StrictArray(Vec<Value>),
Date(f64, i16),
}
/// The deepest nesting a reader follows.
pub const MAX_DEPTH: usize = 16;
/// Why bytes are not AMF0 values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Error {
Truncated,
Marker(u8),
TooDeep,
Utf8,
}
impl Value {
pub fn as_str(&self) -> Option<&str> {
match self {
Value::String(s) => Some(s),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
Value::Number(n) => Some(*n),
_ => None,
}
}
/// The pairs of an object or an ECMA array.
pub fn pairs(&self) -> Option<&[(String, Value)]> {
match self {
Value::Object(p) | Value::EcmaArray(p) => Some(p),
_ => None,
}
}
/// The value of the member `k` of an object or an ECMA array.
pub fn get(&self, k: &str) -> Option<&Value> {
self.pairs()?.iter().find(|(n, _)| n == k).map(|(_, v)| v)
}
pub fn str(s: &str) -> Value {
Value::String(s.to_string())
}
pub fn obj(pairs: &[(&str, Value)]) -> Value {
Value::Object(pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())
}
}
struct Rd<'a> {
b: &'a [u8],
p: usize,
}
impl Rd<'_> {
fn take(&mut self, n: usize) -> Result<&[u8], Error> {
let end = self.p.checked_add(n).ok_or(Error::Truncated)?;
let s = self.b.get(self.p..end).ok_or(Error::Truncated)?;
self.p = end;
Ok(s)
}
fn u8(&mut self) -> Result<u8, Error> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap()))
}
fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap()))
}
fn f64(&mut self) -> Result<f64, Error> {
Ok(f64::from_be_bytes(self.take(8)?.try_into().unwrap()))
}
fn utf8(&mut self, n: usize) -> Result<String, Error> {
String::from_utf8(self.take(n)?.to_vec()).map_err(|_| Error::Utf8)
}
/// Name and value pairs up to the empty name and the object-end marker.
fn pairs(&mut self, depth: usize) -> Result<Vec<(String, Value)>, Error> {
let mut out = Vec::new();
loop {
let n = self.u16()? as usize;
if n == 0 {
return match self.u8()? {
0x09 => Ok(out),
m => Err(Error::Marker(m)),
};
}
let k = self.utf8(n)?;
out.push((k, self.value(depth + 1)?));
}
}
fn value(&mut self, depth: usize) -> Result<Value, Error> {
if depth > MAX_DEPTH {
return Err(Error::TooDeep);
}
Ok(match self.u8()? {
0x00 => Value::Number(self.f64()?),
0x01 => Value::Bool(self.u8()? != 0),
0x02 => {
let n = self.u16()? as usize;
Value::String(self.utf8(n)?)
}
0x03 => Value::Object(self.pairs(depth)?),
0x05 => Value::Null,
0x06 => Value::Undefined,
0x08 => {
// The count is a hint (writers disagree on it); the pairs end as an object's.
let _ = self.u32()?;
Value::EcmaArray(self.pairs(depth)?)
}
0x0A => {
let n = self.u32()? as usize;
// Each value takes at least one byte.
if n > self.b.len() - self.p {
return Err(Error::Truncated);
}
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(self.value(depth + 1)?);
}
Value::StrictArray(v)
}
0x0B => {
let d = self.f64()?;
let z = self.u16()? as i16;
Value::Date(d, z)
}
0x0C => {
let n = self.u32()? as usize;
Value::String(self.utf8(n)?)
}
m => return Err(Error::Marker(m)),
})
}
}
/// Every value of `b`, in order: a command message's or a data message's payload.
pub fn read_all(b: &[u8]) -> Result<Vec<Value>, Error> {
let mut r = Rd { b, p: 0 };
let mut out = Vec::new();
while r.p < b.len() {
out.push(r.value(0)?);
}
Ok(out)
}
fn put_pairs(o: &mut Vec<u8>, pairs: &[(String, Value)]) {
for (k, v) in pairs {
o.extend_from_slice(&(k.len().min(u16::MAX as usize) as u16).to_be_bytes());
o.extend_from_slice(&k.as_bytes()[..k.len().min(u16::MAX as usize)]);
write(o, v);
}
o.extend_from_slice(&[0, 0, 0x09]);
}
/// `v`, appended to `o`. A string longer than a u16 is written as a long string.
pub fn write(o: &mut Vec<u8>, v: &Value) {
match v {
Value::Number(n) => {
o.push(0x00);
o.extend_from_slice(&n.to_be_bytes());
}
Value::Bool(b) => o.extend_from_slice(&[0x01, *b as u8]),
Value::String(s) if s.len() <= u16::MAX as usize => {
o.push(0x02);
o.extend_from_slice(&(s.len() as u16).to_be_bytes());
o.extend_from_slice(s.as_bytes());
}
Value::String(s) => {
o.push(0x0C);
o.extend_from_slice(&(s.len() as u32).to_be_bytes());
o.extend_from_slice(s.as_bytes());
}
Value::Object(p) => {
o.push(0x03);
put_pairs(o, p);
}
Value::Null => o.push(0x05),
Value::Undefined => o.push(0x06),
Value::EcmaArray(p) => {
o.push(0x08);
o.extend_from_slice(&(p.len() as u32).to_be_bytes());
put_pairs(o, p);
}
Value::StrictArray(v) => {
o.push(0x0A);
o.extend_from_slice(&(v.len() as u32).to_be_bytes());
for x in v {
write(o, x);
}
}
Value::Date(d, z) => {
o.push(0x0B);
o.extend_from_slice(&d.to_be_bytes());
o.extend_from_slice(&z.to_be_bytes());
}
}
}
/// `values`, each after the last: a command message's payload.
pub fn encode(values: &[Value]) -> Vec<u8> {
let mut o = Vec::new();
for v in values {
write(&mut o, v);
}
o
}
/// A FOURCC as E-RTMP writes one in an `onMetaData` number: its four bytes, big-endian,
/// as a UI32 (`"av01"` is 1 635 135 537).
pub fn fourcc_number(n: f64) -> Option<[u8; 4]> {
(n.fract() == 0.0 && (0.0..=u32::MAX as f64).contains(&n)).then(|| (n as u32).to_be_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_round_trip() {
let v = vec![
Value::str("connect"),
Value::Number(1.0),
Value::obj(&[("app", Value::str("live")), ("capsEx", Value::Number(3.0)), ("fourCcList", Value::StrictArray(vec![Value::str("hvc1"), Value::str("av01")]))]),
Value::Null,
Value::EcmaArray(vec![("width".into(), Value::Number(1280.0)), ("stereo".into(), Value::Bool(true))]),
Value::Date(0.0, 0),
Value::Undefined,
];
let b = encode(&v);
assert_eq!(read_all(&b).unwrap(), v);
// A number is its marker and eight bytes.
assert_eq!(&b[10..19], &[0x00, 0x3f, 0xf0, 0, 0, 0, 0, 0, 0]);
}
#[test]
fn what_is_refused() {
assert_eq!(read_all(&[0x02, 0x00, 0x05, b'a']), Err(Error::Truncated));
assert_eq!(read_all(&[0x11, 0x06]), Err(Error::Marker(0x11)), "AMF3 is not read");
assert_eq!(read_all(&[0x0A, 0xff, 0xff, 0xff, 0xff]), Err(Error::Truncated), "a count past the bytes left");
let mut deep = Vec::new();
for _ in 0..20 {
deep.extend_from_slice(&[0x0A, 0, 0, 0, 1]);
}
deep.push(0x05);
assert_eq!(read_all(&deep), Err(Error::TooDeep));
assert_eq!(fourcc_number(1_635_135_537.0), Some(*b"av01"));
assert_eq!(fourcc_number(7.5), None);
}
}

View file

@ -0,0 +1,421 @@
//! The RTMP handshake and chunk stream: the transport layer of the legacy RTMP
//! specification that Enhanced RTMP v2 (`v2-2026-01-31-r2`) references as `[RTMP]`
//! (its §5.2 handshake, §5.3 chunking, §5.4 protocol control messages), with the
//! extended-timestamp rule its writers follow.
//!
//! - **Handshake.** The simple handshake: C0 and S0 are the version, 3; C1 and S1 are
//! 1536 bytes, a time, four zero bytes, and random bytes; S2 echoes C1 and C2 echoes S1.
//! A client that computes a digest over its C1 writes it in C1's random part, which a
//! server that does not check it echoes unchanged.
//! - **Chunks.** A chunk is a basic header (fmt, the chunk stream id in one, two or three
//! bytes), a message header of 11, 7, 3 or 0 bytes by fmt, an extended timestamp where
//! the header's 24-bit field is 0xFFFFFF (and on each type-3 chunk of a chunk stream
//! whose last header carried one), and at most the peer's chunk size of payload. A
//! message's timestamp is absolute after fmt 0 and a delta after fmt 1 and 2; a type-3
//! chunk that begins a message adds the chunk stream's last delta, where the delta after
//! a fmt-0 header is that header's field.
//!
//! [`Decoder`] is sans-IO: bytes go in as they arrive, whole messages come out, and its
//! bounds (the largest message, the largest chunk size) refuse what passes them.
/// A whole message off the chunk stream.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Message {
pub csid: u32,
pub type_id: u8,
/// The message stream id (little-endian on the wire).
pub msid: u32,
/// The timestamp, in milliseconds, modulo 2^32.
pub ts: u32,
pub payload: Vec<u8>,
}
/// Message types (`[RTMP]` §5.4, §6.2, §7.1).
pub mod types {
pub const SET_CHUNK_SIZE: u8 = 1;
pub const ABORT: u8 = 2;
pub const ACK: u8 = 3;
pub const USER_CONTROL: u8 = 4;
pub const WINDOW_ACK_SIZE: u8 = 5;
pub const SET_PEER_BANDWIDTH: u8 = 6;
pub const AUDIO: u8 = 8;
pub const VIDEO: u8 = 9;
pub const DATA_AMF3: u8 = 15;
pub const COMMAND_AMF3: u8 = 17;
pub const DATA_AMF0: u8 = 18;
pub const COMMAND_AMF0: u8 = 20;
pub const AGGREGATE: u8 = 22;
}
/// Why the chunk stream cannot be read further: each ends the connection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkError {
/// A chunk header other than fmt 0 on a chunk stream that has had none.
NoPreviousHeader,
/// A header of fmt 0, 1 or 2 while a message of its chunk stream is incomplete.
InterleavedHeader,
/// A message longer than the gateway's bound.
MessageTooLarge,
/// A Set Chunk Size of 0, of more than 2^31 − 1, or past the gateway's bound.
ChunkSize,
/// More chunk streams with an incomplete message than the gateway holds.
TooManyStreams,
}
impl ChunkError {
pub fn code(self) -> &'static str {
match self {
ChunkError::NoPreviousHeader => "chunk_no_header",
ChunkError::InterleavedHeader => "chunk_interleaved",
ChunkError::MessageTooLarge => "message_too_large",
ChunkError::ChunkSize => "chunk_size",
ChunkError::TooManyStreams => "chunk_streams",
}
}
}
#[derive(Clone, Debug, Default)]
struct Cs {
known: bool,
ts: u32,
delta: u32,
len: u32,
type_id: u8,
msid: u32,
ext: bool,
buf: Vec<u8>,
open: bool,
}
/// The receiving side of a chunk stream.
pub struct Decoder {
buf: Vec<u8>,
pos: usize,
chunk_size: usize,
max_message: usize,
max_chunk_size: usize,
streams: std::collections::HashMap<u32, Cs>,
/// Bytes taken off the connection after the handshake.
pub bytes_in: u64,
}
/// The largest number of chunk streams one connection may use at once.
pub const MAX_CHUNK_STREAMS: usize = 64;
impl Decoder {
/// A decoder at the initial chunk size, 128, that refuses a message longer than
/// `max_message` bytes or a chunk size above `max_chunk_size`.
pub fn new(max_message: usize, max_chunk_size: usize) -> Decoder {
Decoder { buf: Vec::new(), pos: 0, chunk_size: 128, max_message, max_chunk_size, streams: Default::default(), bytes_in: 0 }
}
pub fn feed(&mut self, b: &[u8]) {
if self.pos > 0 && self.pos == self.buf.len() {
self.buf.clear();
self.pos = 0;
} else if self.pos > 1 << 16 {
self.buf.drain(..self.pos);
self.pos = 0;
}
self.buf.extend_from_slice(b);
self.bytes_in += b.len() as u64;
}
/// The peer's Set Chunk Size (`[RTMP]` §5.4.1): its high bit is 0, and it is at least 1.
pub fn set_chunk_size(&mut self, n: u32) -> Result<(), ChunkError> {
if n == 0 || n > 0x7fff_ffff || n as usize > self.max_chunk_size {
return Err(ChunkError::ChunkSize);
}
self.chunk_size = n as usize;
Ok(())
}
pub fn chunk_size(&self) -> usize {
self.chunk_size
}
/// Abort Message (`[RTMP]` §5.4.2): the incomplete message of `csid` is discarded.
pub fn abort(&mut self, csid: u32) {
if let Some(c) = self.streams.get_mut(&csid) {
c.buf.clear();
c.open = false;
}
}
/// The next whole message the bytes fed so far hold, if any.
pub fn next_message(&mut self) -> Result<Option<Message>, ChunkError> {
loop {
match self.chunk()? {
Step::Need => return Ok(None),
Step::Partial => continue,
Step::Done(m) => return Ok(Some(m)),
}
}
}
fn chunk(&mut self) -> Result<Step, ChunkError> {
let b = &self.buf[self.pos..];
let Some(&b0) = b.first() else { return Ok(Step::Need) };
let fmt = b0 >> 6;
let (csid, mut p) = match b0 & 0x3f {
0 => match b.get(1) {
Some(&x) => (64 + x as u32, 2),
None => return Ok(Step::Need),
},
1 => match (b.get(1), b.get(2)) {
(Some(&x), Some(&y)) => (64 + x as u32 + 256 * y as u32, 3),
_ => return Ok(Step::Need),
},
n => (n as u32, 1),
};
let mh = [11, 7, 3, 0][fmt as usize];
if b.len() < p + mh {
return Ok(Step::Need);
}
let u24 = |s: &[u8]| (s[0] as u32) << 16 | (s[1] as u32) << 8 | s[2] as u32;
let (field, len, type_id, msid) = match fmt {
0 => (Some(u24(&b[p..])), Some(u24(&b[p + 3..])), Some(b[p + 6]), Some(u32::from_le_bytes(b[p + 7..p + 11].try_into().unwrap()))),
1 => (Some(u24(&b[p..])), Some(u24(&b[p + 3..])), Some(b[p + 6]), None),
2 => (Some(u24(&b[p..])), None, None, None),
_ => (None, None, None, None),
};
p += mh;
if !self.streams.contains_key(&csid) && self.streams.len() >= MAX_CHUNK_STREAMS {
self.streams.retain(|_, c| c.open);
if self.streams.len() >= MAX_CHUNK_STREAMS {
return Err(ChunkError::TooManyStreams);
}
}
let cs = self.streams.entry(csid).or_default();
if fmt != 0 && !cs.known {
return Err(ChunkError::NoPreviousHeader);
}
if fmt != 3 && cs.open {
return Err(ChunkError::InterleavedHeader);
}
let ext = match field {
Some(f) => f == 0xff_ffff,
None => cs.ext,
};
let ext_val = if ext {
if b.len() < p + 4 {
return Ok(Step::Need);
}
let v = u32::from_be_bytes(b[p..p + 4].try_into().unwrap());
p += 4;
Some(v)
} else {
None
};
let starts = !cs.open;
// The header as it stands once this chunk is taken, committed only when the
// chunk's payload has arrived whole.
let (mut ts, mut delta, mut mlen, mut mtype, mut mmsid, mut mext) = (cs.ts, cs.delta, cs.len, cs.type_id, cs.msid, cs.ext);
if starts {
let f = ext_val.or(field);
match fmt {
0 => {
ts = f.unwrap();
delta = f.unwrap();
}
1 | 2 => {
delta = f.unwrap();
ts = ts.wrapping_add(delta);
}
_ => ts = ts.wrapping_add(delta),
}
if let Some(l) = len {
mlen = l;
}
if let Some(t) = type_id {
mtype = t;
}
if let Some(m) = msid {
mmsid = m;
}
if fmt != 3 {
mext = ext;
}
if mlen as usize > self.max_message {
return Err(ChunkError::MessageTooLarge);
}
}
let have = if starts { 0 } else { cs.buf.len() };
let want = (mlen as usize - have).min(self.chunk_size);
if b.len() < p + want {
return Ok(Step::Need);
}
(cs.ts, cs.delta, cs.len, cs.type_id, cs.msid, cs.ext, cs.known) = (ts, delta, mlen, mtype, mmsid, mext, true);
if starts {
cs.buf.clear();
cs.buf.reserve(mlen as usize);
}
cs.buf.extend_from_slice(&b[p..p + want]);
self.pos += p + want;
if cs.buf.len() == mlen as usize {
cs.open = false;
let payload = std::mem::take(&mut cs.buf);
Ok(Step::Done(Message { csid, type_id: mtype, msid: mmsid, ts, payload }))
} else {
cs.open = true;
Ok(Step::Partial)
}
}
}
enum Step {
Need,
Partial,
Done(Message),
}
/// `m` as chunks of at most `chunk_size` bytes: a fmt-0 header, then fmt-3 headers, with
/// the extended timestamp on every chunk where the timestamp needs it.
pub fn encode(m: &Message, chunk_size: usize) -> Vec<u8> {
let mut o = Vec::with_capacity(m.payload.len() + 16 + m.payload.len() / chunk_size.max(1) * 5);
let basic = |o: &mut Vec<u8>, fmt: u8| {
if m.csid < 64 {
o.push(fmt << 6 | m.csid as u8);
} else if m.csid < 320 {
o.push(fmt << 6);
o.push((m.csid - 64) as u8);
} else {
o.push(fmt << 6 | 1);
let x = m.csid - 64;
o.push((x & 0xff) as u8);
o.push((x >> 8) as u8);
}
};
let ext = m.ts >= 0xff_ffff;
basic(&mut o, 0);
let field = if ext { 0xff_ffff } else { m.ts };
o.extend_from_slice(&field.to_be_bytes()[1..]);
o.extend_from_slice(&(m.payload.len() as u32).to_be_bytes()[1..]);
o.push(m.type_id);
o.extend_from_slice(&m.msid.to_le_bytes());
if ext {
o.extend_from_slice(&m.ts.to_be_bytes());
}
let mut chunks = m.payload.chunks(chunk_size.max(1));
if let Some(c) = chunks.next() {
o.extend_from_slice(c);
}
for c in chunks {
basic(&mut o, 3);
if ext {
o.extend_from_slice(&m.ts.to_be_bytes());
}
o.extend_from_slice(c);
}
o
}
/// A protocol control message (`[RTMP]` §5.4) on chunk stream 2, message stream 0.
pub fn control(type_id: u8, payload: Vec<u8>) -> Message {
Message { csid: 2, type_id, msid: 0, ts: 0, payload }
}
/// A User Control message (`[RTMP]` §6.2, §7.1.7): an event type and its data.
pub fn user_control(event: u16, data: &[u8]) -> Message {
let mut p = event.to_be_bytes().to_vec();
p.extend_from_slice(data);
control(types::USER_CONTROL, p)
}
/// The handshake's packet length after C0 and S0.
pub const HANDSHAKE_LEN: usize = 1536;
pub const VERSION: u8 = 3;
/// S0, S1 and S2 for C0 and C1: version 3; a time, four zero bytes and `random` (1528
/// bytes); and C1 echoed with its second four bytes our time.
pub fn server_handshake(c0: u8, c1: &[u8], time_ms: u32, random: &[u8]) -> Result<Vec<u8>, &'static str> {
if c0 != VERSION {
return Err("handshake_version");
}
if c1.len() != HANDSHAKE_LEN || random.len() != HANDSHAKE_LEN - 8 {
return Err("handshake_length");
}
let mut o = Vec::with_capacity(1 + 2 * HANDSHAKE_LEN);
o.push(VERSION);
o.extend_from_slice(&time_ms.to_be_bytes());
o.extend_from_slice(&[0; 4]);
o.extend_from_slice(random);
o.extend_from_slice(&c1[..4]);
o.extend_from_slice(&time_ms.to_be_bytes());
o.extend_from_slice(&c1[8..]);
Ok(o)
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(csid: u32, type_id: u8, ts: u32, n: usize) -> Message {
Message { csid, type_id, msid: 1, ts, payload: (0..n).map(|i| (i * 7 + csid as usize) as u8).collect() }
}
#[test]
fn encode_and_decode_agree_at_every_split() {
let ms = [msg(4, 9, 0, 1000), msg(6, 8, 23, 3), msg(64, 9, 40, 300), msg(400, 9, 0x0100_0000, 513), msg(4, 9, 0xff_ffff, 0)];
let mut wire = Vec::new();
for m in &ms {
wire.extend(encode(m, 128));
}
for step in [1, 7, 128, wire.len()] {
let mut d = Decoder::new(1 << 20, 1 << 20);
let mut got = Vec::new();
for c in wire.chunks(step) {
d.feed(c);
while let Some(m) = d.next_message().unwrap() {
got.push(m);
}
}
assert_eq!(got, ms, "fed {step} bytes at a time");
}
}
#[test]
fn deltas_and_type_3_headers() {
// fmt 0 at ts 1000, then fmt 2 with a delta of 20, then two fmt-3 messages: 1000,
// 1020, 1040, 1060 (`[RTMP]` §5.3.2.1's first example).
let mut w = vec![0x04, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x02, 0x08, 1, 0, 0, 0, 0xaa, 0xbb];
w.extend_from_slice(&[0x84, 0x00, 0x00, 0x14, 0xcc, 0xdd]);
w.extend_from_slice(&[0xc4, 0x01, 0x02]);
w.extend_from_slice(&[0xc4, 0x03, 0x04]);
let mut d = Decoder::new(1024, 1024);
d.feed(&w);
let ts: Vec<u32> = std::iter::from_fn(|| d.next_message().unwrap()).map(|m| m.ts).collect();
assert_eq!(ts, vec![1000, 1020, 1040, 1060]);
}
#[test]
fn what_is_refused() {
let mut d = Decoder::new(100, 1 << 16);
d.feed(&[0x44, 0, 0, 0, 0, 0, 1, 8]);
assert_eq!(d.next_message(), Err(ChunkError::NoPreviousHeader));
let mut d = Decoder::new(100, 1 << 16);
d.feed(&encode(&msg(4, 9, 0, 101), 128));
assert_eq!(d.next_message(), Err(ChunkError::MessageTooLarge));
let mut d = Decoder::new(1000, 1 << 16);
let w = encode(&msg(4, 9, 0, 300), 128);
d.feed(&w[..140]);
assert_eq!(d.next_message(), Ok(None));
d.feed(&[0x04, 0, 0, 0, 0, 0, 1, 8, 1, 0, 0, 0]);
assert_eq!(d.next_message(), Err(ChunkError::InterleavedHeader));
assert_eq!(Decoder::new(1, 4096).set_chunk_size(0), Err(ChunkError::ChunkSize));
assert_eq!(Decoder::new(1, 4096).set_chunk_size(8192), Err(ChunkError::ChunkSize));
}
#[test]
fn the_handshake_echoes() {
let c1: Vec<u8> = (0..HANDSHAKE_LEN).map(|i| i as u8).collect();
let s = server_handshake(3, &c1, 77, &vec![9; HANDSHAKE_LEN - 8]).unwrap();
assert_eq!(s.len(), 1 + 2 * HANDSHAKE_LEN);
assert_eq!(&s[1..5], &77u32.to_be_bytes());
assert_eq!(&s[5..9], &[0; 4]);
let s2 = &s[1 + HANDSHAKE_LEN..];
assert_eq!(&s2[..4], &c1[..4]);
assert_eq!(&s2[8..], &c1[8..]);
assert_eq!(server_handshake(6, &c1, 0, &vec![0; HANDSHAKE_LEN - 8]), Err("handshake_version"));
}
}

View file

@ -0,0 +1,720 @@
//! The bodies of RTMP audio and video messages: FLV tag bodies, legacy and enhanced
//! (Enhanced RTMP v2, `v2-2026-01-31-r2`, *Enhanced Audio*, *Enhanced Video*,
//! *Multitrack Streaming via Enhanced RTMP*), and the payload of one WAI object built
//! from each frame (`extensions/live-ingest.md` §12).
//!
//! A message is read whole: every track a multitrack message carries is one [`Unit`], in
//! the order the message holds them. A ModEx `TimestampOffsetNano` applies to every unit
//! of its message. What a reader cannot read is refused for the message
//! ([`TagError`]); what it reads and the gateway does not carry is a unit the session
//! counts.
/// One packet of one track out of a message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Unit {
pub video: bool,
/// The track's id: 0 for a message that is not multitrack.
pub track: u8,
/// The codec: an enhanced FourCC, or `avc1` and `mp4a` for the legacy AVC and AAC
/// headers; `[0, 0, 0, id]` for another legacy codec id, which is not carried.
pub fourcc: [u8; 4],
pub kind: Kind,
/// Video: the frame type is a key frame (1).
pub key: bool,
/// AVC, HEVC and VVC coded frames: the composition time offset, ms.
pub cts_ms: i32,
/// The message's `TimestampOffsetNano`, ns, 0 without one.
pub nano: u32,
pub data: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
SequenceStart,
Frames,
SequenceEnd,
/// Video `Metadata` (the `colorInfo` object), an audio `MultichannelConfig`, a video
/// command frame, an `MPEG2TSSequenceStart`, a silence message: read and not carried.
Other,
}
/// Why a message body does not read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagError {
Truncated,
/// A ModEx `TimestampOffsetNano` with fewer than 3 bytes of data, or above 999 999.
ModEx,
/// A multitrack header naming `Multitrack` again, or a reserved multitrack type.
Multitrack,
/// A packet type the text reserves.
PacketType,
}
impl TagError {
pub fn code(self) -> &'static str {
match self {
TagError::Truncated => "tag_truncated",
TagError::ModEx => "tag_modex",
TagError::Multitrack => "tag_multitrack",
TagError::PacketType => "tag_packet_type",
}
}
}
pub const AVC1: [u8; 4] = *b"avc1";
pub const HVC1: [u8; 4] = *b"hvc1";
pub const VVC1: [u8; 4] = *b"vvc1";
pub const AV01: [u8; 4] = *b"av01";
pub const VP09: [u8; 4] = *b"vp09";
pub const VP08: [u8; 4] = *b"vp08";
pub const OPUS: [u8; 4] = *b"Opus";
pub const FLAC: [u8; 4] = *b"fLaC";
pub const MP4A: [u8; 4] = *b"mp4a";
struct Rd<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Rd<'a> {
fn u8(&mut self) -> Result<u8, TagError> {
let v = *self.b.get(self.p).ok_or(TagError::Truncated)?;
self.p += 1;
Ok(v)
}
fn take(&mut self, n: usize) -> Result<&'a [u8], TagError> {
let s = self.b.get(self.p..self.p.checked_add(n).ok_or(TagError::Truncated)?).ok_or(TagError::Truncated)?;
self.p += n;
Ok(s)
}
fn u16(&mut self) -> Result<u16, TagError> {
Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap()))
}
fn u24(&mut self) -> Result<u32, TagError> {
let s = self.take(3)?;
Ok((s[0] as u32) << 16 | (s[1] as u32) << 8 | s[2] as u32)
}
fn si24(&mut self) -> Result<i32, TagError> {
let v = self.u24()?;
Ok(((v << 8) as i32) >> 8)
}
fn fourcc(&mut self) -> Result<[u8; 4], TagError> {
Ok(self.take(4)?.try_into().unwrap())
}
fn rest(&mut self) -> &'a [u8] {
let s = &self.b[self.p.min(self.b.len())..];
self.p = self.b.len();
s
}
fn done(&self) -> bool {
self.p >= self.b.len()
}
}
/// The ModEx loop shared by audio and video: from a packet type of 7, each ModEx's data
/// and the next (ModEx type, packet type) nibbles. Returns the packet type that ends the
/// loop and the nanosecond offset, if any.
fn modex(r: &mut Rd, mut packet_type: u8) -> Result<(u8, u32), TagError> {
let mut nano = 0;
while packet_type == 7 {
let mut size = r.u8()? as usize + 1;
if size == 256 {
size = r.u16()? as usize + 1;
}
let data = r.take(size)?;
let b = r.u8()?;
let (modex_type, next) = (b >> 4, b & 0x0f);
if modex_type == 0 {
if data.len() < 3 {
return Err(TagError::ModEx);
}
let n = (data[0] as u32) << 16 | (data[1] as u32) << 8 | data[2] as u32;
if n > 999_999 {
return Err(TagError::ModEx);
}
nano = n;
}
packet_type = next;
}
Ok((packet_type, nano))
}
/// One track of a multitrack body: its FourCC, its id, its body.
type TrackBody<'a> = ([u8; 4], u8, &'a [u8]);
/// The tracks of a multitrack body, after its header.
fn tracks<'a>(r: &mut Rd<'a>, multitrack_type: u8, shared: Option<[u8; 4]>) -> Result<Vec<TrackBody<'a>>, TagError> {
let mut out = Vec::new();
loop {
let fourcc = match shared {
Some(f) => f,
None => r.fourcc()?,
};
let id = r.u8()?;
if multitrack_type == 0 {
out.push((fourcc, id, r.rest()));
return Ok(out);
}
let n = r.u24()? as usize;
out.push((fourcc, id, r.take(n)?));
if r.done() {
return Ok(out);
}
}
}
/// A video message's body (FLV `VIDEODATA`), legacy or enhanced.
pub fn video(b: &[u8]) -> Result<Vec<Unit>, TagError> {
let mut r = Rd { b, p: 0 };
let b0 = r.u8()?;
let unit = |track, fourcc, kind, key, cts_ms, nano, data: &[u8]| Unit { video: true, track, fourcc, kind, key, cts_ms, nano, data: data.to_vec() };
if b0 & 0x80 == 0 {
// Legacy: frame type UB[4], codec id UB[4].
let frame_type = b0 >> 4;
let codec = b0 & 0x0f;
if frame_type == 5 {
return Ok(vec![unit(0, [0, 0, 0, codec], Kind::Other, false, 0, 0, &[])]);
}
if codec != 7 {
return Ok(vec![unit(0, [0, 0, 0, codec], Kind::Frames, frame_type == 1, 0, 0, r.rest())]);
}
let kind = match r.u8()? {
0 => Kind::SequenceStart,
1 => Kind::Frames,
2 => Kind::SequenceEnd,
_ => return Err(TagError::PacketType),
};
let cts = r.si24()?;
return Ok(vec![unit(0, AVC1, kind, frame_type == 1, if kind == Kind::Frames { cts } else { 0 }, 0, r.rest())]);
}
let frame_type = (b0 >> 4) & 0x07;
let (packet_type, nano) = modex(&mut r, b0 & 0x0f)?;
if packet_type != 4 && frame_type == 5 {
let _command = r.u8()?;
return Ok(vec![unit(0, [0; 4], Kind::Other, false, 0, nano, &[])]);
}
let (packet_type, list) = if packet_type == 6 {
let h = r.u8()?;
let (mt, pt) = (h >> 4, h & 0x0f);
if pt == 6 || mt > 2 {
return Err(TagError::Multitrack);
}
let shared = if mt != 2 { Some(r.fourcc()?) } else { None };
(pt, tracks(&mut r, mt, shared)?)
} else {
let f = r.fourcc()?;
(packet_type, vec![(f, 0, r.rest())])
};
let mut out = Vec::new();
for (fourcc, id, body) in list {
let mut t = Rd { b: body, p: 0 };
let (kind, cts) = match packet_type {
0 => (Kind::SequenceStart, 0),
1 if matches!(fourcc, AVC1 | HVC1 | VVC1) => (Kind::Frames, t.si24()?),
1 | 3 => (Kind::Frames, 0),
2 => (Kind::SequenceEnd, 0),
4 | 5 => (Kind::Other, 0),
_ => return Err(TagError::PacketType),
};
out.push(unit(id, fourcc, kind, frame_type == 1, cts, nano, t.rest()));
}
Ok(out)
}
/// An audio message's body (FLV `AUDIODATA`), legacy or enhanced. An empty body is the
/// silence message, which carries nothing.
pub fn audio(b: &[u8]) -> Result<Vec<Unit>, TagError> {
let mut r = Rd { b, p: 0 };
let unit = |track, fourcc, kind, nano, data: &[u8]| Unit { video: false, track, fourcc, kind, key: false, cts_ms: 0, nano, data: data.to_vec() };
let Ok(b0) = r.u8() else { return Ok(vec![unit(0, [0; 4], Kind::Other, 0, &[])]) };
let sound_format = b0 >> 4;
if sound_format != 9 {
if sound_format != 10 {
return Ok(vec![unit(0, [0, 0, 0, sound_format], Kind::Frames, 0, r.rest())]);
}
let kind = match r.u8()? {
0 => Kind::SequenceStart,
1 => Kind::Frames,
_ => return Err(TagError::PacketType),
};
return Ok(vec![unit(0, MP4A, kind, 0, r.rest())]);
}
let (packet_type, nano) = modex(&mut r, b0 & 0x0f)?;
let (packet_type, list) = if packet_type == 5 {
let h = r.u8()?;
let (mt, pt) = (h >> 4, h & 0x0f);
if pt == 5 || mt > 2 {
return Err(TagError::Multitrack);
}
let shared = if mt != 2 { Some(r.fourcc()?) } else { None };
(pt, tracks(&mut r, mt, shared)?)
} else {
let f = r.fourcc()?;
(packet_type, vec![(f, 0, r.rest())])
};
let mut out = Vec::new();
for (fourcc, id, body) in list {
let kind = match packet_type {
0 => Kind::SequenceStart,
1 => Kind::Frames,
2 => Kind::SequenceEnd,
4 => Kind::Other,
_ => return Err(TagError::PacketType),
};
out.push(unit(id, fourcc, kind, nano, body));
}
Ok(out)
}
// ── Configuration records ─────────────────────────────────────────────────────────
/// What a track's sequence start gives the payloads of its frames.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Config {
/// AVC, HEVC: the NAL unit length's size, and the parameter sets in record order.
Nal { length_size: usize, sets: Vec<Vec<u8>>, codec: String },
/// AV1: the configuration record's sequence header OBU, if it carries one.
Av1 { seq_header: Option<Vec<u8>>, codec: String },
/// VP8, VP9: the codec string, where the record states one.
Vp { codec: Option<String> },
/// AAC: what an ADTS header needs (ISO/IEC 13818-7 §6.2): the profile (the core
/// audio object type less one), the sampling frequency index, the channel
/// configuration; and the rate and the codec string.
Aac { profile: u8, sfi: u8, channels: u8, rate: u32, codec: String },
/// Opus: the number of streams one packet carries, and the channels.
Opus { streams: u8, channels: u8 },
/// FLAC: `fLaC` and its STREAMINFO block, marked last, which every object's stream
/// begins with.
Flac { header: Vec<u8>, channels: u8, rate: u32 },
}
struct Bits<'a> {
b: &'a [u8],
bit: usize,
}
impl Bits<'_> {
fn f(&mut self, n: usize) -> Option<u32> {
let mut v = 0u32;
for _ in 0..n {
let byte = *self.b.get(self.bit / 8)?;
v = (v << 1) | ((byte >> (7 - self.bit % 8)) & 1) as u32;
self.bit += 1;
}
Some(v)
}
}
const AAC_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350];
/// An `AudioSpecificConfig` (ISO/IEC 14496-3 §1.6.2.1) as far as ADTS needs it. `None`
/// where ADTS cannot carry the stream: a core object type above 4, an explicit sampling
/// rate, or a channel configuration of 0 (a program config element) or above 7.
pub fn aac_config(asc: &[u8]) -> Option<Config> {
let mut r = Bits { b: asc, bit: 0 };
let aot = |r: &mut Bits| -> Option<u32> {
let a = r.f(5)?;
if a == 31 { Some(32 + r.f(6)?) } else { Some(a) }
};
let mut object = aot(&mut r)?;
let signalled = object;
let sfi = r.f(4)?;
if sfi == 0x0f {
return None;
}
let channels = r.f(4)?;
if object == 5 || object == 29 {
// Explicit SBR (and PS) signalling: the extension rate, then the core type.
if r.f(4)? == 0x0f {
r.f(24)?;
}
object = aot(&mut r)?;
}
if !(1..=4).contains(&object) || sfi > 12 || !(1..=7).contains(&channels) {
return None;
}
let ch = if channels == 7 { 8 } else { channels as u8 };
Some(Config::Aac { profile: (object - 1) as u8, sfi: sfi as u8, channels: ch, rate: AAC_RATES[sfi as usize], codec: format!("mp4a.40.{signalled}") })
}
/// One ADTS frame (ISO/IEC 13818-7 §6.2) of one raw AAC frame: a 7-byte header with
/// `protection_absent` set and one raw data block. `None` past the 13-bit frame length.
pub fn adts(c: &Config, raw: &[u8]) -> Option<Vec<u8>> {
let Config::Aac { profile, sfi, channels, .. } = c else { return None };
let ch = if *channels == 8 { 7 } else { *channels };
let len = raw.len() + 7;
if len > 0x1fff {
return None;
}
let mut o = Vec::with_capacity(len);
o.push(0xff);
o.push(0xf1);
o.push(profile << 6 | sfi << 2 | ch >> 2);
o.push((ch & 3) << 6 | ((len >> 11) & 3) as u8);
o.push(((len >> 3) & 0xff) as u8);
o.push(((len & 7) << 5) as u8 | 0x1f);
o.push(0xfc);
o.extend_from_slice(raw);
Some(o)
}
/// An `AVCDecoderConfigurationRecord` (ISO/IEC 14496-15 §5.3.3.1): its length size, its
/// SPS and PPS, and `avc1.PPCCLL`.
pub fn avc_config(b: &[u8]) -> Option<Config> {
let mut r = Rd { b, p: 0 };
if r.u8().ok()? != 1 {
return None;
}
let (p, c, l) = (r.u8().ok()?, r.u8().ok()?, r.u8().ok()?);
let length_size = (r.u8().ok()? & 3) as usize + 1;
let mut sets = Vec::new();
let n_sps = r.u8().ok()? & 0x1f;
for _ in 0..n_sps {
let n = r.u16().ok()? as usize;
sets.push(r.take(n).ok()?.to_vec());
}
let n_pps = r.u8().ok()?;
for _ in 0..n_pps {
let n = r.u16().ok()? as usize;
sets.push(r.take(n).ok()?.to_vec());
}
(length_size != 3).then(|| Config::Nal { length_size, sets, codec: format!("avc1.{p:02X}{c:02X}{l:02X}") })
}
/// An `HEVCDecoderConfigurationRecord` (ISO/IEC 14496-15 §8.3.3.1): its length size, the
/// NAL units of its arrays, and the codec string of its Annex E.
pub fn hevc_config(b: &[u8]) -> Option<Config> {
let mut r = Rd { b, p: 0 };
if r.u8().ok()? != 1 {
return None;
}
let x = r.u8().ok()?;
let (space, tier, profile) = (x >> 6, (x >> 5) & 1, x & 0x1f);
let compat = u32::from_be_bytes(r.take(4).ok()?.try_into().ok()?);
let constraints = r.take(6).ok()?.to_vec();
let level = r.u8().ok()?;
r.take(8).ok()?; // min_spatial_segmentation_idc .. avgFrameRate
let length_size = (r.u8().ok()? & 3) as usize + 1;
let arrays = r.u8().ok()?;
let mut sets = Vec::new();
for _ in 0..arrays {
r.u8().ok()?;
let n = r.u16().ok()?;
for _ in 0..n {
let len = r.u16().ok()? as usize;
sets.push(r.take(len).ok()?.to_vec());
}
}
let mut codec = String::from("hvc1.");
if space > 0 {
codec.push((b'A' + space - 1) as char);
}
codec.push_str(&format!("{profile}.{:X}.{}{level}", compat.reverse_bits(), if tier == 1 { 'H' } else { 'L' }));
let keep = constraints.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1);
for c in &constraints[..keep] {
codec.push_str(&format!(".{c:X}"));
}
(length_size != 3).then_some(Config::Nal { length_size, sets, codec })
}
/// An `AV1CodecConfigurationRecord` (the AV1 ISOBMFF binding, §2.3.3): `av01.P.LLT.DD`,
/// and the sequence header OBU among its configOBUs.
pub fn av1_config(b: &[u8]) -> Option<Config> {
if b.len() < 4 || b[0] != 0x81 {
return None;
}
let profile = b[1] >> 5;
let level = b[1] & 0x1f;
let tier = b[2] >> 7;
let high = (b[2] >> 6) & 1;
let twelve = (b[2] >> 5) & 1;
let depth = if high == 0 { 8 } else if twelve == 1 && profile == 2 { 12 } else { 10 };
let codec = format!("av01.{profile}.{level:02}{}.{depth:02}", if tier == 1 { 'H' } else { 'M' });
let seq_header = crate::frame::av1_obus(&b[4..]).and_then(|o| o.into_iter().find(|(t, _, _)| *t == 1).map(|(_, whole, _)| whole.to_vec()));
Some(Config::Av1 { seq_header, codec })
}
/// A `VPCodecConfigurationRecord` (the VP codec ISOBMFF binding, §2.2), with or without
/// the version and flags of the box that holds it: `vp09.PP.LL.DD`.
pub fn vp_config(fourcc: [u8; 4], b: &[u8]) -> Config {
let body = if b.len() >= 12 && b[0] == 1 && b[1..4] == [0, 0, 0] { &b[4..] } else { b };
let codec = (fourcc == VP09 && body.len() >= 3).then(|| format!("vp09.{:02}.{:02}.{:02}", body[0], body[1], body[2] >> 4));
Config::Vp { codec }
}
/// An Opus ID header (RFC 7845 §5.1), or an empty sequence start (mono or stereo, one
/// stream).
pub fn opus_config(b: &[u8]) -> Option<Config> {
if b.is_empty() {
return Some(Config::Opus { streams: 1, channels: 2 });
}
if b.len() < 19 || &b[..8] != b"OpusHead" {
return None;
}
let channels = b[9];
let family = b[18];
let streams = if family == 0 { 1 } else { *b.get(19)? };
Some(Config::Opus { streams, channels })
}
/// A FLAC sequence start: `fLaC` and a STREAMINFO metadata block (RFC 9639 §8.2), as
/// E-RTMP's table writes it; or the 34 bytes of STREAMINFO alone, as an encoder sends it.
/// The header kept is `fLaC` and that block alone, marked the last metadata block.
pub fn flac_config(b: &[u8]) -> Option<Config> {
let header = if b.len() == 34 {
let mut h = b"fLaC\x80\x00\x00\x22".to_vec();
h.extend_from_slice(b);
h
} else {
if b.len() < 4 + 4 + 34 || &b[..4] != b"fLaC" || b[4] & 0x7f != 0 {
return None;
}
let len = (b[5] as usize) << 16 | (b[6] as usize) << 8 | b[7] as usize;
if len != 34 {
return None;
}
b[..42].to_vec()
};
let mut header = header;
header[4] |= 0x80;
let si = &header[8..42];
let rate = (si[10] as u32) << 12 | (si[11] as u32) << 4 | (si[12] as u32) >> 4;
let channels = ((si[12] >> 1) & 7) + 1;
Some(Config::Flac { header, channels, rate })
}
/// The configuration a sequence start gives a track of `fourcc`, or `None` where it does
/// not read.
pub fn config(fourcc: [u8; 4], b: &[u8]) -> Option<Config> {
match fourcc {
AVC1 => avc_config(b),
HVC1 => hevc_config(b),
AV01 => av1_config(b),
VP09 | VP08 => Some(vp_config(fourcc, b)),
OPUS => opus_config(b),
FLAC => flac_config(b),
MP4A => aac_config(b),
_ => None,
}
}
// ── Frames to payloads ────────────────────────────────────────────────────────────
/// Why a frame is not carried.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameError {
/// A frame before its track's sequence start.
NoSequenceStart,
/// A frame that does not read under its configuration.
Unreadable,
/// A packet the gateway's framing cannot hold: an Opus body of several streams, an
/// AAC frame past ADTS's length.
NotCarried,
}
/// Length-prefixed NAL units as an Annex B access unit, each after a four-byte start
/// code, with `sets` before them where `key` and the unit carries none of `kinds`.
pub fn annexb(data: &[u8], length_size: usize, sets: &[Vec<u8>], key: bool, is_set: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
let mut nals = Vec::new();
let mut p = 0;
while p < data.len() {
let len = data.get(p..p + length_size)?.iter().fold(0usize, |a, &b| a << 8 | b as usize);
p += length_size;
let nal = data.get(p..p.checked_add(len)?)?;
p += len;
if !nal.is_empty() {
nals.push(nal);
}
}
if nals.is_empty() {
return None;
}
let mut o = Vec::with_capacity(data.len() + 4 * nals.len() + sets.iter().map(|s| s.len() + 4).sum::<usize>());
if key && !nals.iter().any(|n| is_set(n)) {
for s in sets {
o.extend_from_slice(&[0, 0, 0, 1]);
o.extend_from_slice(s);
}
}
for n in nals {
o.extend_from_slice(&[0, 0, 0, 1]);
o.extend_from_slice(n);
}
Some(o)
}
/// Whether an H.264 NAL unit is an SPS or PPS (types 7 and 8).
pub fn avc_is_set(n: &[u8]) -> bool {
matches!(n.first().map(|h| h & 0x1f), Some(7) | Some(8))
}
/// Whether an H.265 NAL unit is a VPS, SPS or PPS (types 32 to 34).
pub fn hevc_is_set(n: &[u8]) -> bool {
matches!(n.first().map(|h| (h >> 1) & 0x3f), Some(32..=34))
}
/// The bytes the gateway hands its packager for one coded frame of a track of
/// `fourcc` under `cfg`: what [`crate::frame::payload`] then frames.
///
/// | FourCC | handed on |
/// |---|---|
/// | `avc1`, `hvc1` | an Annex B access unit, the record's parameter sets before a key frame that carries none |
/// | `av01` | the temporal unit's OBUs without a temporal delimiter (the framing restores one), the record's sequence header before a key unit that carries none |
/// | `vp09`, `vp08`, `Opus` | the frame, or the packet of a single-stream body |
/// | `mp4a` | one ADTS frame |
/// | `fLaC` | `fLaC`, STREAMINFO, the frames |
pub fn frame(fourcc: [u8; 4], cfg: Option<&Config>, key: bool, data: &[u8]) -> Result<Vec<u8>, FrameError> {
match (fourcc, cfg) {
(AVC1, Some(Config::Nal { length_size, sets, .. })) => annexb(data, *length_size, sets, key, avc_is_set).ok_or(FrameError::Unreadable),
(HVC1, Some(Config::Nal { length_size, sets, .. })) => annexb(data, *length_size, sets, key, hevc_is_set).ok_or(FrameError::Unreadable),
(AVC1 | HVC1 | MP4A | FLAC, None) => Err(FrameError::NoSequenceStart),
(AV01, c) => {
let obus = crate::frame::av1_obus(data).ok_or(FrameError::Unreadable)?;
let mut o = Vec::with_capacity(data.len() + 64);
let has_seq = obus.iter().any(|(t, _, _)| *t == 1);
if key
&& !has_seq
&& let Some(Config::Av1 { seq_header: Some(sh), .. }) = c
{
o.extend_from_slice(sh);
}
for (t, whole, _) in obus {
if t != 2 {
o.extend_from_slice(whole);
}
}
if o.is_empty() { Err(FrameError::Unreadable) } else { Ok(o) }
}
(VP09 | VP08, _) => (!data.is_empty()).then(|| data.to_vec()).ok_or(FrameError::Unreadable),
(OPUS, c) => match c {
Some(Config::Opus { streams, .. }) if *streams > 1 => Err(FrameError::NotCarried),
_ => (!data.is_empty()).then(|| data.to_vec()).ok_or(FrameError::Unreadable),
},
(MP4A, Some(c)) => adts(c, data).ok_or(FrameError::NotCarried),
(FLAC, Some(Config::Flac { header, .. })) => {
if data.len() < 2 || data[0] != 0xff || data[1] & 0xfc != 0xf8 {
return Err(FrameError::Unreadable);
}
let mut o = header.clone();
o.extend_from_slice(data);
Ok(o)
}
_ => Err(FrameError::Unreadable),
}
}
/// The codec of a FourCC this gateway carries; `vvc1` is mapped and not carried in this
/// revision (`extensions/live-ingest.md` §12).
pub fn codec_of(fourcc: [u8; 4]) -> Option<crate::frame::Codec> {
use crate::frame::Codec;
Some(match fourcc {
AVC1 => Codec::Avc,
HVC1 => Codec::Hevc,
AV01 => Codec::Av1,
VP09 => Codec::Vp9,
VP08 => Codec::Vp8,
OPUS => Codec::Opus,
FLAC => Codec::Flac,
MP4A => Codec::Aac,
_ => return None,
})
}
/// The header a configuration states: its codec string, and an audio track's channels and
/// rate.
pub fn hint_of(c: &Config) -> crate::frame::Header {
use crate::frame::Header;
match c {
Config::Nal { codec, .. } | Config::Av1 { codec, .. } => Header { codec: Some(codec.clone()), ..Header::default() },
Config::Vp { codec } => Header { codec: codec.clone(), ..Header::default() },
Config::Aac { channels, rate, codec, .. } => Header { codec: Some(codec.clone()), channels: *channels, rate: *rate, ..Header::default() },
Config::Opus { channels, .. } => Header { codec: Some("opus".into()), channels: *channels, rate: 48_000, ..Header::default() },
Config::Flac { channels, rate, .. } => Header { codec: Some("flac".into()), channels: *channels, rate: *rate, ..Header::default() },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_legacy_avc_tag() {
let mut t = vec![0x17, 0x01, 0x00, 0x00, 0x21];
t.extend_from_slice(&[0, 0, 0, 2, 0x65, 0xaa]);
let u = video(&t).unwrap();
assert_eq!(u.len(), 1);
assert_eq!((u[0].fourcc, u[0].kind, u[0].key, u[0].cts_ms, u[0].track), (AVC1, Kind::Frames, true, 33, 0));
assert_eq!(u[0].data, vec![0, 0, 0, 2, 0x65, 0xaa]);
let rec = [1, 0x64, 0x00, 0x1f, 0xff, 0xe1, 0, 3, 0x67, 1, 2, 1, 0, 2, 0x68, 3];
let c = avc_config(&rec).unwrap();
let f = frame(AVC1, Some(&c), true, &u[0].data).unwrap();
assert_eq!(f, vec![0, 0, 0, 1, 0x67, 1, 2, 0, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 0xaa]);
assert_eq!(hint_of(&c).codec.as_deref(), Some("avc1.64001F"));
}
#[test]
fn an_enhanced_multitrack_tag_with_a_nano_offset() {
// ModEx (7) with 3 bytes of data, 500 000 ns, then (ModEx type 0, Multitrack 6);
// then (ManyTracks 1, CodedFramesX 3), FourCC hvc1, two tracks.
let mut t = vec![0x80 | 0x10 | 7, 2, 0x07, 0xa1, 0x20, 0x06, 0x13];
t.extend_from_slice(b"hvc1");
t.extend_from_slice(&[0, 0, 0, 4, 0, 0, 1, 0x26]);
t.extend_from_slice(&[1, 0, 0, 4, 0, 0, 1, 0x02]);
let u = video(&t).unwrap();
assert_eq!(u.len(), 2);
assert!(u.iter().all(|u| u.fourcc == HVC1 && u.kind == Kind::Frames && u.key && u.nano == 500_000 && u.cts_ms == 0));
assert_eq!((u[0].track, u[1].track), (0, 1));
assert_eq!(u[1].data, vec![0, 0, 1, 0x02]);
// Multitrack inside multitrack.
assert_eq!(video(&[0x86, 0x16, b'h', b'v', b'c', b'1']), Err(TagError::Multitrack));
// A short nano offset.
assert_eq!(video(&[0x87, 1, 0, 0, 0x01]), Err(TagError::ModEx));
}
#[test]
fn audio_tags_and_adts() {
let asc = [0x12, 0x10]; // AAC-LC, 44.1 kHz, stereo
let c = aac_config(&asc).unwrap();
assert_eq!(hint_of(&c).codec.as_deref(), Some("mp4a.40.2"));
let a = adts(&c, &[1, 2, 3]).unwrap();
assert_eq!(a, vec![0xff, 0xf1, 0x50, 0x80, 0x01, 0x5f, 0xfc, 1, 2, 3]);
let u = audio(&[0xaf, 0x01, 9, 9]).unwrap();
assert_eq!((u[0].fourcc, u[0].kind, u[0].data.clone()), (MP4A, Kind::Frames, vec![9, 9]));
// Enhanced: ExHeader with SequenceStart, Opus, an empty ID header.
let u = audio(&[0x90, b'O', b'p', b'u', b's']).unwrap();
assert_eq!((u[0].fourcc, u[0].kind), (OPUS, Kind::SequenceStart));
assert_eq!(opus_config(&u[0].data), Some(Config::Opus { streams: 1, channels: 2 }));
// Silence.
assert_eq!(audio(&[]).unwrap()[0].kind, Kind::Other);
// HE-AAC with explicit SBR signalling: the core type's ADTS profile.
let he = [0x2b, 0x92, 0x08, 0x00];
let Config::Aac { profile, codec, .. } = aac_config(&he).unwrap() else { panic!() };
assert_eq!((profile, codec.as_str()), (1, "mp4a.40.5"));
}
#[test]
fn hevc_and_av1_records() {
let mut rec = vec![1, 0x01, 0x60, 0, 0, 0, 0x90, 0, 0, 0, 0, 0, 93, 0xf0, 0, 0xfc, 0xfd, 0xf8, 0xf8, 0, 0, 0x0f, 3];
for (ty, n) in [(32u8, 3usize), (33, 4), (34, 2)] {
rec.extend_from_slice(&[0x80 | ty, 0, 1, 0, n as u8]);
rec.push(ty << 1);
rec.extend(std::iter::repeat_n(1, n - 1));
}
let c = hevc_config(&rec).unwrap();
let Config::Nal { length_size, sets, codec } = &c else { panic!() };
assert_eq!((*length_size, sets.len(), codec.as_str()), (4, 3, "hvc1.1.6.L93.90"));
let au = frame(HVC1, Some(&c), true, &[0, 0, 0, 2, 0x26, 1]).unwrap();
assert!(crate::frame::hevc_is_irap(&au));
assert_eq!(crate::frame::annexb_nals(&au).len(), 4, "VPS, SPS and PPS before the IRAP picture");
let sh = [0x0a, 1, 0x00];
let mut av1 = vec![0x81, 0x08, 0x0c, 0x00];
av1.extend_from_slice(&sh);
let c = av1_config(&av1).unwrap();
assert_eq!(c, Config::Av1 { seq_header: Some(sh.to_vec()), codec: "av01.0.08M.08".into() });
// A key temporal unit without a sequence header gets the record's; the temporal
// delimiter is dropped.
let tu = [0x12, 0x00, 0x32, 1, 7];
assert_eq!(frame(AV01, Some(&c), true, &tu).unwrap(), vec![0x0a, 1, 0x00, 0x32, 1, 7]);
assert_eq!(frame(AV01, Some(&c), false, &tu).unwrap(), vec![0x32, 1, 7]);
}
}

View file

@ -0,0 +1,142 @@
//! Enhanced RTMP ingest (Enhanced RTMP v2, `v2-2026-01-31-r2`; `extensions/live-ingest.md`
//! §12): a publisher's encoder streams to the gateway over RTMP, or RTMPS, and the gateway
//! publishes its tracks into the room as their MoQ publisher, signing every group's
//! receipts as the first hop, as a WHIP session does.
//!
//! - **Admission.** An encoder has a server URL and a stream key, and no way to prove
//! possession of a grant's key. So a publisher first POSTs to the gateway's `/rtmp`
//! resource with its room grant and a proof of possession, admitted as a WHIP request is
//! (`live-ingest.md` §3), and receives a one-time **stream key**: 128 random bits, good
//! for one publish within `stream_key_ttl_s`. The `publish` command's stream name is
//! that key; a refusal is `onStatus` `NetStream.Publish.BadName` with the reason as its
//! description, and the connection is closed.
//! - **The connection** ([`chunk`], [`amf0`]): the simple handshake, the chunk stream,
//! the protocol control messages, and the commands `connect`, `releaseStream`,
//! `FCPublish`, `createStream`, `publish`, `FCUnpublish` and `deleteStream`. The
//! `connect` `_result` declares `capsEx` (Reconnect, Multitrack, ModEx,
//! TimestampNanoOffset) and the FourCCs it carries in `videoFourCcInfoMap` and
//! `audioFourCcInfoMap`.
//! - **Tracks** ([`flv`], [`session`]): legacy AVC and AAC, and the enhanced FourCCs
//! `avc1`, `hvc1`, `av01`, `vp09`, `vp08`, `Opus`, `fLaC` and `mp4a`, single-track or
//! multitrack; a video track's layer is the grant's publish profile layer its
//! `onMetaData` entry matches.
//! - **The reconnect request.** When the gateway shuts down, a publisher that declared
//! `Reconnect` is sent `NetConnection.Connect.ReconnectRequest`, and its stream key is
//! good once more for `stream_key_ttl_s`.
pub mod amf0;
pub mod chunk;
pub mod flv;
pub mod session;
pub mod tracks;
use std::collections::BTreeMap;
/// What a track's `onMetaData` entry states (E-RTMP *Enhancing onMetaData*): the top-level
/// fields for track 0 of each media type, `videoTrackIdInfoMap` and `audioTrackIdInfoMap`
/// for the others.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TrackMeta {
pub width: u32,
pub height: u32,
pub fps: Option<f64>,
pub codec: Option<[u8; 4]>,
pub rate: u32,
pub channels: u8,
}
/// Every track's entry, by (video, track id).
pub type Metadata = BTreeMap<(bool, u8), TrackMeta>;
fn num(v: &amf0::Value, k: &str) -> Option<f64> {
v.get(k).and_then(amf0::Value::as_f64).filter(|n| n.is_finite() && *n >= 0.0)
}
fn codec_id(v: &amf0::Value, k: &str) -> Option<[u8; 4]> {
match v.get(k)? {
amf0::Value::Number(n) if *n >= 256.0 => amf0::fourcc_number(*n),
amf0::Value::Number(n) if (0.0..16.0).contains(n) => Some([0, 0, 0, *n as u8]),
amf0::Value::String(s) if s.len() == 4 => Some(s.as_bytes().try_into().unwrap()),
_ => None,
}
}
fn track_meta(v: &amf0::Value, video: bool) -> TrackMeta {
let mut m = TrackMeta::default();
if video {
m.width = num(v, "width").map_or(0, |n| n.min(u32::MAX as f64) as u32);
m.height = num(v, "height").map_or(0, |n| n.min(u32::MAX as f64) as u32);
m.fps = num(v, "framerate").or_else(|| num(v, "videoframerate"));
m.codec = codec_id(v, "videocodecid");
} else {
m.rate = num(v, "audiosamplerate").or_else(|| num(v, "samplerate")).map_or(0, |n| n.min(u32::MAX as f64) as u32);
m.channels = num(v, "audiochannels").or_else(|| num(v, "channels")).map_or_else(
|| match v.get("stereo") {
Some(amf0::Value::Bool(true)) => 2,
Some(amf0::Value::Bool(false)) => 1,
_ => 0,
},
|n| n.min(255.0) as u8,
);
m.codec = codec_id(v, "audiocodecid");
}
m
}
/// The tracks an `onMetaData` call describes: `["@setDataFrame", "onMetaData", {…}]` or
/// `["onMetaData", {…}]`, the argument an object or an ECMA array. `None` for another call.
pub fn metadata(values: &[amf0::Value]) -> Option<Metadata> {
let args = match values.first().and_then(amf0::Value::as_str) {
Some("@setDataFrame") => &values[1..],
_ => values,
};
if args.first().and_then(amf0::Value::as_str) != Some("onMetaData") {
return None;
}
let obj = args.get(1)?;
obj.pairs()?;
let mut out = Metadata::new();
out.insert((true, 0), track_meta(obj, true));
out.insert((false, 0), track_meta(obj, false));
for (key, video) in [("videoTrackIdInfoMap", true), ("audioTrackIdInfoMap", false)] {
let Some(map) = obj.get(key).and_then(amf0::Value::pairs) else { continue };
for (id, entry) in map {
if let Ok(id) = id.parse::<u8>()
&& id > 0
&& entry.pairs().is_some()
{
out.insert((video, id), track_meta(entry, video));
}
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::amf0::Value;
use super::*;
#[test]
fn metadata_per_track() {
let v = vec![
Value::str("@setDataFrame"),
Value::str("onMetaData"),
Value::EcmaArray(vec![
("width".into(), Value::Number(1280.0)),
("height".into(), Value::Number(720.0)),
("framerate".into(), Value::Number(29.97)),
("videocodecid".into(), Value::Number(u32::from_be_bytes(*b"hvc1") as f64)),
("audiosamplerate".into(), Value::Number(48_000.0)),
("stereo".into(), Value::Bool(true)),
("videoTrackIdInfoMap".into(), Value::obj(&[("1", Value::obj(&[("width", Value::Number(640.0)), ("height", Value::Number(360.0)), ("framerate", Value::Number(30.0))]))])),
]),
];
let m = metadata(&v).unwrap();
assert_eq!(m[&(true, 0)].codec, Some(*b"hvc1"));
assert_eq!((m[&(true, 0)].width, m[&(true, 0)].height), (1280, 720));
assert_eq!((m[&(false, 0)].rate, m[&(false, 0)].channels), (48_000, 2));
assert_eq!((m[&(true, 1)].width, m[&(true, 1)].fps), (640, Some(30.0)));
assert_eq!(metadata(&[Value::str("onCuePoint"), Value::Null]), None);
}
}

View file

@ -0,0 +1,655 @@
//! One RTMP connection: the handshake, the commands, and, once its `publish` is admitted,
//! the ingest that publishes its tracks into the room (`extensions/live-ingest.md` §12).
//!
//! The tracks are published at the relay when `publish` is admitted, before
//! `NetStream.Publish.Start` goes back: the catalog and its receipts track; audio layer 0
//! where the grant publishes audio; and a video track for each layer of the grant's video
//! publish profile, or layer 0 where it names none, where it publishes video. A track the
//! publisher never feeds ends with the others.
//!
//! Each audio and video message goes to the publish's [`Tracks`], which binds each RTMP
//! track to a room track and hands each frame to that track's packager.
use super::amf0::{self, Value};
use super::chunk::{self, types, Decoder, Message};
use super::tracks::{self, Tracks};
use crate::catalog::{self, Entry};
use crate::frame::GrantRef;
use crate::gateway::{Gateway, StreamKey};
use crate::package::{Emit, Packager, TrackOut};
use crate::record::{Energy, LedgerEntry, SessionRecord, SignedRecord, TrackEntry, FORMAT};
use crate::relay::{Published, Publisher};
use crate::room::{media, Admitted, Room, NO_CEILING};
use joule_moq::TrackName;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot};
/// The `capsEx` the gateway declares: Reconnect, Multitrack, ModEx, TimestampNanoOffset.
pub const CAPS_EX: u32 = 0x0f;
/// The client's `capsEx` bit for Reconnect.
pub const CAPS_RECONNECT: u32 = 0x01;
/// FourCcInfoMask CanForward.
const CAN_FORWARD: f64 = 4.0;
/// The window the gateway asks the client to acknowledge, and the bandwidth it sets.
const WINDOW: u32 = 2_500_000;
const CATALOG_ALIAS: u64 = 1;
const CATALOG_RECEIPTS_ALIAS: u64 = 6;
const CATALOG_HOLD: Duration = Duration::from_millis(1_000);
const END_GRACE: Duration = Duration::from_millis(250);
/// Command responses ride chunk stream 3; a stream's status, 5.
const CS_COMMAND: u32 = 3;
const CS_STATUS: u32 = 5;
/// The message stream `createStream` gives.
const STREAM_ID: u32 = 1;
/// What the gateway asks of a running session.
pub enum Cmd {
/// The gateway is shutting down: a reconnect request where the client declared it,
/// then the session ends.
Stop { reply: oneshot::Sender<()> },
}
/// A running session's handle.
#[derive(Clone)]
pub struct Handle {
pub tx: mpsc::Sender<Cmd>,
/// Frames the session has handed to its packagers.
pub frames: Arc<AtomicU64>,
/// Its publish was admitted and its tracks are published at the relay.
pub publishing: Arc<std::sync::atomic::AtomicBool>,
}
/// The aliases of a media track and its receipts track: audio 2 and 3, video layer 0 4
/// and 5, video layer `l` ≥ 1 10 + 2`l` and 11 + 2`l`.
fn aliases(m: u8, layer: u8) -> (u64, u64) {
match (m, layer) {
(media::AUDIO, _) => (2, 3),
(_, 0) => (4, 5),
(_, l) => (10 + 2 * l as u64, 11 + 2 * l as u64),
}
}
fn priority(m: u8) -> u8 {
if m == media::AUDIO { 0 } else { 1 }
}
struct Ingest {
room: Arc<Room>,
ad: Admitted,
owner: String,
session_hex: String,
publisher: Publisher,
tracks: Tracks,
catalog_group: Option<u64>,
catalog_chain: u64,
catalog_link: Option<[u8; 32]>,
first_frame: Option<Instant>,
start_us: u64,
last_media: Instant,
window_start: Instant,
window_bytes: u64,
}
struct Conn {
gw: Arc<Gateway>,
id: String,
tls: bool,
dec: Decoder,
out_chunk: usize,
caps_ex: u32,
tc_url: String,
app: String,
peer_window: u32,
acked: u64,
created: Instant,
/// The `onMetaData` a publisher sent before its publish was admitted.
early: Vec<Value>,
ingest: Option<Ingest>,
key: Option<(String, StreamKey)>,
stop: Option<(Instant, oneshot::Sender<()>)>,
frames: Arc<AtomicU64>,
publishing: Arc<std::sync::atomic::AtomicBool>,
messages: u64,
}
fn status(code: &str, level: &str, description: &str) -> Value {
Value::obj(&[("level", Value::str(level)), ("code", Value::str(code)), ("description", Value::str(description))])
}
fn command(csid: u32, msid: u32, values: &[Value]) -> Message {
Message { csid, type_id: types::COMMAND_AMF0, msid, ts: 0, payload: amf0::encode(values) }
}
/// Serve one accepted connection until it ends. The connection was counted against
/// `max_sessions` by its listener, which releases it.
pub async fn serve<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(gw: Arc<Gateway>, io: S, tls: bool) {
let id = crate::gateway::random_hex(16);
let (tx, mut rx) = mpsc::channel(4);
let frames = Arc::new(AtomicU64::new(0));
let publishing = Arc::new(std::sync::atomic::AtomicBool::new(false));
gw.rtmp.lock().unwrap().insert(id.clone(), Handle { tx, frames: frames.clone(), publishing: publishing.clone() });
let cfg = gw.cfg.rtmp.clone();
let (mut rd, mut wr) = tokio::io::split(io);
let mut c = Conn {
gw: gw.clone(),
id: id.clone(),
tls,
dec: Decoder::new(cfg.max_message_bytes, cfg.max_message_bytes.max(1 << 16)),
out_chunk: 128,
caps_ex: 0,
tc_url: String::new(),
app: String::new(),
peer_window: 0,
acked: 0,
created: Instant::now(),
early: Vec::new(),
ingest: None,
key: None,
stop: None,
frames,
publishing,
messages: 0,
};
let connect = Duration::from_millis(cfg.connect_timeout_ms);
let ended = match tokio::time::timeout(connect, handshake(&mut rd, &mut wr)).await {
Err(_) => "connect_timeout".to_string(),
Ok(Err(e)) => e,
Ok(Ok(())) => c.run(&mut rd, &mut wr, &mut rx).await,
};
let _ = wr.shutdown().await;
c.end(&ended).await;
gw.rtmp.lock().unwrap().remove(&id);
if let Some((_, r)) = c.stop.take() {
let _ = r.send(());
}
tracing::info!(session = %id, "RTMP connection ended: {ended}");
}
async fn handshake<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(rd: &mut R, wr: &mut W) -> Result<(), String> {
let mut c0c1 = vec![0u8; 1 + chunk::HANDSHAKE_LEN];
rd.read_exact(&mut c0c1).await.map_err(|_| "handshake_closed".to_string())?;
let random = {
use rand_core_06::RngCore;
let mut r = vec![0u8; chunk::HANDSHAKE_LEN - 8];
rand_core_06::OsRng.fill_bytes(&mut r);
r
};
let s = chunk::server_handshake(c0c1[0], &c0c1[1..], 0, &random)?;
wr.write_all(&s).await.map_err(|_| "handshake_closed".to_string())?;
let mut c2 = vec![0u8; chunk::HANDSHAKE_LEN];
rd.read_exact(&mut c2).await.map_err(|_| "handshake_closed".to_string())?;
Ok(())
}
impl Conn {
fn room_now(&self, room: &Room) -> u64 {
self.gw.clock.unix_us().saturating_sub(room.descriptor.t0_us)
}
fn push(&mut self, out: &mut Vec<u8>, m: Message) {
out.extend(chunk::encode(&m, self.out_chunk));
if m.type_id == types::SET_CHUNK_SIZE && m.payload.len() == 4 {
self.out_chunk = u32::from_be_bytes(m.payload[..4].try_into().unwrap()) as usize;
}
}
async fn run<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(&mut self, rd: &mut R, wr: &mut W, rx: &mut mpsc::Receiver<Cmd>) -> String {
let mut buf = vec![0u8; 64 << 10];
let mut tick = tokio::time::interval(Duration::from_millis(100));
loop {
let mut out = Vec::new();
let mut ended = None;
tokio::select! {
r = rd.read(&mut buf) => match r {
Ok(0) | Err(_) => {
ended = Some(if self.stop.is_some() { "reconnect_requested".to_string() } else { "closed".to_string() });
}
Ok(n) => {
self.dec.feed(&buf[..n]);
loop {
match self.dec.next_message() {
Ok(Some(m)) => {
if let Some(e) = self.on_message(m, &mut out).await {
ended = Some(e);
break;
}
}
Ok(None) => break,
Err(e) => {
ended = Some(e.code().to_string());
break;
}
}
}
if self.peer_window > 0 && self.dec.bytes_in - self.acked >= self.peer_window as u64 {
self.acked = self.dec.bytes_in;
let ack = chunk::control(types::ACK, (self.dec.bytes_in as u32).to_be_bytes().to_vec());
self.push(&mut out, ack);
}
}
},
c = rx.recv() => match c {
None => ended = Some("gateway_stopped".into()),
Some(Cmd::Stop { reply }) => {
if self.ingest.is_some() && self.caps_ex & CAPS_RECONNECT != 0 {
self.reconnect_request(&mut out);
self.stop = Some((Instant::now(), reply));
} else {
self.stop = Some((Instant::now(), reply));
ended = Some("gateway_stopped".into());
}
}
},
_ = tick.tick() => ended = self.on_tick(),
}
if !out.is_empty() && wr.write_all(&out).await.is_err() {
return ended.unwrap_or_else(|| "closed".into());
}
if let Some(e) = ended {
let _ = wr.flush().await;
return e;
}
}
}
/// `onStatus` `NetConnection.Connect.ReconnectRequest` (E-RTMP *Reconnect Request*),
/// and the stream key good once more for the publisher's reconnection.
fn reconnect_request(&mut self, out: &mut Vec<u8>) {
let mut info = vec![
("level".to_string(), Value::str("status")),
("code".to_string(), Value::str("NetConnection.Connect.ReconnectRequest")),
("description".to_string(), Value::str("the gateway is shutting down")),
];
if !self.gw.cfg.rtmp.reconnect_url.is_empty() {
info.push(("tcUrl".to_string(), Value::str(&self.gw.cfg.rtmp.reconnect_url)));
}
let m = command(CS_COMMAND, 0, &[Value::str("onStatus"), Value::Number(0.0), Value::Null, Value::Object(info)]);
self.push(out, m);
if let Some((k, sk)) = self.key.take() {
self.gw.rearm_stream_key(k, sk);
}
}
fn on_tick(&mut self) -> Option<String> {
let cfg = &self.gw.cfg.rtmp;
if let Some((at, _)) = &self.stop
&& at.elapsed() >= Duration::from_millis(cfg.reconnect_grace_ms)
{
return Some("reconnect_requested".into());
}
let Some(ing) = self.ingest.as_mut() else {
return (self.created.elapsed() > Duration::from_millis(cfg.connect_timeout_ms)).then(|| "connect_timeout".into());
};
if ing.last_media.elapsed() > Duration::from_millis(cfg.idle_timeout_ms) {
return Some("idle".into());
}
if ing.publisher.failed.load(Ordering::Relaxed) {
return Some("relay_lost".into());
}
if ing.window_start.elapsed() >= Duration::from_millis(self.gw.cfg.limits.bitrate_window_ms) {
ing.window_start = Instant::now();
ing.window_bytes = 0;
}
self.publish_catalog(false);
None
}
async fn on_message(&mut self, m: Message, out: &mut Vec<u8>) -> Option<String> {
self.messages += 1;
match m.type_id {
types::SET_CHUNK_SIZE => {
let n = m.payload.get(..4).map(|b| u32::from_be_bytes(b.try_into().unwrap()))?;
if let Err(e) = self.dec.set_chunk_size(n) {
return Some(e.code().into());
}
}
types::ABORT => {
if let Some(b) = m.payload.get(..4) {
self.dec.abort(u32::from_be_bytes(b.try_into().unwrap()));
}
}
types::WINDOW_ACK_SIZE => {
if let Some(b) = m.payload.get(..4) {
self.peer_window = u32::from_be_bytes(b.try_into().unwrap());
}
}
types::USER_CONTROL => {
// A ping request is answered (`[RTMP]` §7.1.7, event 6 then 7).
if m.payload.len() >= 6 && m.payload[..2] == [0, 6] {
let pong = chunk::user_control(7, &m.payload[2..6]);
self.push(out, pong);
}
}
types::COMMAND_AMF0 | types::COMMAND_AMF3 => {
let body = if m.type_id == types::COMMAND_AMF3 { m.payload.get(1..)? } else { &m.payload[..] };
let Ok(values) = amf0::read_all(body) else { return Some("command_malformed".into()) };
return self.on_command(&m, &values, out).await;
}
types::DATA_AMF0 | types::DATA_AMF3 => {
let body = if m.type_id == types::DATA_AMF3 { m.payload.get(1..)? } else { &m.payload[..] };
if let Ok(values) = amf0::read_all(body) {
self.on_data(&values);
}
}
types::AUDIO | types::VIDEO => return self.on_media(m.type_id, m.ts, &m.payload),
types::AGGREGATE => return self.on_media(m.type_id, m.ts, &m.payload),
_ => {}
}
None
}
fn on_data(&mut self, values: &[Value]) {
match self.ingest.as_mut() {
Some(ing) => ing.tracks.on_data(values),
None => self.early = values.to_vec(),
}
}
async fn on_command(&mut self, m: &Message, v: &[Value], out: &mut Vec<u8>) -> Option<String> {
let name = v.first().and_then(Value::as_str).unwrap_or("");
let txn = v.get(1).and_then(Value::as_f64).unwrap_or(0.0);
match name {
"connect" => {
if let Some(o) = v.get(2) {
self.app = o.get("app").and_then(Value::as_str).unwrap_or("").to_string();
self.tc_url = o.get("tcUrl").and_then(Value::as_str).unwrap_or("").to_string();
self.caps_ex = o.get("capsEx").and_then(Value::as_f64).map_or(0, |n| n as u32);
}
self.push(out, chunk::control(types::WINDOW_ACK_SIZE, WINDOW.to_be_bytes().to_vec()));
let mut bw = WINDOW.to_be_bytes().to_vec();
bw.push(2);
self.push(out, chunk::control(types::SET_PEER_BANDWIDTH, bw));
let cs = self.gw.cfg.rtmp.chunk_size.clamp(128, 0x00ff_ffff);
self.push(out, chunk::control(types::SET_CHUNK_SIZE, cs.to_be_bytes().to_vec()));
let video: Vec<(&str, Value)> = ["avc1", "hvc1", "av01", "vp09", "vp08"].iter().map(|f| (*f, Value::Number(CAN_FORWARD))).collect();
let audio: Vec<(&str, Value)> = ["Opus", "fLaC", "mp4a"].iter().map(|f| (*f, Value::Number(CAN_FORWARD))).collect();
let props = Value::obj(&[("capsEx", Value::Number(CAPS_EX as f64)), ("videoFourCcInfoMap", Value::obj(&video)), ("audioFourCcInfoMap", Value::obj(&audio))]);
let mut info = status("NetConnection.Connect.Success", "status", "Connection succeeded.");
if let Value::Object(p) = &mut info {
p.push(("objectEncoding".into(), Value::Number(0.0)));
}
let r = command(CS_COMMAND, 0, &[Value::str("_result"), Value::Number(txn), props, info]);
self.push(out, r);
}
"createStream" => {
let r = command(CS_COMMAND, 0, &[Value::str("_result"), Value::Number(txn), Value::Null, Value::Number(STREAM_ID as f64)]);
self.push(out, r);
}
"releaseStream" | "FCPublish" | "getStreamLength" | "_checkbw" => {
if txn > 0.0 {
let r = command(CS_COMMAND, 0, &[Value::str("_result"), Value::Number(txn), Value::Null, Value::Undefined]);
self.push(out, r);
}
}
"publish" => {
let key = v.get(3).and_then(Value::as_str).unwrap_or("");
let key = key.split('?').next().unwrap_or("").to_string();
return match self.admit(&key).await {
Ok(()) => {
self.push(out, Message { msid: 0, ..chunk::user_control(0, &STREAM_ID.to_be_bytes()) });
let s = command(CS_STATUS, m.msid, &[Value::str("onStatus"), Value::Number(0.0), Value::Null, status("NetStream.Publish.Start", "status", "publishing")]);
self.push(out, s);
None
}
Err(reason) => {
let s = command(CS_STATUS, m.msid, &[Value::str("onStatus"), Value::Number(0.0), Value::Null, status("NetStream.Publish.BadName", "error", reason)]);
self.push(out, s);
Some(format!("refused:{reason}"))
}
};
}
"FCUnpublish" | "deleteStream" | "closeStream" if self.ingest.is_some() => return Some("deleted".into()),
_ => {}
}
None
}
/// The publish command's stream key: issued by `/rtmp`, unused and good, its grant
/// still good now (`live-ingest.md` §3.2, steps 2 to 5).
async fn admit(&mut self, key: &str) -> Result<(), &'static str> {
if self.ingest.is_some() {
return Err("already_publishing");
}
let sk = self.gw.take_stream_key(key)?;
let now = self.gw.clock.unix_us();
let ad = sk.room.admit(&sk.token, now, &self.gw.cfg.http.origin, false).map_err(|r| r.code())?;
let room = sk.room.clone();
self.key = Some((key.to_string(), sk));
match self.start(room, ad).await {
Ok(i) => {
self.ingest = Some(i);
self.publishing.store(true, Ordering::Relaxed);
Ok(())
}
Err(e) => Err(e),
}
}
async fn start(&mut self, room: Arc<Room>, ad: Admitted) -> Result<Ingest, &'static str> {
let gw = self.gw.clone();
let (client, endpoint) = gw.link.connect().await.map_err(|_| "relay_unreachable")?;
let session: [u8; 16] = {
use rand_core_06::RngCore;
let mut s = [0u8; 16];
rand_core_06::OsRng.fill_bytes(&mut s);
s
};
let owner = hex::encode(ad.grant.principal);
let cat_ns = catalog::catalog_namespace(&room.id, &owner);
let mut published = vec![
Published { ns: cat_ns.clone(), name: TrackName::from_str(catalog::CATALOG_NAME), alias: CATALOG_ALIAS, priority: 0 },
Published { ns: cat_ns.clone(), name: catalog::receipts_name(catalog::CATALOG_NAME), alias: CATALOG_RECEIPTS_ALIAS, priority: 0 },
];
let grant_ref = GrantRef { grant_hash: ad.digest, joule_ceiling_micro: ad.grant.joules_ceiling, funds_ceiling: (ad.grant.funds_ceiling != NO_CEILING).then_some(ad.grant.funds_ceiling) };
let vp = ad.profile_for(&room.descriptor, media::VIDEO).cloned();
let ap = ad.profile_for(&room.descriptor, media::AUDIO).cloned();
let (pa, pv) = (ad.grant.caps.publishes(media::AUDIO), ad.grant.caps.publishes(media::VIDEO));
let layout = tracks::layout(pa, pv, vp.as_ref());
let t0 = room.descriptor.t0_us;
let g = &gw.cfg.groups;
let mut packagers = BTreeMap::new();
for &(m, l) in &layout {
let ns = catalog::media_namespace(&room.id, &owner, m);
let name = l.to_string();
let (ma, ra) = aliases(m, l);
let out = TrackOut { media: m, media_alias: ma, receipts_alias: ra, receipt_alias: catalog::receipt_alias(&session, &ns, &name), priority: priority(m) };
published.push(Published { ns: ns.clone(), name: TrackName::from_str(&name), alias: ma, priority: priority(m) });
published.push(Published { ns: ns.clone(), name: catalog::receipts_name(&name), alias: ra, priority: priority(m) });
packagers.insert((m, l), Packager::new(out, 1_000_000, gw.cfg.timeline.max_skew_ms * 1000, grant_ref.clone(), gw.signer.clone(), t0, g.audio_group_ms * 1000, g.max_video_group_ms * 1000, gw.cfg.limits.max_frame_bytes));
}
let publisher = Publisher::start(client, endpoint, published, gw.cfg.limits.max_queue_bytes).await.map_err(|_| "relay_refused")?;
let start_us = self.room_now(&room);
let mut tracks = Tracks::new(pa, pv, vp, ap, packagers);
tracks.on_data(&std::mem::take(&mut self.early));
Ok(Ingest {
catalog_chain: catalog::receipt_alias(&session, &cat_ns, catalog::CATALOG_NAME),
room,
ad,
owner,
session_hex: hex::encode(session),
publisher,
tracks,
catalog_group: None,
catalog_link: None,
first_frame: None,
start_us,
last_media: Instant::now(),
window_start: Instant::now(),
window_bytes: 0,
})
}
/// An audio, video or aggregate message, after the publish was admitted: the
/// session's bitrate bound, then the tracks, then the relay.
fn on_media(&mut self, type_id: u8, ts: u32, payload: &[u8]) -> Option<String> {
let window_ms = self.gw.cfg.limits.bitrate_window_ms.max(1);
let max_kbps = self.gw.cfg.rtmp.max_bitrate_kbps as u64;
let arrival = {
let ing = self.ingest.as_ref()?;
self.gw.clock.unix_us().saturating_sub(ing.room.descriptor.t0_us)
};
let ing = self.ingest.as_mut()?;
ing.last_media = Instant::now();
ing.window_bytes += payload.len() as u64;
if ing.window_bytes * 8 > max_kbps * window_ms {
return Some("bitrate_exceeded".into());
}
let mut emits = Vec::new();
let before = ing.tracks.frames;
if type_id == types::AGGREGATE {
if !ing.tracks.on_aggregate(ts, payload, arrival, &mut emits) {
*ing.tracks.counts.entry("aggregate_malformed".into()).or_default() += 1;
}
} else {
ing.tracks.on_media(type_id == types::VIDEO, ts, payload, arrival, &mut emits);
}
let carried = ing.tracks.frames - before;
if carried > 0 {
ing.first_frame.get_or_insert_with(Instant::now);
self.frames.fetch_add(carried, Ordering::Relaxed);
}
for e in emits {
if !ing.publisher.send(e) {
return Some("relay_backpressure".into());
}
}
self.publish_catalog(false);
None
}
/// The catalog, when it changed and every bound track has begun, or a while after the
/// first did; or, with `force`, now.
fn publish_catalog(&mut self, force: bool) {
let Some(ing) = self.ingest.as_mut() else { return };
if !ing.tracks.catalog_dirty {
return;
}
let bound = ing.tracks.bound();
let all = !bound.is_empty() && bound.iter().all(|k| ing.tracks.packagers.get(k).is_some_and(|p| p.codec.is_some()));
let held = ing.first_frame.is_some_and(|t| t.elapsed() >= CATALOG_HOLD);
if !all && !held && !force {
return;
}
let entries: Vec<Entry> = ing
.tracks
.packagers
.iter()
.filter_map(|(&(m, l), p)| {
let codec = p.codec?;
Some(Entry { media: m, ns: catalog::media_namespace(&ing.room.id, &ing.owner, m), name: l.to_string(), codec, header: p.header.clone(), receipt_alias: p.out.receipt_alias })
})
.collect();
if entries.is_empty() {
return;
}
let now = self.gw.clock.unix_us();
let g = match ing.catalog_group {
Some(g) => g + 1,
None => now.saturating_sub(ing.room.descriptor.t0_us),
};
ing.catalog_group = Some(g);
ing.tracks.catalog_dirty = false;
let bytes = catalog::catalog(&entries);
let ts = crate::package::rfc3339(now);
let hash = joule_moq::receipt::ObjectReceipt::hash_payload(&bytes);
let (bundle, link) = crate::package::sign_group(&self.gw.signer, ing.catalog_chain, g, &[hash], &ts, &ts, ing.catalog_link);
ing.catalog_link = Some(link);
let _ = ing.publisher.send(Emit::Single { alias: CATALOG_ALIAS, group: g, priority: 0, bytes });
let _ = ing.publisher.send(Emit::Single { alias: CATALOG_RECEIPTS_ALIAS, group: g, priority: 0, bytes: bundle });
}
async fn end(&mut self, why: &str) {
let Some(mut ing) = self.ingest.take() else { return };
let mut emits = Vec::new();
for p in ing.tracks.packagers.values_mut() {
p.end_group(&mut emits);
}
for e in emits {
let _ = ing.publisher.send(e);
}
if ing.catalog_group.is_none() && ing.tracks.packagers.values().any(|p| p.codec.is_some()) {
ing.tracks.catalog_dirty = true;
self.ingest = Some(ing);
self.publish_catalog(true);
ing = self.ingest.take().unwrap();
}
ing.publisher.end(END_GRACE).await;
let end_us = self.room_now(&ing.room);
let record = self.record(&ing, why, end_us);
self.gw.sink.record(&SignedRecord::sign(record, &self.gw.signer.key));
}
fn record(&self, ing: &Ingest, why: &str, end: u64) -> SessionRecord {
let mut tracks = Vec::new();
let mut detail = serde_json::Map::new();
let (mut bytes_in, mut bytes_out) = (0, 0);
for (&(m, l), p) in &ing.tracks.packagers {
let ns = catalog::media_namespace(&ing.room.id, &ing.owner, m);
let name = l.to_string();
let digest = hex::encode(catalog::track_digest(&ns, &name));
let base = TrackEntry { kind: 1, track: digest, namespace: catalog::render(&ns), name: name.clone(), ..Default::default() };
let c = &p.counts;
let dropped = c.dropped_before_keyframe + c.dropped_too_large + c.dropped_unreadable;
tracks.push(TrackEntry { dir: 1, objects: c.frames_in, bytes: c.bytes_in, dropped_refused: dropped, ..base.clone() });
tracks.push(TrackEntry { dir: 2, objects: c.objects_out, bytes: c.bytes_out, ..base });
bytes_in += c.bytes_in;
bytes_out += c.bytes_out;
detail.insert(format!("{}/{name}", crate::room::media_name(m).unwrap_or("media")), serde_json::to_value(c).unwrap());
}
tracks.sort_by(|a, b| (a.kind, &a.track, a.dir).cmp(&(b.kind, &b.track, b.dir)));
detail.insert("counts".into(), serde_json::to_value(&ing.tracks.counts).unwrap());
detail.insert("messages".into(), self.messages.into());
detail.insert("caps_ex".into(), self.caps_ex.into());
detail.insert("app".into(), self.app.clone().into());
detail.insert("tc_url".into(), self.tc_url.clone().into());
detail.insert("tls".into(), self.tls.into());
detail.insert("connection".into(), self.id.clone().into());
let g = &ing.ad.grant;
let ledger = vec![LedgerEntry {
grant: hex::encode(ing.ad.digest),
joules_ceiling: g.joules_ceiling,
funds_ceiling: g.funds_ceiling,
currency: String::from_utf8_lossy(&g.currency).trim_matches('\0').to_string(),
sessions: 1,
bytes_in,
bytes_out,
subscriber_us: [0; 9],
}];
SessionRecord {
format: FORMAT.into(),
hop: hex::encode(self.gw.public_key()),
role: 3,
protocol: "ertmp".into(),
session: ing.session_hex.clone(),
room: hex::encode(ing.room.id),
principal: ing.owner.clone(),
start: ing.start_us,
end: end.max(ing.start_us + 1),
energy: Energy::unmetered(),
tracks,
sessions: 0,
subscriber_us: [0; 9],
ledger,
ended: why.into(),
detail: serde_json::Value::Object(detail),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliases_do_not_collide() {
let mut seen = std::collections::BTreeSet::from([CATALOG_ALIAS, CATALOG_RECEIPTS_ALIAS]);
for (m, l) in [(media::AUDIO, 0u8), (media::VIDEO, 0), (media::VIDEO, 1), (media::VIDEO, 2), (media::VIDEO, 7)] {
let (a, b) = aliases(m, l);
assert!(seen.insert(a) && seen.insert(b), "{m} {l}");
}
}
}

View file

@ -0,0 +1,296 @@
//! An Enhanced RTMP publisher's tracks, sans IO: what each audio and video message does to
//! the room tracks of one publish (`extensions/live-ingest.md` §12). The connection
//! ([`super::session`]) feeds it messages with their arrival on the room clock; it binds
//! each RTMP track to a room track, reads its sequence start, and hands each frame's
//! payload to that track's packager, which emits its objects and receipts. The
//! conformance corpus is generated through it too.
//!
//! An RTMP track is bound to a room track at its first packet ([`Tracks::bind`]):
//!
//! 1. its FourCC is one this gateway carries (`codec_not_carried` otherwise), the grant
//! publishes its media kind (`publish_not_permitted`), and the grant's profile for the
//! kind, if it names one, lists its codec's family (`codec_not_permitted`);
//! 2. audio: track 0 is layer 0, another is not carried (`audio_track_not_default`);
//! 3. video: where the grant names a video profile and the track's `onMetaData` entry
//! states its width, height and frame rate, the layer is the profile layer of that
//! width, height and frame rate rounded to an integer (`track_no_layer` where none
//! is); otherwise track 0 is layer 0, and another is not carried (`track_no_layer`);
//! 4. a layer another track already holds is not carried (`track_layer_taken`).
//!
//! A frame's media time is its message timestamp, extended past 32 bits, in µs, plus the
//! message's `TimestampOffsetNano` in whole µs; a video frame's composition time offset
//! goes with it as `live.cto`.
use super::amf0::Value;
use super::flv::{self, Config, Kind, Unit};
use super::{metadata, Metadata};
use crate::frame::{Codec, Header};
use crate::package::{Emit, Packager, Verdict};
use crate::room::{media, Profile};
use std::collections::{BTreeMap, HashMap};
/// A message timestamp extended past its 32 bits: a step back of more than 2^31 is a wrap.
#[derive(Clone, Copy, Debug, Default)]
pub struct TsExt {
last: Option<u32>,
high: u64,
}
impl TsExt {
pub fn extend(&mut self, ts: u32) -> u64 {
if let Some(l) = self.last
&& ts < l
&& l - ts > 1 << 31
{
self.high += 1 << 32;
}
self.last = Some(ts);
self.high + ts as u64
}
}
enum Binding {
Carried { media: u8, layer: u8, codec: Codec, fourcc: [u8; 4], config: Option<Config>, ts: TsExt },
NotCarried(&'static str),
}
/// One publish's tracks.
pub struct Tracks {
publish_audio: bool,
publish_video: bool,
video_profile: Option<Profile>,
audio_profile: Option<Profile>,
/// The room tracks, by (media kind, layer).
pub packagers: BTreeMap<(u8, u8), Packager>,
bindings: HashMap<(bool, u8), Binding>,
pub meta: Metadata,
/// What was read and not carried, and what was dropped, by reason.
pub counts: BTreeMap<String, u64>,
/// A track's codec or configuration changed: the catalog is published again.
pub catalog_dirty: bool,
/// Frames handed to packagers.
pub frames: u64,
}
/// The room tracks a publish under these powers has: audio layer 0 where it publishes
/// audio; a video track per layer of its video profile, or layer 0 where it names none,
/// where it publishes video.
pub fn layout(publish_audio: bool, publish_video: bool, video_profile: Option<&Profile>) -> Vec<(u8, u8)> {
let mut t = Vec::new();
if publish_audio {
t.push((media::AUDIO, 0));
}
if publish_video {
for l in 0..video_profile.map_or(1, |p| p.layers.len().max(1)) as u8 {
t.push((media::VIDEO, l));
}
}
t
}
fn count(c: &mut BTreeMap<String, u64>, r: &str) {
*c.entry(r.to_string()).or_default() += 1;
}
impl Tracks {
pub fn new(publish_audio: bool, publish_video: bool, video_profile: Option<Profile>, audio_profile: Option<Profile>, packagers: BTreeMap<(u8, u8), Packager>) -> Tracks {
Tracks { publish_audio, publish_video, video_profile, audio_profile, packagers, bindings: HashMap::new(), meta: Metadata::new(), counts: BTreeMap::new(), catalog_dirty: false, frames: 0 }
}
/// A data message's values: an `onMetaData` call replaces what the tracks' entries
/// state.
pub fn on_data(&mut self, values: &[Value]) {
if let Some(md) = metadata(values) {
self.meta = md;
count(&mut self.counts, "metadata");
}
}
/// The (media kind, layer) of every bound track.
pub fn bound(&self) -> Vec<(u8, u8)> {
self.bindings.values().filter_map(|b| if let Binding::Carried { media, layer, .. } = b { Some((*media, *layer)) } else { None }).collect()
}
/// One audio or video message's body, its timestamp, and its arrival on the room
/// clock.
pub fn on_media(&mut self, video: bool, ts: u32, payload: &[u8], arrival: u64, emits: &mut Vec<Emit>) {
let units = match if video { flv::video(payload) } else { flv::audio(payload) } {
Ok(u) => u,
Err(e) => return count(&mut self.counts, e.code()),
};
for u in units {
self.on_unit(u, ts, arrival, emits);
}
}
/// An aggregate message (`[RTMP]` §7.1.6): FLV tags, each an 11-byte header, its body
/// and a 4-byte back pointer; their timestamps are offset to the message's. `false`
/// where a tag runs past the message.
pub fn on_aggregate(&mut self, ts: u32, b: &[u8], arrival: u64, emits: &mut Vec<Emit>) -> bool {
let mut p = 0;
let mut first: Option<u32> = None;
while p + 11 <= b.len() {
let ty = b[p] & 0x1f;
let size = (b[p + 1] as usize) << 16 | (b[p + 2] as usize) << 8 | b[p + 3] as usize;
let sub = (b[p + 7] as u32) << 24 | (b[p + 4] as u32) << 16 | (b[p + 5] as u32) << 8 | b[p + 6] as u32;
let Some(body) = b.get(p + 11..p + 11 + size) else { return false };
let at = ts.wrapping_add(sub.wrapping_sub(*first.get_or_insert(sub)));
match ty {
8 | 9 => self.on_media(ty == 9, at, body, arrival, emits),
18 => {
if let Ok(v) = super::amf0::read_all(body) {
self.on_data(&v);
}
}
_ => {}
}
p += 11 + size + 4;
}
true
}
fn publishes(&self, m: u8) -> bool {
if m == media::AUDIO { self.publish_audio } else { self.publish_video }
}
/// The room track an RTMP track is carried on (the module's rules 1 to 4).
fn bind(&self, u: &Unit) -> Binding {
let Some(codec) = flv::codec_of(u.fourcc) else { return Binding::NotCarried("codec_not_carried") };
let m = if u.video { media::VIDEO } else { media::AUDIO };
if !self.publishes(m) {
return Binding::NotCarried("publish_not_permitted");
}
let profile = if u.video { self.video_profile.as_ref() } else { self.audio_profile.as_ref() };
if let Some(p) = profile
&& !p.families().iter().any(|f| f == codec.family())
{
return Binding::NotCarried("codec_not_permitted");
}
let layer = if !u.video {
if u.track != 0 {
return Binding::NotCarried("audio_track_not_default");
}
0
} else {
let tm = self.meta.get(&(true, u.track)).filter(|t| t.width > 0 && t.height > 0 && t.fps.is_some());
match (profile, tm) {
(Some(p), Some(t)) => {
let fps = t.fps.unwrap().round();
match p.layers.iter().position(|l| l.width as u32 == t.width && l.height as u32 == t.height && l.fps as f64 == fps) {
Some(i) => i as u8,
None => return Binding::NotCarried("track_no_layer"),
}
}
_ if u.track == 0 => 0,
_ => return Binding::NotCarried("track_no_layer"),
}
};
if !self.packagers.contains_key(&(m, layer)) {
return Binding::NotCarried("track_no_layer");
}
if self.bound().contains(&(m, layer)) {
return Binding::NotCarried("track_layer_taken");
}
Binding::Carried { media: m, layer, codec, fourcc: u.fourcc, config: None, ts: TsExt::default() }
}
fn on_unit(&mut self, u: Unit, ts: u32, arrival: u64, emits: &mut Vec<Emit>) {
let k = (u.video, u.track);
if !self.bindings.contains_key(&k) {
let b = self.bind(&u);
self.bindings.insert(k, b);
}
let meta = self.meta.get(&k).cloned().unwrap_or_default();
let (m, layer, codec, fourcc, config, tsx) = match self.bindings.get_mut(&k).unwrap() {
Binding::NotCarried(r) => {
let r = *r;
return count(&mut self.counts, r);
}
Binding::Carried { media, layer, codec, fourcc, config, ts } => (media, layer, codec, fourcc, config, ts),
};
let p = self.packagers.get_mut(&(*m, *layer)).expect("a packager for every bound track");
match u.kind {
Kind::SequenceStart => match flv::config(u.fourcc, &u.data) {
Some(c) => {
let mut h = flv::hint_of(&c);
if u.video {
(h.width, h.height) = (meta.width, meta.height);
} else {
if h.channels == 0 {
h.channels = meta.channels;
}
if h.rate == 0 {
h.rate = meta.rate;
}
}
if let Some(nc) = flv::codec_of(u.fourcc) {
*codec = nc;
*fourcc = u.fourcc;
}
p.hint = Some(h);
*config = Some(c);
self.catalog_dirty = true;
}
None => count(&mut self.counts, "sequence_start_unreadable"),
},
Kind::SequenceEnd => p.end_group(emits),
Kind::Other => count(&mut self.counts, "packet_not_carried"),
Kind::Frames => {
// A coded-frames packet of no bytes, as an encoder sends at the end of a
// stream, holds no frame.
if u.data.is_empty() {
return count(&mut self.counts, "packet_not_carried");
}
if u.fourcc != *fourcc {
return count(&mut self.counts, "codec_changed_without_sequence_start");
}
if p.hint.is_none() {
p.hint = Some(Header { width: meta.width, height: meta.height, channels: meta.channels, rate: meta.rate, ..Header::default() });
}
let payload = match flv::frame(u.fourcc, config.as_ref(), u.key, &u.data) {
Ok(x) => x,
Err(e) => {
let r = match e {
flv::FrameError::NoSequenceStart => "no_sequence_start",
flv::FrameError::Unreadable => "frame_unreadable",
flv::FrameError::NotCarried => "frame_not_carried",
};
return count(&mut self.counts, r);
}
};
let us = tsx.extend(ts) * 1000 + u.nano as u64 / 1000;
let cto = u.video.then_some(u.cts_ms as i64 * 1000);
let (verdict, cat) = p.on_frame_cto(*codec, &payload, us, arrival, u.key, cto, emits);
self.frames += 1;
self.catalog_dirty |= cat;
match verdict {
Verdict::NeedKeyframe => count(&mut self.counts, "dropped_before_keyframe"),
Verdict::TooLarge => count(&mut self.counts, "dropped_too_large"),
Verdict::Unreadable => count(&mut self.counts, "dropped_unreadable"),
Verdict::GroupLong => count(&mut self.counts, "group_long"),
Verdict::Carried => {}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_extend_past_32_bits() {
let mut t = TsExt::default();
assert_eq!(t.extend(0xffff_ff00), 0xffff_ff00);
assert_eq!(t.extend(0x10), (1 << 32) + 0x10);
assert_eq!(t.extend(0x08), (1 << 32) + 0x08, "a small step back is not a wrap");
}
#[test]
fn the_layout_follows_the_profile() {
let p = crate::room::registered_profiles().into_iter().find(|p| p.name == "camera-1080").unwrap();
assert_eq!(layout(true, true, Some(&p)), vec![(1, 0), (2, 0), (2, 1), (2, 2)]);
assert_eq!(layout(false, true, None), vec![(2, 0)]);
}
}

View file

@ -0,0 +1,633 @@
//! SCTE 104 automation into the room's signed cues (WAI `extensions/cue-injection.md`).
//!
//! The gateway is an **injector** (SCTE 104 §9): an automation system opens an API
//! connection to it over TCP (port 5167 by default, Appendix A), initialises it, and sends
//! `multiple_operation_message()`s of splice, time-signal and splice-null requests with
//! their descriptor requests. The injector answers each as §9.6 says, translates it
//! ([`scte104::translate`]) into SCTE 35 sections ([`scte35`]) and cues, and, when each
//! cue's splice instant comes on the room clock, signs it as a `cue` event of the room
//! (live-events §4.3) by its own principal, preceded in its chain by a `meta` event whose
//! payload is the cue's **injection record**, `WIJ1`: the SCTE 104 message, when it
//! arrived, and the parameters it was translated with, so that a verifier recomputes the
//! cue from it. Both events go out on the principal's events track (live-events §8.1).
//!
//! [`Engine`] holds what the injector decides across messages: the cues held for a splice
//! still to come, the ones a later request withdraws, and the room's breaks as its own
//! chain makes them (live-ads §4.3), so that it never signs a cue the room would refuse.
pub mod scte104;
pub mod scte35;
use crate::gateway::Gateway;
use crate::package::Emit;
use crate::relay::{Published, Publisher};
use crate::room::{Grant, Room};
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};
use scte104::{op, result, Cue, Params, Refusal, Single, Translated, Withdraw};
use std::collections::{BTreeMap, HashMap};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
/// The domain of an event's signature (live-events §3.2).
pub const EVENT_DOMAIN: &[u8] = b"wai:live-event\x01";
pub const KIND_META: u8 = 0x01;
pub const KIND_CUE: u8 = 0x02;
/// The injection record's magic and version.
pub const INJECTION_MAGIC: &[u8; 4] = b"WIJ1";
pub const INJECTION_VERSION: u8 = 1;
/// One signed `WEV1` event (live-events §3.1, no anchor): its bytes and its digest.
pub fn event(room: &[u8; 32], key: &SigningKey, seq: u64, prev: &[u8; 32], t: u64, kind: u8, payload: &[u8]) -> (Vec<u8>, [u8; 32]) {
let mut o = Vec::with_capacity(121 + payload.len() + 64);
o.extend_from_slice(b"WEV1");
o.push(1);
o.extend_from_slice(room);
o.extend_from_slice(&key.verifying_key().to_bytes());
o.extend_from_slice(&seq.to_le_bytes());
o.extend_from_slice(prev);
o.extend_from_slice(&t.to_le_bytes());
o.push(0);
o.push(kind);
o.extend_from_slice(&(payload.len() as u16).to_le_bytes());
o.extend_from_slice(payload);
let digest = crate::room::sha256(&[&o]);
let sig = key.sign(&[EVENT_DOMAIN, &o].concat());
o.extend_from_slice(&sig.to_bytes());
(o, digest)
}
/// An injection record (cue-injection §6): the magic, the version, when the message's
/// last byte arrived (room clock µs), the leap seconds and the frame rate it was
/// translated with, the index of the cue that follows among its message's cues, and the
/// message.
pub fn injection(received_us: u64, p: &Params, index: u8, message: &[u8]) -> Vec<u8> {
let mut o = Vec::with_capacity(25 + message.len());
o.extend_from_slice(INJECTION_MAGIC);
o.push(INJECTION_VERSION);
o.extend_from_slice(&received_us.to_le_bytes());
o.push(p.leap_s);
o.extend_from_slice(&p.fps_num.to_le_bytes());
o.extend_from_slice(&p.fps_den.to_le_bytes());
o.push(index);
o.extend_from_slice(&(message.len() as u16).to_le_bytes());
o.extend_from_slice(message);
o
}
/// A cue held for its splice instant.
#[derive(Clone, Debug)]
struct Held {
cue: Cue,
message: Arc<Vec<u8>>,
received_us: u64,
index: u8,
message_number: u8,
/// The request's own withdrawal key: what a later cancel names.
from_segmentation: bool,
}
/// One cue the engine signed, with its injection record before it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Signed {
pub injection: Vec<u8>,
pub cue: Vec<u8>,
pub t: u64,
pub message_number: u8,
}
/// A message whose cues are all signed or dropped: its number, the cues signed, its
/// `AS_index` and `DPI_PID_index`.
pub type Completion = (u8, u8, u8, u16);
/// What the engine decided for a cue it did not sign, counted by reason.
pub type Counts = BTreeMap<&'static str, u64>;
/// The injector's state across messages, on a clock it is given.
pub struct Engine {
pub room: [u8; 32],
key: SigningKey,
pub params: Params,
seq: u64,
prev: [u8; 32],
held: Vec<Held>,
/// The open break: its `cue_id` and its out's instant.
open: Option<(u32, u64)>,
last_end: u64,
last_t: u64,
max_pending: usize,
/// Per message number: the cues held, the cues signed, and the `AS_index` and
/// `DPI_PID_index` its completion echoes.
outstanding: HashMap<u8, (usize, u8, u8, u16)>,
pub counts: Counts,
}
/// What one message did: the response's result and extension, and whether it was a
/// duplicate of a message still held.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Accepted {
pub result: u16,
pub ext: u16,
}
impl Engine {
pub fn new(room: [u8; 32], key: SigningKey, params: Params, max_pending: usize) -> Engine {
Engine { room, key, params, seq: 0, prev: [0; 32], held: Vec::new(), open: None, last_end: 0, last_t: 0, max_pending, outstanding: HashMap::new(), counts: Counts::new() }
}
pub fn principal(&self) -> [u8; 32] {
self.key.verifying_key().to_bytes()
}
/// Take a translated message that arrived at `received_us` (cue-injection §7): its
/// withdrawals first, then its cues, held. A message whose number is still held is a
/// repeat (SCTE 104 §9.3), and changes nothing.
pub fn accept(&mut self, msg: &[u8], tr: &Translated, received_us: u64) -> Accepted {
if self.outstanding.contains_key(&tr.message_number) {
*self.counts.entry("repeated_message").or_default() += 1;
return Accepted { result: result::SUCCESS, ext: 0xffff };
}
let mut res = Accepted { result: tr.warning.unwrap_or(result::SUCCESS), ext: 0xffff };
let cues = tr.cues();
if self.held.len() + cues.len() > self.max_pending {
return Accepted { result: result::UNKNOWN_FAILURE, ext: 0xffff };
}
for r in &tr.requests {
for w in &r.withdraws {
let before = self.held.len();
self.held.retain(|h| match *w {
Withdraw::Splice(id) => !(h.cue.cue_id == id && !h.from_segmentation),
Withdraw::Segmentation(id) => !(h.cue.cue_id == id && h.from_segmentation),
Withdraw::AutoReturn(id) => !(h.cue.cue_id == id && h.cue.derived),
});
let gone = before - self.held.len();
if gone == 0 && !matches!(w, Withdraw::AutoReturn(_)) {
// Nothing held to cancel: the splice came already, or never was.
*self.counts.entry("cancel_too_late").or_default() += 1;
res.result = result::SPLICE_REJECTED;
}
if gone > 0 {
*self.counts.entry("withdrawn").or_default() += gone as u64;
}
}
}
let message = Arc::new(msg.to_vec());
let mut i = 0u8;
let mut n = 0usize;
for r in &tr.requests {
for c in &r.cues {
self.held.push(Held { cue: c.clone(), message: message.clone(), received_us, index: i, message_number: tr.message_number, from_segmentation: r.opid == op::TIME_SIGNAL });
i += 1;
n += 1;
}
}
if n > 0 {
self.outstanding.insert(tr.message_number, (n, 0, tr.as_index, tr.dpi_pid_index));
}
self.recount();
res
}
/// Messages whose held cues were all withdrawn have nothing outstanding.
fn recount(&mut self) {
let mut held: HashMap<u8, usize> = HashMap::new();
for h in &self.held {
*held.entry(h.message_number).or_default() += 1;
}
for (m, o) in self.outstanding.iter_mut() {
o.0 = held.get(m).copied().unwrap_or(0);
}
}
/// Sign every held cue whose instant has come by `now_us`, in order of instant and
/// then of its message's index (cue-injection §7.2). Returns the signed pairs, and the
/// messages now complete: their number, the cues each signed (what
/// `inject_complete_response` reports), their `AS_index` and `DPI_PID_index`.
pub fn due(&mut self, now_us: u64) -> (Vec<Signed>, Vec<Completion>) {
let mut due: Vec<Held> = Vec::new();
self.held.retain(|h| {
if h.cue.t <= now_us {
due.push(h.clone());
false
} else {
true
}
});
due.sort_by_key(|h| (h.cue.t, h.received_us, h.index));
let mut out = Vec::new();
for h in due {
let c = &h.cue;
let keep = match c.kind {
1 => {
if self.open.is_some() || c.t < self.last_end {
Err("break_overlap")
} else {
Ok(Some((c.cue_id, c.t)))
}
}
2 => match self.open {
Some((id, out_t)) if id == c.cue_id => {
if (c.t - out_t) / 1000 == 0 {
Err("break_zero_duration")
} else {
Ok(None)
}
}
_ => Err("cue_in_unmatched"),
},
_ => Ok(self.open.map(|o| (o.0, o.1))),
};
match keep {
Err(r) => *self.counts.entry(r).or_default() += 1,
Ok(open) => {
if c.kind == 2 {
self.last_end = c.t;
}
self.open = if c.kind == 2 { None } else { open };
let t = c.t.max(self.last_t);
self.last_t = t;
let inj = injection(h.received_us, &self.params, h.index, &h.message);
let (a, d1) = event(&self.room, &self.key, self.seq, &self.prev, t, KIND_META, &inj);
let (b, d2) = event(&self.room, &self.key, self.seq + 1, &d1, t, KIND_CUE, &c.payload());
self.seq += 2;
self.prev = d2;
if let Some(o) = self.outstanding.get_mut(&h.message_number) {
o.1 = o.1.saturating_add(1);
}
out.push(Signed { injection: a, cue: b, t, message_number: h.message_number });
}
}
}
self.recount();
let mut done = Vec::new();
self.outstanding.retain(|m, (n, signed, a, d)| {
if *n == 0 {
if *signed > 0 {
done.push((*m, *signed, *a, *d));
}
false
} else {
true
}
});
done.sort();
(out, done)
}
/// The open break's out, if any: what a joining subscriber's first object repeats.
pub fn open_break(&self) -> Option<(u32, u64)> {
self.open
}
}
/// An MSF event timeline record of one event (live-events §8.1): `{"t":…,"data":{"wev1":…}}`.
pub fn timeline_record(t0_us: u64, t: u64, wev1: &[u8]) -> String {
format!("{{\"t\":{},\"data\":{{\"wev1\":\"{}\"}}}}", (t0_us + t) / 1000, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(wev1))
}
// ── The network side ──────────────────────────────────────────────────────────────
/// The injector, listening.
pub struct Injector {
gw: Arc<Gateway>,
room: Arc<Room>,
pub engine: Mutex<Engine>,
/// The writer of the API connection that is active, if one is.
active: Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>,
/// Every event signed so far, in chain order.
signed: Mutex<Vec<Vec<u8>>>,
events: tokio::sync::Mutex<EventsTrack>,
/// The largest payload the principal's policy lets one event carry (live-events §5).
max_payload: usize,
stopped: AtomicBool,
tasks: Mutex<Vec<tokio::task::JoinHandle<()>>>,
}
/// The principal's events track at the relay: published at its first event; one group a
/// minute of room clock, whose first object is the join set (live-events §8.2).
struct EventsTrack {
publisher: Option<Publisher>,
group: Option<u64>,
object: u64,
queue: Vec<(u64, Vec<u8>)>,
/// The out of the break open after the last event published: what a group's first
/// object repeats (live-events §8.2).
open_out: Option<Vec<u8>>,
}
/// A cue event's (type, cue_id), read from its record (no anchor: the kind at 118, the
/// payload from 121).
fn cue_of(e: &[u8]) -> Option<(u8, u32)> {
(e.get(118) == Some(&KIND_CUE)).then_some(())?;
Some((*e.get(121)?, u32::from_le_bytes(e.get(122..126)?.try_into().ok()?)))
}
const EVENTS_ALIAS: u64 = 1;
impl Injector {
/// Start the injector where `[injector] listen` is set: its key and grant read and
/// checked against its room (the grant names the key's principal and permits `cue`
/// and `meta`, and is good now).
pub async fn start(gw: Arc<Gateway>) -> Result<Option<(Arc<Injector>, SocketAddr)>, String> {
let c = gw.cfg.injector.clone();
let Some(listen) = c.listen else { return Ok(None) };
let room_id: [u8; 32] = hex::decode(&c.room).ok().and_then(|b| b.try_into().ok()).ok_or("injector.room: a room id of 64 hexadecimal digits")?;
let room = gw.rooms.get(&room_id).cloned().ok_or("injector.room: a room the gateway does not hold")?;
let seed = match (&c.key_seed, &c.key_path) {
(Some(s), _) => *s,
(None, Some(p)) => std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?.get(..32).and_then(|s| s.try_into().ok()).ok_or("injector.key_path: not 32 bytes")?,
_ => return Err("injector: no key".into()),
};
let key = SigningKey::from_bytes(&seed);
let grant_bytes = match (&c.grant, &c.grant_path) {
(Some(g), _) => g.clone(),
(None, Some(p)) => std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?,
_ => return Err("injector: no grant".into()),
};
let (grant, _): (Grant, _) = Grant::read(&grant_bytes, &room.descriptor, &room.id).map_err(|r| format!("injector: the grant is refused: {}", r.code()))?;
if grant.principal != key.verifying_key().to_bytes() {
return Err("injector: the grant names another principal".into());
}
if !grant.policy.permits(KIND_CUE) || !grant.policy.permits(KIND_META) {
return Err("injector: the grant does not permit cue and meta".into());
}
if !grant.good_at(gw.clock.unix_us()) {
return Err("injector: the grant is not good now".into());
}
if c.fps_num == 0 || c.fps_den == 0 {
return Err("injector: a frame rate of zero".into());
}
let params = Params { t0_us: room.descriptor.t0_us, leap_s: c.leap_seconds, fps_num: c.fps_num, fps_den: c.fps_den };
let engine = Engine::new(room.id, key, params, c.max_pending);
let listener = tokio::net::TcpListener::bind(listen).await.map_err(|e| format!("{listen}: {e}"))?;
let addr = listener.local_addr().map_err(|e| e.to_string())?;
let inj = Arc::new(Injector {
gw,
room,
engine: Mutex::new(engine),
active: Mutex::new(None),
signed: Mutex::new(Vec::new()),
events: tokio::sync::Mutex::new(EventsTrack { publisher: None, group: None, object: 0, queue: Vec::new(), open_out: None }),
max_payload: grant.policy.max_payload as usize,
stopped: AtomicBool::new(false),
tasks: Mutex::new(Vec::new()),
});
let a = inj.clone();
let accept = tokio::spawn(async move {
loop {
let Ok((s, _)) = listener.accept().await else { continue };
let _ = s.set_nodelay(true);
let a = a.clone();
tokio::spawn(async move { a.connection(s).await });
}
});
let t = inj.clone();
let tick = tokio::spawn(async move {
let mut iv = tokio::time::interval(Duration::from_millis(10));
loop {
iv.tick().await;
if t.stopped.load(Ordering::Relaxed) {
return;
}
t.flush().await;
}
});
inj.tasks.lock().unwrap().extend([accept, tick]);
{
let mut ev = inj.events.lock().await;
if !inj.ensure_track(&mut ev).await {
tracing::warn!("the injector's events track is not published yet: the relay was not reached");
}
}
Ok(Some((inj, addr)))
}
fn room_now(&self) -> u64 {
self.gw.clock.unix_us().saturating_sub(self.room.descriptor.t0_us)
}
/// Every event the injector has signed, in chain order.
pub fn events(&self) -> Vec<Vec<u8>> {
self.signed.lock().unwrap().clone()
}
pub fn counts(&self) -> Counts {
self.engine.lock().unwrap().counts.clone()
}
pub fn principal(&self) -> [u8; 32] {
self.engine.lock().unwrap().principal()
}
/// Sign what is due, publish it, and tell the automation system which messages are
/// complete.
async fn flush(&self) {
// The instant is taken under the engine's lock, as a message's arrival is, so a
// message is never taken before an instant already signed at.
let (signed, done) = {
let mut e = self.engine.lock().unwrap();
let now = self.room_now();
e.due(now)
};
if !signed.is_empty() {
let mut all = self.signed.lock().unwrap();
for s in &signed {
all.push(s.injection.clone());
all.push(s.cue.clone());
}
}
{
let mut ev = self.events.lock().await;
for s in &signed {
ev.queue.push((s.t, s.injection.clone()));
ev.queue.push((s.t, s.cue.clone()));
}
self.publish(&mut ev).await;
}
if !done.is_empty()
&& let Some(tx) = self.active.lock().unwrap().as_ref()
{
for (m, n, a, d) in done {
let r = Single::response(op::INJECT_COMPLETE_RESPONSE, result::SUCCESS, 0xffff, a, m, d, vec![m, n]);
let _ = tx.send(r.to_bytes());
}
}
}
/// Write the queued events to the events track, opening it at the relay first.
/// The events track published at the relay: at start, so a member can subscribe
/// before the first cue, and again whenever the relay was not reached.
async fn ensure_track(&self, ev: &mut EventsTrack) -> bool {
if ev.publisher.as_ref().is_some_and(|p| !p.failed.load(Ordering::Relaxed)) {
return true;
}
ev.publisher = None;
ev.group = None;
let ns = joule_moq::TrackNamespace::from_strs([hex::encode(self.room.id).as_str(), hex::encode(self.principal()).as_str(), "events"]);
let Ok((client, endpoint)) = self.gw.link.connect().await else { return false };
let tracks = vec![Published { ns, name: joule_moq::TrackName::from_str("0"), alias: EVENTS_ALIAS, priority: 0 }];
match Publisher::start(client, endpoint, tracks, self.gw.cfg.limits.max_queue_bytes).await {
Ok(p) => {
ev.publisher = Some(p);
true
}
Err(_) => false,
}
}
async fn publish(&self, ev: &mut EventsTrack) {
if ev.queue.is_empty() || !self.ensure_track(ev).await {
return;
}
let t0 = self.room.descriptor.t0_us;
let p = ev.publisher.as_ref().unwrap();
for (t, bytes) in std::mem::take(&mut ev.queue) {
let g = t / 60_000_000;
if ev.group != Some(g) {
if let Some(old) = ev.group {
let _ = p.send(Emit::EndGroup { alias: EVENTS_ALIAS, group: old });
}
// The join set at the group's start: the open break's out, if one is open.
let join: Vec<String> = ev.open_out.iter().map(|o| timeline_record(t0, u64::from_le_bytes(o[109..117].try_into().unwrap()), o)).collect();
let _ = p.send(Emit::Object { alias: EVENTS_ALIAS, group: g, object: 0, first: true, priority: 0, bytes: format!("[{}]", join.join(",")).into_bytes() });
ev.group = Some(g);
ev.object = 1;
}
let rec = format!("[{}]", timeline_record(t0, t, &bytes));
let _ = p.send(Emit::Object { alias: EVENTS_ALIAS, group: g, object: ev.object, first: false, priority: 0, bytes: rec.into_bytes() });
ev.object += 1;
match cue_of(&bytes) {
Some((1, _)) => ev.open_out = Some(bytes),
Some((2, id)) if ev.open_out.as_deref().and_then(cue_of).is_some_and(|(_, o)| o == id) => ev.open_out = None,
_ => {}
}
}
}
/// End the events track and stop listening.
pub async fn stop(&self) {
self.stopped.store(true, Ordering::Relaxed);
self.flush().await;
let mut ev = self.events.lock().await;
if let Some(p) = ev.publisher.take() {
if let Some(g) = ev.group {
let _ = p.send(Emit::EndGroup { alias: EVENTS_ALIAS, group: g });
}
p.end(Duration::from_millis(250)).await;
}
for t in self.tasks.lock().unwrap().drain(..) {
t.abort();
}
}
/// One API connection (SCTE 104 §9.1): `init_request` first; one active automation
/// system at a time, a second answered `110`.
async fn connection(self: Arc<Self>, s: tokio::net::TcpStream) {
let (mut rd, mut wr) = s.into_split();
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
let writer = tokio::spawn(async move {
while let Some(b) = rx.recv().await {
if wr.write_all(&b).await.is_err() {
return;
}
}
});
let mut initialised = false;
loop {
let mut head = [0u8; 4];
if rd.read_exact(&mut head).await.is_err() {
break;
}
let size = u16::from_be_bytes([head[2], head[3]]) as usize;
if size < 12 {
break;
}
let mut msg = head.to_vec();
msg.resize(size, 0);
if rd.read_exact(&mut msg[4..]).await.is_err() {
break;
}
self.on_message(&msg, &mut initialised, &tx);
}
{
let mut a = self.active.lock().unwrap();
if a.as_ref().is_some_and(|x| x.same_channel(&tx)) {
*a = None;
}
}
drop(tx);
let _ = writer.await;
}
/// One message, answered on `tx`. A request's response goes out while the engine is
/// held, so its completion, which the clock's task sends, never precedes it.
fn on_message(&self, msg: &[u8], initialised: &mut bool, tx: &mpsc::UnboundedSender<Vec<u8>>) {
for r in self.answer(msg, initialised, tx) {
let _ = tx.send(r);
}
}
fn answer(&self, msg: &[u8], initialised: &mut bool, tx: &mpsc::UnboundedSender<Vec<u8>>) -> Vec<Vec<u8>> {
let opid = u16::from_be_bytes([msg[0], msg[1]]);
let dpi_ok = |d: u16| self.gw.cfg.injector.dpi_pid_index.is_empty() || self.gw.cfg.injector.dpi_pid_index.contains(&d);
if opid != 0xffff {
let s = match Single::read(msg) {
Ok(s) => s,
Err(r) => return vec![Single::response(op::GENERAL_RESPONSE, r.result, opid, 0, 0, 0, Vec::new()).to_bytes()],
};
let reply = |op_: u16, res: u16, data: Vec<u8>| Single::response(op_, res, 0xffff, s.as_index, s.message_number, s.dpi_pid_index, data).to_bytes();
return match s.opid {
op::INIT_REQUEST => {
let mut a = self.active.lock().unwrap();
if a.as_ref().is_some_and(|x| !x.same_channel(tx) && !x.is_closed()) {
return vec![reply(op::INIT_RESPONSE, result::IN_USE, Vec::new())];
}
*a = Some(tx.clone());
*initialised = true;
vec![reply(op::INIT_RESPONSE, result::SUCCESS, Vec::new())]
}
op::ALIVE_REQUEST => {
let res = if *initialised { result::SUCCESS } else { result::UNKNOWN_FAILURE };
vec![reply(op::ALIVE_RESPONSE, res, scte104::time_of(self.gw.clock.unix_us(), self.gw.cfg.injector.leap_seconds).to_vec())]
}
// Responses and the legacy user-defined values are not answered.
op::GENERAL_RESPONSE | 0x0005 | 0x0006 => Vec::new(),
o => vec![Single::response(op::GENERAL_RESPONSE, result::UNKNOWN_OPID, o, s.as_index, s.message_number, s.dpi_pid_index, Vec::new()).to_bytes()],
};
}
let (as_index, message_number, dpi) = scte104::multiple_header(msg);
let respond = |res: u16, ext: u16| vec![Single::response(op::INJECT_RESPONSE, res, ext, as_index, message_number, dpi, vec![message_number]).to_bytes()];
if !*initialised {
return respond(result::UNKNOWN_FAILURE, 0xffff);
}
if !dpi_ok(dpi) {
return respond(result::UNKNOWN_DPI, 0xffff);
}
let mut e = self.engine.lock().unwrap();
let received = self.room_now();
let params = e.params;
let tr = match scte104::translate(msg, received, &params) {
Ok(t) => t,
Err(Refusal { result, ext, reason }) => {
*e.counts.entry(reason).or_default() += 1;
return respond(result, ext);
}
};
// Each signed event must fit its principal's policy (live-events §5).
let max = self.max_payload;
let too_large = tr.cues().iter().any(|c| c.payload().len() > max) || injection(received, &params, 0, msg).len() > max;
if too_large {
*e.counts.entry("injection_too_large").or_default() += 1;
return respond(result::MESSAGE_SYNTAX, 0xffff);
}
let a = e.accept(msg, &tr, received);
for r in respond(a.result, a.ext) {
let _ = tx.send(r);
}
Vec::new()
}
}

View file

@ -0,0 +1,702 @@
//! SCTE 104 messages (ANSI/SCTE 104 2023 §8, §9, §12, §14) and their translation into
//! SCTE 35 sections and the room's cues (WAI `extensions/cue-injection.md` §4 to §6).
//!
//! [`translate`] is a pure function of a `multiple_operation_message()`, the room-clock
//! instant its last byte arrived, and the injector's parameters: the room's `t0`, the
//! GPS − UTC leap seconds and the frame rate. It gives, for each Normal request, the SCTE 35
//! section it builds and the cues it yields, in order: the list a cue's injection record
//! indexes. What it refuses carries the SCTE 104 result code of Table 14-1 and the reason
//! code of cue-injection §9.
//!
//! The program's 90 kHz clock is the room clock: a splice instant `S` µs since the room's
//! `t0` is `pts_time` ⌊`S` · 9 / 100⌋ modulo 2^33, `pts_adjustment` 0.
use super::scte35::{self, SpliceInsert, CUEI};
/// opID values (Tables 8-3 and 8-4).
pub mod op {
pub const GENERAL_RESPONSE: u16 = 0x0000;
pub const INIT_REQUEST: u16 = 0x0001;
pub const INIT_RESPONSE: u16 = 0x0002;
pub const ALIVE_REQUEST: u16 = 0x0003;
pub const ALIVE_RESPONSE: u16 = 0x0004;
pub const INJECT_RESPONSE: u16 = 0x0007;
pub const INJECT_COMPLETE_RESPONSE: u16 = 0x0008;
pub const INJECT_SECTION_DATA: u16 = 0x0100;
pub const SPLICE: u16 = 0x0101;
pub const SPLICE_NULL: u16 = 0x0102;
pub const TIME_SIGNAL: u16 = 0x0104;
pub const INSERT_DESCRIPTOR: u16 = 0x0108;
pub const INSERT_DTMF: u16 = 0x0109;
pub const INSERT_AVAIL: u16 = 0x010A;
pub const INSERT_SEGMENTATION: u16 = 0x010B;
pub const INSERT_TIER: u16 = 0x010F;
pub const INSERT_TIME: u16 = 0x0110;
}
/// Result codes (Table 14-1).
pub mod result {
pub const SUCCESS: u16 = 100;
pub const IN_USE: u16 = 110;
pub const MESSAGE_SIZE: u16 = 114;
pub const MESSAGE_SYNTAX: u16 = 115;
pub const SPLICE_REJECTED: u16 = 121;
pub const PREROLL_TOO_SMALL: u16 = 122;
pub const TIME_TYPE: u16 = 123;
pub const UNKNOWN_FAILURE: u16 = 124;
pub const UNKNOWN_OPID: u16 = 125;
pub const UNKNOWN_DPI: u16 = 126;
pub const VERSION: u16 = 127;
}
/// The seconds from 1970-01-01T00:00:00Z to 1980-01-06T00:00:00Z, where SCTE 104's
/// `time()` and `timestamp()` count from (§12.4.1, §12.5.1).
pub const EPOCH_1980_UNIX_S: u64 = 315_964_800;
/// The smallest non-zero pre-roll of a `spliceStart_normal` (§12.3).
pub const MIN_PREROLL_MS: u16 = 4_000;
/// What a translation is made with (cue-injection §5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Params {
pub t0_us: u64,
pub leap_s: u8,
pub fps_num: u32,
pub fps_den: u32,
}
/// A refusal: the result code the response carries, its `result_extension` (the opID at
/// fault, or 0xFFFF), and the reason.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Refusal {
pub result: u16,
pub ext: u16,
pub reason: &'static str,
}
fn refuse(result: u16, reason: &'static str) -> Refusal {
Refusal { result, ext: 0xffff, reason }
}
/// A cue a request yields (live-events §4.3): its kind, `cue_id`, `duration_ms`, its
/// room-clock instant, and the section it carries (form 1), if any.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cue {
pub t: u64,
/// 1 out, 2 in, 3 chapter.
pub kind: u8,
pub cue_id: u32,
pub duration_ms: u32,
pub section: Option<Vec<u8>>,
/// The in an out with `auto_return` implies, at the end of its break duration.
pub derived: bool,
}
impl Cue {
/// The `cue` payload (live-events §4.3): `type`, `cue_id`, `duration_ms`, `form`,
/// and, for form 1, `scte_len` and the section.
pub fn payload(&self) -> Vec<u8> {
let mut o = vec![self.kind];
o.extend_from_slice(&self.cue_id.to_le_bytes());
o.extend_from_slice(&self.duration_ms.to_le_bytes());
match &self.section {
None => o.push(0),
Some(s) => {
o.push(1);
o.extend_from_slice(&(s.len() as u16).to_le_bytes());
o.extend_from_slice(s);
}
}
o
}
}
/// A pending cue a request withdraws: by `splice_event_id` (a `splice_cancel`), by
/// `segmentation_event_id` (a cancelled segmentation descriptor), or the auto-return in
/// of a break an explicit in ends early.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Withdraw {
Splice(u32),
Segmentation(u32),
AutoReturn(u32),
}
/// One Normal request, translated.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub opid: u16,
/// The splice instant, room clock µs.
pub splice_us: u64,
/// The section it builds; none for a `splice_cancel`.
pub section: Option<Vec<u8>>,
pub cues: Vec<Cue>,
pub withdraws: Vec<Withdraw>,
}
/// A `multiple_operation_message()`, translated.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Translated {
pub as_index: u8,
pub message_number: u8,
pub dpi_pid_index: u16,
/// When its requests are processed, room clock µs (§8.2.3.1).
pub processed_us: u64,
pub requests: Vec<Request>,
/// 122 where a `spliceStart_normal`'s pre-roll was non-zero and below 4 000 ms: the
/// sections are issued, and the response says so.
pub warning: Option<u16>,
}
impl Translated {
/// Every cue of every request, in order: what an injection record's `index` counts.
pub fn cues(&self) -> Vec<&Cue> {
self.requests.iter().flat_map(|r| r.cues.iter()).collect()
}
}
struct Rd<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Rd<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], Refusal> {
let s = self.b.get(self.p..self.p + n).ok_or(refuse(result::MESSAGE_SIZE, "message_size"))?;
self.p += n;
Ok(s)
}
fn u8(&mut self) -> Result<u8, Refusal> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, Refusal> {
Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap()))
}
fn u32(&mut self) -> Result<u32, Refusal> {
Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap()))
}
fn left(&self) -> usize {
self.b.len() - self.p
}
}
/// The header of a `single_operation_message()` (Table 8-1), and its data.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Single {
pub opid: u16,
pub result: u16,
pub result_extension: u16,
pub protocol_version: u8,
pub as_index: u8,
pub message_number: u8,
pub dpi_pid_index: u16,
pub data: Vec<u8>,
}
impl Single {
pub fn read(b: &[u8]) -> Result<Single, Refusal> {
let mut r = Rd { b, p: 0 };
let opid = r.u16()?;
let size = r.u16()? as usize;
if size != b.len() || size < 13 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
Ok(Single {
opid,
result: r.u16()?,
result_extension: r.u16()?,
protocol_version: r.u8()?,
as_index: r.u8()?,
message_number: r.u8()?,
dpi_pid_index: r.u16()?,
data: r.take(r.left())?.to_vec(),
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut o = self.opid.to_be_bytes().to_vec();
o.extend_from_slice(&((13 + self.data.len()) as u16).to_be_bytes());
o.extend_from_slice(&self.result.to_be_bytes());
o.extend_from_slice(&self.result_extension.to_be_bytes());
o.push(self.protocol_version);
o.push(self.as_index);
o.push(self.message_number);
o.extend_from_slice(&self.dpi_pid_index.to_be_bytes());
o.extend_from_slice(&self.data);
o
}
/// A response to a request: `opid`, `result`, its extension, protocol version 0, the
/// request's `AS_index`, `message_number` and `DPI_PID_index`.
pub fn response(opid: u16, result: u16, ext: u16, as_index: u8, message_number: u8, dpi: u16, data: Vec<u8>) -> Single {
Single { opid, result, result_extension: ext, protocol_version: 0, as_index, message_number, dpi_pid_index: dpi, data }
}
}
/// The header fields of a `multiple_operation_message()` that a response echoes, read
/// as far as they are present.
pub fn multiple_header(b: &[u8]) -> (u8, u8, u16) {
(b.get(5).copied().unwrap_or(0), b.get(6).copied().unwrap_or(0), b.get(7..9).map_or(0, |d| u16::from_be_bytes([d[0], d[1]])))
}
/// A `time()` (Table 12-1) for a Unix instant in µs.
pub fn time_of(unix_us: u64, leap_s: u8) -> [u8; 8] {
let s = (unix_us / 1_000_000).saturating_sub(EPOCH_1980_UNIX_S) + leap_s as u64;
let mut o = [0u8; 8];
o[..4].copy_from_slice(&(s as u32).to_be_bytes());
o[4..].copy_from_slice(&((unix_us % 1_000_000) as u32).to_be_bytes());
o
}
/// The 90 kHz `pts_time` of a room-clock instant (the module's rule).
pub fn pts_of(room_us: u64) -> u64 {
((room_us as u128 * 9 / 100) as u64) & ((1 << 33) - 1)
}
/// Segmentation types that open a break: Break Start, Provider and Distributor
/// Advertisement Start, Provider and Distributor Placement Opportunity Start (SCTE 35-1
/// Table 23).
pub const BREAK_STARTS: [u8; 5] = [0x22, 0x30, 0x32, 0x34, 0x36];
/// And their ends.
pub const BREAK_ENDS: [u8; 5] = [0x23, 0x31, 0x33, 0x35, 0x37];
/// Chapter Start.
pub const CHAPTER_START: u8 = 0x20;
/// The types whose descriptor may carry `sub_segment_num` and `sub_segments_expected`.
pub const SUB_SEGMENT_TYPES: [u8; 8] = [0x30, 0x32, 0x34, 0x36, 0x38, 0x3a, 0x44, 0x46];
/// One `insert_segmentation_descriptor_request_data()` (Table 9-29), read.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Segmentation {
event_id: u32,
cancel: bool,
type_id: u8,
/// The segment's duration in 90 kHz ticks, where it states one.
ticks: Option<u64>,
descriptor: Vec<u8>,
}
fn flag(b: u8) -> Result<bool, Refusal> {
match b {
0 => Ok(false),
1 => Ok(true),
_ => Err(refuse(result::MESSAGE_SYNTAX, "field_range")),
}
}
fn segmentation(d: &[u8], p: &Params) -> Result<Segmentation, Refusal> {
let mut r = Rd { b: d, p: 0 };
let event_id = r.u32()?;
let cancel = flag(r.u8()?)?;
let duration = r.u16()?;
let upid_type = r.u8()?;
let upid_len = r.u8()? as usize;
let upid = r.take(upid_len)?;
let type_id = r.u8()?;
let segment_num = r.u8()?;
let segments_expected = r.u8()?;
// The fields after segments_expected came in later editions; a request ends after
// segments_expected, after duration_extension_frames, after device_restrictions, or
// after sub_segments_expected (§9.8.7.1: the data_length tells which).
let rest = r.left();
let (ext, flags, sub) = match rest {
0 => (0, None, None),
1 => (r.u8()?, None, None),
6 | 9 => {
let ext = r.u8()?;
let f = [r.u8()?, r.u8()?, r.u8()?, r.u8()?, r.u8()?];
let sub = if rest == 9 { Some([r.u8()?, r.u8()?, r.u8()?]) } else { None };
(ext, Some(f), sub)
}
_ => return Err(refuse(result::MESSAGE_SIZE, "message_size")),
};
let mut o = CUEI.to_vec();
o.extend_from_slice(&event_id.to_be_bytes());
if cancel {
o.push(0xff); // cancel 1, compliance 1, reserved
return Ok(Segmentation { event_id, cancel, type_id, ticks: None, descriptor: scte35::descriptor(0x02, &o) });
}
o.push(0x7f); // cancel 0, segmentation_event_id_compliance_indicator 1, reserved
let (dnr, web, nrb, archive, device) = match flags {
None => (true, false, false, false, 0),
Some([a, b, c, d, e]) => {
if e > 3 {
return Err(refuse(result::MESSAGE_SYNTAX, "field_range"));
}
(flag(a)?, flag(b)?, flag(c)?, flag(d)?, e)
}
};
let ticks = (duration > 0).then(|| duration as u64 * 90_000 + (ext as u64 * 90_000 * p.fps_den as u64) / p.fps_num.max(1) as u64);
let mut b = 1 << 7 | (ticks.is_some() as u8) << 6 | (dnr as u8) << 5;
b |= if dnr { 0x1f } else { (web as u8) << 4 | (nrb as u8) << 3 | (archive as u8) << 2 | device };
o.push(b);
if let Some(t) = ticks {
o.extend_from_slice(&t.to_be_bytes()[3..]);
}
o.push(upid_type);
o.push(upid_len as u8);
o.extend_from_slice(upid);
o.push(type_id);
o.push(segment_num);
o.push(segments_expected);
if let Some([insert, n, expected]) = sub
&& flag(insert)?
{
if !SUB_SEGMENT_TYPES.contains(&type_id) {
return Err(refuse(result::MESSAGE_SYNTAX, "field_range"));
}
o.push(n);
o.push(expected);
}
if o.len() > 254 {
return Err(refuse(result::MESSAGE_SYNTAX, "descriptor_too_long"));
}
Ok(Segmentation { event_id, cancel, type_id, ticks, descriptor: scte35::descriptor(0x02, &o) })
}
/// A Normal request and the Supplemental requests after it.
struct Group<'a> {
opid: u16,
data: &'a [u8],
supplements: Vec<(u16, &'a [u8])>,
}
fn is_supplemental(opid: u16) -> bool {
matches!(opid, op::INSERT_DESCRIPTOR | op::INSERT_DTMF | op::INSERT_AVAIL | op::INSERT_SEGMENTATION | op::INSERT_TIER | op::INSERT_TIME)
}
/// Requests the text defines that this injector does not implement (cue-injection §4.4).
fn is_unimplemented(opid: u16) -> bool {
matches!(opid, 0x0100 | 0x0103 | 0x0105..=0x0107 | 0x010C..=0x010E | 0x0111..=0x0113 | 0x0300 | 0x0301)
}
/// Translate one `multiple_operation_message()` that arrived whole at room-clock
/// `received_us` (cue-injection §4, §5).
pub fn translate(msg: &[u8], received_us: u64, p: &Params) -> Result<Translated, Refusal> {
let mut r = Rd { b: msg, p: 0 };
if r.u16()? != 0xffff {
return Err(refuse(result::MESSAGE_SYNTAX, "not_multiple"));
}
if r.u16()? as usize != msg.len() {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let protocol_version = r.u8()?;
let as_index = r.u8()?;
let message_number = r.u8()?;
let dpi_pid_index = r.u16()?;
let scte35_version = r.u8()?;
if protocol_version != 0 || scte35_version != 0 {
return Err(refuse(result::VERSION, "version_unsupported"));
}
let processed_us = match r.u8()? {
0 => received_us,
1 => {
let s = r.u32()? as u64;
let us = (r.u16()? as u64) << 8;
let unix_us = (s + EPOCH_1980_UNIX_S).saturating_sub(p.leap_s as u64) * 1_000_000 + us;
if unix_us < p.t0_us {
return Err(refuse(result::MESSAGE_SYNTAX, "timestamp_before_room"));
}
// A request whose time has passed is processed as soon as it arrives.
(unix_us - p.t0_us).max(received_us)
}
_ => return Err(refuse(result::TIME_TYPE, "time_type_unsupported")),
};
let num_ops = r.u8()?;
if num_ops == 0 {
return Err(refuse(result::MESSAGE_SYNTAX, "no_operation"));
}
let mut ops = Vec::new();
for _ in 0..num_ops {
let opid = r.u16()?;
let n = r.u16()? as usize;
ops.push((opid, r.take(n)?));
}
if r.left() != 0 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let mut groups: Vec<Group> = Vec::new();
for (opid, data) in ops {
if matches!(opid, op::SPLICE | op::SPLICE_NULL | op::TIME_SIGNAL) {
groups.push(Group { opid, data, supplements: Vec::new() });
} else if is_supplemental(opid) {
match groups.last_mut() {
Some(g) => g.supplements.push((opid, data)),
None => return Err(refuse(result::MESSAGE_SYNTAX, "supplemental_first")),
}
} else if is_unimplemented(opid) {
return Err(Refusal { result: result::UNKNOWN_FAILURE, ext: opid, reason: "operation_unsupported" });
} else {
return Err(Refusal { result: result::UNKNOWN_OPID, ext: opid, reason: "opid_unknown" });
}
}
let mut requests = Vec::new();
let mut warning = None;
for g in &groups {
let (req, w) = request(g, processed_us, p)?;
warning = warning.or(w);
requests.push(req);
}
if requests.iter().map(|r| r.cues.len()).sum::<usize>() > 255 {
return Err(refuse(result::MESSAGE_SYNTAX, "too_many_cues"));
}
Ok(Translated { as_index, message_number, dpi_pid_index, processed_us, requests, warning })
}
/// The descriptors of a group's Supplemental requests, in order, its tier, and its
/// segmentation requests.
type Supplements = (Vec<Vec<u8>>, u16, Vec<Segmentation>);
fn supplements(g: &Group, p: &Params) -> Result<Supplements, Refusal> {
let mut descriptors = Vec::new();
let mut tier = None;
let mut segs = Vec::new();
for &(opid, d) in &g.supplements {
match opid {
op::INSERT_AVAIL => {
let n = *d.first().ok_or(refuse(result::MESSAGE_SIZE, "message_size"))? as usize;
if d.len() != 1 + 4 * n {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
for id in d[1..].chunks(4) {
let mut b = CUEI.to_vec();
b.extend_from_slice(id);
descriptors.push(scte35::descriptor(0x00, &b));
}
}
op::INSERT_DTMF => {
if d.len() < 2 || d.len() != 2 + d[1] as usize {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
if d[1] > 7 {
return Err(refuse(result::MESSAGE_SYNTAX, "field_range"));
}
let mut b = CUEI.to_vec();
b.push(d[0]);
b.push(d[1] << 5 | 0x1f);
b.extend_from_slice(&d[2..]);
descriptors.push(scte35::descriptor(0x01, &b));
}
op::INSERT_TIME => {
if d.len() != 12 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let mut b = CUEI.to_vec();
b.extend_from_slice(d);
descriptors.push(scte35::descriptor(0x03, &b));
}
op::INSERT_DESCRIPTOR => {
let n = *d.first().ok_or(refuse(result::MESSAGE_SIZE, "message_size"))?;
let mut q = 1;
for _ in 0..n {
let len = *d.get(q + 1).ok_or(refuse(result::MESSAGE_SIZE, "message_size"))? as usize;
let img = d.get(q..q + 2 + len).ok_or(refuse(result::MESSAGE_SIZE, "message_size"))?;
// A descriptor SCTE 35 defines comes through its own request
// (§9.8.5.1), so the injector knows what it says.
if img[0] <= 0x04 && img.get(2..6) == Some(&CUEI[..]) {
return Err(refuse(result::MESSAGE_SYNTAX, "descriptor_image_standard"));
}
descriptors.push(img.to_vec());
q += 2 + len;
}
if q != d.len() {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
}
op::INSERT_TIER => {
if d.len() != 2 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let t = u16::from_be_bytes([d[0], d[1]]);
if t >> 12 != 0 || tier.is_some() {
return Err(refuse(result::MESSAGE_SYNTAX, "field_range"));
}
tier = Some(t);
}
op::INSERT_SEGMENTATION => {
let s = segmentation(d, p)?;
descriptors.push(s.descriptor.clone());
segs.push(s);
}
_ => unreachable!("a supplemental request"),
}
}
Ok((descriptors, tier.unwrap_or(0xfff), segs))
}
fn request(g: &Group, processed: u64, p: &Params) -> Result<(Request, Option<u16>), Refusal> {
let (descriptors, tier, segs) = supplements(g, p)?;
let build = |ty: u8, cmd: &[u8]| scte35::section(0, tier, ty, cmd, &descriptors).ok_or(refuse(result::MESSAGE_SYNTAX, "section_too_long"));
let mut warning = None;
match g.opid {
op::SPLICE => {
let d = g.data;
if d.len() != 14 && d.len() != 15 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let ty = d[0];
let event_id = u32::from_be_bytes(d[1..5].try_into().unwrap());
let upid = u16::from_be_bytes([d[5], d[6]]);
let preroll = u16::from_be_bytes([d[7], d[8]]);
let tenths = u16::from_be_bytes([d[9], d[10]]);
let (avail_num, avails_expected, auto) = (d[11], d[12], d[13] != 0);
if ty == 5 {
let req = Request { opid: g.opid, splice_us: processed, section: None, cues: Vec::new(), withdraws: vec![Withdraw::Splice(event_id)] };
return Ok((req, None));
}
if !(1..=4).contains(&ty) {
return Err(refuse(result::SPLICE_REJECTED, "splice_type"));
}
let start = ty <= 2;
let normal = ty == 1 || ty == 3;
let delay = if normal { preroll } else { 0 };
if ty == 1 && delay > 0 && delay < MIN_PREROLL_MS {
warning = Some(result::PREROLL_TOO_SMALL);
}
let splice = processed + delay as u64 * 1000;
let duration = (start && tenths > 0).then_some((auto, tenths as u64 * 9000));
let cmd = scte35::splice_insert(&SpliceInsert {
event_id,
out_of_network: start,
pts: (delay > 0).then(|| pts_of(splice)),
duration,
unique_program_id: upid,
avail_num,
avails_expected,
});
let section = build(scte35::SPLICE_INSERT, &cmd)?;
let dur_ms = if start { tenths as u32 * 100 } else { 0 };
let mut cues = vec![Cue { t: splice, kind: if start { 1 } else { 2 }, cue_id: event_id, duration_ms: dur_ms, section: Some(section.clone()), derived: false }];
let mut withdraws = Vec::new();
if start && auto && tenths > 0 {
cues.push(Cue { t: splice + tenths as u64 * 100_000, kind: 2, cue_id: event_id, duration_ms: dur_ms, section: None, derived: true });
}
if !start {
withdraws.push(Withdraw::AutoReturn(event_id));
}
Ok((Request { opid: g.opid, splice_us: splice, section: Some(section), cues, withdraws }, warning))
}
op::TIME_SIGNAL => {
if g.data.len() != 2 {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let preroll = u16::from_be_bytes([g.data[0], g.data[1]]);
let splice = processed + preroll as u64 * 1000;
let section = build(scte35::TIME_SIGNAL, &scte35::splice_time(pts_of(splice)))?;
let mut ins = Vec::new();
let mut outs = Vec::new();
let mut chapters = Vec::new();
let mut withdraws = Vec::new();
for s in &segs {
if s.cancel {
withdraws.push(Withdraw::Segmentation(s.event_id));
continue;
}
let dur = s.ticks.map_or(0, |t| (t / 90).min(u32::MAX as u64) as u32);
let cue = |kind| Cue { t: splice, kind, cue_id: s.event_id, duration_ms: dur, section: Some(section.clone()), derived: false };
if BREAK_ENDS.contains(&s.type_id) {
ins.push(Cue { duration_ms: 0, ..cue(2) });
} else if BREAK_STARTS.contains(&s.type_id) {
outs.push(cue(1));
} else if s.type_id == CHAPTER_START {
chapters.push(cue(3));
}
}
let cues = ins.into_iter().chain(outs).chain(chapters).collect();
Ok((Request { opid: g.opid, splice_us: splice, section: Some(section), cues, withdraws }, None))
}
op::SPLICE_NULL => {
if !g.data.is_empty() {
return Err(refuse(result::MESSAGE_SIZE, "message_size"));
}
let section = build(scte35::SPLICE_NULL, &[])?;
Ok((Request { opid: g.opid, splice_us: processed, section: Some(section), cues: Vec::new(), withdraws: Vec::new() }, None))
}
_ => unreachable!("a Normal request"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
/// A `multiple_operation_message()` of `ops` with an immediate timestamp.
pub fn message(message_number: u8, ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut o = vec![0xff, 0xff, 0, 0, 0, 0, message_number, 0, 1, 0, 0, ops.len() as u8];
for (id, d) in ops {
o.extend_from_slice(&id.to_be_bytes());
o.extend_from_slice(&(d.len() as u16).to_be_bytes());
o.extend_from_slice(d);
}
let n = o.len() as u16;
o[2..4].copy_from_slice(&n.to_be_bytes());
o
}
const P: Params = Params { t0_us: 1_000_000_000_000_000, leap_s: 18, fps_num: 30_000, fps_den: 1_001 };
/// SCTE 35-1 2023r2 §14.1's sample time_signal with a Provider Placement Opportunity
/// Start: the request its fields come from, at the instant whose `pts_time` is the
/// sample's, gives the sample's bytes.
#[test]
fn the_standard_s_time_signal_sample() {
let want = unhex("FC3034000000000000FFFFF00506FE72BD0050001E021C435545494800008E7FCF0001A599B00808000000002CA0A18A3402009AC9D17E");
// pts 0x072BD0050 = 1 924 989 008 ticks: room clock ⌈ticks · 100 / 9⌉ µs.
let t = (0x0_72bd_0050u64 * 100).div_ceil(9);
assert_eq!(pts_of(t), 0x0_72bd_0050);
let mut seg = 0x4800_008eu32.to_be_bytes().to_vec();
seg.push(0); // not cancelled
seg.extend_from_slice(&307u16.to_be_bytes()); // 27 630 000 ticks
seg.extend_from_slice(&[0x08, 0x08, 0, 0, 0, 0, 0x2c, 0xa0, 0xa1, 0x8a]);
seg.extend_from_slice(&[0x34, 0x02, 0x00]);
seg.extend_from_slice(&[0, 0, 0, 1, 1, 3]); // ext frames, dnr 0, web 0, nrb 1, archive 1, device 3
let m = message(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg)]);
let tr = translate(&m, t, &P).unwrap();
assert_eq!(tr.requests[0].section.as_deref(), Some(&want[..]));
let c = tr.cues();
assert_eq!(c.len(), 1);
assert_eq!((c[0].kind, c[0].cue_id, c[0].duration_ms, c[0].t), (1, 0x4800_008e, 307_000, t));
}
#[test]
fn splice_requests_and_their_cues() {
// spliceStart_normal, event 7, pre-roll 8 s, 30 s, auto-return.
let mut d = 1u8.to_be_bytes().to_vec();
d.extend_from_slice(&7u32.to_be_bytes());
d.extend_from_slice(&[0, 0, 0x1f, 0x40, 0x01, 0x2c, 1, 1, 1, 0]);
let tr = translate(&message(2, &[(op::SPLICE, d.clone())]), 5_000_000, &P).unwrap();
let c = tr.cues();
assert_eq!(c.len(), 2);
assert_eq!((c[0].kind, c[0].t, c[0].duration_ms), (1, 13_000_000, 30_000));
assert_eq!((c[1].kind, c[1].t, c[1].derived, c[1].section.is_none()), (2, 43_000_000, true, true));
let s = c[0].section.as_ref().unwrap();
assert_eq!(scte35::crc32_mpeg2(s), 0);
// A pre-roll of 1 s is issued and answered 122.
d[7..9].copy_from_slice(&1000u16.to_be_bytes());
assert_eq!(translate(&message(3, &[(op::SPLICE, d.clone())]), 0, &P).unwrap().warning, Some(122));
// A cancel withdraws; a type of 6 is refused 121.
d[0] = 5;
assert_eq!(translate(&message(4, &[(op::SPLICE, d.clone())]), 0, &P).unwrap().requests[0].withdraws, vec![Withdraw::Splice(7)]);
d[0] = 6;
assert_eq!(translate(&message(5, &[(op::SPLICE, d)]), 0, &P).unwrap_err().result, 121);
}
#[test]
fn refusals_carry_their_result_codes() {
let ok = message(1, &[(op::SPLICE_NULL, vec![])]);
assert!(translate(&ok, 0, &P).is_ok());
let mut bad = ok.clone();
bad.push(0);
assert_eq!(translate(&bad, 0, &P).unwrap_err().result, 114);
assert_eq!(translate(&message(1, &[(op::INSERT_TIER, vec![0, 1])]), 0, &P).unwrap_err().reason, "supplemental_first");
let e = translate(&message(1, &[(0x0107, vec![])]), 0, &P).unwrap_err();
assert_eq!((e.result, e.ext), (124, 0x0107));
let e = translate(&message(1, &[(0x7777, vec![])]), 0, &P).unwrap_err();
assert_eq!((e.result, e.ext), (125, 0x7777));
let mut vitc = ok.clone();
vitc[10] = 2;
assert_eq!(translate(&vitc, 0, &P).unwrap_err().result, 123);
}
}

View file

@ -0,0 +1,151 @@
//! SCTE 35 sections as the injector writes them (ANSI/SCTE 35-1 2023r2 §9.6 Table 5, §9.7,
//! §9.8, §10.3): a `splice_info_section()` around one command and its descriptors, in the
//! clear, `pts_adjustment` 0, `cw_index` 0xFF (present and undefined in the clear, §9.6.1,
//! as the standard's samples of §14 write it), `sap_type` 3 (not specified), every
//! reserved bit 1 (its definition of reserved), and the MPEG-2 CRC over the whole section.
/// `splice_command_type` values (Table 7).
pub const SPLICE_NULL: u8 = 0x00;
pub const SPLICE_INSERT: u8 = 0x05;
pub const TIME_SIGNAL: u8 = 0x06;
/// The identifier of every descriptor SCTE 35 defines: ASCII `CUEI`.
pub const CUEI: [u8; 4] = *b"CUEI";
/// The largest `section_length` (§9.6.1).
pub const MAX_SECTION_LENGTH: usize = 4093;
/// The MPEG-2 CRC (ISO/IEC 13818-1 Annex A): polynomial 0x04C11DB7, register preset to
/// all ones, no reflection, no final inversion. A section's `CRC_32` is this over every
/// byte before it, so the CRC over the whole section is 0.
pub fn crc32_mpeg2(b: &[u8]) -> u32 {
let mut c: u32 = 0xffff_ffff;
for &x in b {
c ^= (x as u32) << 24;
for _ in 0..8 {
c = if c & 0x8000_0000 != 0 { (c << 1) ^ 0x04c1_1db7 } else { c << 1 };
}
}
c
}
/// `splice_time()` (Table 14): `time_specified_flag` 1, six reserved bits, `pts_time`.
pub fn splice_time(pts: u64) -> [u8; 5] {
let p = pts & ((1 << 33) - 1);
[0xfe | (p >> 32) as u8, (p >> 24) as u8, (p >> 16) as u8, (p >> 8) as u8, p as u8]
}
/// `break_duration()` (Table 15): `auto_return`, six reserved bits, `duration`.
pub fn break_duration(auto_return: bool, ticks: u64) -> [u8; 5] {
let d = ticks & ((1 << 33) - 1);
[(auto_return as u8) << 7 | 0x7e | (d >> 32) as u8, (d >> 24) as u8, (d >> 16) as u8, (d >> 8) as u8, d as u8]
}
/// The fields of a program-mode `splice_insert()` (Table 10) that are not cancelled.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpliceInsert {
pub event_id: u32,
pub out_of_network: bool,
/// `None`: `splice_immediate_flag` 1, no `splice_time()`.
pub pts: Option<u64>,
/// `break_duration()`: `auto_return` and the duration in 90 kHz ticks.
pub duration: Option<(bool, u64)>,
pub unique_program_id: u16,
pub avail_num: u8,
pub avails_expected: u8,
}
/// A `splice_insert()` command's bytes. `event_id_compliance_flag` is 1: the injector
/// is not told whether the automation system's ids keep §9.9.3.
pub fn splice_insert(s: &SpliceInsert) -> Vec<u8> {
let mut o = s.event_id.to_be_bytes().to_vec();
o.push(0x7f); // splice_event_cancel_indicator 0, seven reserved bits
o.push((s.out_of_network as u8) << 7 | 1 << 6 | (s.duration.is_some() as u8) << 5 | (s.pts.is_none() as u8) << 4 | 1 << 3 | 0x07);
if let Some(p) = s.pts {
o.extend_from_slice(&splice_time(p));
}
if let Some((a, d)) = s.duration {
o.extend_from_slice(&break_duration(a, d));
}
o.extend_from_slice(&s.unique_program_id.to_be_bytes());
o.push(s.avail_num);
o.push(s.avails_expected);
o
}
/// One descriptor: its tag, its length, and its bytes after the length.
pub fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
let mut o = vec![tag, body.len() as u8];
o.extend_from_slice(body);
o
}
/// A `splice_info_section()` with `command` of `command_type`, `descriptors` (each whole)
/// in its loop, and `tier`; `None` where its `section_length` would pass 4093.
pub fn section(protocol_version: u8, tier: u16, command_type: u8, command: &[u8], descriptors: &[Vec<u8>]) -> Option<Vec<u8>> {
let loop_len: usize = descriptors.iter().map(Vec::len).sum();
// From protocol_version through CRC_32.
let section_length = 1 + 1 + 4 + 1 + 3 + 1 + command.len() + 2 + loop_len + 4;
if section_length > MAX_SECTION_LENGTH || command.len() > 0xfff || loop_len > 0xffff {
return None;
}
let mut o = Vec::with_capacity(3 + section_length);
o.push(0xfc);
// section_syntax_indicator 0, private_indicator 0, sap_type 3, section_length.
o.push(0x30 | (section_length >> 8) as u8);
o.push(section_length as u8);
o.push(protocol_version);
// encrypted_packet 0, encryption_algorithm 0, pts_adjustment 0 (33 bits).
o.extend_from_slice(&[0, 0, 0, 0, 0]);
o.push(0xff); // cw_index, undefined in the clear
let tier = tier & 0xfff;
let len = command.len() as u16;
o.push((tier >> 4) as u8);
o.push(((tier & 0x0f) << 4) as u8 | (len >> 8) as u8);
o.push(len as u8);
o.push(command_type);
o.extend_from_slice(command);
o.extend_from_slice(&(loop_len as u16).to_be_bytes());
for d in descriptors {
o.extend_from_slice(d);
}
let crc = crc32_mpeg2(&o);
o.extend_from_slice(&crc.to_be_bytes());
Some(o)
}
#[cfg(test)]
mod tests {
use super::*;
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
#[test]
fn the_crc_of_the_check_string() {
assert_eq!(crc32_mpeg2(b"123456789"), 0x0376_e6e7);
}
/// SCTE 35-1 2023r2 §14's sample splice_insert (14.2): an out with a break duration,
/// auto-return, and an avail descriptor. Written by this module's functions from its
/// fields, the bytes are the standard's.
#[test]
fn the_standard_s_splice_insert_sample() {
let want = unhex("FC302F000000000000FFFFF014054800008F7FEFFE7369C02EFE0052CCF500000000000A0008435545490000013562DBA30A");
let cmd = splice_insert(&SpliceInsert {
event_id: 0x4800_008f,
out_of_network: true,
pts: Some(0x0_7369_c02e),
duration: Some((true, 0x00_0052_ccf5)),
unique_program_id: 0,
avail_num: 0,
avails_expected: 0,
});
let mut avail = CUEI.to_vec();
avail.extend_from_slice(&0x0000_0135u32.to_be_bytes());
let s = section(0, 0xfff, SPLICE_INSERT, &cmd, &[descriptor(0x00, &avail)]).unwrap();
assert_eq!(s, want);
assert_eq!(crc32_mpeg2(&s), 0, "the CRC over the whole section is zero");
}
}

View file

@ -370,7 +370,9 @@ async fn open(gw: &Arc<Gateway>, room: &Arc<Room>, ad: &Admitted, token_hash: [u
let mut want: Vec<(u8, Declared)> = Vec::new();
for m in [media::AUDIO, media::VIDEO] {
let ns = catalog::render(&catalog::media_namespace(&room.id, owner, m));
if let Some(d) = declared.iter().find(|d| d.namespace == ns && d.name == layer.to_string() && d.codec.is_video() == (m == media::VIDEO)) {
// A track of a codec WebRTC does not carry here (HEVC, AAC from a transport stream)
// is not offered to a WHEP player.
if let Some(d) = declared.iter().find(|d| d.namespace == ns && d.name == layer.to_string() && d.codec.is_video() == (m == media::VIDEO) && d.codec.webrtc()) {
want.push((m, d.clone()));
}
}
@ -650,7 +652,7 @@ impl Session {
/// wallclock placed from the session's first frame.
fn write(&mut self, f: Frame) {
let Some(&mid) = self.mids.get(&f.kind) else { return };
let Some(pt) = self.rtc.writer(mid).and_then(|w| w.payload_params().find(|p| p.spec().codec == f.codec.to_str0m()).map(|p| p.pt())) else { return };
let Some(pt) = self.rtc.writer(mid).and_then(|w| w.payload_params().find(|p| Some(p.spec().codec) == f.codec.to_str0m()).map(|p| p.pt())) else { return };
let (base_i, base_t) = *self.base.get_or_insert((Instant::now(), f.t));
let wallclock = if f.t >= base_t { base_i + Duration::from_micros(f.t - base_t) } else { base_i.checked_sub(Duration::from_micros(base_t - f.t)).unwrap_or(base_i) };
let (ticks, freq) = if f.codec.is_video() { (f.t as u128 * 90_000 / 1_000_000, Frequency::NINETY_KHZ) } else { (f.t as u128 * 48_000 / 1_000_000, Frequency::FORTY_EIGHT_KHZ) };

View file

@ -46,7 +46,7 @@ const END_GRACE: Duration = Duration::from_millis(250);
/// The codecs of media kind `m` the session accepts: those this gateway carries, narrowed
/// to the publish profile the grant names for `m`, if it names one (WAI live-rooms §8.1).
pub fn codecs_for(ad: &Admitted, room: &Room, m: u8) -> Vec<Codec> {
let all: Vec<Codec> = Codec::ALL.into_iter().filter(|c| c.is_video() == (m != media::AUDIO)).collect();
let all: Vec<Codec> = Codec::WEBRTC.into_iter().filter(|c| c.is_video() == (m != media::AUDIO)).collect();
match ad.profile_for(&room.descriptor, m) {
None => all,
Some(p) => {
@ -445,7 +445,7 @@ impl Session {
.values()
.filter_map(|p| {
let codec = p.codec?;
Some(Entry { media: p.out.media, ns: catalog::media_namespace(&self.room.id, &self.owner, p.out.media), codec, header: p.header.clone(), receipt_alias: p.out.receipt_alias })
Some(Entry { media: p.out.media, ns: catalog::media_namespace(&self.room.id, &self.owner, p.out.media), name: catalog::LAYER0.into(), codec, header: p.header.clone(), receipt_alias: p.out.receipt_alias })
})
.collect();
if entries.is_empty() {

View file

@ -6,6 +6,8 @@
#![allow(dead_code)]
pub mod rtmp;
use base64::Engine;
use bytes::Bytes;
use ed25519_dalek::SigningKey;
@ -58,6 +60,12 @@ impl World {
/// key, and a gateway that publishes to it, with `f` adjusting the gateway's
/// configuration.
pub async fn start(f: impl FnOnce(&mut GatewayConfig)) -> World {
World::start_with(|_, _, _| {}, f).await
}
/// As [`World::start`], with `fr` adjusting the relay's configuration once its door
/// rule is in place.
pub async fn start_with(fr: impl FnOnce(&mut RelayConfig, &[u8; 32], &str), f: impl FnOnce(&mut GatewayConfig)) -> World {
let authority = SigningKey::from_bytes(&[0x51; 32]);
let publisher = SigningKey::from_bytes(&[0x52; 32]);
let gateway_key = SigningKey::from_bytes(&[0x53; 32]);
@ -65,7 +73,8 @@ impl World {
let room = descriptor.room_id();
let gk = hex::encode(gateway_key.verifying_key().to_bytes());
let mut rc = RelayConfig::loopback();
rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![gk], trust_field: None, hold_ms: 10_000 }];
rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![gk.clone()], trust_field: None, hold_ms: 10_000 }];
fr(&mut rc, &room, &gk);
let relay = RelayServer::start(rc).await.expect("the relay starts");
let mut gc = GatewayConfig::loopback(relay.quic_addr.expect("a UDP endpoint"));
gc.relay.trust_der = vec![relay.identity.chain[0].to_vec()];
@ -256,6 +265,7 @@ fn client_config(codecs: &[Codec]) -> RtcConfig {
Codec::Vp9 => c.enable_vp9(true),
Codec::Av1 => c.enable_av1(true),
Codec::Opus => c.enable_opus(true, false),
_ => c,
};
}
c
@ -353,7 +363,7 @@ async fn run_peer(mut rtc: Rtc, socket: UdpSocket, mut rx: mpsc::UnboundedReceiv
}
Some(PeerCmd::Write { kind, codec, data, ticks }) => {
let Some(&mid) = mids.get(&kind) else { continue };
let Some(pt) = rtc.writer(mid).and_then(|w| w.payload_params().find(|p| p.spec().codec == codec.to_str0m()).map(|p| p.pt())) else { continue };
let Some(pt) = rtc.writer(mid).and_then(|w| w.payload_params().find(|p| Some(p.spec().codec) == codec.to_str0m()).map(|p| p.pt())) else { continue };
let freq = if kind == MediaKind::Audio { Frequency::FORTY_EIGHT_KHZ } else { Frequency::NINETY_KHZ };
if let Some(w) = rtc.writer(mid) {
let _ = w.write(pt, Instant::now(), MediaTime::new(ticks, freq), data);
@ -525,6 +535,9 @@ pub async fn relay_client(w: &World) -> (Client, quinn::Endpoint) {
/// One subscribed media track and its receipts track, as the subscriber reads them.
pub struct Watched {
pub kind: u8,
/// The layer: the Track Name, where it is a number (a layer); 0 for a track of
/// another name, such as the link track.
pub layer: u8,
pub media_alias: u64,
pub receipts_alias: u64,
/// The request streams, held so the subscriptions stand.
@ -551,15 +564,28 @@ pub async fn watch(w: &World) -> Watcher {
/// As [`watch`], for the media kinds `kinds` (1 audio, 2 video).
pub async fn watch_kinds(w: &World, kinds: &[u8]) -> Watcher {
let t: Vec<(u8, u8)> = kinds.iter().map(|&k| (k, 0)).collect();
watch_layers(w, &t).await
}
/// As [`watch`], for the (media kind, layer) tracks `tracks_wanted`.
pub async fn watch_layers(w: &World, tracks_wanted: &[(u8, u8)]) -> Watcher {
let owner = w.owner();
let specs = tracks_wanted.iter().map(|&(k, layer)| (k, catalog::media_namespace(&w.room, &owner, k), layer.to_string())).collect();
watch_tracks(w, specs).await
}
/// Subscribe at the relay to each (kind, namespace, name) and its receipts track, from
/// their start, reading every stream concurrently.
pub async fn watch_tracks(w: &World, specs: Vec<(u8, TrackNamespace, String)>) -> Watcher {
let (c, ep) = relay_client(w).await;
let c = Arc::new(c);
let owner = w.owner();
let mut tracks = Vec::new();
let ended = Arc::new(Mutex::new(Vec::new()));
for &kind in kinds {
let ns = catalog::media_namespace(&w.room, &owner, kind);
let (m, mtx, mrx) = c.subscribe(ns.clone(), TrackName::from_str("0"), Parameters::new()).await.expect("SUBSCRIBE the media track");
let (r, rtx, rrx) = c.subscribe(ns.clone(), catalog::receipts_name("0"), Parameters::new()).await.expect("SUBSCRIBE the receipts track");
for (kind, ns, name) in specs {
let layer: u8 = name.parse().unwrap_or(0);
let (m, mtx, mrx) = c.subscribe(ns.clone(), TrackName::from_str(&name), Parameters::new()).await.expect("SUBSCRIBE the media track");
let (r, rtx, rrx) = c.subscribe(ns.clone(), catalog::receipts_name(&name), Parameters::new()).await.expect("SUBSCRIBE the receipts track");
// PUBLISH_DONE on the media track: the track ended.
let ended = ended.clone();
let alias = m.track_alias;
@ -572,7 +598,7 @@ pub async fn watch_kinds(w: &World, kinds: &[u8]) -> Watcher {
}
}
});
tracks.push(Watched { kind, media_alias: m.track_alias, receipts_alias: r.track_alias, held: vec![mtx, rtx], receipts_rx: rrx });
tracks.push(Watched { kind, layer, media_alias: m.track_alias, receipts_alias: r.track_alias, held: vec![mtx, rtx], receipts_rx: rrx });
}
let objects: Objects = Arc::default();
let (c2, o2) = (c.clone(), objects.clone());
@ -697,3 +723,77 @@ pub fn same(got: &[Vec<u8>], want: &[Vec<u8>], what: &str) {
pub async fn delete(w: &World, location: &str, auth: &Auth) -> Resp {
auth.send("DELETE", &format!("{}{}", w.base(), location), &[], b"").await
}
// ── A transport stream over SRT or RIST ───────────────────────────────────────────
/// An ADTS frame of AAC-LC at 48 kHz, stereo: a header and `i`-shaped bytes.
pub fn adts(i: u64) -> Vec<u8> {
let body: Vec<u8> = (0..(150 + i % 40)).map(|j| ((i * 7 + j) % 200 + 11) as u8).collect();
let len = body.len() + 7;
let mut f = vec![0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) as u8 & 0x3), (len >> 3) as u8, ((len as u8 & 0x7) << 5) | 0x1f, 0xfc];
f.extend(body);
f
}
/// An HEVC access unit: an IDR (type 19) with a VPS, SPS and PPS before it when `key`, a
/// TRAIL_R (type 1) otherwise.
pub fn hevc(i: u64, key: bool) -> Vec<u8> {
let mut f = Vec::new();
if key {
for t in [32u8, 33, 34] {
f.extend_from_slice(&[0, 0, 0, 1, t << 1, 0x01, 0x0c, 0x01]);
}
f.extend_from_slice(&[0, 0, 0, 1, 19 << 1, 0x01]);
} else {
f.extend_from_slice(&[0, 0, 0, 1, 1 << 1, 0x01]);
}
f.extend((0..(400 + i % 9 * 30)).map(|j| ((i * 31 + j * 3) % 251 + 1) as u8));
f
}
pub struct SentTs {
pub video: Vec<Vec<u8>>,
pub audio: Vec<Vec<u8>>,
}
/// Send `seconds` of a program in real time on `link`: 30 video access units a second
/// (`video_type`, a keyframe every `gop`) and 25 PES of two ADTS frames, in TS packets
/// seven to a payload.
pub async fn send_ts(link: &joule_contrib::link::Link, seconds: u64, gop: u64, video_type: u8) -> SentTs {
let tx = link.send.clone().expect("a sending link");
let mut m = joule_contrib::ts::Mux::new(video_type);
let mut pending = Vec::new();
let mut sent = SentTs { video: Vec::new(), audio: Vec::new() };
let start = tokio::time::Instant::now();
let mut a = 0u64;
for i in 0..seconds * 30 {
tokio::time::sleep_until(start + Duration::from_micros(i * 33_333)).await;
let key = i % gop == 0;
let au = if video_type == joule_contrib::ts::ST_HEVC { hevc(i, key) } else { avc(i, key) };
pending.extend(m.pes(true, 900_000 + i * 3000, &au, key));
sent.video.push(au);
while a * 3840 <= i * 3000 {
let (f0, f1) = (adts(2 * a), adts(2 * a + 1));
pending.extend(m.pes(false, 900_000 + a * 3840, &[f0.clone(), f1.clone()].concat(), false));
sent.audio.push(f0);
sent.audio.push(f1);
a += 1;
}
while pending.len() >= 1316 {
let c: Vec<u8> = pending.drain(..1316).collect();
tx.send(c).await.expect("the link takes the payload");
}
}
// What is left, and the last PES ended by a packet of the PAT.
pending.extend(m.psi());
for c in pending.chunks(1316) {
tx.send(c.to_vec()).await.unwrap();
}
sent
}
/// Wait, bounded, until the gateway's contribution session `id` has published its tracks.
pub async fn contrib_live(w: &World, id: &str) -> bool {
until(STEP, || w.gateway.gateway.contrib.sessions.lock().unwrap().contains_key(id)).await
}

View file

@ -0,0 +1,447 @@
//! An Enhanced RTMP publisher of our own, for the gateway's tests: the handshake, the
//! commands, `onMetaData`, and FLV tag bodies, legacy and enhanced, built from Enhanced
//! RTMP v2's tables; and the synthetic frames and configuration records it sends.
#![allow(dead_code)]
use super::{Auth, World, STEP};
use joule_moq_ingest::rtmp::amf0::{self, Value};
use joule_moq_ingest::rtmp::chunk::{self, types, Decoder, Message};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::time::timeout;
/// A stream key from the gateway's `/rtmp` resource, under `auth`'s grant.
pub async fn stream_key(w: &World, auth: &Auth) -> String {
let r = auth.send("POST", &format!("{}/rtmp", w.base()), &[], b"").await;
assert_eq!(r.status, 201, "POST /rtmp: {}", r.text());
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
v["stream_key"].as_str().unwrap().to_string()
}
/// One status the gateway sent: its code, level and description.
#[derive(Clone, Debug)]
pub struct Status {
pub code: String,
pub level: String,
pub description: String,
}
pub struct Client<S> {
io: S,
dec: Decoder,
out_chunk: usize,
pub received: Vec<Message>,
}
impl<S: AsyncRead + AsyncWrite + Unpin> Client<S> {
/// The simple handshake over `io`.
pub async fn start(mut io: S) -> Client<S> {
let mut c0c1 = vec![3u8];
c0c1.extend_from_slice(&[0; 8]);
c0c1.extend((0..chunk::HANDSHAKE_LEN - 8).map(|i| (i * 13) as u8));
io.write_all(&c0c1).await.unwrap();
let mut s = vec![0u8; 1 + 2 * chunk::HANDSHAKE_LEN];
timeout(STEP, io.read_exact(&mut s)).await.expect("S0 S1 S2 in time").unwrap();
assert_eq!(s[0], 3);
assert_eq!(&s[1 + chunk::HANDSHAKE_LEN + 8..], &c0c1[9..], "S2 echoes C1");
io.write_all(&s[1..1 + chunk::HANDSHAKE_LEN]).await.unwrap();
Client { io, dec: Decoder::new(1 << 24, 1 << 24), out_chunk: 128, received: Vec::new() }
}
pub async fn send(&mut self, m: Message) {
let b = chunk::encode(&m, self.out_chunk);
self.io.write_all(&b).await.unwrap();
if m.type_id == types::SET_CHUNK_SIZE {
self.out_chunk = u32::from_be_bytes(m.payload[..4].try_into().unwrap()) as usize;
}
}
pub async fn command(&mut self, msid: u32, values: &[Value]) {
self.send(Message { csid: 3, type_id: types::COMMAND_AMF0, msid, ts: 0, payload: amf0::encode(values) }).await;
}
/// The next message the gateway sends, bounded.
pub async fn next(&mut self) -> Option<Message> {
let mut buf = vec![0u8; 8192];
loop {
if let Some(m) = self.dec.next_message().expect("the gateway's chunk stream reads") {
if m.type_id == types::SET_CHUNK_SIZE {
self.dec.set_chunk_size(u32::from_be_bytes(m.payload[..4].try_into().unwrap())).unwrap();
}
self.received.push(m.clone());
return Some(m);
}
match timeout(STEP, self.io.read(&mut buf)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(n)) => self.dec.feed(&buf[..n]),
}
}
}
/// The values of the next command message, skipping the others.
pub async fn next_command(&mut self) -> Option<Vec<Value>> {
while let Some(m) = self.next().await {
if m.type_id == types::COMMAND_AMF0 {
return Some(amf0::read_all(&m.payload).expect("a command reads"));
}
}
None
}
/// The next `onStatus`.
pub async fn status(&mut self) -> Option<Status> {
while let Some(v) = self.next_command().await {
if v.first().and_then(Value::as_str) == Some("onStatus") {
let info = v.get(3)?;
let s = |k| info.get(k).and_then(Value::as_str).unwrap_or("").to_string();
return Some(Status { code: s("code"), level: s("level"), description: s("description") });
}
}
None
}
/// `connect` with `capsEx` and the FourCCs it can send, then the `_result`, and a
/// chunk size of 4096 from here.
pub async fn connect(&mut self, caps_ex: u32) -> Vec<Value> {
let obj = Value::obj(&[
("app", Value::str("live")),
("type", Value::str("nonprivate")),
("tcUrl", Value::str("rtmp://127.0.0.1/live")),
("capsEx", Value::Number(caps_ex as f64)),
("fourCcList", Value::StrictArray(["av01", "vp09", "vp08", "hvc1", "avc1", "Opus", "fLaC", "mp4a"].iter().map(|f| Value::str(f)).collect())),
]);
self.command(0, &[Value::str("connect"), Value::Number(1.0), obj]).await;
let r = loop {
let v = self.next_command().await.expect("the connect _result");
if v.first().and_then(Value::as_str) == Some("_result") {
break v;
}
};
self.send(chunk::control(types::SET_CHUNK_SIZE, 4096u32.to_be_bytes().to_vec())).await;
r
}
/// `createStream` and `publish` with `key`; the gateway's status.
pub async fn publish(&mut self, key: &str) -> Status {
self.command(0, &[Value::str("releaseStream"), Value::Number(2.0), Value::Null, Value::str(key)]).await;
self.command(0, &[Value::str("FCPublish"), Value::Number(3.0), Value::Null, Value::str(key)]).await;
self.command(0, &[Value::str("createStream"), Value::Number(4.0), Value::Null]).await;
let id = loop {
let v = self.next_command().await.expect("the createStream _result");
if v.first().and_then(Value::as_str) == Some("_result") && v.get(1).and_then(Value::as_f64) == Some(4.0) {
break v.get(3).and_then(Value::as_f64).unwrap() as u32;
}
};
self.command(id, &[Value::str("publish"), Value::Number(5.0), Value::Null, Value::str(key), Value::str("live")]).await;
self.status().await.expect("a status for publish")
}
/// `@setDataFrame` `onMetaData` with `pairs`.
pub async fn metadata(&mut self, pairs: Vec<(String, Value)>) {
let p = amf0::encode(&[Value::str("@setDataFrame"), Value::str("onMetaData"), Value::EcmaArray(pairs)]);
self.send(Message { csid: 4, type_id: types::DATA_AMF0, msid: 1, ts: 0, payload: p }).await;
}
pub async fn video(&mut self, ts: u32, body: Vec<u8>) {
self.send(Message { csid: 6, type_id: types::VIDEO, msid: 1, ts, payload: body }).await;
}
pub async fn audio(&mut self, ts: u32, body: Vec<u8>) {
self.send(Message { csid: 4, type_id: types::AUDIO, msid: 1, ts, payload: body }).await;
}
pub async fn delete_stream(&mut self) {
self.command(0, &[Value::str("FCUnpublish"), Value::Number(6.0), Value::Null, Value::str("")]).await;
self.command(0, &[Value::str("deleteStream"), Value::Number(7.0), Value::Null, Value::Number(1.0)]).await;
}
pub async fn shutdown(&mut self) {
let _ = self.io.shutdown().await;
}
/// Read until the gateway closes the connection, bounded.
pub async fn drain(&mut self) {
while self.next().await.is_some() {}
}
}
// ── FLV tag bodies (Enhanced RTMP v2, Enhanced Video and Enhanced Audio) ──────────
pub fn legacy_avc(packet_type: u8, key: bool, cts: i32, body: &[u8]) -> Vec<u8> {
let mut o = vec![if key { 0x17 } else { 0x27 }, packet_type];
o.extend_from_slice(&cts.to_be_bytes()[1..]);
o.extend_from_slice(body);
o
}
pub fn legacy_aac(packet_type: u8, body: &[u8]) -> Vec<u8> {
let mut o = vec![0xaf, packet_type];
o.extend_from_slice(body);
o
}
/// An enhanced video body: `isExVideoHeader`, the frame type, the packet type, the
/// FourCC, then `body` (which, for CodedFrames of avc1, hvc1 and vvc1, begins with its
/// SI24 composition time offset).
pub fn ex_video(key: bool, packet_type: u8, fourcc: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut o = vec![0x80 | if key { 0x10 } else { 0x20 } | packet_type];
o.extend_from_slice(fourcc);
o.extend_from_slice(body);
o
}
/// The same, after a ModEx `TimestampOffsetNano` of `nano`.
pub fn ex_video_nano(key: bool, packet_type: u8, fourcc: &[u8; 4], nano: u32, body: &[u8]) -> Vec<u8> {
let mut o = vec![0x80 | if key { 0x10 } else { 0x20 } | 7, 2];
o.extend_from_slice(&nano.to_be_bytes()[1..]);
o.push(packet_type);
o.extend_from_slice(fourcc);
o.extend_from_slice(body);
o
}
/// A multitrack video body of type ManyTracksManyCodecs: each track its FourCC, its id,
/// its size and its body.
pub fn ex_video_tracks(key: bool, packet_type: u8, tracks: &[(&[u8; 4], u8, Vec<u8>)]) -> Vec<u8> {
let mut o = vec![0x80 | if key { 0x10 } else { 0x20 } | 6, 2 << 4 | packet_type];
for (f, id, b) in tracks {
o.extend_from_slice(*f);
o.push(*id);
o.extend_from_slice(&(b.len() as u32).to_be_bytes()[1..]);
o.extend_from_slice(b);
}
o
}
pub fn ex_audio(packet_type: u8, fourcc: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut o = vec![0x90 | packet_type];
o.extend_from_slice(fourcc);
o.extend_from_slice(body);
o
}
/// A multitrack audio body of type ManyTracksManyCodecs.
pub fn ex_audio_tracks(packet_type: u8, tracks: &[(&[u8; 4], u8, Vec<u8>)]) -> Vec<u8> {
let mut o = vec![0x90 | 5, 2 << 4 | packet_type];
for (f, id, b) in tracks {
o.extend_from_slice(*f);
o.push(*id);
o.extend_from_slice(&(b.len() as u32).to_be_bytes()[1..]);
o.extend_from_slice(b);
}
o
}
// ── Synthetic configurations and frames ────────────────────────────────────────────
/// Bytes none of which is zero, so no start code appears inside a NAL unit.
pub fn body(seed: u64, n: usize) -> Vec<u8> {
(0..n).map(|j| ((seed.wrapping_mul(31).wrapping_add(j as u64 * 7)) % 255 + 1) as u8).collect()
}
/// NAL units, each after a four-byte length.
pub fn lengths(nals: &[Vec<u8>]) -> Vec<u8> {
let mut o = Vec::new();
for n in nals {
o.extend_from_slice(&(n.len() as u32).to_be_bytes());
o.extend_from_slice(n);
}
o
}
/// NAL units as an Annex B access unit, each after a four-byte start code.
pub fn annexb(nals: &[Vec<u8>]) -> Vec<u8> {
let mut o = Vec::new();
for n in nals {
o.extend_from_slice(&[0, 0, 0, 1]);
o.extend_from_slice(n);
}
o
}
pub const AVC_SPS: [u8; 6] = [0x67, 0x64, 0x00, 0x1f, 0xac, 0xd9];
pub const AVC_PPS: [u8; 4] = [0x68, 0xeb, 0xe3, 0xcb];
/// An AVCDecoderConfigurationRecord: High profile, level 3.1, four-byte lengths.
pub fn avc_record() -> Vec<u8> {
let mut o = vec![1, 0x64, 0x00, 0x1f, 0xff, 0xe1];
o.extend_from_slice(&(AVC_SPS.len() as u16).to_be_bytes());
o.extend_from_slice(&AVC_SPS);
o.push(1);
o.extend_from_slice(&(AVC_PPS.len() as u16).to_be_bytes());
o.extend_from_slice(&AVC_PPS);
o
}
/// AVC access unit `i`'s NAL units: an IDR slice when `key`, a non-IDR one otherwise.
pub fn avc_nals(i: u64, key: bool) -> Vec<Vec<u8>> {
let mut n = vec![if key { 0x65 } else { 0x41 }];
n.extend(body(i, if key { 2000 } else { 400 + (i % 5) as usize * 30 }));
vec![n]
}
pub fn hevc_sets() -> Vec<Vec<u8>> {
vec![vec![0x40, 0x01, 0x0c, 0x01], vec![0x42, 0x01, 0x01, 0x01, 0x60], vec![0x44, 0x01, 0xc1, 0x72]]
}
/// An HEVCDecoderConfigurationRecord: Main profile, level 3.1 (93), four-byte lengths, its
/// VPS, SPS and PPS.
pub fn hevc_record() -> Vec<u8> {
let mut o = vec![1, 0x01, 0x60, 0, 0, 0, 0x90, 0, 0, 0, 0, 0, 93, 0xf0, 0, 0xfc, 0xfd, 0xf8, 0xf8, 0, 0, 0x0f, 3];
for (ty, s) in [32u8, 33, 34].iter().zip(hevc_sets()) {
o.push(0x80 | ty);
o.extend_from_slice(&1u16.to_be_bytes());
o.extend_from_slice(&(s.len() as u16).to_be_bytes());
o.extend_from_slice(&s);
}
o
}
/// HEVC access unit `i`: an IDR_W_RADL slice (type 19) when `key`, TRAIL_R (1) otherwise.
pub fn hevc_nals(i: u64, key: bool) -> Vec<Vec<u8>> {
let mut n = if key { vec![0x26, 0x01] } else { vec![0x02, 0x01] };
n.extend(body(i + 50, if key { 1800 } else { 350 }));
vec![n]
}
struct Bw(Vec<u8>, usize);
impl Bw {
fn put(&mut self, v: u64, n: usize) {
for i in (0..n).rev() {
if self.1.is_multiple_of(8) {
self.0.push(0);
}
if (v >> i) & 1 == 1 {
*self.0.last_mut().unwrap() |= 0x80 >> (self.1 % 8);
}
self.1 += 1;
}
}
}
fn obu(ty: u8, payload: &[u8]) -> Vec<u8> {
let mut o = vec![(ty << 3) | 0x02];
let mut n = payload.len();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
if n == 0 {
o.push(b);
break;
}
o.push(b | 0x80);
}
o.extend_from_slice(payload);
o
}
/// An AV1 sequence header OBU: profile 0, one operating point at level 8, the size.
pub fn av1_sequence_header(w: u32, h: u32) -> Vec<u8> {
let mut b = Bw(Vec::new(), 0);
b.put(0, 3);
b.put(0, 1);
b.put(0, 1);
b.put(0, 1);
b.put(0, 1);
b.put(0, 5);
b.put(0, 12);
b.put(8, 5);
b.put(0, 1);
b.put(15, 4);
b.put(15, 4);
b.put(w as u64 - 1, 16);
b.put(h as u64 - 1, 16);
obu(1, &b.0)
}
/// An AV1CodecConfigurationRecord with the sequence header as its configOBUs.
pub fn av1_record(w: u32, h: u32) -> Vec<u8> {
let mut o = vec![0x81, 0x08, 0x0c, 0x00];
o.extend(av1_sequence_header(w, h));
o
}
/// AV1 temporal unit `i`: a frame OBU (with a temporal delimiter before it, which the
/// gateway drops and its framing restores).
pub fn av1_tu(i: u64, key: bool) -> Vec<u8> {
let mut o = vec![0x12, 0x00];
o.extend(obu(6, &body(i + 90, if key { 1500 } else { 300 })));
o
}
/// VP9 frame `i`: an uncompressed header (keyframe with its sync code, colour config and
/// size; or an interframe) and bytes.
pub fn vp9_frame(i: u64, key: bool, w: u32, h: u32) -> Vec<u8> {
let mut b = Bw(Vec::new(), 0);
b.put(2, 2);
b.put(0, 2);
b.put(0, 1);
b.put(if key { 0 } else { 1 }, 1);
b.put(1, 1);
b.put(0, 1);
if key {
b.put(0x49_83_42, 24);
b.put(1, 3);
b.put(0, 1);
b.put(w as u64 - 1, 16);
b.put(h as u64 - 1, 16);
}
let mut f = b.0;
f.extend(body(i + 300, if key { 1200 } else { 250 }));
f
}
/// A VPCodecConfigurationRecord: profile 0, level 3.1, 8 bits.
pub fn vp9_record() -> Vec<u8> {
vec![0, 31, 0x80, 1, 1, 1, 0, 0]
}
/// An AudioSpecificConfig: AAC-LC, 48 kHz, stereo.
pub const AAC_ASC: [u8; 2] = [0x11, 0x90];
pub fn aac_raw(i: u64) -> Vec<u8> {
body(i + 1000, 120 + (i % 3) as usize)
}
/// The ADTS frame the gateway builds for a raw frame under `AAC_ASC`: profile 1, index 3,
/// two channels.
pub fn adts(raw: &[u8]) -> Vec<u8> {
let len = raw.len() + 7;
let mut o = vec![0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) & 3) as u8, ((len >> 3) & 0xff) as u8, (((len & 7) << 5) as u8) | 0x1f, 0xfc];
o.extend_from_slice(raw);
o
}
pub fn opus_packet(i: u64) -> Vec<u8> {
let mut f = vec![0xfc];
f.extend(body(i + 7_000, 80 + (i % 7) as usize));
f
}
/// An Opus ID header (RFC 7845 §5.1): two channels, mapping family 0.
pub fn opus_head() -> Vec<u8> {
let mut o = b"OpusHead".to_vec();
o.extend_from_slice(&[1, 2, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0]);
o
}
/// `fLaC` and a STREAMINFO block (not marked last): 48 kHz, two channels, 16 bits.
pub fn flac_header() -> Vec<u8> {
let mut o = b"fLaC".to_vec();
o.extend_from_slice(&[0x00, 0, 0, 34]);
let mut si = vec![0x10, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0, 0];
// sample rate 48 000 (20 bits), channels − 1 = 1 (3 bits), bits − 1 = 15 (5 bits),
// total samples 0 (36 bits).
let rate = 48_000u64;
let v: u64 = rate << 44 | 1 << 41 | 15 << 36;
si.extend_from_slice(&v.to_be_bytes());
si.extend_from_slice(&[0; 16]);
o.extend(si);
o
}
pub fn flac_frame(i: u64) -> Vec<u8> {
let mut f = vec![0xff, 0xf8, 0x69, 0x18, i as u8];
f.extend(body(i + 5_000, 200));
f
}

View file

@ -0,0 +1,762 @@
//! The conformance corpora this crate is the reference for, generated through its own code
//! paths and compared with the committed files byte for byte:
//!
//! - `wai/live-conformance/injection/vectors.json` (WAI `extensions/cue-injection.md`):
//! SCTE 104 messages and their translations (sections, cues, withdrawals), among them
//! requests whose sections are SCTE 35-1 2023r2 §14's own samples; refusals with their
//! result codes and reasons; scenarios of messages on a clock and the chain the injector
//! signs for them, with the breaks live-ads §4.3 derives; and chains a verifier refuses.
//! - `wai/live-conformance/ertmp/vectors.json` (`extensions/live-ingest.md` §12): RTMP
//! message sequences of one publish, with their arrivals, and the objects each room
//! track carries (group, object, `live` member, capability, payload), and what was read
//! and not carried.
//!
//! `JOULE_MOQ_INGEST_WRITE_CORPUS=1` writes them; otherwise each is regenerated and
//! compared. The second implementations, `injection_verify.py` and `ertmp_verify.py`, share
//! no code with this crate.
//!
//! A third test holds the texts' reason tables to the codes this crate reports.
use ed25519_dalek::SigningKey;
use joule_moq_ingest::frame::{self, GrantRef};
use joule_moq_ingest::package::{Emit, Packager, Signer, TrackOut};
use joule_moq_ingest::room::{registered_profiles, Profile};
use joule_moq_ingest::rtmp::amf0::{self, Value};
use joule_moq_ingest::rtmp::tracks::{self, Tracks};
use joule_moq_ingest::scte::scte104::{self, op, Params};
use joule_moq_ingest::scte::{event, injection, Engine, KIND_CUE, KIND_META};
use serde_json::{json, Value as J};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
fn dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../../wai/live-conformance")
}
fn check(rel: &str, v: &J) {
let path = dir().join(rel);
let text = serde_json::to_string_pretty(v).unwrap() + "\n";
if std::env::var("JOULE_MOQ_INGEST_WRITE_CORPUS").is_ok_and(|v| v == "1") {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &text).unwrap();
return;
}
let have = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e} (JOULE_MOQ_INGEST_WRITE_CORPUS=1 writes it)", path.display()));
assert!(have == text, "{} does not regenerate byte for byte", path.display());
}
fn hx(b: &[u8]) -> String {
hex::encode(b)
}
// ── The injection corpus ───────────────────────────────────────────────────────────
const T0: u64 = 1_790_000_000_000_000;
const P: Params = Params { t0_us: T0, leap_s: 18, fps_num: 30_000, fps_den: 1_001 };
const ROOM: [u8; 32] = [0xab; 32];
const SEED: [u8; 32] = [0x71; 32];
fn msg(n: u8, time: &[u8], ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut o = vec![0xff, 0xff, 0, 0, 0, 2, n, 0, 7, 0];
o.extend_from_slice(time);
o.push(ops.len() as u8);
for (id, d) in ops {
o.extend_from_slice(&id.to_be_bytes());
o.extend_from_slice(&(d.len() as u16).to_be_bytes());
o.extend_from_slice(d);
}
let len = o.len() as u16;
o[2..4].copy_from_slice(&len.to_be_bytes());
o
}
fn now(n: u8, ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
msg(n, &[0], ops)
}
fn splice(ty: u8, event: u32, preroll: u16, tenths: u16, auto: bool) -> Vec<u8> {
let mut d = vec![ty];
d.extend_from_slice(&event.to_be_bytes());
d.extend_from_slice(&0x0102u16.to_be_bytes());
d.extend_from_slice(&preroll.to_be_bytes());
d.extend_from_slice(&tenths.to_be_bytes());
d.extend_from_slice(&[1, 4, auto as u8, 0]);
d
}
/// A segmentation request: `tail` after `segments_expected` (empty, the extension frames,
/// the five delivery fields, the sub-segment fields).
fn seg(event: u32, cancel: bool, seconds: u16, type_id: u8, tail: &[u8]) -> Vec<u8> {
let mut d = event.to_be_bytes().to_vec();
d.push(cancel as u8);
d.extend_from_slice(&seconds.to_be_bytes());
d.extend_from_slice(&[0x09, 8, 0x53, 0x48, 0x4f, 0x57, 0x30, 0x30, 0x30, 0x31, type_id, 1, 2]);
d.extend_from_slice(tail);
d
}
fn translation_json(name: &str, m: &[u8], received: u64, note: &str) -> J {
let tr = scte104::translate(m, received, &P).unwrap_or_else(|e| panic!("{name}: {e:?}"));
let requests: Vec<J> = tr
.requests
.iter()
.map(|r| {
let withdraws: Vec<J> = r
.withdraws
.iter()
.map(|w| match w {
scte104::Withdraw::Splice(id) => json!(["splice", id]),
scte104::Withdraw::Segmentation(id) => json!(["segmentation", id]),
scte104::Withdraw::AutoReturn(id) => json!(["auto_return", id]),
})
.collect();
let cues: Vec<J> = r
.cues
.iter()
.map(|c| json!({"t": c.t, "kind": c.kind, "cue_id": c.cue_id, "duration_ms": c.duration_ms, "derived": c.derived, "section": c.section.as_deref().map(hx), "payload": hx(&c.payload())}))
.collect();
json!({"opid": r.opid, "splice_us": r.splice_us, "section": r.section.as_deref().map(hx), "cues": cues, "withdraws": withdraws})
})
.collect();
json!({"name": name, "note": note, "message": hx(m), "received_us": received, "processed_us": tr.processed_us, "warning": tr.warning, "requests": requests})
}
fn refusal_json(name: &str, m: &[u8], received: u64) -> J {
let e = scte104::translate(m, received, &P).expect_err(name);
json!({"name": name, "message": hx(m), "received_us": received, "result": e.result, "ext": e.ext, "reason": e.reason})
}
/// The UTC `timestamp()` of a room-clock instant: GPS-epoch seconds and µs >> 8.
fn utc(room_us: u64) -> Vec<u8> {
let unix = T0 + room_us;
let s = unix / 1_000_000 - scte104::EPOCH_1980_UNIX_S + P.leap_s as u64;
let mut o = vec![1];
o.extend_from_slice(&(s as u32).to_be_bytes());
o.extend_from_slice(&(((unix % 1_000_000) >> 8) as u16).to_be_bytes());
o
}
#[allow(clippy::vec_init_then_push)]
fn translations() -> Vec<J> {
let r = 10_000_000u64;
let mut v = Vec::new();
v.push(translation_json(
"splice-start-normal-preroll-auto-return-avail",
&now(1, &[(op::SPLICE, splice(1, 0x100, 8000, 300, true)), (op::INSERT_AVAIL, vec![2, 0, 0, 0, 0x35, 0, 0, 0, 0x36])]),
r,
"an out 8 s on, a 30 s break with auto-return, two avail descriptors; its in, derived, 30 s after",
));
v.push(translation_json("splice-start-immediate-no-duration", &now(2, &[(op::SPLICE, splice(2, 0x101, 5000, 0, false))]), r, "splice_immediate_flag 1, no splice_time; the pre-roll is ignored"));
v.push(translation_json("splice-start-normal-zero-preroll", &now(3, &[(op::SPLICE, splice(1, 0x102, 0, 600, false))]), r, "a pre-roll of 0: immediate; a planned duration, no auto-return"));
v.push(translation_json("splice-start-normal-preroll-too-small", &now(4, &[(op::SPLICE, splice(1, 0x103, 1000, 0, false))]), r, "issued, and answered 122"));
v.push(translation_json("splice-end-normal", &now(5, &[(op::SPLICE, splice(3, 0x100, 4000, 0, false))]), r, "an in 4 s on; it withdraws a held auto-return in"));
v.push(translation_json("splice-end-immediate", &now(6, &[(op::SPLICE, splice(4, 0x100, 9000, 99, true))]), r, "an early return: no break_duration, the pre-roll ignored"));
v.push(translation_json("splice-cancel", &now(7, &[(op::SPLICE, splice(5, 0x103, 0, 0, false))]), r, "no section and no cue: a withdrawal"));
// SCTE 35-1 2023r2 §14.1's sample, from the request its fields come from.
let sample_t = (0x0_72bd_0050u64 * 100).div_ceil(9);
let mut s = translation_json(
"time-signal-ppo-start-standard-sample",
&now(8, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, {
let mut d = 0x4800_008eu32.to_be_bytes().to_vec();
d.push(0);
d.extend_from_slice(&307u16.to_be_bytes());
d.extend_from_slice(&[0x08, 0x08, 0, 0, 0, 0, 0x2c, 0xa0, 0xa1, 0x8a, 0x34, 0x02, 0x00, 0, 0, 0, 1, 1, 3]);
d
})]),
sample_t,
"the section is the standard's sample 14.1 (hex below), byte for byte",
);
s["standard_section"] = json!("fc3034000000000000fffff00506fe72bd0050001e021c435545494800008e7fcf0001a599b00808000000002ca0a18a3402009ac9d17e");
v.push(s);
v.push(translation_json(
"time-signal-ppo-end-and-dpo-start",
&now(9, &[(op::TIME_SIGNAL, vec![0x0f, 0xa0]), (op::INSERT_SEGMENTATION, seg(0x201, false, 60, 0x36, &[])), (op::INSERT_SEGMENTATION, seg(0x200, false, 0, 0x35, &[]))]),
r,
"two descriptors in one section: the end's in before the start's out",
));
v.push(translation_json("time-signal-chapter-start", &now(10, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(0x300, false, 600, 0x20, &[5]))]), r, "a chapter of 600 s and 5 frames at 30000/1001"));
v.push(translation_json("time-signal-program-start-no-cue", &now(11, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(0x301, false, 0, 0x10, &[]))]), r, "a section, and no cue: Program Start opens no break and no chapter"));
v.push(translation_json("time-signal-segmentation-cancel", &now(12, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(0x201, true, 0, 0x36, &[]))]), r, "a cancelled descriptor: a withdrawal"));
v.push(translation_json("splice-null-heartbeat", &now(13, &[(op::SPLICE_NULL, vec![])]), r, "a section, no cue"));
v.push(translation_json("utc-timestamp-ahead", &msg(14, &utc(r + 2_500_000), &[(op::SPLICE, splice(2, 0x104, 0, 150, true))]), r, "processed 2.5 s after arrival, at the stated UTC instant (256 µs granularity)"));
v.push(translation_json("utc-timestamp-passed", &msg(15, &utc(r - 2_000_000), &[(op::SPLICE, splice(2, 0x105, 0, 0, false))]), r, "a time already passed: processed on arrival"));
v.push(translation_json(
"splice-with-dtmf-tier-time-and-private-descriptors",
&now(16, &[
(op::SPLICE, splice(1, 0x106, 6000, 0, false)),
(op::INSERT_DTMF, vec![50, 3, b'1', b'2', b'*']),
(op::INSERT_TIER, vec![0x01, 0x23]),
(op::INSERT_TIME, vec![0, 0, 0x68, 0xd6, 0x3c, 0x80, 0, 0, 0, 0, 0, 37]),
(op::INSERT_DESCRIPTOR, vec![1, 0xf0, 6, b'P', b'R', b'I', b'V', 9, 9]),
]),
r,
"tier 0x123, a DTMF descriptor, a time descriptor, and a private descriptor copied whole",
));
v.push(translation_json(
"segmentation-with-delivery-flags-and-sub-segments",
&now(17, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(0x400, false, 30, 0x34, &[0, 0, 1, 0, 1, 2, 1, 3, 4]))]),
r,
"restrictions stated; sub_segment_num 3 of 4",
));
v.push(translation_json("two-normal-requests", &now(18, &[(op::SPLICE_NULL, vec![]), (op::TIME_SIGNAL, vec![0x07, 0xd0]), (op::INSERT_SEGMENTATION, seg(0x500, false, 10, 0x22, &[]))]), r, "the cues of both requests, in order: what an injection record's index counts"));
v
}
fn refusals() -> Vec<J> {
let r = 10_000_000u64;
let ok = now(1, &[(op::SPLICE_NULL, vec![])]);
let mut v = Vec::new();
let mut m = ok.clone();
m[3] += 1;
v.push(refusal_json("message-size-mismatch", &m, r));
let mut m = ok.clone();
m.push(0);
let n = m.len() as u16;
m[2..4].copy_from_slice(&n.to_be_bytes());
v.push(refusal_json("bytes-after-the-operations", &m, r));
let mut m = ok.clone();
m[4] = 1;
v.push(refusal_json("protocol-version-1", &m, r));
let mut m = ok.clone();
m[9] = 1;
v.push(refusal_json("scte35-protocol-version-1", &m, r));
v.push(refusal_json("time-type-vitc", &msg(1, &[2, 10, 0, 0, 0], &[(op::SPLICE_NULL, vec![])]), r));
v.push(refusal_json("time-type-gpi", &msg(1, &[3, 1, 0], &[(op::SPLICE_NULL, vec![])]), r));
v.push(refusal_json("time-type-reserved", &msg(1, &[7], &[(op::SPLICE_NULL, vec![])]), r));
v.push(refusal_json("no-operation", &now(1, &[]), r));
v.push(refusal_json("supplemental-first", &now(1, &[(op::INSERT_TIER, vec![0, 1]), (op::SPLICE_NULL, vec![])]), r));
v.push(refusal_json("inject-section-data-unsupported", &now(1, &[(op::INJECT_SECTION_DATA, vec![0, 0, 0, 5])]), r));
v.push(refusal_json("encrypted-dpi-unsupported", &now(1, &[(op::SPLICE_NULL, vec![]), (0x0107, vec![1, 0, 0])]), r));
v.push(refusal_json("opid-reserved", &now(1, &[(0x7777, vec![])]), r));
v.push(refusal_json("opid-user-defined", &now(1, &[(0xc001, vec![1])]), r));
v.push(refusal_json("splice-type-reserved-0", &now(1, &[(op::SPLICE, splice(0, 1, 0, 0, false))]), r));
v.push(refusal_json("splice-type-6", &now(1, &[(op::SPLICE, splice(6, 1, 0, 0, false))]), r));
let mut short = splice(2, 1, 0, 0, false);
short.truncate(13);
v.push(refusal_json("splice-data-short", &now(1, &[(op::SPLICE, short)]), r));
v.push(refusal_json("segmentation-flag-2", &now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(1, false, 0, 0x34, &[0, 2, 0, 0, 0, 0]))]), r));
v.push(refusal_json("segmentation-device-restrictions-4", &now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(1, false, 0, 0x34, &[0, 0, 0, 0, 0, 4]))]), r));
v.push(refusal_json("segmentation-tail-length", &now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(1, false, 0, 0x34, &[0, 0]))]), r));
v.push(refusal_json("sub-segment-on-a-type-without-them", &now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(1, false, 0, 0x35, &[0, 1, 0, 0, 0, 0, 1, 1, 1]))]), r));
v.push(refusal_json("tier-high-nibble", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_TIER, vec![0x10, 0])]), r));
v.push(refusal_json("tier-twice", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_TIER, vec![0, 1]), (op::INSERT_TIER, vec![0, 2])]), r));
v.push(refusal_json("dtmf-eight-characters", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_DTMF, vec![10, 8, 1, 2, 3, 4, 5, 6, 7, 8])]), r));
v.push(refusal_json("avail-count-mismatch", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_AVAIL, vec![2, 0, 0, 0, 1])]), r));
v.push(refusal_json("descriptor-image-standard", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_DESCRIPTOR, vec![1, 0x02, 4, b'C', b'U', b'E', b'I'])]), r));
v.push(refusal_json("descriptor-image-overrun", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_DESCRIPTOR, vec![1, 0xf0, 9, 1, 2])]), r));
v.push(refusal_json("timestamp-before-the-room", &msg(1, &[1, 0, 0, 0, 1, 0, 0], &[(op::SPLICE_NULL, vec![])]), r));
let mut d = 7u32.to_be_bytes().to_vec();
d.push(0);
d.extend_from_slice(&[0, 0, 0x0c, 250]);
d.extend(std::iter::repeat_n(0x41, 250));
d.extend_from_slice(&[0x34, 0, 0]);
v.push(refusal_json("segmentation-descriptor-too-long", &now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, d)]), r));
let images: Vec<u8> = std::iter::once(17u8).chain((0..17).flat_map(|_| std::iter::once(0xf0).chain(std::iter::once(250)).chain(std::iter::repeat_n(0x42, 250)))).collect();
v.push(refusal_json("section-too-long", &now(1, &[(op::SPLICE_NULL, vec![]), (op::INSERT_DESCRIPTOR, images)]), r));
v
}
fn events_hex(e: &Engine, signed: &[joule_moq_ingest::scte::Signed]) -> Vec<String> {
let _ = e;
signed.iter().flat_map(|s| [hx(&s.injection), hx(&s.cue)]).collect()
}
/// A scenario: messages with their arrivals, the engine run as the injector runs it (every
/// cue due by a message's arrival signed before the message is taken), until `until`.
fn scenario(name: &str, note: &str, msgs: &[(u64, Vec<u8>)], until: u64, breaks: J) -> (J, Vec<Vec<u8>>) {
let mut e = Engine::new(ROOM, SigningKey::from_bytes(&SEED), P, 256);
let mut signed = Vec::new();
let mut ms = Vec::new();
for (at, m) in msgs {
signed.extend(e.due(*at).0);
let tr = scte104::translate(m, *at, &P).unwrap();
let a = e.accept(m, &tr, *at);
ms.push(json!({"received_us": at, "message": hx(m), "result": a.result}));
}
signed.extend(e.due(until).0);
let counts: BTreeMap<&str, u64> = e.counts.clone();
let raw: Vec<Vec<u8>> = signed.iter().flat_map(|s| [s.injection.clone(), s.cue.clone()]).collect();
// `breaks` is written by hand from live-ads §4.3, not computed here: what the chain's
// cues make, which the verifier derives on its own.
(json!({"name": name, "note": note, "messages": ms, "until_us": until, "events": events_hex(&e, &signed), "counts": counts, "breaks": breaks}), raw)
}
fn scenarios() -> (Vec<J>, Vec<Vec<u8>>) {
let s = 20_000_000u64;
let mut v = Vec::new();
let brk = |id: u32, o: u64, i: u64| json!({"cue_id": id, "out_t": o, "in_t": i, "duration_ms": (i - o) / 1000});
let (a, first) = scenario("break-with-auto-return", "an immediate out of 2.5 s with auto-return: the out, then its derived in", &[(s, now(1, &[(op::SPLICE, splice(2, 1, 0, 25, true))]))], s + 3_000_000, json!({"breaks": [brk(1, s, s + 2_500_000)], "chapters": []}));
v.push(a);
v.push(
scenario(
"early-return",
"an out of 30 s with auto-return; an early return at 5 s withdraws the derived in",
&[(s, now(1, &[(op::SPLICE, splice(2, 2, 0, 300, true))])), (s + 5_000_000, now(2, &[(op::SPLICE, splice(4, 2, 0, 0, false))]))],
s + 40_000_000,
json!({"breaks": [brk(2, s, s + 5_000_000)], "chapters": []}),
)
.0,
);
v.push(
scenario(
"cancel-before-the-splice",
"an out 8 s on, cancelled at 2 s: nothing signed; a second cancel finds nothing and is answered 121",
&[(s, now(1, &[(op::SPLICE, splice(1, 3, 8000, 0, false))])), (s + 2_000_000, now(2, &[(op::SPLICE, splice(5, 3, 0, 0, false))])), (s + 3_000_000, now(3, &[(op::SPLICE, splice(5, 3, 0, 0, false))]))],
s + 10_000_000,
json!({"breaks": [], "chapters": []}),
)
.0,
);
v.push(
scenario(
"overlap-and-unmatched",
"a second out while a break is open, and an in of another id: neither signed",
&[(s, now(1, &[(op::SPLICE, splice(2, 4, 0, 0, false))])), (s + 1_000_000, now(2, &[(op::SPLICE, splice(2, 5, 0, 0, false))])), (s + 2_000_000, now(3, &[(op::SPLICE, splice(4, 6, 0, 0, false))])), (s + 3_000_000, now(4, &[(op::SPLICE, splice(4, 4, 0, 0, false))]))],
s + 4_000_000,
json!({"breaks": [brk(4, s, s + 3_000_000)], "chapters": []}),
)
.0,
);
v.push(
scenario(
"placement-opportunity-and-chapter",
"a chapter, then a provider placement opportunity of 15 s and its end at 15 s",
&[
(s, now(1, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(7, false, 3600, 0x20, &[]))])),
(s + 1_000_000, now(2, &[(op::TIME_SIGNAL, vec![0x0f, 0xa0]), (op::INSERT_SEGMENTATION, seg(8, false, 15, 0x34, &[]))])),
(s + 20_000_000, now(3, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, seg(8, false, 0, 0x35, &[]))])),
],
s + 21_000_000,
json!({"breaks": [brk(8, s + 5_000_000, s + 20_000_000)], "chapters": [{"cue_id": 7, "t": s, "duration_ms": 3_600_000}]}),
)
.0,
);
v.push(
scenario(
"repeated-message",
"a message sent twice before its splice (SCTE 104 §9.3): taken once",
&[(s, now(9, &[(op::SPLICE, splice(1, 9, 4000, 50, true))])), (s + 500_000, now(9, &[(op::SPLICE, splice(1, 9, 4000, 50, true))]))],
s + 12_000_000,
json!({"breaks": [brk(9, s + 4_000_000, s + 9_000_000)], "chapters": []}),
)
.0,
);
(v, first)
}
/// Chains a verifier refuses, each a mutation of `good` (the first scenario's events)
/// re-signed where the mutation is not of the signature itself.
fn negatives(good: &[Vec<u8>]) -> Vec<J> {
let key = SigningKey::from_bytes(&SEED);
// Rebuild a chain from (t, kind, payload) entries, signed.
let sign = |entries: &[(u64, u8, Vec<u8>)]| -> Vec<String> {
let mut prev = [0u8; 32];
let mut out = Vec::new();
for (i, (t, k, p)) in entries.iter().enumerate() {
let (b, d) = event(&ROOM, &key, i as u64, &prev, *t, *k, p);
prev = d;
out.push(hx(&b));
}
out
};
let parse = |e: &Vec<u8>| (u64::from_le_bytes(e[109..117].try_into().unwrap()), e[118], e[121..e.len() - 64].to_vec());
let entries: Vec<(u64, u8, Vec<u8>)> = good.iter().map(parse).collect();
let mut v = Vec::new();
// The cue without its injection record.
let mut e = entries.clone();
e.remove(0);
v.push(json!({"name": "cue-without-injection-record", "events": sign(&e), "refused_at": 0, "reason": "injection_missing"}));
// The cue's duration changed.
let mut e = entries.clone();
e[1].2[5] ^= 1;
v.push(json!({"name": "cue-not-the-translation", "events": sign(&e), "refused_at": 1, "reason": "injection_mismatch"}));
// The cue's instant changed.
let mut e = entries.clone();
e[1].0 += 1;
e[2].0 += 1;
e[3].0 += 1;
v.push(json!({"name": "cue-at-another-instant", "events": sign(&e), "refused_at": 1, "reason": "injection_mismatch"}));
// The record's index points past the message's cues.
let mut e = entries.clone();
e[0].2[22] = 9;
v.push(json!({"name": "index-past-the-cues", "events": sign(&e), "refused_at": 1, "reason": "injection_mismatch"}));
// A record of version 2.
let mut e = entries.clone();
e[0].2[4] = 2;
v.push(json!({"name": "record-version-2", "events": sign(&e), "refused_at": 0, "reason": "injection_malformed"}));
// A record whose message length runs past it.
let mut e = entries.clone();
e[0].2[23] = e[0].2[23].wrapping_add(1);
v.push(json!({"name": "record-length-past-its-bytes", "events": sign(&e), "refused_at": 0, "reason": "injection_malformed"}));
// A record whose message does not translate.
let mut e = entries.clone();
e[0].2[25 + 4] = 1;
v.push(json!({"name": "record-message-refused", "events": sign(&e), "refused_at": 0, "reason": "injection_untranslatable"}));
// A record followed by another record.
let mut e = entries.clone();
e.swap(1, 2);
v.push(json!({"name": "record-not-followed-by-its-cue", "events": sign(&e), "refused_at": 0, "reason": "injection_unanswered"}));
// The signature of the cue flipped.
let mut g: Vec<String> = good.iter().map(|b| hx(b)).collect();
let mut b = good[1].clone();
let n = b.len();
b[n - 1] ^= 0x80;
g[1] = hx(&b);
v.push(json!({"name": "cue-signature-flipped", "events": g, "refused_at": 1, "reason": "bad_signature"}));
// A link that is not the event before's digest.
let mut g: Vec<String> = good.iter().map(|b| hx(b)).collect();
let mut b = good[2].clone();
b[77] ^= 1;
let body = &b[..b.len() - 64];
let sig = ed25519_dalek::Signer::sign(&key, &[joule_moq_ingest::scte::EVENT_DOMAIN, body].concat());
let n = b.len();
b[n - 64..].copy_from_slice(&sig.to_bytes());
g[2] = hx(&b);
v.push(json!({"name": "chain-broken", "events": g, "refused_at": 2, "reason": "chain_broken"}));
v
}
#[test]
fn the_injection_corpus_regenerates_byte_for_byte() {
let (sc, first) = scenarios();
let key = SigningKey::from_bytes(&SEED);
let v = json!({
"about": "SCTE 104 to SCTE 35 to signed cues (WAI extensions/cue-injection.md). Generated by joule-moq-ingest's tests/conformance_corpus.rs; verified independently by injection_verify.py.",
"params": {"t0_us": P.t0_us, "leap_s": P.leap_s, "fps_num": P.fps_num, "fps_den": P.fps_den},
"room": hx(&ROOM),
"principal": hx(&key.verifying_key().to_bytes()),
"translations": translations(),
"refusals": refusals(),
"scenarios": sc,
"negative_chains": negatives(&first),
});
check("injection/vectors.json", &v);
}
// ── The E-RTMP corpus ──────────────────────────────────────────────────────────────
struct Session {
name: &'static str,
note: &'static str,
audio: bool,
video: bool,
video_profile: Option<&'static str>,
skew_us: u64,
msgs: Vec<(u8, u32, u64, Vec<u8>)>,
}
const GSEED: [u8; 32] = [0x53; 32];
fn run_session(s: &Session) -> J {
let profile: Option<Profile> = s.video_profile.map(|n| registered_profiles().into_iter().find(|p| p.name == n).unwrap());
let signer = Signer { key: Arc::new(SigningKey::from_bytes(&GSEED)), signer_id: "did:key:test".into(), origin_label: "wai-ingest".into() };
let grant = GrantRef { grant_hash: [0x47; 32], joule_ceiling_micro: u64::MAX, funds_ceiling: None };
let mut packagers = BTreeMap::new();
let mut alias_of = BTreeMap::new();
for (i, &(m, l)) in tracks::layout(s.audio, s.video, profile.as_ref()).iter().enumerate() {
let a = 100 + 2 * i as u64;
alias_of.insert(a, (m, l));
let out = TrackOut { media: m, media_alias: a, receipts_alias: a + 1, receipt_alias: 7, priority: 0 };
packagers.insert((m, l), Packager::new(out, 1_000_000, s.skew_us, grant.clone(), signer.clone(), T0, 200_000, 2_000_000, 1 << 20));
}
let mut t = Tracks::new(s.audio, s.video, profile.clone(), None, packagers);
let mut emits = Vec::new();
for (ty, ts, arrival, payload) in &s.msgs {
match ty {
8 | 9 => t.on_media(*ty == 9, *ts, payload, *arrival, &mut emits),
18 => t.on_data(&amf0::read_all(payload).unwrap()),
22 => assert!(t.on_aggregate(*ts, payload, *arrival, &mut emits)),
_ => unreachable!(),
}
}
for p in t.packagers.values_mut() {
p.end_group(&mut emits);
}
let mut objs: BTreeMap<(u8, u8), Vec<J>> = BTreeMap::new();
for e in emits {
if let Emit::Object { alias, group, object, bytes, .. } = e {
let o = frame::open(&bytes).unwrap();
let live = &o.manifest["live"];
objs.entry(alias_of[&alias]).or_default().push(json!({
"group": group, "object": object, "t": live["t"], "rtp": live["rtp"], "rate": live["rate"],
"key": live.get("key").cloned().unwrap_or(J::Null), "cto": live.get("cto").cloned().unwrap_or(J::Null),
"capability": o.manifest["model_requirement"]["capability"], "payload": hx(&o.payload),
}));
}
}
let tracks: Vec<J> = objs.into_iter().map(|((m, l), o)| json!({"media": if m == 1 { "audio" } else { "video" }, "layer": l, "objects": o})).collect();
let msgs: Vec<J> = s.msgs.iter().map(|(ty, ts, at, p)| json!({"type": ty, "ts": ts, "arrival_us": at, "payload": hx(p)})).collect();
let prof = profile.map(|p| json!({"name": p.name, "families": p.families(), "layers": p.layers.iter().map(|l| json!([l.width, l.height, l.fps])).collect::<Vec<_>>()}));
json!({
"name": s.name, "note": s.note,
"grant": {"audio": s.audio, "video": s.video, "video_profile": prof},
"params": {"skew_us": s.skew_us, "audio_group_us": 200_000, "max_video_group_us": 2_000_000, "max_frame_bytes": 1 << 20},
"messages": msgs,
"tracks": tracks,
"counts": t.counts,
})
}
fn b(n: u64, len: usize) -> Vec<u8> {
(0..len).map(|j| ((n * 31 + j as u64 * 7) % 251 + 1) as u8).collect()
}
fn meta(pairs: &[(&str, Value)]) -> Vec<u8> {
amf0::encode(&[Value::str("@setDataFrame"), Value::str("onMetaData"), Value::EcmaArray(pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())])
}
fn lp(nals: &[Vec<u8>]) -> Vec<u8> {
nals.iter().flat_map(|n| (n.len() as u32).to_be_bytes().into_iter().chain(n.iter().copied())).collect()
}
fn avc_rec() -> Vec<u8> {
vec![1, 0x42, 0xc0, 0x1e, 0xff, 0xe1, 0, 4, 0x67, 0x42, 0xc0, 0x1e, 1, 0, 2, 0x68, 0xce]
}
fn hevc_rec() -> Vec<u8> {
let mut o = vec![1, 0x01, 0x60, 0, 0, 0, 0x90, 0, 0, 0, 0, 0, 90, 0xf0, 0, 0xfc, 0xfd, 0xf8, 0xf8, 0, 0, 0x0f, 3];
for (ty, s) in [(32u8, vec![0x40, 0x01, 0x0c]), (33, vec![0x42, 0x01, 0x01]), (34, vec![0x44, 0x01, 0xc1])] {
o.push(0x80 | ty);
o.extend_from_slice(&1u16.to_be_bytes());
o.extend_from_slice(&(s.len() as u16).to_be_bytes());
o.extend_from_slice(&s);
}
o
}
fn seq_header(w: u16, h: u16) -> Vec<u8> {
// profile 0, still 0, reduced 0, timing 0, delay 0, one operating point (idc 0,
// level 8, tier 0), 16-bit sizes.
let bits = format!("{:03b}{}{}{}{}{:05b}{:012b}{:05b}{}{:04b}{:04b}{:016b}{:016b}", 0, 0, 0, 0, 0, 0, 0, 8, 0, 15, 15, w - 1, h - 1);
let mut s = bits;
while !s.len().is_multiple_of(8) {
s.push('0');
}
let p: Vec<u8> = (0..s.len()).step_by(8).map(|i| u8::from_str_radix(&s[i..i + 8], 2).unwrap()).collect();
let mut o = vec![0x0a, p.len() as u8];
o.extend(p);
o
}
fn vp9(key: bool, n: u64) -> Vec<u8> {
// frame_marker 2, profile 0, show_existing 0, frame_type, show 1, error_res 0; a key
// frame's sync code, colour space 1, range 0, 320×180.
let bits = if key { format!("10{:02b}0{}10{:024b}{:03b}0{:016b}{:016b}", 0, 0, 0x49_83_42, 1, 319, 179) } else { "1000" .to_string() + "110" };
let mut s = bits;
while !s.len().is_multiple_of(8) {
s.push('0');
}
let mut o: Vec<u8> = (0..s.len()).step_by(8).map(|i| u8::from_str_radix(&s[i..i + 8], 2).unwrap()).collect();
o.extend(b(n, 20));
o
}
fn ex(key: bool, pt: u8, f: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut o = vec![0x80 | if key { 0x10 } else { 0x20 } | pt];
o.extend_from_slice(f);
o.extend_from_slice(body);
o
}
fn exa(pt: u8, f: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut o = vec![0x90 | pt];
o.extend_from_slice(f);
o.extend_from_slice(body);
o
}
fn multi(video: bool, key: bool, pt: u8, tracks: &[(&[u8; 4], u8, Vec<u8>)]) -> Vec<u8> {
let mut o = if video { vec![0x80 | if key { 0x10 } else { 0x20 } | 6, 0x20 | pt] } else { vec![0x95, 0x20 | pt] };
for (f, id, body) in tracks {
o.extend_from_slice(*f);
o.push(*id);
o.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]);
o.extend_from_slice(body);
}
o
}
fn sessions() -> Vec<Session> {
let a0 = 5_000_000u64;
let at = |i: u64| a0 + i * 33_333;
let mut v = Vec::new();
// Legacy AVC and AAC.
let mut m = vec![(18u8, 0, a0, meta(&[("width", Value::Number(320.0)), ("height", Value::Number(180.0)), ("framerate", Value::Number(30.0))])), (9, 0, a0, vec![0x17, 0, 0, 0, 0].into_iter().chain(avc_rec()).collect()), (8, 0, a0, vec![0xaf, 0, 0x12, 0x10])];
for i in 0..6u64 {
let key = i % 3 == 0;
let nal = [vec![if key { 0x65 } else { 0x41 }], b(i, 12)].concat();
m.push((9, (i * 33) as u32, at(i), [vec![if key { 0x17 } else { 0x27 }, 1, 0, 0, 0], lp(&[nal])].concat()));
m.push((8, (i * 21) as u32, at(i), [vec![0xaf, 1], b(i + 50, 9)].concat()));
}
v.push(Session { name: "legacy-avc-aac", note: "Annex B with the record's SPS and PPS before each IDR; one ADTS frame per raw AAC frame; audio windows of 200 ms", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// Enhanced HEVC with composition offsets, and Opus.
let mut m = vec![(9u8, 0, a0, ex(true, 0, b"hvc1", &hevc_rec())), (8, 0, a0, exa(0, b"Opus", &[]))];
for i in 0..5u64 {
let key = i == 0 || i == 3;
let nal = [if key { vec![0x26, 0x01] } else { vec![0x02, 0x01] }, b(i, 10)].concat();
let body = if i % 2 == 0 { [vec![0, 0, 66], lp(&[nal])].concat() } else { lp(&[nal]) };
m.push((9, (i * 33) as u32, at(i), ex(key, if i % 2 == 0 { 1 } else { 3 }, b"hvc1", &body)));
m.push((8, (i * 20) as u32, at(i), exa(1, b"Opus", &[[0xfc].as_slice(), &b(i, 8)].concat())));
}
v.push(Session { name: "enhanced-hevc-opus", note: "CodedFrames with a composition time offset of 66 ms (live.cto 66000) and CodedFramesX; VPS, SPS and PPS before each IRAP", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// AV1 and FLAC, a ModEx nanosecond offset.
let mut flac = b"fLaC".to_vec();
flac.extend_from_slice(&[0, 0, 0, 34, 0x10, 0, 0x10, 0, 0, 0, 0, 0, 0, 0]);
flac.extend_from_slice(&((48_000u64 << 44) | (1 << 41) | (15 << 36)).to_be_bytes());
flac.extend_from_slice(&[0; 16]);
let mut m = vec![(9u8, 0, a0, ex(true, 0, b"av01", &[vec![0x81, 0x08, 0x0c, 0], seq_header(320, 180)].concat())), (8, 0, a0, exa(0, b"fLaC", &flac))];
for i in 0..4u64 {
let key = i == 0;
let tu = [vec![0x12, 0], vec![0x32, 6], b(i, 6)].concat();
let body = if i == 2 {
let mut o = vec![0x80 | 0x20 | 7, 2, 0x06, 0x1a, 0x80, 0x01];
o.extend_from_slice(b"av01");
o.extend_from_slice(&tu);
o
} else {
ex(key, 1, b"av01", &tu)
};
m.push((9, (i * 33) as u32, at(i), body));
m.push((8, (i * 85) as u32, at(i), exa(1, b"fLaC", &[vec![0xff, 0xf8, 0x69, 0x18], b(i, 7)].concat())));
}
v.push(Session { name: "av1-flac-nano-offset", note: "the record's sequence header before the key temporal unit, the temporal delimiter restored by the framing; a ModEx TimestampOffsetNano of 400 000 ns on frame 2; a FLAC stream per object", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// FLAC whose sequence start is STREAMINFO alone.
let si = flac[8..42].to_vec();
let mut m = vec![(8u8, 0, a0, exa(0, b"fLaC", &si))];
for i in 0..3u64 {
m.push((8, (i * 85) as u32, at(i), exa(1, b"fLaC", &[vec![0xff, 0xf8, 0x69, 0x18], b(i + 40, 6)].concat())));
}
m.push((8, 255, at(3), exa(1, b"fLaC", &[])));
v.push(Session { name: "flac-streaminfo-alone", note: "a FLAC sequence start of the 34 bytes of STREAMINFO alone, read as that block: each object's stream begins with fLaC and the block, marked last; then a coded-frames packet of no bytes, not carried", audio: true, video: false, video_profile: None, skew_us: 250_000, msgs: m });
// VP9 and enhanced AAC.
let mut m = vec![(9u8, 0, a0, ex(true, 0, b"vp09", &[0, 31, 0x80, 1, 1, 1, 0, 0])), (8, 0, a0, exa(0, b"mp4a", &[0x11, 0x90]))];
for i in 0..4u64 {
m.push((9, (i * 33) as u32, at(i), ex(i == 0, 1, b"vp09", &vp9(i == 0, i))));
m.push((8, (i * 21) as u32, at(i), exa(1, b"mp4a", &b(i + 9, 11))));
}
v.push(Session { name: "vp9-enhanced-aac", note: "one frame in IVF with the size from the uncompressed header; ADTS from the AudioSpecificConfig", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// Multitrack under a two-layer profile.
let info = |w: f64, h: f64| Value::obj(&[("width", Value::Number(w)), ("height", Value::Number(h)), ("framerate", Value::Number(30.0))]);
let mut m = vec![
(18u8, 0, a0, meta(&[("width", Value::Number(1280.0)), ("height", Value::Number(720.0)), ("framerate", Value::Number(29.97)), ("videoTrackIdInfoMap", Value::obj(&[("1", info(640.0, 360.0)), ("2", info(320.0, 180.0))]))])),
(9, 0, a0, multi(true, true, 0, &[(b"avc1", 0, avc_rec()), (b"av01", 1, [vec![0x81, 0x08, 0x0c, 0], seq_header(640, 360)].concat()), (b"hvc1", 2, hevc_rec())])),
(8, 0, a0, multi(false, false, 0, &[(b"mp4a", 0, vec![0x11, 0x90]), (b"Opus", 1, vec![])])),
];
for i in 0..3u64 {
let key = i == 0;
let nal = [vec![if key { 0x65 } else { 0x41 }], b(i, 9)].concat();
let tu = [vec![0x32, 5], b(i + 3, 5)].concat();
m.push((9, (i * 33) as u32, at(i), multi(true, key, 1, &[(b"avc1", 0, [vec![0, 0, 0], lp(&[nal])].concat()), (b"av01", 1, tu), (b"hvc1", 2, [vec![0, 0, 0], lp(&[vec![0x26, 1, 9]])].concat())])));
m.push((8, (i * 21) as u32, at(i), multi(false, false, 1, &[(b"mp4a", 0, b(i, 7)), (b"Opus", 1, vec![0xfc, 1, 2])])));
}
v.push(Session { name: "multitrack-profile-layers", note: "under camera-720 (layer 0 640×360, layer 1 1280×720, av01 and avc1): track 1 is layer 0, track 0 layer 1; track 2 is HEVC (not in the profile) and audio track 1 is not the default", audio: true, video: true, video_profile: Some("camera-720"), skew_us: 250_000, msgs: m });
// Re-anchoring and a timestamp that wraps.
let mut m = vec![(9u8, 0, a0, vec![0x17, 0, 0, 0, 0].into_iter().chain(avc_rec()).collect())];
for (i, (ts, arr)) in [(0xffff_ff00u32, a0), (0xffff_ff21, a0 + 33_000), (0x0000_0010, a0 + 66_000), (0x0000_0031, a0 + 900_000), (0x0000_0052, a0 + 933_000)].iter().enumerate() {
let key = i == 0;
let nal = [vec![if key { 0x65 } else { 0x41 }], b(i as u64, 5)].concat();
m.push((9, *ts, *arr, [vec![if key { 0x17 } else { 0x27 }, 1, 0, 0, 0], lp(&[nal])].concat()));
}
v.push(Session { name: "wrap-and-reanchor", note: "a timestamp that wraps past 2^32 is extended; a frame 800 ms late re-anchors the mapping at its arrival (§7.1)", audio: false, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// Codecs the gateway does not carry, each the first packet of its track.
let m = vec![
(9u8, 0, a0, ex(true, 0, b"vvc1", &[1, 2, 3])),
(9, 33, a0 + 33_000, ex(true, 1, b"vvc1", &[0, 0, 0, 0, 0, 0, 2, 0x40, 1])),
(8, 0, a0, vec![0x2f, 1, 2]),
(8, 21, a0 + 21_000, vec![0x2f, 3, 4]),
];
v.push(Session { name: "codecs-not-carried", note: "vvc1, mapped and not carried in this revision; legacy MP3 (sound format 2): every packet of each track counted", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// Frames the gateway reads and does not carry.
let opus_ms = [b"OpusHead".as_slice(), &[1, 6, 0, 0, 0x80, 0xbb, 0, 0, 0, 0, 1, 4, 2, 0, 4, 1, 2, 5, 3]].concat();
let m = vec![
(9u8, 0, a0, [vec![0x27, 1, 0, 0, 0], lp(&[vec![0x41, 1, 2]])].concat()),
(9, 10, a0 + 10_000, vec![0x17, 0, 0, 0, 0].into_iter().chain(avc_rec()).collect()),
(9, 20, a0 + 20_000, [vec![0x27, 1, 0, 0, 0], lp(&[vec![0x41, 7, 7]])].concat()),
(9, 30, a0 + 30_000, [vec![0x17, 1, 0, 0, 0], vec![0, 0, 0, 9, 0x65]].concat()),
(9, 40, a0 + 40_000, [vec![0x17, 1, 0, 0, 0], lp(&[vec![0x65, 8, 8]])].concat()),
(8, 0, a0, exa(0, b"Opus", &opus_ms)),
(8, 20, a0 + 20_000, exa(1, b"Opus", &[0xfc, 1])),
];
v.push(Session { name: "frames-not-carried", note: "an AVC frame before its sequence start; an interframe before the first keyframe; a NAL unit length past the frame; then a keyframe, carried; an Opus body of four streams", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// Bodies that do not read.
let m = vec![
(9u8, 0, a0, vec![0x87, 1, 0, 0, 0x01]),
(9, 10, a0, vec![0x86, 0x16]),
(9, 20, a0, vec![0x9f, b'a', b'v', b'0', b'1']),
(9, 30, a0, vec![0x17, 0x01]),
(8, 0, a0, exa(0, b"ac-3", &[1])),
];
v.push(Session { name: "bodies-refused", note: "a ModEx TimestampOffsetNano of two bytes; multitrack inside multitrack; a reserved packet type; a legacy AVC header that ends early; and ac-3, not carried", audio: true, video: true, video_profile: None, skew_us: 250_000, msgs: m });
// An aggregate message.
let tag = |ty: u8, ts: u32, body: &[u8]| -> Vec<u8> {
let mut o = vec![ty];
o.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]);
o.extend_from_slice(&ts.to_be_bytes()[1..]);
o.push((ts >> 24) as u8);
o.extend_from_slice(&[0, 0, 0]);
o.extend_from_slice(body);
o.extend_from_slice(&(11 + body.len() as u32).to_be_bytes());
o
};
let mut agg = tag(9, 1000, &[vec![0x17, 0, 0, 0, 0], avc_rec()].concat());
agg.extend(tag(9, 1000, &[vec![0x17, 1, 0, 0, 0], lp(&[vec![0x65, 3, 3]])].concat()));
agg.extend(tag(9, 1033, &[vec![0x27, 1, 0, 0, 0], lp(&[vec![0x41, 4, 4]])].concat()));
v.push(Session { name: "aggregate", note: "FLV tags in one aggregate message, their timestamps offset to the message's (5000)", audio: false, video: true, video_profile: None, skew_us: 250_000, msgs: vec![(22, 5000, a0, agg)] });
v
}
#[test]
fn the_ertmp_corpus_regenerates_byte_for_byte() {
let v = json!({
"about": "Enhanced RTMP v2 (v2-2026-01-31-r2) into room tracks (WAI extensions/live-ingest.md §12). Generated by joule-moq-ingest's tests/conformance_corpus.rs; verified independently by ertmp_verify.py.",
"t0_us": T0,
"sessions": sessions().iter().map(run_session).collect::<Vec<_>>(),
});
check("ertmp/vectors.json", &v);
}
// ── The texts' reason tables ─────────────────────────────────────────────────────
fn codes_in(text: &str, heading: &str) -> std::collections::BTreeSet<String> {
let start = text.find(heading).unwrap_or_else(|| panic!("{heading}"));
let rest = &text[start..];
let end = rest[1..].find("\n## ").map_or(rest.len(), |i| i + 1);
let mut out = std::collections::BTreeSet::new();
for line in rest[..end].lines().filter(|l| l.starts_with("| `")) {
let first = line.split('|').nth(1).unwrap();
for c in first.split('`').skip(1).step_by(2) {
out.insert(c.to_string());
}
}
out
}
#[test]
fn the_texts_list_the_codes() {
let root = dir().join("../extensions");
let ci = std::fs::read_to_string(root.join("cue-injection.md")).unwrap();
let listed = codes_in(&ci, "## 9. Reasons");
let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for r in refusals() {
used.insert(r["reason"].as_str().unwrap().to_string());
}
for n in negatives(&scenarios().1) {
used.insert(n["reason"].as_str().unwrap().to_string());
}
for c in ["break_overlap", "cue_in_unmatched", "break_zero_duration", "cancel_too_late", "repeated_message", "not_initialized", "injector_in_use", "dpi_pid_unknown", "injection_too_large", "too_many_cues", "operation_unsupported", "opid_unknown"] {
used.insert(c.into());
}
let missing: Vec<_> = used.difference(&listed).collect();
assert!(missing.is_empty(), "cue-injection §9 lists every code the injector and its verifier give: missing {missing:?}");
let li = std::fs::read_to_string(root.join("live-ingest.md")).unwrap();
let listed = codes_in(&li, "## 14. Reasons");
let rtmp = [
"stream_key_unknown", "stream_key_expired", "already_publishing", "codec_not_carried", "codec_not_permitted", "audio_track_not_default", "track_no_layer", "track_layer_taken",
"tag_truncated", "tag_modex", "tag_multitrack", "tag_packet_type", "no_sequence_start", "frame_unreadable", "frame_not_carried", "sequence_start_unreadable",
"chunk_no_header", "chunk_interleaved", "message_too_large", "chunk_size", "chunk_streams", "handshake_version", "command_malformed",
];
let missing: Vec<_> = rtmp.iter().filter(|c| !listed.contains(**c)).collect();
assert!(missing.is_empty(), "live-ingest §14 lists every RTMP code: missing {missing:?}");
let _ = (KIND_CUE, KIND_META, injection);
}

View file

@ -0,0 +1,116 @@
//! The gateway's throughput and energy on a contribution link: a sender in another
//! process (`joule-contrib-send`, named by `WAI_CONTRIB_SEND`) sends a synthetic program at
//! `WAI_BENCH_KBPS` (20 000 by default) for `WAI_BENCH_SECONDS` (20) over SRT, encrypted, into
//! the gateway, which publishes every object with its receipts to a relay whose door checks
//! every receipt; both are this process. It prints one JSON line: the payload rate carried,
//! the objects published, the process's CPU time and its energy by the counter's profile,
//! class and scope (the gateway and the relay; the sender's process is not counted).
//!
//! ```text
//! WAI_CONTRIB_SEND=…/release/joule-contrib-send cargo test --release -p joule-moq-ingest \
//! --test contrib_bench -- --ignored --nocapture
//! ```
mod common;
use common::*;
use joule_contrib::energy;
use joule_moq_ingest::linkrec;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn env_u64(k: &str, d: u64) -> u64 {
std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(d)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
#[ignore = "a measurement: set WAI_CONTRIB_SEND, run with --release"]
async fn contribution_throughput_and_energy() {
// What the relay refuses at its door, and why, goes to the test's output.
let _ = tracing_subscriber::fmt().with_max_level(tracing::Level::WARN).with_test_writer().try_init();
let send = std::env::var("WAI_CONTRIB_SEND").expect("WAI_CONTRIB_SEND names joule-contrib-send");
let kbps = env_u64("WAI_BENCH_KBPS", 20_000);
let seconds = env_u64("WAI_BENCH_SECONDS", 20);
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.srt.latency_ms = 200;
c.contrib.max_bitrate_kbps = 2_000_000;
c.limits.max_frame_bytes = 16 << 20;
c.limits.max_queue_bytes = 512 << 20;
})
.await;
let auth = w.auth(&w.publisher, 0);
let r = auth.send("POST", &format!("{}/srt", w.base()), &[], b"").await;
assert_eq!(r.status, 201, "{}", r.text());
let s: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let url = format!(
"srt://127.0.0.1:{}?latency=200&streamid={}&passphrase={}&pbkeylen=16",
w.gateway.srt_addr.unwrap().port(),
s["stream_id"].as_str().unwrap().replace('#', "%23").replace(',', "%2C").replace('=', "%3D").replace('/', "%2F"),
s["passphrase"].as_str().unwrap()
);
// On a counter every user of the package shares (linux-rapl), the figure is a bracket:
// the run's reading less an idle reading taken just before it, scaled to the run's length.
let idle = match energy::read() {
Some(r) if r.scope == "bracket" => {
let t = Instant::now();
tokio::time::sleep(Duration::from_secs(seconds)).await;
Some((r.to(&energy::read().unwrap()), t.elapsed().as_secs_f64()))
}
_ => None,
};
let e0 = energy::read();
let c0 = energy::cpu_us();
let t0 = Instant::now();
let mut child = Command::new(&send).args([&url, "--synthetic", &seconds.to_string(), "--kbps", &kbps.to_string(), "--linger", "1000"]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().expect("the sender runs");
let out = tokio::task::spawn_blocking(move || {
let mut o = String::new();
std::io::Read::read_to_string(child.stdout.as_mut().unwrap(), &mut o).unwrap();
let st = child.wait().unwrap();
(st, o)
})
.await
.unwrap();
assert!(out.0.success(), "the sender finished: {}", out.1);
assert!(until(Duration::from_secs(30), || w.gateway.gateway.link_records().iter().any(|b| linkrec::LinkRecord::read(b).is_ok_and(|r| r.last))).await, "the session ended");
let wall = t0.elapsed().as_secs_f64();
let c1 = energy::cpu_us();
let e1 = energy::read();
let parts = linkrec::verify_series(&w.gateway.gateway.link_records()).expect("the link record verifies");
let bytes: u64 = parts.iter().map(|p| p.account.bytes).sum();
let packets: u64 = parts.iter().map(|p| p.account.delivered).sum();
// The session's record is signed once its tracks have ended at the relay, after its
// last link part.
assert!(until(Duration::from_secs(30), || w.gateway.gateway.records().iter().any(|r| r.record.protocol == "srt")).await, "the session record");
let rec = w.gateway.gateway.records().into_iter().find(|r| r.record.protocol == "srt").unwrap();
let objects: u64 = rec.record.tracks.iter().filter(|t| t.dir == 2).map(|t| t.objects).sum();
let raw = match (&e0, &e1) {
(Some(a), Some(b)) => Some(a.to(b) as f64 / 1e9),
_ => None,
};
let idle_j = idle.map(|(nj, s)| nj as f64 / 1e9 * wall / s);
let joules = match (raw, idle_j) {
(Some(r), Some(i)) => Some(r - i),
(r, _) => r,
};
let gb = bytes as f64 / 1e9;
println!(
"{}",
serde_json::json!({
"bench": "gateway-srt-aes128",
"kbps_target": kbps,
"seconds": seconds,
"wall_s": wall,
"payload_bytes": bytes,
"packets": packets,
"objects_published": objects,
"payload_mbps": bytes as f64 * 8.0 / 1e6 / seconds as f64,
"cpu_s": (c1 - c0) as f64 / 1e6,
"refused_at_door": w.relay.relay().stats().refused_at_door,
"sender": serde_json::from_str::<serde_json::Value>(out.1.trim()).unwrap_or(serde_json::Value::Null),
"energy": e1.map(|e| serde_json::json!({"profile": e.profile, "class": e.class, "scope": e.scope, "joules": joules, "raw_joules": raw, "idle_joules": idle_j, "j_per_gb": joules.map(|j| j / gb.max(1e-9)), "covers": if e.scope == "task" { "the gateway and the relay, one process; not the sender" } else { "the package less an idle bracket: the gateway, the relay and the sender together" }})),
})
);
assert_eq!(w.relay.relay().stats().refused_at_door, 0);
w.stop().await;
}

View file

@ -0,0 +1,184 @@
//! The reference tools into the gateway: a transport stream of H.264 or HEVC and AAC made
//! by a locally run encoder, sent by the reference SRT caller with the session's stream ID
//! and passphrase, and by the reference RIST sender in the Main Profile under PSK, each
//! through a path that drops datagrams. A subscriber at the relay verifies every receipt;
//! every access unit and ADTS frame of the file arrives; the encoder's own probe counts as
//! many video units; the link record verifies and shows the losses recovered.
//!
//! Ignored unless asked for; with `--ignored`, a tool not named is a failure, never a skip:
//!
//! ```text
//! WAI_SRT_LIVE_TRANSMIT=… WAI_RISTSENDER=… WAI_FFMPEG=… WAI_FFPROBE=… \
//! cargo test -p joule-moq-ingest --features rist --test contrib_interop -- --ignored
//! ```
mod common;
use common::*;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::ts::{self, Demux, Pacer};
use joule_moq_ingest::frame::Codec;
use joule_moq_ingest::linkrec;
use joule_moq_ingest::room::media;
use std::process::{Command, Stdio};
use std::time::Duration;
fn tool(var: &str) -> String {
std::env::var(var).unwrap_or_else(|_| panic!("{var} names the tool this test runs; it is not set"))
}
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn spawn(bin: &str, args: &[&str]) -> Child {
Child(Command::new(bin).args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap_or_else(|e| panic!("{bin}: {e}")))
}
/// Four seconds of test pattern and tone, encoded by the local encoder into a transport
/// stream: `codec` "libx264" or "libx265".
fn encode(codec: &str) -> Vec<u8> {
let dir = std::env::temp_dir().join(format!("wai-contrib-interop-{}-{codec}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("in.ts");
let st = Command::new(tool("WAI_FFMPEG"))
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=30", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "4", "-c:v", codec, "-preset", "veryfast", "-g", "30", "-bf", "0", "-pix_fmt", "yuv420p"])
.args(if codec == "libx265" { vec!["-x265-params", "log-level=error"] } else { vec![] })
.args(["-c:a", "aac", "-b:a", "96k", "-muxrate", "3000000", "-f", "mpegts"])
.arg(&out)
.status()
.unwrap();
assert!(st.success(), "the encoder runs");
std::fs::read(out).unwrap()
}
/// The encoder's probe's count of the video stream's packets.
fn probe_video_units(ts: &[u8]) -> u64 {
let f = std::env::temp_dir().join(format!("wai-contrib-probe-{}.ts", std::process::id()));
std::fs::write(&f, ts).unwrap();
let o = Command::new(tool("WAI_FFPROBE")).args(["-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"]).arg(&f).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout).into_owned();
out.split(|c: char| !c.is_ascii_digit()).find(|t| !t.is_empty()).and_then(|t| t.parse().ok()).unwrap_or_else(|| panic!("a packet count: {out:?} {}", String::from_utf8_lossy(&o.stderr)))
}
/// The access units and ADTS frames of a transport stream, as the gateway should carry
/// them.
fn units(ts_bytes: &[u8]) -> (Vec<Vec<u8>>, Vec<Vec<u8>>) {
let mut d = Demux::new();
let mut p = d.push(ts_bytes);
p.extend(d.flush());
let video = p.iter().filter(|x| matches!(x.stream_type, ts::ST_H264 | ts::ST_HEVC)).map(|x| x.data.clone()).collect();
let audio = p.iter().filter(|x| x.stream_type == ts::ST_AAC_ADTS).flat_map(|x| ts::adts_frames(&x.data).into_iter().map(|f| f.to_vec()).collect::<Vec<_>>()).collect();
(video, audio)
}
async fn feed(ts_bytes: &[u8], to: std::net::SocketAddr) {
let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let chunks = Pacer::new(ts_bytes, 7).chunks().to_vec();
let start = tokio::time::Instant::now();
for (at, c) in chunks {
tokio::time::sleep_until(start + Duration::from_micros(at)).await;
s.send_to(&c, to).await.unwrap();
}
}
fn free_port() -> u16 {
let s = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
s.local_addr().unwrap().port()
}
async fn check(w: &World, watcher: &Watcher, input: &[u8], codec: Codec) {
assert!(until(Duration::from_secs(30), || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
settle(watcher, Duration::from_millis(500)).await;
let (want_v, want_a) = units(input);
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let audio = watcher.tracks.iter().find(|t| t.kind == media::AUDIO).unwrap();
let (vf, vg) = verified(watcher, video, &w.gateway_key, codec);
let (af, _) = verified(watcher, audio, &w.gateway_key, Codec::Aac);
// The link's own account first: a packet the link dropped explains a missing frame, and
// one it did not drop places the loss outside the link.
let ps = linkrec::verify_series(&w.gateway.gateway.link_records()).expect("the link record's parts verify");
assert!(ps.last().unwrap().last);
let sum = |f: fn(&linkrec::LinkRecord) -> u64| ps.iter().map(f).sum::<u64>();
let (delivered, first_try, dropped, outstanding) = (sum(|p| p.account.delivered), sum(|p| p.account.first_try), sum(|p| p.account.dropped), sum(|p| p.account.outstanding));
let bytes = sum(|p| p.account.bytes);
eprintln!("the link's account: delivered {delivered} ({first_try} at the first try), dropped {dropped}, outstanding {outstanding}, {bytes} bytes of the input's {}", input.len());
assert_eq!(dropped, 0, "the link dropped nothing");
assert!(delivered > first_try, "losses were recovered");
same(&vf, &want_v, "every access unit of the file");
same(&af, &want_a, "every ADTS frame of the file");
assert_eq!(vf.len() as u64, probe_video_units(input), "as many video units as the encoder's probe counts");
assert!(vg >= 4, "a group per keyframe, at least one a second: {vg}");
assert_eq!(w.relay.relay().stats().refused_at_door, 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference SRT tool and a local encoder: set WAI_SRT_LIVE_TRANSMIT, WAI_FFMPEG, WAI_FFPROBE"]
async fn the_reference_srt_caller_into_the_gateway() {
for (codec, wai) in [("libx264", Codec::Avc), ("libx265", Codec::Hevc)] {
let input = encode(codec);
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.record_interval_ms = 1_000;
c.contrib.srt.latency_ms = 300;
})
.await;
let auth = w.auth(&w.publisher, 0);
let r = auth.send("POST", &format!("{}/srt", w.base()), &[], b"").await;
assert_eq!(r.status, 201, "{}", r.text());
let s: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let imp = Impair { loss_ppm: 20_000, spare_first: 10, ..Impair::default() };
let px = Proxy::start(w.gateway.srt_addr.unwrap(), imp, imp, 41).await.unwrap();
let sid = s["stream_id"].as_str().unwrap().replace('#', "%23");
let url = format!("srt://127.0.0.1:{}?latency=300&streamid={sid}&passphrase={}&pbkeylen=16", px.addr.port(), s["passphrase"].as_str().unwrap());
let udp_in = free_port();
let child = spawn(&tool("WAI_SRT_LIVE_TRANSMIT"), &["-q", "-a", "no", &format!("udp://127.0.0.1:{udp_in}"), &url]);
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await, "the reference caller is admitted");
let watcher = watch(&w).await;
feed(&input, format!("127.0.0.1:{udp_in}").parse().unwrap()).await;
tokio::time::sleep(Duration::from_millis(1_500)).await;
// The reference caller ends the connection when it is stopped.
drop(child);
check(&w, &watcher, &input, wai).await;
px.stop();
w.stop().await;
}
}
#[cfg(feature = "rist")]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "runs the reference RIST tool and a local encoder: set WAI_RISTSENDER, WAI_FFMPEG, WAI_FFPROBE"]
async fn the_reference_rist_sender_into_the_gateway() {
let input = encode("libx264");
let w = World::start(|c| {
c.contrib.rist.enabled = true;
c.contrib.rist.aes = 128;
c.contrib.record_interval_ms = 1_000;
})
.await;
let auth = w.auth(&w.publisher, 0);
let r = auth.send("POST", &format!("{}/rist", w.base()), &[("content-type", "application/json")], br#"{"profile":"main"}"#).await;
assert_eq!(r.status, 201, "{}", r.text());
let s: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let imp = Impair { loss_ppm: 20_000, spare_first: 16, ..Impair::default() };
let target: std::net::SocketAddr = format!("127.0.0.1:{}", s["port"]).parse().unwrap();
let px = Proxy::start(target, imp, imp, 42).await.unwrap();
let url = format!("rist://127.0.0.1:{}?cname={}&buffer=1000&secret={}&aes-type=128", px.addr.port(), s["cname"].as_str().unwrap(), s["passphrase"].as_str().unwrap());
let udp_in = free_port();
let child = spawn(&tool("WAI_RISTSENDER"), &["-i", &format!("udp://@127.0.0.1:{udp_in}"), "-o", &url, "-p", "1", "-v", "2"]);
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await, "the reference sender's flow is the session's");
let watcher = watch(&w).await;
feed(&input, format!("127.0.0.1:{udp_in}").parse().unwrap()).await;
tokio::time::sleep(Duration::from_millis(2_500)).await;
drop(child);
check(&w, &watcher, &input, Codec::Avc).await;
let ps = linkrec::verify_series(&w.gateway.gateway.link_records()).unwrap();
assert!(ps.iter().all(|p| p.protocol == 3 && p.cipher == 128));
px.stop();
w.stop().await;
}

View file

@ -0,0 +1,102 @@
//! RIST into a room's tracks, end to end on loopback: a publisher POSTs `/rist` for the
//! Simple Profile (a port pair of its own and a CNAME) and for the Main Profile (a tunnel
//! port, a CNAME and a PSK passphrase), and sends a transport stream of H.264 and AAC; a
//! subscriber at the relay verifies every receipt and gets every unit as sent; the link
//! record names the profile, the PSK key length and the receive window. A flow with another
//! CNAME opens no session.
#![cfg(feature = "rist")]
mod common;
use common::*;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::rist::{self, Profile, SendConfig};
use joule_contrib::ts;
use joule_moq_ingest::frame::Codec;
use joule_moq_ingest::linkrec;
use joule_moq_ingest::room::media;
use std::time::Duration;
use tokio::time::timeout;
async fn open(w: &World, auth: &Auth, profile: &str) -> serde_json::Value {
let body = serde_json::json!({"profile": profile}).to_string();
let r = auth.send("POST", &format!("{}/rist", w.base()), &[("content-type", "application/json")], body.as_bytes()).await;
assert_eq!(r.status, 201, "POST /rist: {}", r.text());
serde_json::from_slice(&r.body).unwrap()
}
async fn one(profile: Profile) {
let w = World::start(|c| {
c.contrib.rist.enabled = true;
c.contrib.rist.aes = 256;
c.contrib.rist.buffer_ms = 800;
c.contrib.record_interval_ms = 1_000;
})
.await;
let auth = w.auth(&w.publisher, 0);
let s = open(&w, &auth, if profile == Profile::Main { "main" } else { "simple" }).await;
let port = s["port"].as_u64().unwrap() as u16;
let target: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
assert_eq!(s["passphrase"].is_string(), profile == Profile::Main);
// A wrong CNAME first: no session.
let stranger = rist::send(target, SendConfig { profile, cname: "not-the-session".into(), secret: s["passphrase"].as_str().map(str::to_string), aes_256: true, ..SendConfig::default() }).await.unwrap();
let tx = stranger.send.clone().unwrap();
for _ in 0..30 {
let mut p = vec![0x47u8; 1316];
p[1] = 0x1f;
p[2] = 0xff;
let _ = tx.send(p).await;
}
tokio::time::sleep(Duration::from_millis(500)).await;
assert_eq!(w.gateway.gateway.contrib.active.load(std::sync::atomic::Ordering::SeqCst), 0, "no session for another CNAME");
stranger.close();
tokio::time::sleep(Duration::from_millis(300)).await;
let (target, px) = if profile == Profile::Main {
let imp = Impair { loss_ppm: 25_000, spare_first: 16, ..Impair::default() };
let px = Proxy::start(target, imp, imp, 5).await.unwrap();
(px.addr, Some(px))
} else {
(target, None)
};
let cfg = SendConfig { profile, cname: s["cname"].as_str().unwrap().into(), secret: s["passphrase"].as_str().map(str::to_string), aes_256: true, buffer_ms: 1500, ..SendConfig::default() };
let link = rist::send(target, cfg).await.unwrap();
// The flow is the session's once its first RTCP names the CNAME; then the tracks are
// published, and a subscriber joins before the first frame.
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await, "the session publishes its tracks");
let watcher = watch(&w).await;
let sent = send_ts(&link, 3, 30, ts::ST_H264).await;
tokio::time::sleep(Duration::from_millis(1_500)).await;
link.close();
// RIST says nothing when a sender stops: the session ends when the flow goes idle.
assert!(until(Duration::from_secs(15), || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
settle(&watcher, Duration::from_millis(500)).await;
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let audio = watcher.tracks.iter().find(|t| t.kind == media::AUDIO).unwrap();
let (vf, _) = verified(&watcher, video, &w.gateway_key, Codec::Avc);
let (af, _) = verified(&watcher, audio, &w.gateway_key, Codec::Aac);
same(&vf, &sent.video, "every access unit");
same(&af, &sent.audio, "every ADTS frame");
let ps = linkrec::verify_series(&w.gateway.gateway.link_records()).expect("the parts verify as a series");
let last = ps.last().unwrap();
assert!(last.last && last.ended == linkrec::ended::IDLE);
let (want_proto, want_cipher) = if profile == Profile::Main { (3, 256) } else { (2, 0) };
assert!(ps.iter().all(|p| p.protocol == want_proto && p.cipher == want_cipher && p.window == 800_000));
if px.is_some() {
let recovered: u64 = ps.iter().map(|p| p.account.delivered - p.account.first_try).sum();
assert!(recovered > 0, "losses on the tunnel were recovered");
assert_eq!(ps.iter().map(|p| p.account.dropped).sum::<u64>(), 0);
}
w.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_simple_profile_flow_reaches_the_relay() {
timeout(Duration::from_secs(120), one(Profile::Simple)).await.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_main_profile_tunnel_under_psk_reaches_the_relay() {
timeout(Duration::from_secs(120), one(Profile::Main)).await.expect("in time");
}

View file

@ -0,0 +1,326 @@
//! SRT into a room's tracks, end to end on loopback: a publisher POSTs `/srt` with its grant
//! and gets a one-time session; it calls the gateway's listener with the stream ID and the
//! passphrase, through a path that drops datagrams each way, and sends a transport stream
//! of H.264 and AAC. A subscriber at the relay receives every group of both tracks, verifies
//! every receipt under the gateway's key, and gets every access unit and every ADTS frame
//! as sent. The link record's parts verify as a series, account for every sequence number,
//! show the losses recovered, and name the head of each track's receipt chain, which is the
//! last group receipt the subscriber verified; they are on the owner's `link` track too.
//! Then the refusals, the gateway calling a publisher's listener, and HEVC.
mod common;
use common::*;
use joule_contrib::lossy::{Impair, Proxy};
use joule_contrib::srt::{self, conn::CallError, packet::rej, Admitted, CallConfig, ConnRequest, ListenerConfig, Role};
use joule_contrib::ts;
use joule_moq::carriage::ReceiptBundle;
use joule_moq::receipt::ReceiptVerifier;
use joule_moq::{GroupId, ObjectId};
use joule_moq_ingest::catalog;
use joule_moq_ingest::frame::Codec;
use joule_moq_ingest::linkrec::{self, ended, LinkRecord};
use joule_moq_ingest::room::media;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
/// Every object of `t` verified against its receipts under `key`, as its raw payload, in
/// group order: one object a group.
fn verified_raw(wt: &Watcher, t: &Watched, key: &ed25519_dalek::SigningKey) -> Vec<Vec<u8>> {
let v = ReceiptVerifier::with_anchors(vec![key.verifying_key()]);
let objs = wt.objects.lock().unwrap();
let mut out = Vec::new();
let mut prev = None;
for ((alias, g, o), p) in objs.iter() {
if *alias != t.media_alias {
continue;
}
let b = objs.iter().find(|(k, _)| k.0 == t.receipts_alias && k.1 == *g).map(|(_, b)| ReceiptBundle::from_payload(b).unwrap()).expect("each group's receipts arrived");
let vg = v.verify_group(&b.group).expect("the group receipt verifies under the gateway's key");
if let Some(link) = prev {
assert_eq!(b.group.parent_receipt_hash, Some(link), "each group chains to the one before");
}
prev = Some(b.group.link_hash());
let r = &b.objects[*o as usize];
r.check_moqt(b.group.track_alias, GroupId(*g), ObjectId(*o)).unwrap();
v.verify_payload(p, r, &vg).expect("the object verifies against its receipt");
out.push(p.clone());
}
out
}
async fn open_srt(w: &World, auth: &Auth) -> serde_json::Value {
let r = auth.send("POST", &format!("{}/srt", w.base()), &[("content-type", "application/json")], b"").await;
assert_eq!(r.status, 201, "POST /srt: {}", r.text());
assert!(r.header("location").unwrap().starts_with("/srt/s/"));
serde_json::from_slice(&r.body).unwrap()
}
fn call_cfg(s: &serde_json::Value) -> CallConfig {
CallConfig {
stream_id: s["stream_id"].as_str().map(str::to_string),
passphrase: s["passphrase"].as_str().map(str::to_string),
keylen: s["pbkeylen"].as_u64().unwrap() as usize,
latency_ms: 300,
..CallConfig::default()
}
}
/// The link records of the gateway, read as one series.
fn parts(w: &World) -> Vec<LinkRecord> {
let recs = w.gateway.gateway.link_records();
linkrec::verify_series(&recs).unwrap_or_else(|e| panic!("the link record's parts verify as a series: {e:?}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_srt_publisher_reaches_a_relay_subscriber_with_every_receipt() {
timeout(Duration::from_secs(120), run()).await.expect("the test finishes in time");
}
async fn run() {
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.record_interval_ms = 1_000;
c.contrib.srt.latency_ms = 250;
})
.await;
let auth = w.auth(&w.publisher, 0);
let s = open_srt(&w, &auth).await;
let sid = s["stream_id"].as_str().unwrap();
assert!(sid.starts_with(&format!("#!::r={}/{},m=publish,s=", hex::encode(w.room), w.owner())), "{sid}");
// 3% of the datagrams dropped each way, and a burst of six toward the gateway.
let up = Impair { loss_ppm: 30_000, drop_index: &[200, 201, 202, 203, 204, 205], ..Impair::default() };
let px = Proxy::start(w.gateway.srt_addr.unwrap(), up, Impair { loss_ppm: 30_000, ..Impair::default() }, 0xabc).await.unwrap();
let link = timeout(STEP, srt::call(px.addr, call_cfg(&s))).await.unwrap().expect("the call is admitted");
assert_eq!(link.info.cipher_bits, 128);
// The tracks are published once the call is admitted; a subscriber joins before the
// first frame.
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await, "the session publishes its tracks");
let watcher = watch(&w).await;
// A watcher of the owner's link track and its receipts.
let links = watch_tracks(&w, vec![(0, catalog::catalog_namespace(&w.room, &w.owner()), "link".into())]).await;
let sent = send_ts(&link, 3, 30, ts::ST_H264).await;
assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 10, "the path dropped datagrams");
tokio::time::sleep(Duration::from_millis(800)).await;
link.close();
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
settle(&watcher, Duration::from_millis(500)).await;
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let audio = watcher.tracks.iter().find(|t| t.kind == media::AUDIO).unwrap();
let (vf, vg) = verified(&watcher, video, &w.gateway_key, Codec::Avc);
let (af, _) = verified(&watcher, audio, &w.gateway_key, Codec::Aac);
same(&vf, &sent.video, "every access unit, byte for byte");
same(&af, &sent.audio, "every ADTS frame, byte for byte");
assert_eq!(vg, 3, "one video group per keyframe");
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "the relay's door verified every object");
let env = envelopes(&watcher, video);
assert_eq!(env[0].manifest["model_requirement"]["capability"], "wai.video.avc");
assert_eq!(env[0].manifest["live"]["rate"], 90_000);
assert_eq!(envelopes(&watcher, audio)[0].manifest["model_requirement"]["capability"], "wai.audio.aac");
// The link record.
assert!(until(STEP, || w.gateway.gateway.link_records().iter().any(|b| LinkRecord::read(b).is_ok_and(|r| r.last))).await, "the last part is signed");
let ps = parts(&w);
assert!(ps.len() >= 3, "a part a second, and the last: {}", ps.len());
let last = ps.last().unwrap();
assert!(last.last && last.ended == ended::PEER_SHUTDOWN);
let (mut delivered, mut first, mut dropped, mut count) = (0, 0, 0, 0);
for p in &ps {
assert_eq!((p.protocol, p.mode, p.cipher), (1, 1, 128));
assert_eq!(p.window, 300_000, "the larger of the two ends' latencies");
delivered += p.account.delivered;
first += p.account.first_try;
dropped += p.account.dropped;
count += p.account.seq_count;
}
assert_eq!(dropped, 0, "every loss recovered within the window");
assert!(delivered - first >= 6, "the losses were found and recovered: {}", delivered - first);
assert_eq!(count, delivered + last.account.outstanding);
let sent_payloads = link.sent.lock().unwrap().sent;
assert_eq!(delivered, sent_payloads, "every payload the publisher sent, accounted for");
// Each track's head is the link hash of the last group receipt the subscriber verified.
for t in [video, audio] {
let objs = watcher.objects.lock().unwrap();
let bundles: Vec<ReceiptBundle> = objs.iter().filter(|(k, _)| k.0 == t.receipts_alias).map(|(_, p)| ReceiptBundle::from_payload(p).unwrap()).collect();
let ns = catalog::media_namespace(&w.room, &w.owner(), t.kind);
let head = last.tracks.iter().find(|h| h.track == catalog::track_digest(&ns, "0")).expect("the record names the track");
assert_eq!(head.groups, bundles.len() as u64);
assert_eq!(head.head, bundles.last().unwrap().group.link_hash(), "the record names the head of the track's receipt chain");
}
// Every part is on the owner's link track, each object receipted by the gateway.
settle(&links, Duration::from_millis(300)).await;
let lt = &links.tracks[0];
let got = verified_raw(&links, lt, &w.gateway_key);
assert_eq!(got, w.gateway.gateway.link_records(), "the link track carries every part, as signed");
// The record's session record names the protocol.
assert!(until(STEP, || w.gateway.gateway.records().iter().any(|r| r.record.protocol == "srt")).await);
let rec = w.gateway.gateway.records().into_iter().find(|r| r.record.protocol == "srt").unwrap();
rec.verify().unwrap();
assert_eq!(rec.record.ended, "peer_shutdown");
// The session's resource is gone.
let loc = format!("{}/srt/s/{}", w.base(), s["session"].as_str().unwrap());
assert_eq!(auth.send("DELETE", &loc, &[], b"").await.status, 404);
px.stop();
w.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_listener_refuses_with_the_mappings_codes() {
timeout(Duration::from_secs(120), refusals()).await.expect("in time");
}
async fn refusals() {
let w = World::start(|c| c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap())).await;
let addr = w.gateway.srt_addr.unwrap();
let auth = w.auth(&w.publisher, 0);
let s = open_srt(&w, &auth).await;
let call = |cfg: CallConfig| srt::call(addr, CallConfig { connect_timeout_ms: 3_000, ..cfg });
// No stream ID; one without a session; a session the gateway never gave.
assert_eq!(call(CallConfig { passphrase: s["passphrase"].as_str().map(str::to_string), ..CallConfig::default() }).await.err(), Some(CallError::Rejected(rej::UNAUTHORIZED)));
let fake = format!("#!::r={}/{},m=publish,s={}", hex::encode(w.room), w.owner(), "00".repeat(16));
assert_eq!(call(CallConfig { stream_id: Some(fake), ..call_cfg(&s) }).await.err(), Some(CallError::Rejected(rej::UNAUTHORIZED)));
// Play mode, which this gateway does not serve.
let req = s["stream_id"].as_str().unwrap().replace("m=publish", "m=request");
assert_eq!(call(CallConfig { stream_id: Some(req), ..call_cfg(&s) }).await.err(), Some(CallError::Rejected(rej::BAD_MODE)));
// Another room; another owner.
let other_room = s["stream_id"].as_str().unwrap().replace(&hex::encode(w.room), &"ab".repeat(32));
assert_eq!(call(CallConfig { stream_id: Some(other_room), ..call_cfg(&s) }).await.err(), Some(CallError::Rejected(rej::NOTFOUND)));
let other_owner = s["stream_id"].as_str().unwrap().replace(&w.owner(), &"cd".repeat(32));
assert_eq!(call(CallConfig { stream_id: Some(other_owner), ..call_cfg(&s) }).await.err(), Some(CallError::Rejected(rej::FORBIDDEN)));
// The right session and a wrong passphrase: refused, and the session is spent.
assert_eq!(call(CallConfig { passphrase: Some("not the session's passphrase".into()), ..call_cfg(&s) }).await.err(), Some(CallError::Rejected(rej::BADSECRET)));
assert_eq!(call(call_cfg(&s)).await.err(), Some(CallError::Rejected(rej::UNAUTHORIZED)), "a session ID is good for one use");
// A new session, used once.
let s2 = open_srt(&w, &auth).await;
let l = call(call_cfg(&s2)).await.expect("admitted");
assert_eq!(call(call_cfg(&s2)).await.err(), Some(CallError::Rejected(rej::UNAUTHORIZED)));
// While its owner publishes, another session of the same owner is refused: 1409.
let s3 = open_srt(&w, &auth).await;
assert!(until(STEP, || w.gateway.gateway.contrib.active.load(std::sync::atomic::Ordering::SeqCst) == 1).await);
assert_eq!(call(call_cfg(&s3)).await.err(), Some(CallError::Rejected(rej::CONFLICT)));
l.close();
// A viewer's grant cannot open a contribution session.
let viewer = w.auth(&w.publisher, 1);
let r = viewer.send("POST", &format!("{}/srt", w.base()), &[], b"").await;
assert_eq!(r.status, 403, "{}", r.text());
// DELETE of a session waiting to connect cancels it.
let s4 = open_srt(&w, &auth).await;
let loc = format!("{}/srt/s/{}", w.base(), s4["session"].as_str().unwrap());
assert_eq!(auth.send("DELETE", &loc, &[], b"").await.status, 200);
assert_eq!(call(call_cfg(&s4)).await.err(), Some(CallError::Rejected(rej::UNAUTHORIZED)));
w.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_gateway_calls_a_publishers_listener() {
timeout(Duration::from_secs(120), calls()).await.expect("in time");
}
async fn calls() {
let w = World::start(|c| c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap())).await;
// The publisher's listener: it sends to whoever calls with its passphrase.
let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("the encoder's passphrase".into()), role: Role::Send, latency_ms: Some(200), tag: 0 });
let mut enc = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap();
let auth = w.auth(&w.publisher, 0);
let body = serde_json::json!({"call": enc.addr.to_string(), "passphrase": "the encoder's passphrase"}).to_string();
// Not in call_allow: refused.
let r = auth.send("POST", &format!("{}/srt", w.base()), &[("content-type", "application/json")], body.as_bytes()).await;
assert_eq!(r.status, 403, "{}", r.text());
w.stop().await;
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.srt.call_allow = vec!["127.0.0.1".into()];
})
.await;
let auth = w.auth(&w.publisher, 0);
let r = auth.send("POST", &format!("{}/srt", w.base()), &[("content-type", "application/json")], body.as_bytes()).await;
assert_eq!(r.status, 201, "{}", r.text());
let id: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
let link = timeout(STEP, enc.accept()).await.unwrap().unwrap();
assert!(contrib_live(&w, id["session"].as_str().unwrap()).await);
let watcher = watch(&w).await;
let sent = send_ts(&link, 2, 30, ts::ST_HEVC).await;
tokio::time::sleep(Duration::from_millis(700)).await;
link.close();
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await);
settle(&watcher, Duration::from_millis(500)).await;
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let (vf, vg) = verified(&watcher, video, &w.gateway_key, Codec::Hevc);
same(&vf, &sent.video, "every HEVC access unit, byte for byte");
assert_eq!(vg, 2, "one group per IRAP");
let ps = parts(&w);
assert!(ps.iter().all(|p| p.mode == 2 && p.cipher == 128), "the gateway called");
w.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn contribution_at_twenty_megabits_passes_the_door() {
timeout(Duration::from_secs(120), big_groups()).await.expect("in time");
}
/// Contribution at 20 Mbit/s for four seconds, a keyframe each second: video groups of about
/// 2.6 MB beside the audio. The relay's door holds each object until its group's receipts
/// follow the group's last object, so it reads each group whole off its stream before it
/// releases any of it, which the relay's stream window (`limits.quic_stream_window_bytes`)
/// must hold: under QUIC's default of about 1.25 MB the stream stops and the door refuses.
async fn big_groups() {
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.max_bitrate_kbps = 1_000_000;
c.limits.max_queue_bytes = 256 << 20;
})
.await;
let auth = w.auth(&w.publisher, 0);
let s = open_srt(&w, &auth).await;
let link = timeout(STEP, srt::call(w.gateway.srt_addr.unwrap(), call_cfg(&s))).await.unwrap().unwrap();
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await);
let watcher = watch(&w).await;
let chunks = joule_contrib::cli::synthetic(4, 20_000);
joule_contrib::cli::pace(&link, &chunks, 800).await.unwrap();
link.close();
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
settle(&watcher, Duration::from_millis(500)).await;
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "the door released every group");
let all: Vec<u8> = chunks.iter().flat_map(|c| c.1.clone()).collect();
let mut d = ts::Demux::new();
let mut pes = d.push(&all);
pes.extend(d.flush());
let want: Vec<Vec<u8>> = pes.iter().filter(|p| p.stream_type == ts::ST_H264).map(|p| p.data.clone()).collect();
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let (vf, vg) = verified(&watcher, video, &w.gateway_key, Codec::Avc);
assert!(want.iter().map(|f| f.len()).sum::<usize>() > 9_000_000, "groups of megabytes");
same(&vf, &want, "every access unit");
assert_eq!(vg, 4);
w.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn contribution_at_twenty_megabits_passes_the_door_with_no_subscriber() {
timeout(Duration::from_secs(120), async {
let w = World::start(|c| {
c.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap());
c.contrib.max_bitrate_kbps = 1_000_000;
c.limits.max_queue_bytes = 256 << 20;
})
.await;
let auth = w.auth(&w.publisher, 0);
let s = open_srt(&w, &auth).await;
let link = timeout(STEP, srt::call(w.gateway.srt_addr.unwrap(), call_cfg(&s))).await.unwrap().unwrap();
let chunks = joule_contrib::cli::synthetic(4, 20_000);
joule_contrib::cli::pace(&link, &chunks, 800).await.unwrap();
link.close();
assert!(until(STEP, || w.gateway.gateway.records().iter().any(|r| r.record.protocol == "srt")).await, "the session ended");
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "the door released every group");
let rec = w.gateway.gateway.records().into_iter().find(|r| r.record.protocol == "srt").unwrap();
assert_eq!(rec.record.ended, "peer_shutdown", "the session ended as the publisher ended it, not by the relay");
w.stop().await;
})
.await
.expect("in time");
}

View file

@ -0,0 +1,410 @@
//! Enhanced RTMP into the room, with a publisher of our own (`tests/common/rtmp.rs`): a
//! stream key from `/rtmp` under a grant, the handshake, `connect` with `capsEx`, and
//! FLV tag bodies built from Enhanced RTMP v2's tables. A subscriber at the relay
//! receives every group, verifies every receipt under the gateway's key (and the relay's
//! door verifies them too), and gets each frame in the payload framing
//! `live-ingest.md` §12's table gives it, at the room-clock time §7 maps it to.
//!
//! - legacy AVC and AAC: Annex B with the record's SPS and PPS before each IDR access
//! unit, one ADTS frame per raw AAC frame;
//! - enhanced HEVC (CodedFrames with a composition time offset, `live.cto`) and Opus;
//! - AV1 and FLAC, VP9 and enhanced AAC, a ModEx nanosecond offset;
//! - multitrack: under a grant whose video profile has two layers, each track carried as
//! the layer its `onMetaData` entry matches, and the tracks that match none, or are not
//! audio track 0, counted and not carried;
//! - RTMPS; the refusals of a stream key; the reconnect request at shutdown.
mod common;
use common::rtmp::*;
use common::*;
// Both modules have an `adts`: this file's frames are the RTMP helper's.
use common::rtmp::adts;
use joule_moq_ingest::frame::{self, Codec};
use joule_moq_ingest::room::media;
use joule_moq_ingest::rtmp::amf0::Value;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::timeout;
const GOP: u64 = 30;
const FRAMES: u64 = 90;
/// A world with RTMP listening and a skew bound wide enough that a publisher sending
/// faster than real time is mapped by its timestamps alone (§7).
async fn world(f: impl FnOnce(&mut joule_moq_ingest::GatewayConfig)) -> World {
World::start(|c| {
c.rtmp.listen = Some("127.0.0.1:0".parse().unwrap());
c.timeline.max_skew_ms = 600_000;
f(c)
})
.await
}
async fn client(w: &World) -> Client<TcpStream> {
let s = TcpStream::connect(w.gateway.rtmp_addr.expect("an RTMP listener")).await.unwrap();
Client::start(s).await
}
/// Publish `key`, and fail the test with the gateway's description where it refuses.
async fn publishing(w: &World, key: &str, caps_ex: u32) -> Client<TcpStream> {
let mut c = client(w).await;
let r = c.connect(caps_ex).await;
let props = &r[2];
assert_eq!(props.get("capsEx").and_then(Value::as_f64), Some(15.0), "Reconnect, Multitrack, ModEx, TimestampNanoOffset");
assert!(props.get("videoFourCcInfoMap").and_then(|m| m.get("hvc1")).is_some());
let s = c.publish(key).await;
assert_eq!((s.code.as_str(), s.level.as_str()), ("NetStream.Publish.Start", "status"), "{}", s.description);
c
}
/// End the publish and wait for every track to end at the relay.
async fn finish(w: &World, c: &mut Client<TcpStream>, watcher: &Watcher, n: usize) {
c.delete_stream().await;
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == n).await, "PUBLISH_DONE on every track");
settle(watcher, Duration::from_millis(400)).await;
c.shutdown().await;
assert!(until(STEP, || !w.gateway.gateway.records().is_empty()).await, "the session's record");
}
fn track(wt: &Watcher, kind: u8, layer: u8) -> &Watched {
wt.tracks.iter().find(|t| t.kind == kind && t.layer == layer).unwrap()
}
/// The room-clock time of each object of `t`, and its `live` member.
fn lives(wt: &Watcher, t: &Watched) -> Vec<serde_json::Value> {
envelopes(wt, t).into_iter().map(|e| e.manifest["live"].clone()).collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn legacy_avc_and_aac() {
timeout(Duration::from_secs(120), async {
let w = world(|_| {}).await;
let auth = w.auth(&w.publisher, 0);
let key = stream_key(&w, &auth).await;
let mut c = publishing(&w, &key, 0).await;
let watcher = watch(&w).await;
c.metadata(vec![("width".into(), Value::Number(640.0)), ("height".into(), Value::Number(360.0)), ("framerate".into(), Value::Number(30.0)), ("videocodecid".into(), Value::Number(7.0)), ("audiocodecid".into(), Value::Number(10.0))]).await;
c.video(0, legacy_avc(0, true, 0, &avc_record())).await;
c.audio(0, legacy_aac(0, &AAC_ASC)).await;
let (mut want_v, mut want_a) = (Vec::new(), Vec::new());
for i in 0..FRAMES {
let key = i % GOP == 0;
let nals = avc_nals(i, key);
c.video((i * 1000 / 30) as u32, legacy_avc(1, key, 0, &lengths(&nals))).await;
let mut au = if key { vec![AVC_SPS.to_vec(), AVC_PPS.to_vec()] } else { Vec::new() };
au.extend(nals);
want_v.push(annexb(&au));
let raw = aac_raw(i);
c.audio((i * 1000 / 30) as u32, legacy_aac(1, &raw)).await;
want_a.push(adts(&raw));
}
finish(&w, &mut c, &watcher, 2).await;
let (vf, vg) = verified(&watcher, track(&watcher, media::VIDEO, 0), &w.gateway_key, Codec::Avc);
let (af, _) = verified(&watcher, track(&watcher, media::AUDIO, 0), &w.gateway_key, Codec::Aac);
same(&vf, &want_v, "AVC: Annex B, the record's parameter sets before each IDR");
same(&af, &want_a, "AAC: one ADTS frame each");
assert_eq!(vg as u64, FRAMES / GOP, "one video group per keyframe");
// §7: t follows the message timestamps from the first frame's arrival.
let l = lives(&watcher, track(&watcher, media::VIDEO, 0));
let t0 = l[0]["t"].as_u64().unwrap();
for (i, x) in l.iter().enumerate() {
assert_eq!(x["t"].as_u64().unwrap() - t0, (i as u64 * 1000 / 30) * 1000, "frame {i}'s room-clock time");
assert_eq!(x["rate"], 1_000_000);
assert!(x.get("cto").is_none(), "no composition offset when it is 0");
}
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "the relay's door verified every object");
let rec = w.gateway.gateway.records().remove(0);
rec.verify().expect("the record verifies under the gateway's key");
assert_eq!((rec.record.protocol.as_str(), rec.record.role, rec.record.ended.as_str()), ("ertmp", 3, "deleted"));
let out: u64 = rec.record.tracks.iter().filter(|t| t.dir == 2).map(|t| t.objects).sum();
assert_eq!(out, 2 * FRAMES);
w.stop().await;
})
.await
.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn enhanced_hevc_with_composition_offsets_and_opus() {
timeout(Duration::from_secs(120), async {
let w = world(|_| {}).await;
let auth = w.auth(&w.publisher, 0);
let key = stream_key(&w, &auth).await;
let mut c = publishing(&w, &key, 0x0f).await;
let watcher = watch(&w).await;
c.metadata(vec![("width".into(), Value::Number(1280.0)), ("height".into(), Value::Number(720.0)), ("framerate".into(), Value::Number(30.0)), ("videocodecid".into(), Value::Number(u32::from_be_bytes(*b"hvc1") as f64))]).await;
c.video(0, ex_video(true, 0, b"hvc1", &hevc_record())).await;
c.audio(0, ex_audio(0, b"Opus", &opus_head())).await;
let (mut want_v, mut want_a) = (Vec::new(), Vec::new());
for i in 0..FRAMES {
let key = i % GOP == 0;
let nals = hevc_nals(i, key);
let ts = (i * 1000 / 30) as u32;
// CodedFrames with a composition time offset of 66 ms, or CodedFramesX.
if i % 2 == 0 {
let mut b = 66i32.to_be_bytes()[1..].to_vec();
b.extend(lengths(&nals));
c.video(ts, ex_video(key, 1, b"hvc1", &b)).await;
} else {
c.video(ts, ex_video(key, 3, b"hvc1", &lengths(&nals))).await;
}
let mut au = if key { hevc_sets() } else { Vec::new() };
au.extend(nals);
want_v.push(annexb(&au));
let p = opus_packet(i);
c.audio(ts, ex_audio(1, b"Opus", &p)).await;
want_a.push(p);
}
finish(&w, &mut c, &watcher, 2).await;
let (vf, vg) = verified(&watcher, track(&watcher, media::VIDEO, 0), &w.gateway_key, Codec::Hevc);
let (af, _) = verified(&watcher, track(&watcher, media::AUDIO, 0), &w.gateway_key, Codec::Opus);
same(&vf, &want_v, "HEVC: Annex B, VPS, SPS and PPS before each IRAP access unit");
same(&af, &want_a, "Opus packets");
assert_eq!(vg as u64, FRAMES / GOP);
let l = lives(&watcher, track(&watcher, media::VIDEO, 0));
assert_eq!(l[0]["cto"], 66_000, "CodedFrames' offset, in µs");
assert!(l[1].get("cto").is_none(), "CodedFramesX: an offset of 0, not written");
let env = envelopes(&watcher, track(&watcher, media::VIDEO, 0));
assert_eq!(env[0].manifest["model_requirement"]["capability"], "wai.video.hevc");
assert!(frame::hevc_is_irap(&env[0].payload));
w.stop().await;
})
.await
.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn av1_with_flac_and_vp9_with_aac_and_a_nano_offset() {
timeout(Duration::from_secs(150), async {
for (vc, ac) in [(Codec::Av1, Codec::Flac), (Codec::Vp9, Codec::Aac)] {
let w = world(|_| {}).await;
let auth = w.auth(&w.publisher, 0);
let key = stream_key(&w, &auth).await;
let mut c = publishing(&w, &key, 0x0f).await;
let watcher = watch(&w).await;
let (vf4, af4) = match (vc, ac) {
(Codec::Av1, _) => (b"av01", b"fLaC"),
_ => (b"vp09", b"mp4a"),
};
c.metadata(vec![("width".into(), Value::Number(640.0)), ("height".into(), Value::Number(360.0)), ("framerate".into(), Value::Number(30.0))]).await;
let rec = if vc == Codec::Av1 { av1_record(640, 360) } else { vp9_record() };
c.video(0, ex_video(true, 0, vf4, &rec)).await;
let aseq = if ac == Codec::Flac { flac_header() } else { AAC_ASC.to_vec() };
c.audio(0, ex_audio(0, af4, &aseq)).await;
let mut want_v = Vec::new();
let mut want_a = Vec::new();
for i in 0..FRAMES {
let key = i % GOP == 0;
let ts = (i * 1000 / 30) as u32;
let f = if vc == Codec::Av1 { av1_tu(i, key) } else { vp9_frame(i, key, 640, 360) };
// Every tenth frame carries a ModEx nanosecond offset of 400 µs.
if i % 10 == 5 {
c.video(ts, ex_video_nano(key, 1, vf4, 400_000, &f)).await;
} else {
c.video(ts, ex_video(key, 1, vf4, &f)).await;
}
want_v.push(if vc == Codec::Av1 {
let mut o = if key { av1_sequence_header(640, 360) } else { Vec::new() };
o.extend_from_slice(&f[2..]);
o
} else {
f
});
let (a, want) = if ac == Codec::Flac {
let fr = flac_frame(i);
let mut s = flac_header();
s[4] |= 0x80;
s.extend_from_slice(&fr);
(fr, s)
} else {
let raw = aac_raw(i);
(raw.clone(), adts(&raw))
};
c.audio(ts, ex_audio(1, af4, &a)).await;
want_a.push(want);
}
finish(&w, &mut c, &watcher, 2).await;
let (vf, vg) = verified(&watcher, track(&watcher, media::VIDEO, 0), &w.gateway_key, vc);
let (af, _) = verified(&watcher, track(&watcher, media::AUDIO, 0), &w.gateway_key, ac);
same(&vf, &want_v, &format!("{vc:?}"));
same(&af, &want_a, &format!("{ac:?}"));
assert_eq!(vg as u64, FRAMES / GOP);
let env = envelopes(&watcher, track(&watcher, media::VIDEO, 0));
let size = match vc {
Codec::Vp9 => (u16::from_le_bytes([env[0].payload[12], env[0].payload[13]]) as u32, u16::from_le_bytes([env[0].payload[14], env[0].payload[15]]) as u32),
_ => (u32::from_le_bytes(env[0].payload[8..12].try_into().unwrap()), u32::from_le_bytes(env[0].payload[4..8].try_into().unwrap())),
};
assert_eq!(size, (640, 360), "the framing states the size the bitstream gives");
let l = lives(&watcher, track(&watcher, media::VIDEO, 0));
assert_eq!(l[5]["rtp"].as_u64().unwrap() - l[4]["rtp"].as_u64().unwrap(), 33_000 + 400, "the nanosecond offset, in µs, on frame 5's media time");
w.stop().await;
}
})
.await
.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multitrack_layers_by_the_grant_s_profile() {
timeout(Duration::from_secs(120), async {
let w = world(|_| {}).await;
// A grant with the room's `camera-720` profile for video: layer 0 640×360 at 30,
// layer 1 1280×720 at 30, codecs av01 and avc1.
let mut g = w.grant(&w.publisher, 0);
g.profiles = vec![1];
let g = g.sign(&w.authority);
let auth = Auth::new(g, &w.publisher);
let key = stream_key(&w, &auth).await;
let mut c = publishing(&w, &key, 0x0f).await;
let watcher = watch_layers(&w, &[(media::VIDEO, 0), (media::VIDEO, 1), (media::AUDIO, 0)]).await;
let info = |w: f64, h: f64| Value::obj(&[("width", Value::Number(w)), ("height", Value::Number(h)), ("framerate", Value::Number(30.0))]);
c.metadata(vec![
("width".into(), Value::Number(1280.0)),
("height".into(), Value::Number(720.0)),
("framerate".into(), Value::Number(29.97)),
("videoTrackIdInfoMap".into(), Value::obj(&[("1", info(640.0, 360.0)), ("2", info(1920.0, 1080.0))])),
])
.await;
c.video(0, ex_video_tracks(true, 0, &[(b"avc1", 0, avc_record()), (b"av01", 1, av1_record(640, 360)), (b"avc1", 2, avc_record())])).await;
c.audio(0, ex_audio_tracks(0, &[(b"mp4a", 0, AAC_ASC.to_vec()), (b"Opus", 1, opus_head())])).await;
let (mut hi, mut lo) = (Vec::new(), Vec::new());
for i in 0..60u64 {
let key = i % GOP == 0;
let ts = (i * 1000 / 30) as u32;
let nals = avc_nals(i, key);
let mut avc_body = vec![0, 0, 0];
avc_body.extend(lengths(&nals));
let tu = av1_tu(i, key);
c.video(ts, ex_video_tracks(key, 1, &[(b"avc1", 0, avc_body.clone()), (b"av01", 1, tu.clone()), (b"avc1", 2, avc_body)])).await;
let mut au = if key { vec![AVC_SPS.to_vec(), AVC_PPS.to_vec()] } else { Vec::new() };
au.extend(nals);
hi.push(annexb(&au));
let mut o = if key { av1_sequence_header(640, 360) } else { Vec::new() };
o.extend_from_slice(&tu[2..]);
lo.push(o);
c.audio(ts, ex_audio_tracks(1, &[(b"mp4a", 0, aac_raw(i)), (b"Opus", 1, opus_packet(i))])).await;
}
finish(&w, &mut c, &watcher, 3).await;
let (l0, _) = verified(&watcher, track(&watcher, media::VIDEO, 0), &w.gateway_key, Codec::Av1);
let (l1, _) = verified(&watcher, track(&watcher, media::VIDEO, 1), &w.gateway_key, Codec::Avc);
let (a0, _) = verified(&watcher, track(&watcher, media::AUDIO, 0), &w.gateway_key, Codec::Aac);
same(&l0, &lo, "layer 0: track 1, 640×360 AV1");
same(&l1, &hi, "layer 1: track 0, 1280×720 at 29.97 (30 rounded) AVC");
assert_eq!(a0.len(), 60, "audio track 0");
let rec = w.gateway.gateway.records().remove(0);
let counts = &rec.record.detail["counts"];
assert_eq!(counts["track_no_layer"], 61, "video track 2 (1920×1080, no layer of the profile), every packet");
assert_eq!(counts["audio_track_not_default"], 61, "audio track 1, every packet");
w.stop().await;
})
.await
.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stream_key_refusals_and_admission() {
timeout(Duration::from_secs(120), async {
let w = world(|c| c.rtmp.stream_key_ttl_s = 1).await;
let auth = w.auth(&w.publisher, 0);
// A viewer's grant publishes nothing: 403.
let viewer = ed25519_dalek::SigningKey::from_bytes(&[0x61; 32]);
let va = w.auth(&viewer, 1);
let r = va.send("POST", &format!("{}/rtmp", w.base()), &[], b"").await;
assert_eq!((r.status, r.header("www-authenticate").is_some_and(|h| h.contains("insufficient_scope"))), (403, true), "{}", r.text());
// Without a proof of possession: 401 pop_missing.
let r = auth.send_raw("POST", &format!("{}/rtmp", w.base()), &[], b"", None).await;
assert!(r.status == 401 && r.text().contains("pop_missing"), "{} {}", r.status, r.text());
// A key the gateway did not issue.
let mut c = client(&w).await;
c.connect(0).await;
let s = c.publish("00112233445566778899aabbccddeeff").await;
assert_eq!((s.code.as_str(), s.level.as_str(), s.description.as_str()), ("NetStream.Publish.BadName", "error", "stream_key_unknown"));
assert!(c.next().await.is_none(), "the connection is closed");
// A key used once is not good twice.
let key = stream_key(&w, &auth).await;
let mut c = publishing(&w, &key, 0).await;
let mut c2 = client(&w).await;
c2.connect(0).await;
assert_eq!(c2.publish(&key).await.description, "stream_key_unknown");
c.delete_stream().await;
c.drain().await;
// A key past its time.
let key = stream_key(&w, &auth).await;
tokio::time::sleep(Duration::from_millis(1_300)).await;
let mut c = client(&w).await;
c.connect(0).await;
assert_eq!(c.publish(&key).await.description, "stream_key_expired");
w.stop().await;
// A grant whose window has closed by the time its key is used is refused for it
// (§3.2 step 3, checked again at publish).
let w = world(|_| {}).await;
let s = now_us() / 1_000_000;
let short = joule_moq_ingest::room::build::grant(&w.descriptor, &w.authority, &w.publisher.verifying_key().to_bytes(), 0, s - 60, s + 2);
let key = stream_key(&w, &Auth::new(short, &w.publisher)).await;
tokio::time::sleep(Duration::from_millis(2_600)).await;
let mut c = client(&w).await;
c.connect(0).await;
assert_eq!(c.publish(&key).await.description, "outside_grant_window");
w.stop().await;
})
.await
.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rtmps_and_the_reconnect_request() {
timeout(Duration::from_secs(120), async {
let id = joule_moq_relay::cert::RelayIdentity::dev_self_signed(&["localhost".to_string()]).unwrap();
let chain: Vec<Vec<u8>> = id.chain.iter().map(|c| c.to_vec()).collect();
let key_der = id.key.secret_der().to_vec();
let root = chain[0].clone();
let w = world(move |c| {
c.rtmp.tls_listen = Some("127.0.0.1:0".parse().unwrap());
c.rtmp.tls_der = Some((chain, key_der));
c.rtmp.reconnect_url = "rtmps://ingest-2.example/live".into();
c.rtmp.reconnect_grace_ms = 2_000;
})
.await;
let auth = w.auth(&w.publisher, 0);
let key = stream_key(&w, &auth).await;
let mut roots = rustls::RootCertStore::empty();
roots.add(rustls::pki_types::CertificateDer::from(root)).unwrap();
let cfg = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider())).with_safe_default_protocol_versions().unwrap().with_root_certificates(roots).with_no_client_auth();
let tcp = TcpStream::connect(w.gateway.rtmps_addr.unwrap()).await.unwrap();
let tls = tokio_rustls::TlsConnector::from(std::sync::Arc::new(cfg)).connect(rustls::pki_types::ServerName::try_from("localhost").unwrap(), tcp).await.expect("the TLS handshake");
let mut c = Client::start(tls).await;
c.connect(0x01).await;
let s = c.publish(&key).await;
assert_eq!(s.code, "NetStream.Publish.Start");
let watcher = watch(&w).await;
c.video(0, legacy_avc(0, true, 0, &avc_record())).await;
for i in 0..30u64 {
c.video((i * 33) as u32, legacy_avc(1, i == 0, 0, &lengths(&avc_nals(i, i == 0)))).await;
}
assert!(until(STEP, || w.gateway.gateway.rtmp_frames().iter().any(|(_, n)| *n >= 30)).await, "every frame reached the gateway");
// Shut the gateway down: the publisher declared Reconnect, so it is asked to.
let gw = w.gateway.gateway.clone();
let gateway = w.gateway;
let stop = tokio::spawn(async move { gateway.shutdown().await });
let s = c.status().await.expect("a status");
assert_eq!((s.code.as_str(), s.level.as_str()), ("NetConnection.Connect.ReconnectRequest", "status"));
let tc = c.received.iter().rev().find_map(|m| {
let v = joule_moq_ingest::rtmp::amf0::read_all(&m.payload).ok()?;
v.get(3)?.get("tcUrl")?.as_str().map(str::to_string)
});
assert_eq!(tc.as_deref(), Some("rtmps://ingest-2.example/live"));
c.shutdown().await;
stop.await.unwrap();
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await, "the tracks end");
let (vf, _) = verified(&watcher, &watcher.tracks[1], &w.gateway_key, Codec::Avc);
assert_eq!(vf.len(), 30);
assert!(gw.stream_keys.lock().unwrap().contains_key(&key), "the key is good once more, for the reconnection");
let rec = gw.records().remove(0);
assert_eq!(rec.record.ended, "reconnect_requested");
assert_eq!(rec.record.detail["tls"], true);
w.relay.shutdown().await;
})
.await
.expect("in time");
}

View file

@ -0,0 +1,207 @@
//! Enhanced RTMP from a local encoder, used purely as a test client. Each case runs the
//! encoder on a generated test pattern and tone, publishes to the gateway with a stream key
//! from `/rtmp`, and a subscriber at the relay receives every group, verifies every
//! receipt under the gateway's key, writes each track's frames out as an elementary stream,
//! and decodes it with the encoder's tool, a conformant decoder: the count of decoded frames
//! is the count of objects the relay carried, which is the count the encoder made.
//!
//! The encoder sends no media before the gateway's `NetStream.Publish.Start`, so a proxy
//! between the two holds that answer until the subscriber is in place: every group is seen.
//!
//! These tests need the encoder binary (`WAI_TEST_FFMPEG`, or `ffmpeg` and `ffprobe` on the
//! PATH) and, for the hardware cases, the platform's media engine. They are ignored by
//! default and run where both are present: `cargo test --test ertmp_ffmpeg -- --ignored`.
//! A case whose encoder is missing fails; it is never passed over.
mod common;
use common::rtmp::stream_key;
use common::*;
use joule_moq_ingest::frame::Codec;
use joule_moq_ingest::room::media;
use std::process::Command;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::timeout;
fn tool(name: &str) -> String {
match std::env::var("WAI_TEST_FFMPEG") {
Ok(p) if name == "ffmpeg" => p,
Ok(p) => std::path::Path::new(&p).with_file_name(name).to_string_lossy().into_owned(),
Err(_) => name.to_string(),
}
}
/// A proxy from a listener of its own to the gateway's RTMP port; it holds the gateway's
/// bytes once the publish is admitted until `ready` is set.
async fn proxy(w: &World, ready: std::sync::Arc<std::sync::atomic::AtomicBool>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
let up = w.gateway.rtmp_addr.unwrap();
let gw = w.gateway.gateway.clone();
tokio::spawn(async move {
let (c, _) = l.accept().await.unwrap();
let s = TcpStream::connect(up).await.unwrap();
let (mut cr, mut cw) = c.into_split();
let (mut sr, mut sw) = s.into_split();
tokio::spawn(async move {
let mut b = vec![0u8; 65536];
while let Ok(n) = cr.read(&mut b).await {
if n == 0 || sw.write_all(&b[..n]).await.is_err() {
break;
}
}
let _ = sw.shutdown().await;
});
let mut b = vec![0u8; 65536];
while let Ok(n) = sr.read(&mut b).await {
if n == 0 {
break;
}
if gw.rtmp_publishing() {
while !ready.load(std::sync::atomic::Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
if cw.write_all(&b[..n]).await.is_err() {
break;
}
}
});
port
}
struct Case {
name: &'static str,
/// The encoder's arguments between its inputs and its output.
args: &'static [&'static str],
video: Codec,
audio: Option<Codec>,
/// The elementary stream's extension, as the decoder reads it.
vformat: &'static str,
aformat: Option<&'static str>,
}
const FRAMES: usize = 90;
async fn run(c: &Case) {
let w = World::start(|g| g.rtmp.listen = Some("127.0.0.1:0".parse().unwrap())).await;
let auth = w.auth(&w.publisher, 0);
let key = stream_key(&w, &auth).await;
let ready = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let port = proxy(&w, ready.clone()).await;
let url = format!("rtmp://127.0.0.1:{port}/live/{key}");
let mut args: Vec<String> = ["-hide_banner", "-loglevel", "error", "-re", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=30", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"]
.iter()
.map(|s| s.to_string())
.collect();
args.extend(["-frames:v".into(), FRAMES.to_string(), "-t".into(), "3".into()]);
args.extend(c.args.iter().map(|s| s.to_string()));
if c.audio.is_none() {
args.push("-an".into());
}
args.extend(["-f".into(), "flv".into(), url]);
let mut child = tokio::process::Command::new(tool("ffmpeg")).args(&args).spawn().unwrap_or_else(|e| panic!("{}: the encoder ({}): {e}", c.name, tool("ffmpeg")));
// Subscribe once the publish is admitted, then let the encoder start.
assert!(until(STEP, || w.gateway.gateway.rtmp_publishing()).await, "{}: the publish is admitted", c.name);
let kinds: Vec<u8> = if c.audio.is_some() { vec![media::AUDIO, media::VIDEO] } else { vec![media::VIDEO] };
let watcher = watch_kinds(&w, &kinds).await;
ready.store(true, std::sync::atomic::Ordering::Relaxed);
let st = timeout(Duration::from_secs(60), child.wait()).await.expect("the encoder finishes").unwrap();
assert!(st.success(), "{}: the encoder's status {st}", c.name);
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == kinds.len()).await, "{}: every track ends", c.name);
settle(&watcher, Duration::from_millis(400)).await;
let dir = std::env::temp_dir().join(format!("ertmp-ffmpeg-{}-{}", c.name, std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
let (frames, groups) = verified(&watcher, video, &w.gateway_key, c.video);
assert_eq!(frames.len(), FRAMES, "{}: every frame the encoder made reached the subscriber", c.name);
assert!(groups >= 3, "{}: {groups} groups", c.name);
let env = envelopes(&watcher, video);
let es = elementary(c.video, &env.iter().map(|e| e.payload.clone()).collect::<Vec<_>>());
let vpath = dir.join(format!("video.{}", c.vformat));
std::fs::write(&vpath, es).unwrap();
assert_eq!(decoded(&vpath, c.vformat, "v"), FRAMES, "{}: the decoder decodes every frame", c.name);
if let Some(ac) = c.audio {
let audio = watcher.tracks.iter().find(|t| t.kind == media::AUDIO).unwrap();
let (af_, _) = verified(&watcher, audio, &w.gateway_key, ac);
assert!(!af_.is_empty(), "{}: audio arrived", c.name);
// An Opus packet is not a stream a decoder's command line reads without a
// container, so its packets are verified and counted, not decoded here.
if let Some(af) = c.aformat {
let env = envelopes(&watcher, audio);
let es = elementary(ac, &env.iter().map(|e| e.payload.clone()).collect::<Vec<_>>());
let apath = dir.join(format!("audio.{af}"));
std::fs::write(&apath, es).unwrap();
assert_eq!(decoded(&apath, af, "a"), env.len(), "{}: the decoder decodes every audio frame", c.name);
}
}
assert_eq!(w.relay.relay().stats().refused_at_door, 0, "{}: the relay's door verified every object", c.name);
assert!(until(STEP, || !w.gateway.gateway.records().is_empty()).await);
let rec = w.gateway.gateway.records().remove(0);
rec.verify().unwrap();
let counts = rec.record.detail["counts"].as_object().unwrap();
eprintln!("{}: what the gateway read and did not carry: {counts:?}", c.name);
assert!(counts.keys().all(|k| k == "metadata" || k == "packet_not_carried"), "{}: no frame refused: {counts:?}", c.name);
eprintln!("{}: {} video objects in {groups} groups, catalog codec from the configuration; decoded", c.name, frames.len());
let _ = std::fs::remove_dir_all(&dir);
w.stop().await;
}
/// The objects' payloads as one elementary stream the decoder reads.
fn elementary(codec: Codec, payloads: &[Vec<u8>]) -> Vec<u8> {
match codec {
// Annex B access units and ADTS frames concatenate.
Codec::Avc | Codec::Hevc | Codec::Aac => payloads.concat(),
// SPEC §5's AV1 framing: 24 bytes, then the temporal unit (a low-overhead stream).
Codec::Av1 => payloads.iter().flat_map(|p| p[24..].to_vec()).collect(),
// One-frame IVF files: the first's file header, then each frame header and frame.
Codec::Vp8 | Codec::Vp9 => {
let mut o = payloads[0][..32].to_vec();
o[24..28].copy_from_slice(&(payloads.len() as u32).to_le_bytes());
for p in payloads {
o.extend_from_slice(&p[32..]);
}
o
}
// A FLAC stream: the first object's, then the frames of the others.
Codec::Flac => {
let mut o = payloads[0].clone();
for p in &payloads[1..] {
o.extend_from_slice(&p[42..]);
}
o
}
_ => payloads.concat(),
}
}
/// The frames the decoder decodes from `path` read as `format`, by the stream type `s`.
fn decoded(path: &std::path::Path, format: &str, s: &str) -> usize {
let fmt = format;
let out = Command::new(tool("ffprobe"))
.args(["-v", "error", "-f", fmt, "-count_frames", "-select_streams", &format!("{s}:0"), "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0"])
.arg(path)
.output()
.expect("the decoder runs");
assert!(out.status.success(), "decoding {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().trim_end_matches(',').parse().unwrap_or_else(|_| panic!("a frame count: {:?}", String::from_utf8_lossy(&out.stdout)))
}
macro_rules! case {
($fn:ident, $c:expr) => {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "needs a local encoder (WAI_TEST_FFMPEG or ffmpeg on the PATH); run with --ignored"]
async fn $fn() {
timeout(Duration::from_secs(120), run(&$c)).await.expect("in time");
}
};
}
case!(legacy_avc_with_b_frames_and_aac, Case { name: "x264-aac", args: &["-c:v", "libx264", "-preset", "veryfast", "-g", "30", "-bf", "2", "-c:a", "aac", "-b:a", "96k"], video: Codec::Avc, audio: Some(Codec::Aac), vformat: "h264", aformat: Some("aac") });
case!(hevc_on_the_media_engine_and_aac, Case { name: "hw-hevc-aac", args: &["-c:v", "hevc_videotoolbox", "-g", "30", "-b:v", "2M", "-c:a", "aac", "-b:a", "96k"], video: Codec::Hevc, audio: Some(Codec::Aac), vformat: "hevc", aformat: Some("aac") });
case!(h264_on_the_media_engine, Case { name: "hw-h264", args: &["-c:v", "h264_videotoolbox", "-g", "30", "-b:v", "2M"], video: Codec::Avc, audio: None, vformat: "h264", aformat: None });
case!(hevc_in_software_and_opus, Case { name: "x265-opus", args: &["-c:v", "libx265", "-preset", "ultrafast", "-x265-params", "keyint=30:min-keyint=30:log-level=error", "-c:a", "libopus", "-b:a", "96k"], video: Codec::Hevc, audio: Some(Codec::Opus), vformat: "hevc", aformat: None });
case!(av1_and_flac, Case { name: "svtav1-flac", args: &["-c:v", "libsvtav1", "-preset", "12", "-g", "30", "-svtav1-params", "keyint=30:scd=0", "-c:a", "flac"], video: Codec::Av1, audio: Some(Codec::Flac), vformat: "obu", aformat: Some("flac") });
case!(vp9_and_opus, Case { name: "vp9-opus", args: &["-c:v", "libvpx-vp9", "-deadline", "realtime", "-cpu-used", "8", "-g", "30", "-b:v", "1M", "-c:a", "libopus"], video: Codec::Vp9, audio: Some(Codec::Opus), vformat: "ivf", aformat: None });

View file

@ -0,0 +1,51 @@
//! The link record's corpus, `wai/live-conformance/links/`: every vector's outcome as this
//! crate's reader gives it is the one the corpus states, and the corpus is what the
//! generator writes (`joule-moq-ingest-link-vectors --check`, run by CI beside this).
use joule_moq_ingest::linkrec::{self, LinkRecord, Reason};
use serde_json::Value;
use std::path::PathBuf;
fn dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../../wai/live-conformance/links")
}
#[test]
fn every_vector_has_its_stated_outcome() {
let index: Value = serde_json::from_slice(&std::fs::read(dir().join("vectors.json")).expect("the corpus index")).unwrap();
let vectors = index["vectors"].as_array().unwrap();
assert!(vectors.len() > 40);
let mut seen = std::collections::BTreeSet::new();
for v in vectors {
let name = v["name"].as_str().unwrap();
let files: Vec<Vec<u8>> = v["files"].as_array().unwrap().iter().map(|f| std::fs::read(dir().join(f.as_str().unwrap())).unwrap()).collect();
let accept = v["outcome"] == "accept";
match v["kind"].as_str().unwrap() {
"part" => {
let r = LinkRecord::verify(&files[0]);
if accept {
let r = r.unwrap_or_else(|e| panic!("{name}: {e:?}"));
assert_eq!(hex::encode(linkrec::digest(&files[0])), v["digest"].as_str().unwrap(), "{name}");
assert_eq!(r.account.seq_count, v["summary"]["seq_count"].as_u64().unwrap(), "{name}");
assert_eq!(r.account.delivered - r.account.first_try, v["summary"]["recovered"].as_u64().unwrap(), "{name}");
} else {
let e = r.err().unwrap_or_else(|| panic!("{name}: refused"));
assert_eq!(e.code(), v["reason"].as_str().unwrap(), "{name}");
seen.insert(e.code());
}
}
_ => {
let r = linkrec::verify_series(&files);
if accept {
r.unwrap_or_else(|e| panic!("{name}: {e:?}"));
} else {
let (at, e) = r.err().unwrap_or_else(|| panic!("{name}: refused"));
assert_eq!((at as u64, e.code()), (v["at"].as_u64().unwrap(), v["reason"].as_str().unwrap()), "{name}");
seen.insert(e.code());
}
}
}
}
let all: std::collections::BTreeSet<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
assert_eq!(seen, all, "every reason refuses a vector");
}

View file

@ -0,0 +1,266 @@
//! SCTE 104 into the room's cues, end to end: an automation system of our own opens an
//! API connection to the gateway's injector (SCTE 104 §9.1), initialises it, keeps it
//! alive, and sends splice and time-signal requests. Each is answered as §9.6 says; each
//! cue is signed when its splice instant comes, after its injection record, and goes out
//! on the injector principal's events track at the relay, where a subscriber reads it.
//! Every event verifies under the principal's key, chains to the one before, and every
//! cue is the translation of the message its injection record carries; the cues make the
//! breaks live-ads §4.3 derives.
mod common;
use common::*;
use ed25519_dalek::{SigningKey, Verifier};
use joule_moq::draft22::{Parameters, StreamHeader, UniEvent};
use joule_moq::{TrackName, TrackNamespace};
use joule_moq_ingest::room::{build, Descriptor};
use joule_moq_ingest::scte::scte104::{self, op, Single};
use joule_moq_ingest::scte::{EVENT_DOMAIN, KIND_CUE, KIND_META};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
const INJECTOR_SEED: [u8; 32] = [0x71; 32];
async fn world() -> World {
// The relay's door holds the room's media tracks to their receipts; an events track
// carries none (live-rooms §6.1), so the door names the media kinds.
let door = |rc: &mut joule_moq_relay::RelayConfig, room: &[u8; 32], gk: &str| {
rc.receipts.door = ["audio", "video"]
.iter()
.map(|k| joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room), "*".into(), k.to_string()], trust: vec![gk.to_string()], trust_field: None, hold_ms: 10_000 })
.collect();
};
World::start_with(door, |c| {
// The room's authority is World's, seed 0x51; its descriptor is the one handed in.
let authority = SigningKey::from_bytes(&[0x51; 32]);
let d = Descriptor::read(c.rooms[0].descriptor.as_ref().unwrap()).unwrap();
let key = SigningKey::from_bytes(&INJECTOR_SEED);
let s = now_us() / 1_000_000;
let g = build::grant(&d, &authority, &key.verifying_key().to_bytes(), 0, s - 60, s + 3600);
c.injector.listen = Some("127.0.0.1:0".parse().unwrap());
c.injector.room = hex::encode(d.room_id());
c.injector.key_seed = Some(INJECTOR_SEED);
c.injector.grant = Some(g.to_bytes());
})
.await
}
/// An automation system's API connection.
struct As {
s: TcpStream,
}
impl As {
async fn connect(w: &World) -> As {
As { s: TcpStream::connect(w.gateway.injector_addr.unwrap()).await.unwrap() }
}
async fn send(&mut self, b: &[u8]) {
self.s.write_all(b).await.unwrap();
}
async fn reply(&mut self) -> Single {
let mut head = [0u8; 4];
timeout(STEP, self.s.read_exact(&mut head)).await.expect("a response in time").unwrap();
let n = u16::from_be_bytes([head[2], head[3]]) as usize;
let mut m = head.to_vec();
m.resize(n, 0);
self.s.read_exact(&mut m[4..]).await.unwrap();
Single::read(&m).unwrap()
}
async fn single(&mut self, opid: u16, data: Vec<u8>) -> Single {
let m = Single { opid, result: 0xffff, result_extension: 0xffff, protocol_version: 0, as_index: 3, message_number: 0, dpi_pid_index: 1, data };
self.send(&m.to_bytes()).await;
self.reply().await
}
}
/// A `multiple_operation_message()` of `ops`, processed on arrival.
fn message(n: u8, ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut o = vec![0xff, 0xff, 0, 0, 0, 3, n, 0, 1, 0, 0, ops.len() as u8];
for (id, d) in ops {
o.extend_from_slice(&id.to_be_bytes());
o.extend_from_slice(&(d.len() as u16).to_be_bytes());
o.extend_from_slice(d);
}
let len = o.len() as u16;
o[2..4].copy_from_slice(&len.to_be_bytes());
o
}
/// `splice_request_data()` (Table 9-5).
fn splice(ty: u8, event: u32, preroll_ms: u16, tenths: u16, auto: bool) -> Vec<u8> {
let mut d = vec![ty];
d.extend_from_slice(&event.to_be_bytes());
d.extend_from_slice(&[0, 9]);
d.extend_from_slice(&preroll_ms.to_be_bytes());
d.extend_from_slice(&tenths.to_be_bytes());
d.extend_from_slice(&[1, 2, auto as u8, 0]);
d
}
/// `insert_segmentation_descriptor_request_data()` (Table 9-29), ending after
/// `device_restrictions`.
fn segmentation(event: u32, type_id: u8, seconds: u16) -> Vec<u8> {
let mut d = event.to_be_bytes().to_vec();
d.push(0);
d.extend_from_slice(&seconds.to_be_bytes());
d.extend_from_slice(&[0x0c, 4, 0xde, 0xad, 0xbe, 0xef, type_id, 1, 1, 0, 1, 0, 0, 0, 0]);
d
}
/// The objects a subscriber read, by (group, object).
type Objects = Arc<Mutex<BTreeMap<(u64, u64), Vec<u8>>>>;
/// Every object of the injector's events track at the relay, by (group, object).
async fn watch_events(w: &World, principal: &[u8; 32]) -> (Objects, Vec<Box<dyn std::any::Any + Send>>) {
let (c, ep) = relay_client(w).await;
let c = Arc::new(c);
let ns = TrackNamespace::from_strs([hex::encode(w.room).as_str(), hex::encode(principal).as_str(), "events"]);
let (_, tx, rx) = c.subscribe(ns, TrackName::from_str("0"), Parameters::new()).await.expect("SUBSCRIBE the events track");
let objects: Objects = Arc::default();
let (c2, o2) = (c.clone(), objects.clone());
tokio::spawn(async move {
loop {
let Some(mut r) = c2.next_stream(Duration::from_secs(1)).await else {
if Arc::strong_count(&o2) == 1 {
return;
}
continue;
};
let o2 = o2.clone();
tokio::spawn(async move {
let Ok(Ok(h)) = timeout(STEP, r.start()).await else { return };
while let Ok(Ok(Some(e))) = timeout(STEP, r.next()).await {
if let (StreamHeader::Subgroup(sh), UniEvent::SubgroupObject(o)) = (&h, e) {
o2.lock().unwrap().insert((sh.group_id, o.object_id), o.payload);
}
}
});
}
});
(objects, vec![Box::new(c), Box::new(ep), Box::new(tx), Box::new(rx)])
}
fn b64(s: &str) -> Vec<u8> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s).unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn splices_and_time_signals_become_signed_cues() {
timeout(Duration::from_secs(90), run()).await.expect("the test finishes in time");
}
async fn run() {
let w = world().await;
let injector = w.gateway.injector.clone().expect("the injector runs");
let principal = injector.principal();
let (objects, _held) = watch_events(&w, &principal).await;
let mut a = As::connect(&w).await;
// Before init_request, a request is not taken.
a.send(&message(1, &[(op::SPLICE_NULL, vec![])])).await;
let r = a.reply().await;
assert_eq!((r.opid, r.result), (op::INJECT_RESPONSE, 124));
let r = a.single(op::INIT_REQUEST, vec![]).await;
assert_eq!((r.opid, r.result, r.as_index, r.dpi_pid_index), (op::INIT_RESPONSE, 100, 3, 1));
// A second automation system is answered "Injector is already in use".
let mut b = As::connect(&w).await;
assert_eq!(b.single(op::INIT_REQUEST, vec![]).await.result, 110);
let r = a.single(op::ALIVE_REQUEST, vec![0; 8]).await;
assert_eq!((r.opid, r.result, r.data.len()), (op::ALIVE_RESPONSE, 100, 8));
// 1. An immediate break of 1.5 s with auto-return: an out now, its in 1.5 s later.
a.send(&message(2, &[(op::SPLICE, splice(2, 0x10, 0, 15, true)), (op::INSERT_AVAIL, vec![1, 0, 0, 1, 0x35])])).await;
let r = a.reply().await;
assert_eq!((r.opid, r.result, r.message_number, r.data.clone()), (op::INJECT_RESPONSE, 100, 2, vec![2]));
// 2. A splice with a pre-roll of 1 s (below 4 000 ms: issued, answered 122), then
// cancelled before it comes: never signed.
a.send(&message(3, &[(op::SPLICE, splice(1, 0x20, 1000, 0, false))])).await;
assert_eq!(a.reply().await.result, 122);
a.send(&message(4, &[(op::SPLICE, splice(5, 0x20, 0, 0, false))])).await;
assert_eq!(a.reply().await.result, 100);
// Refusals carry their result codes.
a.send(&message(5, &[(0x7777, vec![])])).await;
let r = a.reply().await;
assert_eq!((r.result, r.result_extension), (125, 0x7777));
a.send(&message(6, &[(op::INSERT_TIER, vec![0, 1])])).await;
assert_eq!(a.reply().await.result, 115);
// The first message completes once its in is signed: two cues.
let done = timeout(STEP, a.reply()).await.unwrap();
assert_eq!((done.opid, done.message_number, done.data.clone()), (op::INJECT_COMPLETE_RESPONSE, 2, vec![2, 2]));
// 3. A time signal with a Provider Placement Opportunity Start of 1 s, and its End.
a.send(&message(7, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, segmentation(0x30, 0x34, 1))])).await;
assert_eq!(a.reply().await.result, 100);
assert_eq!(a.reply().await.opid, op::INJECT_COMPLETE_RESPONSE);
tokio::time::sleep(Duration::from_millis(1_100)).await;
a.send(&message(8, &[(op::TIME_SIGNAL, vec![0, 0]), (op::INSERT_SEGMENTATION, segmentation(0x30, 0x35, 0))])).await;
assert_eq!(a.reply().await.result, 100);
assert_eq!(a.reply().await.opid, op::INJECT_COMPLETE_RESPONSE);
// The injector's chain: four cues, each after its injection record.
let events = injector.events();
assert_eq!(events.len(), 8, "four cues and their injection records");
let key = ed25519_dalek::VerifyingKey::from_bytes(&principal).unwrap();
let t0 = w.descriptor.t0_us;
let params = scte104::Params { t0_us: t0, leap_s: 18, fps_num: 30_000, fps_den: 1_001 };
let mut prev = [0u8; 32];
let mut cues = Vec::new();
for (i, e) in events.iter().enumerate() {
let body = &e[..e.len() - 64];
let sig = ed25519_dalek::Signature::from_bytes(e[e.len() - 64..].try_into().unwrap());
key.verify(&[EVENT_DOMAIN, body].concat(), &sig).expect("every event verifies under the injector's key");
assert_eq!(&e[5..37], &w.room[..]);
assert_eq!(u64::from_le_bytes(e[69..77].try_into().unwrap()), i as u64, "seq");
assert_eq!(&e[77..109], &prev, "each event chains to the one before");
prev = joule_moq_ingest::room::sha256(&[body]);
let t = u64::from_le_bytes(e[109..117].try_into().unwrap());
let kind = e[118];
let payload = &e[121..e.len() - 64];
if i % 2 == 0 {
assert_eq!(kind, KIND_META);
assert_eq!(&payload[..4], b"WIJ1");
let received = u64::from_le_bytes(payload[5..13].try_into().unwrap());
let index = payload[22] as usize;
let n = u16::from_le_bytes([payload[23], payload[24]]) as usize;
let msg = &payload[25..25 + n];
let tr = scte104::translate(msg, received, &params).expect("the recorded message translates");
let want = tr.cues()[index];
let next = &events[i + 1];
assert_eq!(next[118], KIND_CUE);
assert_eq!(&next[121..next.len() - 64], &want.payload()[..], "the cue is its message's translation");
assert_eq!(u64::from_le_bytes(next[109..117].try_into().unwrap()), want.t);
assert_eq!(t, want.t);
cues.push((want.kind, want.cue_id, want.t, want.duration_ms, want.section.is_some()));
}
}
let kinds: Vec<(u8, u32)> = cues.iter().map(|c| (c.0, c.1)).collect();
assert_eq!(kinds, vec![(1, 0x10), (2, 0x10), (1, 0x30), (2, 0x30)], "out and in of each break, the cancelled splice absent");
assert_eq!(cues[1].2 - cues[0].2, 1_500_000, "the auto-return in, 1.5 s after the out");
assert!(!cues[1].4, "an auto-return in carries no section");
assert_eq!(cues[2].3, 1_000, "the placement opportunity's planned duration");
let counts = injector.counts();
assert_eq!(counts.get("withdrawn"), Some(&1));
assert_eq!(counts.get("opid_unknown"), Some(&1));
// The events track at the relay: an MSF event timeline, every record one of the chain.
assert!(until(STEP, || objects.lock().unwrap().values().filter(|o| o.as_slice() != b"[]").count() >= 8).await, "every event reached the relay");
let mut seen = Vec::new();
for o in objects.lock().unwrap().values() {
let v: serde_json::Value = serde_json::from_slice(o).expect("a JSON array");
for r in v.as_array().unwrap() {
let e = b64(r["data"]["wev1"].as_str().unwrap());
let t = u64::from_le_bytes(e[109..117].try_into().unwrap());
assert_eq!(r["t"].as_u64().unwrap(), (t0 + t) / 1000, "the record's index is its wallclock in ms");
if !seen.contains(&e) {
seen.push(e);
}
}
}
assert_eq!(seen, events, "the relay carries the chain, in order");
drop(b);
w.stop().await;
}

View file

@ -155,7 +155,7 @@ fn check_commit(model: &Room, refs: &[ProposalRef], sender: &Sender, staged: &St
let g = model.group().ok_or(Refusal::CommitMismatch)?;
let Sender::Member(s) = sender else { return Err(Refusal::CommitNotCommitter) };
let removed: BTreeSet<u32> = staged.remove_proposals().map(|r| r.remove_proposal().removed().u32()).collect();
if model::committer(&g.roster, &removed) != Some(s.u32()) {
if !model.rule().may_commit(&g.roster, &removed, s.u32()) {
return Err(Refusal::CommitNotCommitter);
}
let mut holds = Vec::new();
@ -530,14 +530,21 @@ impl RoomMember {
}
}
/// Whether this member is the committer of the clock's pending proposals.
/// Whether this member may commit the clock's pending proposals: their committer (§5.5),
/// or, under a conversation's rule, any member they do not remove.
pub fn is_committer(&self) -> bool {
if self.group.is_none() {
return false;
}
let (Some(g), Some(me)) = (self.model.group(), self.keyring.my_leaf()) else { return false };
let removed: BTreeSet<u32> = self.model.pending().iter().filter(|p| p.from == By::Clock).filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect();
model::committer(&g.roster, &removed) == Some(me)
self.model.rule().may_commit(&g.roster, &removed, me)
}
/// Who may commit: a conversation's members commit by [`model::CommitRule::AnyMember`]
/// (WAI live-messaging §7.2); a room's by the lowest leaf, the default.
pub fn set_commit_rule(&mut self, rule: model::CommitRule) {
self.model.set_rule(rule);
}
/// Whether the clock has proposals pending for the current epoch.
@ -630,6 +637,12 @@ impl Clock {
&self.model
}
/// Who may commit, as the clock checks each commit it follows (a conversation's rule, WAI
/// live-messaging §7.2).
pub fn set_commit_rule(&mut self, rule: model::CommitRule) {
self.model.set_rule(rule);
}
/// The framed proposals made since the last call, in order, for the clock's `mls` track.
pub fn take_outbox(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.outbox)

View file

@ -29,5 +29,5 @@ pub mod record;
pub mod strict;
pub use group::{Clock, Commit, Error, Received, RoomMember, CIPHERSUITE};
pub use model::{Event, Room};
pub use model::{CommitRule, Event, Room};
pub use record::Record;

View file

@ -82,6 +82,30 @@ pub fn committer(roster: &Roster, removed: &BTreeSet<u32>) -> Option<u32> {
roster.keys().copied().find(|l| !removed.contains(l))
}
/// Who may commit the end of an epoch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitRule {
/// The member at the lowest leaf index the commit does not remove (WAI confidentiality
/// §5.5): a room, whose clock removes a member that stops responding.
#[default]
Lowest,
/// Any member the commit does not remove, the carrier ordering the commits (WAI
/// live-messaging §7.2): a conversation, whose members stay members while offline.
AnyMember,
}
impl CommitRule {
/// Whether the member at `sender` may commit the end of an epoch with `roster`, where the
/// commit removes `removed`.
pub fn may_commit(self, roster: &Roster, removed: &BTreeSet<u32>, sender: u32) -> bool {
match self {
CommitRule::Lowest => committer(roster, removed) == Some(sender),
CommitRule::AnyMember => roster.contains_key(&sender) && !removed.contains(&sender),
}
}
}
/// The roster a commit leaves (§5.6, RFC 9420 §12.3): the removes blank their leaves, then
/// each add, in the commit's order, takes the lowest blank leaf, which is the leftmost
/// blank leaf of the tree or, when none is blank, the next at its right edge.
@ -127,11 +151,22 @@ pub struct Room {
gone: BTreeMap<Principal, u64>,
/// The proposals for the current epoch, in the order they arrived.
pending: Vec<Proposal>,
/// Who may commit.
rule: CommitRule,
}
impl Room {
pub fn new(room: [u8; 32], clock: Principal) -> Self {
Room { room, clock, generation: 0, group: None, seq: 0, keyed: BTreeMap::new(), gone: BTreeMap::new(), pending: Vec::new() }
Room { room, clock, generation: 0, group: None, seq: 0, keyed: BTreeMap::new(), gone: BTreeMap::new(), pending: Vec::new(), rule: CommitRule::Lowest }
}
/// Set who may commit (a conversation's rule, WAI live-messaging §7.2).
pub fn set_rule(&mut self, rule: CommitRule) {
self.rule = rule;
}
pub fn rule(&self) -> CommitRule {
self.rule
}
pub fn room(&self) -> [u8; 32] {
@ -298,7 +333,7 @@ impl Room {
let Some(g) = &self.group else { return Err(Refusal::CommitMismatch) };
let held: Vec<Proposal> = holds.iter().map(|i| self.pending.get(*i).copied().ok_or(Refusal::CommitMismatch)).collect::<Result<_, _>>()?;
let removed: BTreeSet<u32> = held.iter().filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect();
if committer(&g.roster, &removed) != Some(sender) {
if !self.rule.may_commit(&g.roster, &removed, sender) {
return Err(Refusal::CommitNotCommitter);
}
if held.iter().any(|p| p.from != From::Clock) {
@ -411,4 +446,30 @@ mod tests {
assert_eq!(m.next_owed(), Some(Owed::Create(B)));
assert_eq!(&m.group_id()[32..], &1u32.to_be_bytes());
}
/// A conversation's rule (WAI live-messaging §7.2): any member the commit does not remove
/// commits; a member it removes, or a leaf no member holds, does not.
#[test]
fn in_a_conversation_any_member_commits() {
let mut m = Room::new(R, K);
m.set_rule(CommitRule::AnyMember);
m.event(&Event::Join { subject: A, keyed: true });
m.create(A, &m.group_id(), &[K]).unwrap();
m.event(&Event::Join { subject: B, keyed: true });
m.event(&Event::Join { subject: C, keyed: true });
m.propose(add(0, B)).unwrap();
m.propose(add(0, C)).unwrap();
assert_eq!(m.clone().commit(1, &[0]), Err(Refusal::CommitNotCommitter), "leaf 1 is not a member yet");
m.commit(0, &[0, 1]).unwrap();
// B, at leaf 1, commits the removal of C while A is away.
m.event(&Event::Leave { subject: C, reason: 2 });
m.propose(Proposal { from: From::Clock, epoch: 1, change: Change::Remove { leaf: 2 } }).unwrap();
assert_eq!(m.clone().commit(2, &[0]), Err(Refusal::CommitNotCommitter), "the member the commit removes");
assert_eq!(m.clone().commit(7, &[0]), Err(Refusal::CommitNotCommitter), "a leaf no member holds");
let c = m.commit(1, &[0]).unwrap();
assert_eq!((c.epoch, c.removed), (2, vec![(2, C)]));
// The room's rule is unchanged: there, only the lowest leaf commits.
assert!(!CommitRule::Lowest.may_commit(&[(0, A), (1, B)].into(), &BTreeSet::new(), 1));
assert!(CommitRule::AnyMember.may_commit(&[(0, A), (1, B)].into(), &BTreeSet::new(), 1));
}
}

View file

@ -25,6 +25,11 @@ ring = "0.17"
# endpoint serve both the native MOQT ALPN and `h3`.
wtransport = { version = "0.7.2", default-features = false, features = ["ring", "self-signed", "quinn"] }
tokio-tungstenite = "0.29"
# TLS under a `wss://` upstream (crate::cascade), with the relay's own verifier: ring, as
# rustls above, and no other provider.
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
# The platform's roots, for an upstream whose front holds a public certificate.
rustls-native-certs = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
bytes = "1"
futures = "0.3"

View file

@ -308,9 +308,18 @@ not verify.
error in the log; each media object must match the receipt of its position (its group,
its Object ID, its content hash, its Merkle proof to the signed root), and an object that
carries its receipt as a property must carry that same one. An object waits for its
group's receipts up to `hold_ms`. Receipt bytes are forwarded unaltered and nothing is
re-signed. A refused object ends its downstream streams rather than leaving a hole in
them.
group's receipts up to `hold_ms`, and its stream is not read meanwhile; a publisher
whose receipts follow its group's last object can finish the group only while the whole
group fits the stream's flow-control window, `limits.quic_stream_window_bytes` (16 MiB
by default). What bounds the bytes one session leaves unread is not that window but the
session's, `limits.quic_connection_window_bytes`, over all its streams together (64 MiB
by default, the budget `websocket_buffer_bytes` gives a WebSocket session, and never
below the stream window): a peer that opens many streams and finishes none of their
groups stalls there, however many streams it opens. Both windows hold as well for the
links the relay dials to its upstreams, over native QUIC or WebTransport
(`tests/upstream_window.rs`). Receipt bytes are forwarded
unaltered and nothing is re-signed. A refused object ends its downstream streams rather
than leaving a hole in them.
## Tokens and room grants
@ -350,8 +359,9 @@ authority's key instead (and, optionally, the room ids it is honoured for): a to
`joule-moq`'s `GrantHead`) is a room grant, honoured only beside a proof as above and only
within its window (`nbf` to `exp`), and mapped onto rights as live-rooms §10.2 maps it:
PUBLISH under `[R, P, m]` for each media kind `m` its `publish` bits allow, of `[R, P]` (its
catalog) when it may publish any, and under `[R, P, events]` when its policy permits an
event kind; SUBSCRIBE, FETCH and the rest under `[R]` when it may subscribe. The relay's
catalog) when it may publish any, under `[R, P, events]` when its policy permits an
event kind, and under `[R, P, world]` when its attribute `world` is `act` (its world track,
WAI live-worlds §5.2); SUBSCRIBE, FETCH and the rest under `[R]` when it may subscribe. The relay's
rights are per namespace, so §10.2's track matches (layers up to `max_layer`) are not
narrowed. A session ends, `EXPIRED_AUTH_TOKEN`, when the earliest such grant it proved
expires.
@ -398,7 +408,8 @@ so it follows and logs a rated room's tracks.
room's clock token can be given `["*", "<clock>", "events"]`, its events track in every
room. A door rule's `trust_field` names the namespace field holding the one key it accepts
there: `prefix = ["*", "*", "video"]` with `trust_field = 1` holds each publisher's video in
a room to its own key (live-rooms §6.1), whoever joins.
a room to its own key (live-rooms §6.1), whoever joins; `["*", "*", "world"]` does the same
for each actor's world track (WAI live-worlds §4.3).
**Limits per session.** `limits.max_session_ms` closes a session at that age (`NO_ERROR`,
the limit as its reason); `limits.max_ingress_kbps` closes one that sends more object bytes
@ -407,6 +418,102 @@ presents a token whose rule says `unlimited = true` is exempt from both: a deplo
long-lived sessions, such as a room service's clock. `limits.max_resident_mb` makes the
binary exit with status 70 past that resident memory, for its supervisor to start it again.
## Cascading: one room across relays
A relay can carry a room's tracks to and from other relays, so a room spans relays in
different places: the home relay behind an HTTP tunnel that carries WebSocket alone, say, and
an edge relay with public UDP. `draft-ietf-moq-transport-22` leaves how relays interconnect out
of scope (§7) and says namespace publication "does not protect against loops" (§4.1); what the
relay does is below, and WAI `live-rooms.md` §10.7 is what a room needs of it. The module is
[`cascade`](src/cascade.rs).
**Links.** Each `[[upstream]]` entry is a relay this one dials, as a client:
```toml
[[upstream]]
name = "home"
url = "wss://home.example/moq" # moqt://host:4433/moq (QUIC), https://host:4433/moq (WebTransport)
transport = "websocket" # "quic", "webtransport" or "websocket"
prefixes = [["*"]] # the namespaces routed there; a field "*" matches any one field
native_roots = true # or ca_path = "<PEM file>", or cert_sha256 = "<hex>" (pinned)
token_sha256 = "<hex>" # the token presented in SETUP, named by its SHA-256 ...
token_path = "/etc/joule-moq-relay/home.token" # ... and read from this file
relay_key = "<hex>" # optional: the relay id the upstream must declare
```
A pinned certificate is trusted by the SHA-256 of its DER alone, whatever its validity or names,
its handshake signature still verified with its key. The token's file holds its value (trailing
white space removed), and the relay does not start when it does not hash to `token_sha256`. The
session is a MOQT session at -22 in which each end publishes and subscribes; it is redialed
after it ends, waiting `limits.upstream_reconnect_ms` (1 s) and doubling to 30 s. Either relay
can dial the other: an edge relay dials the home relay's `wss://` endpoint through its tunnel,
or the home relay dials an edge's UDP port over QUIC or WebTransport.
**Relay ids.** Every relay's SETUP declares the Setup Option `RELAY_ID` (`0x3A4F`): its id, the
public key it signs its session summaries and per-hop records with. A relay that dials proves
it: its SETUP carries a `GRANT_PROOF` naming no grant, by that key, over the other's
`GRANT_CHALLENGE` and the target it addressed, which the other checks as it checks a room key's
proof (*Tokens and room grants*); a declared id without its proof closes the session
`UNAUTHORIZED` (`relay_id_unproved`), and a relay's session to itself `PROTOCOL_VIOLATION`
(`relay_loop`). Every other client ignores the option (§9.1). A session with both ids is a
**hop link**, at -22 alone (the drafts the bridge speaks have no form for what follows).
**Routes.** A SUBSCRIBE, FETCH or TRACK_STATUS for a track that no session of this relay
publishes (by PUBLISH, or under a namespace published by PUBLISH_NAMESPACE) is forwarded on the
first link, in the configuration's order, that routes the track's namespace and whose session
is open and not going away. It is forwarded once: the subscription it makes is the relay's own
upstream subscription of the track (§7.4), cached and fanned out to every local subscriber as a
publisher's is, and a RENDEZVOUS_TIMEOUT goes upstream with the time left of it. A link that
refuses (`TIMEOUT`, `INTERNAL_ERROR`, `GOING_AWAY`, `EXCESSIVE_LOAD`) or does not answer is
passed over for the next; when every link that routes the track is down, the request waits for
one within its rendezvous, and is then refused `REQUEST_ERROR` `TIMEOUT` with the reason phrase
`no_upstream`. A FETCH of a track the relay knows nothing of asks the upstream for its live edge
first (TRACK_STATUS), so the range it forwards is the request's own and the seek power is held
here (below).
**Announcements.** Every namespace published to this relay under a link's prefixes, by
PUBLISH_NAMESPACE or as the namespace of a track a publisher sent by PUBLISH, is published on
the link (PUBLISH_NAMESPACE), so the upstream's subscribers are sent here (§7.6), and is
withdrawn when it goes. A SUBSCRIBE_NAMESPACE whose prefix overlaps a link's is forwarded on it,
as each link's session opens, and the upstream's NAMESPACE and NAMESPACE_DONE, of the
namespaces the link routes, are merged with this relay's own: one NAMESPACE when a namespace is
first seen from either, one NAMESPACE_DONE when the last source of it goes.
**No loops.** On a hop link the Message Parameter `RELAY_PATH` (`0x3A51`) is negotiated (-22
§9.20: "negotiated via Setup Options"): every request one relay sends another carries the
relays it has passed through, the sender last. A relay never forwards a request back on the
session it came from, nor to a relay on its path, nor past `limits.max_relay_hops` (8); a
namespace is published on no link to a relay its path holds. A request that arrives naming the
relay that receives it is refused `relay_loop`, one past the bound `relay_hops_exceeded`
(`UNINTERESTED` for a PUBLISH_NAMESPACE, `DOES_NOT_EXIST` otherwise), and one that could only be
sent toward a relay on its path is refused `DOES_NOT_EXIST` `relay_loop` at once. RELAY_PATH on
a session that did not negotiate it, or whose last relay is not the peer, closes the session
`PROTOCOL_VIOLATION`. A relay-id path rather than a hop limit alone: the path stops a request
at the first relay it would repeat and names the relay that closed the loop, and its length is
the hop limit.
**GOAWAY.** An upstream's GOAWAY ends nothing downstream: the relay sends that session no new
request (§9.2), opens a session to the GOAWAY's New Session URI (one of the link's own scheme;
else the link's URL), subscribes there to every track it held through the old session (§7.6.1:
what arrives on both is deduplicated, §7.3), publishes its namespaces there, and closes the old
session. It does not pass the GOAWAY on to its own sessions. A GOAWAY from a relay that dialed
this one stops new requests to it. The relay's own GOAWAY on shutdown names no URI on a session
it dialed (a client's, §9.2).
**What each relay holds to its own rules.** The door checks what a peer forwards as it checks
a publisher's: a tampered object one relay forwarded is refused at the next relay's door. A
link runs under its token's rule: the home relay names the edge's token in `[[auth.grants]]`
with `publish`, `subscribe` and `seek` over the routed prefixes, and `unlimited`; the edge
grants its upstream the right to subscribe to what it routes there. Each relay holds its own
sessions to their grants (rights, the seek power, a rated track's threshold); a hop link proves
no room grant, so its threshold is 0 and a rated room is not carried across one. The floor and
audience inputs are decided at the relay the performer or the author publishes to: an object or
an input that arrived over a hop link is forwarded as that relay decided, and its events are read
for their schedules, handovers, windows and commitments alone (WAI `co-performance.md` §8). The
counters `forwarded_upstream`, `no_upstream` and `relay_loops` count the requests forwarded and
Established upstream, those no upstream answered, and those refused for their path; each link
keeps its dials, sessions, GOAWAYs, moved subscriptions and announcements (`Link::stats`).
## The session summary
When a session ends the relay signs a summary of it with its own Ed25519 key (from
@ -424,14 +531,17 @@ holds, for the session:
room's media tracks it held, or 8 when it held none of them but other tracks of the room;
- as a publisher: objects and bytes received, objects refused at the door, objects
deduplicated, and the same per track;
- `relay_energy`: `{"acquisition": "unmetered", "joules_micro": 0}`. No meter backs this
relay. The 0 is the unmetered marker of WAI `extensions/energy-measurement.md` §2, never
a measurement of zero joules, and must not be read or shown as one;
- `relay_energy`: `{"acquisition": "unmetered", "joules_micro": 0}`. A session's own share
of the relay's energy is not read: the counter covers the whole relay process, in which
sessions run together (a room's record carries the relay's figure where it can, below).
The 0 is the unmetered marker of WAI `extensions/energy-measurement.md` §2, never a
measurement of zero joules, and must not be read or shown as one;
- `publisher_signed`: where the door verified an object's receipt, the publisher's signed
figure for producing it, summed under the acquisition class its leaf was signed with.
These are the publisher's statements about the objects, not the relay's energy;
- `room_grant`, when the token the session presented in SETUP is one `[[metering.grants]]`
names: the room grant's digest and its ceilings.
names: the room grant's digest and its ceilings;
- `peer`, when the session is a hop link: the other relay's id, hex.
The summary is relay-scoped telemetry (jwp-receipts §4.2), not a receipt of the publisher's.
Its format is `joule-moq-relay/session-summary/2`; the signature covers
@ -456,15 +566,42 @@ track of the room otherwise. The record holds:
delivered to them;
- the root of its ledger, one entry for each room grant its sessions ran under (or none, for
sessions with no grant): the sessions, the bytes in and out and the subscriber time;
- its energy, the unmetered marker.
- its energy over the span (below).
**Its energy.** The relay reads a counter the host keeps, every 100 ms while it runs, and
keeps four hours of samples (`src/energy.rs`):
- on macOS, the relay process's own CPU energy, `ri_energy_nj` from
`proc_pid_rusage(getpid(), RUSAGE_INFO_V6)`, unprivileged: scope **task**, every thread of
the relay and nothing else (not other processes, the GPU, the network interface, or work a
system service does for the relay);
- on Linux, the CPU packages' RAPL counters through powercap, scope **package**: the whole
package, every process on it. It is read only with `[metering] dedicated_host = true`,
the operator's statement that the host runs the relay alone, since it is otherwise not the
relay's energy;
- elsewhere, or with `[metering] energy = "off"`, none.
A record carries the counter's advance over its span as `OnChipCounter`, filtering state
`Unknown`, with a declared uncertainty (the interpolation at each end of the span, the
counter's lag at each end, for a package counter the noise a filtered counter's vendor
states for the window, and the rounding to whole microjoules) over the span as its window,
when three things hold: the counter was live across the span (WAI energy-measurement §3.1:
it advanced in two consecutive intervals, in at least five, and in four of every five, over
at least 300 ms); the relay served that room alone in the span (every session that
overlapped it has ended and carried tracks of that room and of no other); and the figure is
at least one microjoule. Otherwise the record carries the unmetered marker, and the relay
logs why. A figure of the task scope covers the relay's CPU and nothing past it; a figure of
the package scope covers the whole package of a host the operator stated was the relay's
alone.
As a session closes, the relay ends its subscriptions one at a time, each when its request
stream fails; the moments between are classed by what the session still holds, so a
session whose media subscriptions end before its receipts or events ones has a span of
class 8, typically microseconds, at its end. `HopRecord::to_bytes` is the record and
`HopRecord::ledger_bytes` the ledger beside it. The
relay connects to no other hop, so every session of it that subscribes is a subscriber
session. Nothing in the record is per object or per viewer, so its size depends on the
`HopRecord::ledger_bytes` the ledger beside it. A hop link (*Cascading*) is counted in the
track entries, its objects in at one end and out at the other, and is neither a subscriber
session nor in the ledger (live-metering §3.10); every other session of the relay that
subscribes is a subscriber session. Nothing in the record is per object or per viewer, so its size depends on the
room's published tracks and not on its audience. `tests/room_metering.rs` checks that the
record recomputes from the summaries.
@ -603,9 +740,9 @@ development certificate. `relay.example.toml` shows every field: the listen addr
path, the certificate files (or the names for a development certificate), the cache's byte
budget, the limits (sessions, requests and streams in flight, MAX_REQUEST_UPDATES, the token
cache, the rendezvous, upstream and fill timeouts, write concurrency, the QUIC send window,
the GOAWAY timeout, the session, ingress and memory limits), the receipts suffix and door
rules, the summary's key and file, the grants and the room authorities, and the DVR store and its
windows. The configuration holds no secret: keys are files, and tokens are named by their
the GOAWAY timeout, the session, ingress and memory limits, the relay-path bound and the
upstream redial wait), the receipts suffix and door rules, the summary's key and file, the
grants and the room authorities, the DVR store and its windows, and the upstream links. The configuration holds no secret: keys are files, and tokens are named by their
SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level.
## Not yet implemented
@ -622,8 +759,15 @@ SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level.
with the code rather than with `WT_CLOSE_SESSION`; and quinn 0.11 has no RESET_STREAM_AT,
so a reset WebTransport stream is not guaranteed to deliver its stream header first.
- **0-RTT** is not accepted.
- **Migration as a client:** the relay sends GOAWAY to its sessions, but does not itself
move its upstream subscriptions to a new relay, or follow a REDIRECT.
- **Migration as a client:** the relay moves an upstream link's subscriptions after its
GOAWAY (*Cascading*), but follows no REDIRECT, and a publisher's session that receives the
relay's GOAWAY is not moved by the relay.
- **Cascading:** the per-hop record names no peer hop (a link is counted in the track
entries, live-metering §3.10), so the chain is read by track, not by link; a rated room is
not carried across a hop link (its threshold is 0); a FETCH piped through from upstream is
checked against its receipts by the subscriber, not at this relay's door, as on any upstream
fetch; routes are the configured prefixes, and a namespace an upstream names in NAMESPACE
routes nothing by itself.
- **Pausing upstream** when every downstream subscriber is paused (§7.2), and forwarding
NEW_GROUP_REQUEST upstream. EXPIRES is neither issued nor acted on.
- **TOO_FAR_BEHIND:** a subscription whose subscriber does not read is held, not ended
@ -634,8 +778,11 @@ SHA-256. `JOULE_MOQ_RELAY_LOG` sets the log level.
- **Receipts:** the receipts track of a media track is found by the configured suffix, not
by reading the catalog's `wai_receipts_track`; the relay signs no `delivery-gap` claim;
datagrams at the door are checked against their group's receipts, not a property.
- **Energy:** no meter backs the relay, so its own figure is always the unmetered marker.
Attributing a metered host's energy to sessions is not done here.
- **Energy:** a room's record carries the relay's own energy only where the relay served
that room alone across the record's span; dividing the counter among rooms or sessions
that ran together is not done here, and a session's summary always carries the unmetered
marker. No figure is read on a host whose counter is neither the macOS task counter nor
the RAPL package counter of a host stated to run the relay alone.
- **The DVR store:** decisions withhold from FETCH and fills, not yet from live subscriptions
(a reviewed item's `held` state, live-moderation §6.3, needs the review policy); objects
are checked against their receipts at a restart, and by the door as they arrive where a door
@ -658,12 +805,14 @@ All on loopback, every wait bounded:
| `auth_tokens` | Tokens in SETUP, by value and by alias; UNAUTHORIZED; UNKNOWN_AUTH_TOKEN_ALIAS. |
| `grant_proof` | A room grant with its proof over each transport gives its rights, each session with a challenge of its own; the grant as a bare token, a proof by another key, one for another relay and one over an earlier session's challenge each close the session with UNAUTHORIZED; a grant presented only in a request is refused it. |
| `request_ids` | INVALID_REQUEST_ID for the wrong parity and for a repeat; requests in flight bounded by the stream limit. |
| `relay_energy` | On a host with a counter (macOS's task counter; on Linux, the package counter with `dedicated_host`): a room's record, after a publisher sends for over a second, carries `OnChipCounter` with its evidence, reads back, and verifies; with a second room's session in the span it carries the unmetered marker; with `energy = "off"` it carries the marker. Where the host has no counter the test says SKIPPED, and with `JOULE_MOQ_RELAY_ENERGY_REQUIRE=1` it fails instead. The figure's arithmetic, its liveness rule and its uncertainty are `src/energy.rs`'s unit tests, which run on every host. |
| `dvr` | A long publish crosses the window: the oldest groups go, whole, each media group with its receipts group, within the byte budget; a window by age empties as its groups pass it. FETCH inside the window (ascending and descending), across it (an End of Unknown Range through the last evicted group, then every object, from the store and the cache together) and before it (the marker alone); every object verifies against the fetched receipts. A restart keeps the store, drops and counts a flipped byte and bytes changed with their checks made again (only the receipt finds those), removes a cut-short write, and never serves what it dropped. A room key's redaction erases its target and withholds it, a deny withholds until an allow, a redacted event leaves the object that carries it, a decision by no moderator changes nothing, and the decisions hold across a restart. Without the seek power a FETCH and a fill below the live edge are refused `seek_not_granted`, and a FETCH at the live edge is served; with it, the window. The record of the room is `WMH1` version 2 with the store's figures. A room whose objects are sealed is never kept: its first sealed group is not written, its events and receipts already held and its decisions are deleted, nothing more of it is written, and so after a restart, while a room in the clear beside it keeps its window. A room in which a session proves a minor's publishing grant is never kept from then on, likewise, while a minor's grant that publishes nothing changes nothing. A window past 24 hours is refused. |
| `age_serving` | A room rated 13 by its authority, the claim on the publisher's PUBLISH, and a stranger's rating ignored: a session at no threshold is refused the subscription `below_threshold`; the performer rates groups 5 to 7 of its track 18 on group 5's object, after group 4 went out, and from then the viewer at 16 is forwarded none of them while the viewer at 18 is forwarded all; a FETCH across the span is answered up to group 4 and one from inside it is refused; a SUBSCRIBE at a live edge rated 18 is refused. |
| `age_room_service` | A room rated 18 by its authority, with the floor held (`[floor] room_keys` the clock): sessions proving the clock's key and the authority's (a `GRANT_PROOF` naming no grant) are served every group, verified; the clock's token with no proof, and a proof by a key that is no room key, are refused the subscription `below_threshold`; a proof naming the clock's key under another key's signature, and the clock's proof over another session's challenge, close the session `UNAUTHORIZED`. |
| `age_rising` | A rating that rises while a subscriber's objects wait at the relay: a viewer at 18 that reads nothing, with small QUIC windows and the relay a small send window, holds groups 0 to 9 queued when the performer rates its whole track 21 on group 10's object; once the relay holds the claim the viewer is given no more of what waited (those streams ended, `CANCELLED`) and nothing of group 10, while a viewer at 21 is given every group. |
| `priority_fan_out` | Two publishers, four tracks, three subscribers with their own subscriber priorities and group orders, their transports held shut while the load arrives: each receives every object in §5.1.2's order. |
| `drafts` | The two-publisher scenario with every session at -18 (over QUIC and over WebTransport), at -16, and with publishers at -16 and -18 and subscribers at -22, -18 and -16: every object verifies against its publisher's receipts at every subscriber. FETCH from the cache at -18 and -16 (the drafts' exclusive End Locations, -16's absolute fetch IDs). A SUBSCRIBE with a Range Filter, refused at an -18 client by name. An -18 session and a -16 session written from those drafts' own messages, not through this crate's client: SETUP each way, a request answered, a Joining FETCH refused with NOT_SUPPORTED, an unknown Fetch Type closing the session with PROTOCOL_VIOLATION; a -16 Request ID ahead of one in flight on another stream admitted and one seen before closing the session with INVALID_REQUEST_ID. An -18 publisher's FIN on PUBLISH_NAMESPACE withdrawing the namespace, a -22 FIN not; an -18 publisher that closes its control stream before its last object and PUBLISH_DONE losing neither. |
| `cascade` | An edge that dials a home relay over native QUIC, WebTransport (both trusting a pinned certificate) and the WebSocket binding: a publisher on each, a subscriber of the other's track on each, every object verified at the subscriber against its publisher's receipts and by both doors; each namespace published both ways and discovered by SUBSCRIBE_NAMESPACE from either relay; each relay's summary of the link naming the other. Three relays in a chain, a room's track each way: a namespace's path; a per-hop record at each relay, each signed by its own key with its own unmetered figure, the in and out entries of each track chaining hop to hop, only the end viewers subscriber sessions, the middle relay's ledger empty. A home relay with no door forwarding a tampered object that the edge's door refuses. A loop of three relays: an announcement never returns to its origin, and a SUBSCRIBE for a track nobody publishes refused `relay_loop` at once. A link to an upstream that is down passed over for the next, and with none up `TIMEOUT` `no_upstream`, with and without a rendezvous. An upstream's GOAWAY naming another relay: the edge moves its subscriptions there, its subscriber sees no GOAWAY and loses no group. The home relay's DVR window fetched from the edge with the seek power, and refused `seek_not_granted` at the edge without it, by FETCH and by fill. A member's votes, sent out of order on the edge, decided once there in chain order, a watcher on the home relay given exactly what one on the edge is; and an object a relay with no floor forwarded not held to the floor again over the link. |
| `publish_done_streams` | A PUBLISH_DONE that arrives before the data stream it counts, at -22, -18 (QUIC and WebTransport) and -16: the object still reaches the subscriber, then PUBLISH_DONE; a count no stream meets ends the track after the wait; 0 and the unknown count at once. A close right after PUBLISH_DONE ends the track with the publisher's status, not GOING_AWAY, and without the wait. A track published again after TRACK_ENDED is a new instance. |
## Licence

View file

@ -41,9 +41,15 @@ max_session_ms = 0
max_ingress_kbps = 0
ingress_burst_ms = 4000
max_resident_mb = 0
# The most relays a request may have passed through (its RELAY_PATH), and the wait before an
# upstream link is dialed again, doubled on each failure to 30 s.
max_relay_hops = 8
upstream_reconnect_ms = 1000
websocket_buffer_bytes = 67108864
idle_timeout_ms = 30000
quic_send_window_bytes = 1048576
quic_stream_window_bytes = 16777216 # a stream's flow-control window: more than the largest group a door holds
quic_connection_window_bytes = 67108864 # a session's, all its streams together: what bounds the bytes it leaves unread; never below the stream window
[receipts]
receipts_track_suffix = "_receipts"
@ -61,6 +67,14 @@ hold_ms = 2000
# key_path = "/etc/joule-moq-relay/summary-seed"
# out_path = "/var/log/joule-moq-relay/sessions.jsonl"
# The relay's own energy in its per-hop session records (WAI live-metering §8): "auto"
# reads the counter the host offers (macOS: the relay process's CPU energy; Linux: the CPU
# packages' RAPL energy, only with dedicated_host = true, the statement that this host runs
# the relay alone); "off" reads none, and every record carries the unmetered marker.
[metering]
energy = "auto"
dedicated_host = false
# The room grants tokens carry, for the per-hop session records of WAI rooms (an example;
# not a default): a session whose SETUP presents this token runs under this grant.
[[metering.grants]]
@ -125,3 +139,18 @@ hold_ms = 2000
# [[auth.authorities]]
# key = "<the authority's Ed25519 key, hex>"
# rooms = []
# Relays this one dials (relay-to-relay cascading): a SUBSCRIBE, FETCH or TRACK_STATUS for a
# track no session here publishes, under one of `prefixes`, goes to the first whose session is
# open; namespaces published here under them are published there. Trust is one of ca_path,
# cert_sha256 (a pinned certificate) and native_roots; ws:// takes none. The token is read from
# token_path and must hash to token_sha256.
#
# [[upstream]]
# name = "home"
# url = "wss://home.example/moq"
# transport = "websocket"
# prefixes = [["*"]]
# native_roots = true
# token_sha256 = "<64 hex digits>"
# token_path = "/etc/joule-moq-relay/home.token"

View file

@ -155,8 +155,9 @@ fn unix_s() -> u64 {
/// Whether a room grant's head gives `right` over `ns` (WAI live-rooms §10.2, with the
/// room id `R` and the principal `P` as 64 lowercase hexadecimal digits): PUBLISH under
/// `[R, P, m]` for each media kind `m` it may publish, of `[R, P]` (its catalog track)
/// when it may publish any, and under `[R, P, events]` when its policy permits any event
/// kind; SUBSCRIBE and FETCH under `[R]` when it may subscribe. The relay's rights are per
/// when it may publish any, under `[R, P, events]` when its policy permits any event
/// kind, and under `[R, P, world]` when its attribute `world` is `act` (WAI live-worlds
/// §5.2: its world track); SUBSCRIBE and FETCH under `[R]` when it may subscribe. The relay's rights are per
/// namespace, so §10.2's track matches (the layers up to `max_layer`) are not narrowed
/// here.
pub fn room_grant_allows(g: &GrantHead, ns: &TrackNamespace, right: Right) -> bool {
@ -177,6 +178,7 @@ pub fn room_grant_allows(g: &GrantHead, ns: &TrackNamespace, right: Right) -> bo
Some(b"audio") => g.publishes(1),
Some(b"video") => g.publishes(2),
Some(b"screen") => g.publishes(3),
Some(b"world") => g.attrs.iter().any(|(k, v)| k == "world" && v == "act"),
Some(_) => false,
}
}
@ -356,6 +358,12 @@ mod tests {
/// A grant laid out as WAI live-rooms §4.1 lays it out, signed by `authority`.
pub(crate) fn grant(authority: &ed25519_dalek::SigningKey, room: [u8; 32], principal: [u8; 32], nbf: u64, exp: u64, publish: u8, kinds: bool) -> Vec<u8> {
grant_with(authority, room, principal, nbf, exp, publish, kinds, &[])
}
/// [`grant`] with attributes (live-rooms §4.4), keys in ascending order.
#[allow(clippy::too_many_arguments)]
pub(crate) fn grant_with(authority: &ed25519_dalek::SigningKey, room: [u8; 32], principal: [u8; 32], nbf: u64, exp: u64, publish: u8, kinds: bool, attrs: &[(&str, &str)]) -> Vec<u8> {
use ed25519_dalek::Signer;
let mut g = b"WRG1\x01".to_vec();
g.extend_from_slice(&room);
@ -374,7 +382,16 @@ mod tests {
g.extend_from_slice(&[0; 28]);
g.extend_from_slice(&[0xff; 16]);
g.extend_from_slice(&[0; 3]);
g.extend_from_slice(&[0, 0, 0, 0]);
g.extend_from_slice(&[0, 0]); // no profile, no origin
let mut a = Vec::new();
for (k, v) in attrs {
a.push(k.len() as u8);
a.extend_from_slice(k.as_bytes());
a.extend_from_slice(&(v.len() as u16).to_le_bytes());
a.extend_from_slice(v.as_bytes());
}
g.extend_from_slice(&(a.len() as u16).to_le_bytes());
g.extend_from_slice(&a);
let mut m = joule_moq::grant_proof::GRANT_DOMAIN.to_vec();
m.extend_from_slice(&g);
g.extend_from_slice(&authority.sign(&m).to_bytes());
@ -435,4 +452,37 @@ mod tests {
assert!(!a.allows(&ns(&[&r, &ph]), Right::Publish, &vt, &vp) && !a.allows(&ns(&[&r, &ph, "events"]), Right::Publish, &vt, &vp));
assert!(a.allows(&ns(&[&r]), Right::Subscribe, &vt, &vp));
}
/// WAI live-worlds §5.2: a grant whose attribute `world` is `act` publishes its own world
/// track, and only that; one without the attribute, or with another value, publishes none.
#[test]
fn the_world_attribute_maps_onto_the_world_track() {
let authority = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
let (room, p) = ([0x11; 32], [0x22; 32]);
let a = Authorizer::from_config(&AuthConfig {
required: true,
grants: vec![],
authorities: vec![crate::config::Authority { key: hex::encode(authority.verifying_key().to_bytes()), rooms: vec![] }],
})
.unwrap();
let now = unix_s();
let (r, ph) = (hex::encode(room), hex::encode(p));
let ns = |f: &[&str]| TrackNamespace::from_strs(f.iter().copied());
let world = ns(&[&r, &ph, "world"]);
let with = |attrs: &[(&str, &str)]| {
let g = grant_with(&authority, room, p, now - 60, now + 600, 0, true, attrs);
(vec![(0u64, g.clone())], HashSet::from([sha256(&g)]))
};
let (t, pr) = with(&[("order", "0"), ("world", "act")]);
assert!(a.authority_grant(&t[0].1).is_some_and(|g| g.attrs.len() == 2), "the attributes read");
assert!(a.allows(&world, Right::Publish, &t, &pr));
assert!(!a.allows(&world, Right::Publish, &t, &HashSet::new()), "not without its proof");
assert!(!a.allows(&ns(&[&r, &"33".repeat(32), "world"]), Right::Publish, &t, &pr), "another principal's world track");
assert!(!a.allows(&ns(&[&r, &ph, "video"]), Right::Publish, &t, &pr) && !a.allows(&ns(&[&r, &ph]), Right::Publish, &t, &pr), "no media, no catalog");
assert!(a.allows(&ns(&[&r, &"33".repeat(32), "world"]), Right::Subscribe, &t, &pr), "every world track of the room to read");
for attrs in [&[][..], &[("world", "watch")][..]] {
let (t, pr) = with(attrs);
assert!(!a.allows(&world, Right::Publish, &t, &pr), "{attrs:?}");
}
}
}

View file

@ -0,0 +1,589 @@
//! Relay-to-relay cascading: one room carried by relays in different places.
//!
//! `draft-ietf-moq-transport-22` leaves how relays interconnect out of scope (§7) and says
//! namespace publication "is not a full-fledged routing protocol and does not protect
//! against loops" (§4.1). This module is the relay's own topology on top of the draft's
//! relay rules (§7.4, §7.6):
//!
//! - **Links.** Each `[[upstream]]` entry is a relay this one dials, over native QUIC,
//! WebTransport or the WebSocket binding, trusting a CA, a pinned certificate or the
//! platform's roots, and presenting a token read from a file. The session is a MOQT session
//! like any other in which this relay is the client: each end publishes and subscribes.
//! - **Routes.** A SUBSCRIBE, FETCH or TRACK_STATUS for a track no session of this relay
//! publishes, under one of a link's prefixes, is forwarded on the first link (in the
//! configuration's order) whose session is open and not going away, once, and the
//! subscription it makes is the relay's own upstream subscription: cached and fanned out to
//! every local subscriber as a publisher's is. A link that refuses or does not answer is
//! passed over for the next; when none answers the request is refused `TIMEOUT` with the
//! reason phrase [`NO_UPSTREAM`].
//! - **Announcements.** Every namespace published to this relay under a link's prefixes, by a
//! local publisher or by another peer, is published on the link (PUBLISH_NAMESPACE), so the
//! upstream's subscribers reach this relay's publishers (§7.6), and withdrawn when it goes.
//! A SUBSCRIBE_NAMESPACE whose prefix overlaps a link's is forwarded on it, and the upstream's
//! NAMESPACE and NAMESPACE_DONE are merged with this relay's own.
//! - **Loops.** Both ends of a link declare the Setup Option `RELAY_ID`
//! ([`joule_moq::draft22::params::RELAY_ID_OPTION`], `0x3A4F`): the relay's id, the public
//! key it signs its session summaries and per-hop records with. The dialer proves it: its
//! SETUP carries a `GRANT_PROOF` naming no grant, by that key, over the listener's challenge.
//! With both declared, the Message Parameter `RELAY_PATH` (`0x3A51`) is negotiated (§9.20:
//! "negotiated via Setup Options"), and every request one relay sends another carries it:
//! the relays the request passed through, the sender last. A relay never forwards a request
//! back on the session it came from, nor to a relay already on its path, nor past
//! `limits.max_relay_hops`; one that receives a request whose path holds its own id refuses
//! it with [`RELAY_LOOP`], and one past the limit with [`RELAY_HOPS_EXCEEDED`]. A relay-id
//! path, not a hop limit alone: the path stops a loop at the first relay it would repeat,
//! names the relay that closed it, and its length is the hop limit.
//! - **GOAWAY.** A GOAWAY from an upstream ends nothing downstream: the relay sends that
//! session no new request (§9.2), opens a session to the GOAWAY's URI (else the link's own),
//! subscribes there again to every track it held on the old one (§7.6.1: objects received
//! on both are deduplicated), publishes its namespaces there, and then closes the old one.
//! A GOAWAY is never passed on to this relay's own sessions: it is hop by hop. The relay's
//! own GOAWAY, when it shuts down, names no URI on a session it dialed (a client's, §9.2).
//!
//! What the WAI evidence does across hops (the receipts at every door, a per-hop record for
//! every hop, the grants a hop meters, the floor) is in the crate README.
use crate::config::{UpstreamConfig, UpstreamTransport};
use crate::relay::{Relay, SessionCtx};
use crate::transport::mux::MuxConfig;
use crate::transport::SharedConnection;
use joule_moq::draft22::{ControlMessage, NamespaceRequest, Parameter, Parameters, StreamResetCode};
use joule_moq::TrackNamespace;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
pub use joule_moq::draft22::params::{MAX_RELAY_PATH, RELAY_ID_OPTION};
/// The reason phrase of a request whose relay path holds the relay that received it, or that
/// could only be forwarded toward a relay on its path.
pub const RELAY_LOOP: &str = "relay_loop";
/// The reason phrase of a request that has passed through `limits.max_relay_hops` relays.
pub const RELAY_HOPS_EXCEEDED: &str = "relay_hops_exceeded";
/// The reason phrase of a request no publisher here serves and no upstream answered.
pub const NO_UPSTREAM: &str = "no_upstream";
/// A session's peer relay.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Peer {
/// The relay id it declared (and, for a relay that dialed this one, proved).
pub id: [u8; 32],
/// The index of the `[[upstream]]` entry this relay dialed it as; `None` for a relay that
/// dialed this one.
pub link: Option<usize>,
}
/// One link's counters.
#[derive(Debug, Default)]
pub struct LinkStats {
/// Sessions the relay tried to open, and those that opened.
pub dials: AtomicU64,
pub opened: AtomicU64,
/// GOAWAYs the upstream sent, and the subscriptions moved to a new session after one.
pub goaways: AtomicU64,
pub moved: AtomicU64,
/// PUBLISH_NAMESPACEs sent on it, and those it refused.
pub announced: AtomicU64,
pub announce_refused: AtomicU64,
}
/// One configured upstream relay and its session.
pub struct Link {
pub index: usize,
pub name: String,
pub cfg: UpstreamConfig,
pub prefixes: Vec<TrackNamespace>,
/// The token presented in SETUP, read from `token_path`.
pub token: Option<Vec<u8>>,
pub tls: Option<Arc<rustls::ClientConfig>>,
session: Mutex<Option<Arc<SessionCtx>>>,
/// Bumped each time a session to it opens.
pub opened: watch::Sender<u64>,
/// GOAWAY from the upstream: the New Session URI, to the link's task.
goaway: Mutex<Option<mpsc::UnboundedSender<String>>>,
pub stats: LinkStats,
}
impl Link {
pub fn new(index: usize, cfg: &UpstreamConfig) -> Result<Link, String> {
let token = cfg.check()?;
let tls = match cfg.url.starts_with("ws://") {
true => None,
false => Some(Arc::new(client_tls(cfg)?)),
};
Ok(Link {
index,
name: if cfg.name.is_empty() { cfg.url.clone() } else { cfg.name.clone() },
prefixes: cfg.prefix_namespaces(),
cfg: cfg.clone(),
token,
tls,
session: Mutex::new(None),
opened: watch::channel(0).0,
goaway: Mutex::new(None),
stats: LinkStats::default(),
})
}
/// Whether a track under `ns` is routed to this link.
pub fn routes(&self, ns: &TrackNamespace) -> bool {
self.prefixes.iter().any(|p| crate::auth::pattern_matches(p, ns))
}
/// Whether a namespace subscription to `prefix` can see a namespace this link routes.
pub fn overlaps(&self, prefix: &TrackNamespace) -> bool {
self.prefixes.iter().any(|p| p.0.iter().zip(&prefix.0).all(|(a, b)| a.as_slice() == b"*" || a == b))
}
/// The session open to the upstream now, unless it sent GOAWAY.
pub fn session(&self) -> Option<Arc<SessionCtx>> {
self.session.lock().unwrap().clone().filter(|c| !c.peer_goaway.load(Ordering::Acquire))
}
/// The upstream sent GOAWAY on its session: the link's task moves to a new one.
pub fn goaway(&self, uri: String) {
self.stats.goaways.fetch_add(1, Ordering::Relaxed);
if let Some(tx) = self.goaway.lock().unwrap().as_ref() {
let _ = tx.send(uri);
}
}
}
/// The path a request carries on to the next relay: the one it arrived with, then this relay.
pub fn onward(path: &[[u8; 32]], me: [u8; 32]) -> Vec<[u8; 32]> {
let mut p = path.to_vec();
p.push(me);
p
}
/// Whether a request that arrived on the session `requester` with the relay path `path` may
/// be sent on to the session `to`: not back where it came from, not to a session that sent
/// GOAWAY, and, to a peer relay, not to one on the path nor past the hop limit.
pub fn may_forward(to: &SessionCtx, requester: u64, path: &[[u8; 32]], max_hops: usize) -> bool {
if to.id == requester || to.peer_goaway.load(Ordering::Acquire) {
return false;
}
match &to.peer {
None => true,
Some(p) => !path.contains(&p.id) && path.len() < max_hops,
}
}
/// The parameter a request to `to` carries: RELAY_PATH, on a session to a peer relay alone.
pub fn path_param(to: &SessionCtx, path: &[[u8; 32]], me: [u8; 32]) -> Option<Parameter> {
to.peer.map(|_| Parameter::RelayPath(onward(path, me)))
}
/// What a request's RELAY_PATH says, as the session it arrived on reads it.
#[derive(Debug, PartialEq, Eq)]
pub enum Arrival {
/// Its path (empty from a session that is not a peer relay).
Path(Vec<[u8; 32]>),
/// The session is closed with PROTOCOL_VIOLATION: RELAY_PATH on a session that did not
/// negotiate it (§9.20), or a path whose last relay is not the peer that sent it.
Close(&'static str),
/// The request is refused with this reason phrase.
Refuse(&'static str),
}
/// Read a request's RELAY_PATH on `ctx`, for the relay `me` with its hop limit.
pub fn arrival(ctx: &SessionCtx, params: Option<&Parameters>, me: [u8; 32], max_hops: usize) -> Arrival {
let path = params.and_then(|p| p.relay_path());
match (&ctx.peer, path) {
(None, None) => Arrival::Path(Vec::new()),
(None, Some(_)) => Arrival::Close("RELAY_PATH on a session that did not negotiate it (§9.20)"),
(Some(p), None) => Arrival::Path(vec![p.id]),
(Some(p), Some(path)) => {
if path.last() != Some(&p.id) {
return Arrival::Close("a RELAY_PATH whose last relay is not the peer that sent it");
}
if path.contains(&me) {
return Arrival::Refuse(RELAY_LOOP);
}
if path.len() > max_hops {
return Arrival::Refuse(RELAY_HOPS_EXCEEDED);
}
Arrival::Path(path.to_vec())
}
}
}
// ── TLS ────────────────────────────────────────────────────────────────────
/// The TLS configuration a link dials with: TLS 1.3, trusting the entry's CA certificates,
/// its pinned leaf, or the platform's roots.
pub fn client_tls(cfg: &UpstreamConfig) -> Result<rustls::ClientConfig, String> {
crate::cert::install_provider();
let provider = Arc::new(rustls::crypto::ring::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider.clone()).with_protocol_versions(&[&rustls::version::TLS13]).map_err(|e| e.to_string())?;
if let Some(h) = &cfg.cert_sha256 {
let pin: [u8; 32] = hex::decode(h).map_err(|e| e.to_string())?.try_into().map_err(|_| "cert_sha256 is not 32 bytes".to_string())?;
return Ok(builder.dangerous().with_custom_certificate_verifier(Arc::new(Pinned { pin, provider })).with_no_client_auth());
}
let mut roots = rustls::RootCertStore::empty();
if let Some(p) = &cfg.ca_path {
use rustls::pki_types::pem::PemObject;
let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_file_iter(p).map_err(|e| format!("{}: {e}", p.display()))?.collect::<Result<_, _>>().map_err(|e| format!("{}: {e}", p.display()))?;
if certs.is_empty() {
return Err(format!("{}: no certificate", p.display()));
}
for c in certs {
roots.add(c).map_err(|e| format!("{}: {e}", p.display()))?;
}
} else if cfg.native_roots {
let found = rustls_native_certs::load_native_certs();
for c in found.certs {
let _ = roots.add(c);
}
if roots.is_empty() {
return Err("native_roots: the platform has no root certificate".into());
}
}
Ok(builder.with_root_certificates(roots).with_no_client_auth())
}
/// A server certificate trusted by the SHA-256 of its DER alone, its handshake signature
/// still verified with its key: a pinned certificate, of any validity and any name.
#[derive(Debug)]
struct Pinned {
pin: [u8; 32],
provider: Arc<rustls::crypto::CryptoProvider>,
}
impl rustls::client::danger::ServerCertVerifier for Pinned {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if crate::cert::sha256(end_entity.as_ref()) == self.pin {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure))
}
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Err(rustls::Error::PeerIncompatible(rustls::PeerIncompatible::Tls12NotOffered))
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.provider.signature_verification_algorithms.supported_schemes()
}
}
// ── Dialing ────────────────────────────────────────────────────────────────
/// `//authority/path` of a URL, the target the upstream reads (WAI live-rooms §10.5.1), and
/// its host and port.
pub fn target_of(url: &str) -> Option<(String, String, String, u16)> {
let (scheme, rest) = url.split_once("://")?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let path = path.split('?').next().unwrap_or("/");
let default_port = match scheme {
"ws" => 80,
_ => 443,
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if !h.ends_with(']') || authority.starts_with('[') => match p.parse::<u16>() {
Ok(p) => (h.trim_start_matches('[').trim_end_matches(']').to_string(), p),
Err(_) => (authority.trim_start_matches('[').trim_end_matches(']').to_string(), default_port),
},
_ => (authority.trim_start_matches('[').trim_end_matches(']').to_string(), default_port),
};
Some((authority.to_string(), path.to_string(), host, port))
}
/// Open the transport of a session to `url` over the link's transport.
async fn dial(relay: &Relay, link: &Link, url: &str) -> Result<(SharedConnection, Option<quinn::Endpoint>), String> {
let cfg = relay.config();
let step = Duration::from_secs(10);
match link.cfg.transport {
UpstreamTransport::WebSocket => {
let mux = MuxConfig { max_bidi: cfg.limits.max_requests, max_uni: cfg.limits.max_uni_streams, max_buffered: cfg.limits.websocket_buffer_bytes, ..MuxConfig::default() };
let conn = match &link.tls {
None => tokio::time::timeout(step, crate::transport::websocket::connect(url, mux)).await,
Some(tls) => tokio::time::timeout(step, crate::transport::websocket::connect_tls(url, tls.clone(), mux)).await,
};
let conn = conn.map_err(|_| "no WebSocket session in time".to_string())?.map_err(|e| e.to_string())?;
Ok((Arc::new(conn), None))
}
UpstreamTransport::WebTransport => {
let tls = link.tls.clone().ok_or("no TLS for WebTransport")?;
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().expect("an address");
let t = crate::server::transport_config(cfg);
let conn = tokio::time::timeout(step, crate::transport::webtransport::connect_draft_tls_with(url, (*tls).clone(), bind, joule_moq::draft::Draft::D22, t))
.await
.map_err(|_| "no WebTransport session in time".to_string())?
.map_err(|e| e.to_string())?;
Ok((Arc::new(conn), None))
}
UpstreamTransport::Quic => {
let tls = link.tls.clone().ok_or("no TLS for QUIC")?;
let (_, _, host, port) = target_of(url).ok_or("a moqt:// URL that does not read")?;
let addr = tokio::net::lookup_host((host.as_str(), port)).await.map_err(|e| format!("{host}: {e}"))?.next().ok_or_else(|| format!("{host}: no address"))?;
let bind: std::net::SocketAddr = if addr.is_ipv6() { "[::]:0".parse().expect("an address") } else { "0.0.0.0:0".parse().expect("an address") };
// The listener's transport configuration, its stream and connection receive windows
// among it: an upstream can leave this relay holding no more unread than a session
// it accepted can.
let t = crate::server::transport_config(cfg);
let ep = crate::transport::quic::client_endpoint_tls(bind, (*tls).clone(), Some(t)).map_err(|e| e.to_string())?;
let conn = tokio::time::timeout(step, crate::transport::quic::connect(&ep, addr, &host)).await.map_err(|_| "no QUIC session in time".to_string())?.map_err(|e| e.to_string())?;
Ok((Arc::new(conn), Some(ep)))
}
}
}
/// Run a link for as long as the relay runs: dial, keep the session, dial again after it
/// ends (backing off, doubling to 30 seconds), and move to a new session after a GOAWAY.
pub async fn run_link(relay: Relay, link: Arc<Link>) {
let base = Duration::from_millis(relay.config().limits.upstream_reconnect_ms.max(10));
let mut backoff = base;
let (gtx, mut grx) = mpsc::unbounded_channel::<String>();
*link.goaway.lock().unwrap() = Some(gtx);
let mut next_url: Option<String> = None;
let mut previous: Option<Arc<SessionCtx>> = None;
loop {
if relay.going_away() {
return;
}
let url = next_url.take().unwrap_or_else(|| link.cfg.url.clone());
link.stats.dials.fetch_add(1, Ordering::Relaxed);
let opened = match dial(&relay, &link, &url).await {
Ok((conn, ep)) => crate::session::dial_session(relay.clone(), conn, link.clone(), &url, ep).await,
Err(e) => Err(e),
};
let ctx = match opened {
Ok(ctx) => ctx,
Err(e) => {
// After a GOAWAY whose URI did not answer, the next try is the link's own URL;
// the old session serves until it closes, and what it held moves once a new
// session opens.
tracing::warn!(upstream = %link.name, url = %url, error = %e, "an upstream session did not open");
sleep_unless_going_away(&relay, backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(30));
continue;
}
};
backoff = base;
link.stats.opened.fetch_add(1, Ordering::Relaxed);
tracing::info!(upstream = %link.name, url = %url, peer = %hex::encode(&ctx.peer.map(|p| p.id).unwrap_or_default()[..6]), "an upstream session is open");
if let Some(old) = previous.take() {
let n = migrate(&relay, &old, &ctx).await;
link.stats.moved.fetch_add(n as u64, Ordering::Relaxed);
tracing::info!(upstream = %link.name, moved = n, "subscriptions moved to the new session after GOAWAY");
old.close(joule_moq::draft22::SessionError::NO_ERROR.0, "moved after GOAWAY");
}
*link.session.lock().unwrap() = Some(ctx.clone());
link.opened.send_modify(|g| *g += 1);
relay.inner.ns_changed.notify_waiters();
relay.inner.change.notify_waiters();
let announcer = tokio::spawn(announce(relay.clone(), link.clone(), ctx.clone()));
tokio::select! {
_ = ctx.conn.closed() => {
announcer.abort();
{
let mut s = link.session.lock().unwrap();
if s.as_ref().is_some_and(|c| c.id == ctx.id) {
*s = None;
}
}
tracing::info!(upstream = %link.name, "the upstream session ended");
sleep_unless_going_away(&relay, base).await;
}
uri = grx.recv() => {
announcer.abort();
let uri = uri.unwrap_or_default();
tracing::info!(upstream = %link.name, uri = %uri, "the upstream sent GOAWAY: moving to a new session");
*link.session.lock().unwrap() = None;
// A URI over another transport than the link's is not followed: the link's own.
next_url = (!uri.is_empty() && same_scheme(&uri, &link.cfg.url)).then_some(uri);
previous = Some(ctx);
}
}
}
}
fn same_scheme(a: &str, b: &str) -> bool {
let s = |u: &str| u.split("://").next().unwrap_or("").to_ascii_lowercase();
let (x, y) = (s(a), s(b));
x == y || (x.starts_with("ws") && y.starts_with("ws"))
}
async fn sleep_unless_going_away(relay: &Relay, d: Duration) {
let until = tokio::time::Instant::now() + d;
while tokio::time::Instant::now() < until && !relay.going_away() {
tokio::time::sleep(Duration::from_millis(50).min(d)).await;
}
}
/// Subscribe on `new` to every track the relay held through `old`, its relay's own upstream
/// subscriptions (§7.6.1: the objects that arrive on both are deduplicated). Returns how many.
async fn migrate(relay: &Relay, old: &Arc<SessionCtx>, new: &Arc<SessionCtx>) -> usize {
let keys: Vec<crate::relay::TrackKey> = {
let st = relay.inner.state.lock().unwrap();
st.tracks.values().filter(|t| !t.downs.is_empty() && t.upstreams.iter().any(|u| u.session == old.id && matches!(u.kind, crate::relay::UpstreamKind::Subscribe(_)))).map(|t| t.key.clone()).collect()
};
let mut n = 0;
for k in keys {
match crate::session::upstream_subscribe(relay, new.clone(), &k, &[], 0).await {
Ok(()) => n += 1,
Err((code, why)) => tracing::warn!(track = %joule_moq::draft22::names::render(&k.0, Some(&k.1)), code, why = %why, "a subscription not moved after GOAWAY"),
}
}
n
}
/// Publish on the link's session every namespace published here under its prefixes (by a
/// session other than the link's, and whose path does not pass through the upstream), and the
/// namespace of every track a publisher published here by PUBLISH, and withdraw each when it
/// goes: until the session ends.
async fn announce(relay: Relay, link: Arc<Link>, ctx: Arc<SessionCtx>) {
let Some(peer) = ctx.peer else { return };
let me = relay.relay_id();
let max = relay.config().limits.max_relay_hops;
// Each namespace announced, with its request stream.
let mut held: HashMap<TrackNamespace, crate::wire::ReqTx> = HashMap::new();
let mut refused: std::collections::HashSet<TrackNamespace> = std::collections::HashSet::new();
loop {
let changed = relay.inner.ns_changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
let want: HashMap<TrackNamespace, Vec<[u8; 32]>> = {
let st = relay.inner.state.lock().unwrap();
let mut w: HashMap<TrackNamespace, Vec<[u8; 32]>> = HashMap::new();
let mut offer = |ns: &TrackNamespace, path: &[[u8; 32]]| {
if path.contains(&peer.id) || path.len() >= max || !link.routes(ns) {
return;
}
let e = w.entry(ns.clone()).or_insert_with(|| path.to_vec());
if path.len() < e.len() {
*e = path.to_vec();
}
};
for n in st.namespaces.iter().filter(|n| n.session != ctx.id) {
offer(&n.namespace, &n.path);
}
// A track a publisher's session published by PUBLISH is reached through its
// namespace, as one published by PUBLISH_NAMESPACE is: the upstream's subscribers
// to it are sent here (§7.6).
for t in st.tracks.values() {
for u in t.upstreams.iter().filter(|u| u.session != ctx.id && matches!(u.kind, crate::relay::UpstreamKind::Publish)) {
let path: Vec<[u8; 32]> = st.sessions.get(&u.session).and_then(|c| c.peer).map(|p| vec![p.id]).unwrap_or_default();
offer(&t.key.0, &path);
}
}
w
};
let gone: Vec<TrackNamespace> = held.keys().filter(|k| !want.contains_key(*k)).cloned().collect();
for ns in gone {
if let Some(tx) = held.remove(&ns) {
// Cancelling a PUBLISH_NAMESPACE withdraws it (§4.1).
tx.reset(StreamResetCode::CANCELLED.0).await;
}
}
refused.retain(|ns| want.contains_key(ns));
for (ns, path) in want {
if held.contains_key(&ns) || refused.contains(&ns) {
continue;
}
let Some(id) = ctx.next_request_id() else { return };
let m = ControlMessage::PublishNamespace(NamespaceRequest { request_id: id, prefix: ns.clone(), params: Parameters::new().with(Parameter::RelayPath(onward(&path, me))) });
let Ok((tx, mut rx)) = crate::wire::open_request(&*ctx.conn, &m, ctx.my_max_updates, ctx.peer_max_updates).await else { return };
link.stats.announced.fetch_add(1, Ordering::Relaxed);
match tokio::time::timeout(Duration::from_millis(relay.config().limits.upstream_timeout_ms), rx.next()).await {
Ok(Ok(Some(ControlMessage::RequestOk(_)))) => {
held.insert(ns.clone(), tx);
// The rest of the stream is read so a cancellation by the upstream is seen.
tokio::spawn(async move { while let Ok(Some(_)) = rx.next().await {} });
}
Ok(Ok(Some(ControlMessage::RequestError(e)))) => {
link.stats.announce_refused.fetch_add(1, Ordering::Relaxed);
tracing::info!(upstream = %link.name, namespace = %joule_moq::draft22::names::render(&ns, None), code = %e.code, reason = %String::from_utf8_lossy(&e.reason), "a namespace the upstream refused");
refused.insert(ns);
}
_ => {
tx.reset(StreamResetCode::CANCELLED.0).await;
refused.insert(ns);
}
}
}
tokio::select! {
_ = changed => {}
_ = ctx.conn.closed() => return,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_url_gives_its_target() {
assert_eq!(target_of("wss://home.example/moq"), Some(("home.example".into(), "/moq".into(), "home.example".into(), 443)));
assert_eq!(target_of("moqt://10.0.0.1:4433/moq?x=1"), Some(("10.0.0.1:4433".into(), "/moq".into(), "10.0.0.1".into(), 4433)));
assert_eq!(target_of("ws://127.0.0.1:9/moq"), Some(("127.0.0.1:9".into(), "/moq".into(), "127.0.0.1".into(), 9)));
assert_eq!(target_of("https://[::1]:4433/moq"), Some(("[::1]:4433".into(), "/moq".into(), "::1".into(), 4433)));
assert!(target_of("no scheme").is_none());
}
#[test]
fn a_link_routes_its_prefixes() {
let cfg = UpstreamConfig {
name: "home".into(),
url: "ws://127.0.0.1:1/moq".into(),
transport: UpstreamTransport::WebSocket,
prefixes: vec![vec!["*".into(), "x".into()]],
ca_path: None,
cert_sha256: None,
native_roots: false,
token_sha256: None,
token_path: None,
relay_key: None,
};
let l = Link::new(0, &cfg).expect("a link");
assert!(l.routes(&TrackNamespace::from_strs(["r", "x", "audio"])));
assert!(!l.routes(&TrackNamespace::from_strs(["r", "y"])));
assert!(!l.routes(&TrackNamespace::from_strs(["r"])), "a namespace shorter than the prefix");
assert!(l.overlaps(&TrackNamespace::from_strs(["r"])) && l.overlaps(&TrackNamespace::from_strs([] as [&str; 0])));
assert!(!l.overlaps(&TrackNamespace::from_strs(["r", "y"])));
let bad = UpstreamConfig { transport: UpstreamTransport::Quic, ..cfg.clone() };
assert!(Link::new(0, &bad).is_err(), "a QUIC upstream with a ws:// URL");
let both = UpstreamConfig { url: "wss://h/moq".into(), native_roots: true, cert_sha256: Some("00".repeat(32)), ..cfg.clone() };
assert!(Link::new(0, &both).is_err(), "two trusts");
let none = UpstreamConfig { url: "wss://h/moq".into(), ..cfg.clone() };
assert!(Link::new(0, &none).is_err(), "no trust for wss://");
let half = UpstreamConfig { token_sha256: Some("00".repeat(32)), ..cfg };
assert!(Link::new(0, &half).is_err(), "a token's hash without its file");
}
#[test]
fn a_path_is_read_as_it_arrives() {
// `arrival` on a session needs a SessionCtx; its rules are checked through the
// relay's tests (tests/cascade.rs). Here: the onward path.
assert_eq!(onward(&[[1; 32]], [2; 32]), vec![[1; 32], [2; 32]]);
}
}

View file

@ -48,6 +48,115 @@ pub struct RelayConfig {
/// The New Session URI a GOAWAY names (§9.2); empty: reuse the current one.
#[serde(default)]
pub goaway_uri: String,
/// The relays this one dials as their peer, each for the namespace prefixes it routes
/// there (relay-to-relay cascading, [`crate::cascade`]). Absent: none, and the relay
/// serves only the publishers and subscribers that reach it.
#[serde(default)]
pub upstream: Vec<UpstreamConfig>,
}
/// The transport an upstream relay is dialed over.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamTransport {
/// Native QUIC, ALPN `moqt-22`; the URL is `moqt://host:port/path`.
Quic,
/// WebTransport over HTTP/3; the URL is `https://host:port/path`.
WebTransport,
/// The WebSocket binding; the URL is `wss://host/path`, or `ws://` on a trusted path
/// such as loopback.
WebSocket,
}
/// One upstream relay ([`crate::cascade`]). The configuration holds no secret: the token is
/// named by its SHA-256 and read from `token_path`.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamConfig {
/// For logs and the relay's counters.
#[serde(default)]
pub name: String,
pub url: String,
pub transport: UpstreamTransport,
/// The Track Namespace prefixes routed to this upstream, each one entry per field; a field
/// `*` matches any one field. A SUBSCRIBE, FETCH or TRACK_STATUS for a track under one of
/// them that no session of this relay publishes is forwarded here, and every namespace
/// published here under one of them is published to it.
pub prefixes: Vec<Vec<String>>,
/// Trust for its certificate, one of three (none for `ws://`): a PEM file of the CA
/// certificates that may issue it,
#[serde(default)]
pub ca_path: Option<PathBuf>,
/// the SHA-256 of its leaf certificate's DER, hex (a pinned certificate),
#[serde(default)]
pub cert_sha256: Option<String>,
/// or the platform's roots, for an upstream whose front holds a public certificate.
#[serde(default)]
pub native_roots: bool,
/// The SHA-256 of the token presented in SETUP, hex, and the file it is read from (its
/// value, trailing white space removed). Both or neither; the relay refuses to start
/// when the file's token does not hash to `token_sha256`.
#[serde(default)]
pub token_sha256: Option<String>,
#[serde(default)]
pub token_path: Option<PathBuf>,
/// The relay id the upstream must declare (its session-summary key, hex). Absent: any.
#[serde(default)]
pub relay_key: Option<String>,
}
impl UpstreamConfig {
/// The prefixes as namespaces.
pub fn prefix_namespaces(&self) -> Vec<joule_moq::TrackNamespace> {
self.prefixes.iter().map(|p| joule_moq::TrackNamespace::from_strs(p.iter().map(String::as_str))).collect()
}
/// Check the entry, and read its token: `Ok(Some(token))` when it presents one.
pub fn check(&self) -> Result<Option<Vec<u8>>, String> {
let who = if self.name.is_empty() { self.url.clone() } else { self.name.clone() };
let scheme = self.url.split("://").next().unwrap_or("");
let want: &[&str] = match self.transport {
UpstreamTransport::Quic => &["moqt"],
UpstreamTransport::WebTransport => &["https"],
UpstreamTransport::WebSocket => &["wss", "ws"],
};
if !want.contains(&scheme) {
return Err(format!("upstream {who}: a {:?} upstream's URL is {}://…", self.transport, want.join("://… or ")));
}
if self.prefixes.is_empty() {
return Err(format!("upstream {who}: no prefixes routed to it"));
}
let trusts = self.ca_path.is_some() as u8 + self.cert_sha256.is_some() as u8 + self.native_roots as u8;
match (scheme, trusts) {
("ws", 0) => {}
("ws", _) => return Err(format!("upstream {who}: ws:// has no TLS to trust")),
(_, 1) => {}
_ => return Err(format!("upstream {who}: give exactly one of ca_path, cert_sha256, native_roots")),
}
if let Some(h) = &self.cert_sha256 {
let b = hex::decode(h).map_err(|e| format!("upstream {who}: cert_sha256: {e}"))?;
if b.len() != 32 {
return Err(format!("upstream {who}: cert_sha256 is not 32 bytes"));
}
}
if let Some(k) = &self.relay_key {
let b: [u8; 32] = hex::decode(k).map_err(|e| format!("upstream {who}: relay_key: {e}"))?.try_into().map_err(|_| format!("upstream {who}: relay_key is not 32 bytes"))?;
ed25519_dalek::VerifyingKey::from_bytes(&b).map_err(|e| format!("upstream {who}: relay_key: {e}"))?;
}
match (&self.token_sha256, &self.token_path) {
(None, None) => Ok(None),
(Some(h), Some(p)) => {
let raw = std::fs::read(p).map_err(|e| format!("upstream {who}: the token {}: {e}", p.display()))?;
let end = raw.iter().rposition(|b| !b.is_ascii_whitespace()).map_or(0, |i| i + 1);
let token = raw[..end].to_vec();
if !hex::encode(crate::auth::sha256(&token)).eq_ignore_ascii_case(h) {
return Err(format!("upstream {who}: the token in {} does not hash to token_sha256", p.display()));
}
Ok(Some(token))
}
_ => Err(format!("upstream {who}: token_sha256 and token_path go together")),
}
}
}
fn default_path() -> String {
@ -142,6 +251,27 @@ pub struct LimitsConfig {
/// still apply (§5.2), rather than in the transport's buffers.
#[serde(default = "d_send_window")]
pub quic_send_window_bytes: u64,
/// Bytes a peer may send on one QUIC stream before the relay has read them: the
/// stream's flow-control window. A door (`receipts.door`) holds each object of a group
/// until the group's receipts arrive, and the receipts follow the group's last object,
/// so a group is read whole before the first of its objects is released: the window
/// must exceed the largest group a publisher sends, or its stream stops and the
/// group's receipts never come. 16 MiB holds 64 Mbit/s for two seconds.
#[serde(default = "d_stream_window")]
pub quic_stream_window_bytes: u64,
/// Bytes a peer may have sent on all the streams of one QUIC or WebTransport session
/// together before the relay has read them: the session's connection-level
/// flow-control window. This, not the stream window, bounds what one session can make
/// the relay hold unread; without it a peer could fill `max_uni_streams` streams each
/// to its stream window. It must hold what one session legitimately leaves unread at
/// once, a few groups in flight across its tracks (the group a door holds whole, the
/// start of the next one, its receipts, the session's other tracks): 64 MiB by
/// default, the budget `websocket_buffer_bytes` gives a WebSocket session and four
/// stream windows. Never below `quic_stream_window_bytes`: the relay raises it to that.
/// Both windows hold alike for the sessions the relay accepts and for the links it
/// dials to its upstreams (`[[upstream]]`, native QUIC or WebTransport).
#[serde(default = "d_connection_window")]
pub quic_connection_window_bytes: u64,
/// The longest a session may last, in milliseconds; past it the relay closes it
/// (`NO_ERROR`, with the limit as its reason). 0: no limit.
#[serde(default)]
@ -160,6 +290,22 @@ pub struct LimitsConfig {
/// no limit.
#[serde(default)]
pub max_resident_mb: u64,
/// The most relays a request may have passed through (its RELAY_PATH, [`crate::cascade`]),
/// this one not counted: a request that arrives with this many is refused
/// `relay_hops_exceeded`, and the relay forwards none that would reach it. At most 15.
#[serde(default = "d_relay_hops")]
pub max_relay_hops: usize,
/// How long the relay waits before it dials an upstream again after its session ended or
/// could not open; doubled on each failure, to at most 30 seconds.
#[serde(default = "d_reconnect")]
pub upstream_reconnect_ms: u64,
}
fn d_relay_hops() -> usize {
8
}
fn d_reconnect() -> u64 {
1_000
}
fn d_sessions() -> usize {
@ -207,6 +353,12 @@ fn d_idle() -> u64 {
fn d_send_window() -> u64 {
1 << 20
}
fn d_stream_window() -> u64 {
16 << 20
}
fn d_connection_window() -> u64 {
64 << 20
}
fn d_ingress_burst() -> u64 {
4_000
}
@ -229,10 +381,14 @@ impl Default for LimitsConfig {
websocket_buffer_bytes: d_ws_buffer(),
idle_timeout_ms: d_idle(),
quic_send_window_bytes: d_send_window(),
quic_stream_window_bytes: d_stream_window(),
quic_connection_window_bytes: d_connection_window(),
max_session_ms: 0,
max_ingress_kbps: 0,
ingress_burst_ms: d_ingress_burst(),
max_resident_mb: 0,
max_relay_hops: d_relay_hops(),
upstream_reconnect_ms: d_reconnect(),
}
}
}
@ -473,6 +629,25 @@ impl DvrConfig {
pub struct MeteringConfig {
#[serde(default)]
pub grants: Vec<MeteredGrant>,
/// Whether the relay reads its own energy ([`crate::energy`]): `"auto"`, the default,
/// reads the counter the host offers; `"off"` reads none, and every record carries the
/// unmetered marker.
#[serde(default)]
pub energy: EnergyMode,
/// The operator's statement that this host runs the relay alone. Only then is a Linux
/// host's CPU package counter, which covers every process on the package, read as the
/// relay's energy.
#[serde(default)]
pub dedicated_host: bool,
}
/// [`MeteringConfig::energy`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EnergyMode {
#[default]
Auto,
Off,
}
#[derive(Clone, Debug, Deserialize)]
@ -518,6 +693,7 @@ impl RelayConfig {
dvr: None,
floor: None,
goaway_uri: String::new(),
upstream: Vec::new(),
}
}
@ -545,6 +721,9 @@ mod tests {
d.check().expect("the example's DVR configuration holds");
assert_eq!(d.window[0].max_age_s, 900);
assert!(RelayConfig::from_toml("unknown_field = 1").is_err());
// A session's window holds several of its streams' (the example's and the default).
assert_eq!(c.limits.quic_connection_window_bytes, LimitsConfig::default().quic_connection_window_bytes);
assert!(c.limits.quic_connection_window_bytes >= 4 * c.limits.quic_stream_window_bytes);
let long = DvrConfig { path: "/x".into(), window: vec![DvrWindow { prefix: vec![], max_age_s: DVR_MAX_AGE_S + 1, max_bytes: 1 }], room_keys: vec![], takedown_keys: vec![] };
assert!(long.check().is_err(), "a window past 24 hours");
let floor = c.floor.as_ref().expect("the example holds the floor");

View file

@ -0,0 +1,593 @@
//! The relay's own energy, read from a counter the host keeps (WAI `live-metering.md` §8,
//! `energy-measurement.md` §2, §2.3, §3.1).
//!
//! A sampler thread reads one counter every [`INTERVAL`] for as long as the relay runs and
//! keeps the samples of the last [`Meter::KEEP`]. A span's figure is the counter's advance
//! across it, interpolated between the samples on either side of each end, and is a figure
//! only where the counter was **live** across it (energy-measurement §3.1: it advanced in
//! two consecutive intervals, in at least five, and in at least four of every five, over at
//! least 300 ms). Otherwise there is no figure, and the relay signs the unmetered marker.
//!
//! The counters, and what each covers:
//!
//! - **macOS: the relay process's own CPU energy** (`ri_energy_nj` from
//! `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)`, public and unprivileged), scope
//! [`Scope::Task`]: every thread of the relay process, as the kernel accounts it. Not
//! other processes, not the GPU or the Neural Engine (the relay uses neither), not the
//! network interface, and not work a system service does for the relay.
//! - **Linux: the CPU packages' energy** (RAPL through powercap: every top-level zone whose
//! name is `package-<n>`, each counter's wrap corrected at its `max_energy_range_uj`),
//! scope [`Scope::Package`]: the whole package, every process on it included. It is read
//! only where the operator states the host runs the relay alone (`dedicated_host`), since
//! it is otherwise not the relay's energy.
//! - Anywhere else, or where the counter cannot be read: none, and every figure is the
//! unmetered marker.
//!
//! Both are on-chip counters, so a figure is `OnChipCounter`, with its filtering state
//! `Unknown` (an unprivileged reader cannot see one) and a declared uncertainty
//! ([`figure`]): the interpolation at each end, the counter's lag at each end (the relay's
//! running threads' unfolded quanta for the task counter; the register's update period for
//! the package counter), the noise a filtered package counter's vendor states for the
//! window, and the rounding to whole microjoules.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// How often the sampler reads its counter.
pub const INTERVAL: Duration = Duration::from_millis(100);
/// The shortest span a figure covers (energy-measurement §3.1).
pub const MIN_SPAN_US: u64 = 300_000;
/// The task counter folds a running thread's energy at its context switch or its quantum,
/// about 10 ms of the thread's CPU time: what a running thread can hold unfolded at a read.
pub const QUANTUM_NS: u64 = 10_000_000;
/// The package counter's register update period, about 1 ms.
pub const REGISTER_PERIOD_NS: u64 = 1_000_000;
/// The resolution floor of energy-measurement §2.3: `joules_micro × relative_ppm` at least.
pub const RESOLUTION_FLOOR: u64 = 288_676;
/// What a counter covers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
/// The relay process's own CPU energy.
Task,
/// The CPU packages of a host that runs the relay alone.
Package,
}
impl Scope {
pub const fn label(self) -> &'static str {
match self {
Scope::Task => "task",
Scope::Package => "package",
}
}
}
/// One read of a counter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reading {
/// The counter's energy since an arbitrary origin, in nanojoules, never decreasing: a
/// counter that wraps has its wrap corrected before it is a reading.
pub nj: u64,
/// The process's CPU time, user and system, in nanoseconds, where the counter has one.
pub cpu_ns: Option<u64>,
/// The threads of the process on a core at the read, where the counter has them.
pub running: u32,
}
/// A counter the sampler reads.
pub trait Counter: Send + 'static {
fn scope(&self) -> Scope;
fn read(&mut self) -> Option<Reading>;
}
/// One sample: a reading and when it was taken.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sample {
/// Microseconds since the Unix epoch.
pub unix_us: u64,
pub reading: Reading,
}
/// A figure for a span: what [`figure`] gives where it can.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Figure {
pub joules_micro: u64,
/// The declared standard relative uncertainty, in parts per million, rounded up.
pub relative_ppm: u32,
/// The span, in microseconds.
pub window_us: u64,
pub scope: Scope,
}
impl Figure {
/// energy-measurement §2.7's evidence after tag 4 (`OnChipCounter`): `relative_ppm` u32
/// BE, `window_us` u64 BE, filtering `0` (`Unknown`).
pub fn evidence(&self) -> Vec<u8> {
let mut o = Vec::with_capacity(13);
o.extend_from_slice(&self.relative_ppm.to_be_bytes());
o.extend_from_slice(&self.window_us.to_be_bytes());
o.push(0);
o
}
}
/// Why a span has no figure, so that its record carries the unmetered marker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unmetered {
/// No counter backs the relay on this host, or the configuration turns it off.
NoCounter,
/// The span is shorter than 300 ms.
SpanTooShort,
/// The samples kept do not reach both ends of the span.
NotSampled,
/// The counter was not live across the span (energy-measurement §3.1).
NotLive,
/// The figure rounds to zero microjoules: no measurement of nothing (§4 there).
Zero,
/// The declared uncertainty does not fit its field.
Uncertain,
/// The relay did other work in the span (another room's sessions, a session still
/// open), so its energy over the span is not this room's.
Shared,
}
impl Unmetered {
pub const fn label(self) -> &'static str {
match self {
Unmetered::NoCounter => "no counter on this host",
Unmetered::SpanTooShort => "span under 300 ms",
Unmetered::NotSampled => "span outside the samples kept",
Unmetered::NotLive => "counter not live across the span",
Unmetered::Zero => "figure rounds to zero",
Unmetered::Uncertain => "uncertainty does not fit",
Unmetered::Shared => "the relay served other work in the span",
}
}
}
/// The counter's value at `t`, interpolated between the samples either side of it, in
/// nanojoules, with the energy of the interval it falls in. `None` where no sample is at or
/// before `t`, or none at or after it.
fn at(samples: &[Sample], t: u64) -> Option<(u128, u128, usize)> {
let after = samples.iter().position(|s| s.unix_us >= t)?;
let s1 = samples[after];
if s1.unix_us == t {
// On a sample: the interval is the one before it, or after it at the first sample.
let e = match (after.checked_sub(1).map(|i| samples[i]), samples.get(after + 1)) {
(Some(s0), _) => u128::from(s1.reading.nj - s0.reading.nj),
(None, Some(s2)) => u128::from(s2.reading.nj - s1.reading.nj),
(None, None) => 0,
};
return Some((u128::from(s1.reading.nj), e, after));
}
let s0 = samples[after.checked_sub(1)?];
let (dt, dx) = (u128::from(s1.unix_us - s0.unix_us), u128::from(s1.reading.nj - s0.reading.nj));
let v = u128::from(s0.reading.nj) + dx * u128::from(t - s0.unix_us) / dt;
Some((v, dx, after))
}
/// The figure for the span `[from_us, to_us)`, from samples in time order.
pub fn figure(samples: &[Sample], from_us: u64, to_us: u64, scope: Scope) -> Result<Figure, Unmetered> {
if to_us <= from_us || to_us - from_us < MIN_SPAN_US {
return Err(Unmetered::SpanTooShort);
}
let (e0, edge0, i0) = at(samples, from_us).ok_or(Unmetered::NotSampled)?;
let (e1, edge1, i1) = at(samples, to_us).ok_or(Unmetered::NotSampled)?;
// The bracket: from the sample at or before the span's start to the one at or after its
// end. Liveness over its intervals (§3.1).
let first = if samples[i0].unix_us == from_us { i0 } else { i0 - 1 };
let bracket = &samples[first..=i1];
let advanced: Vec<bool> = bracket.windows(2).map(|w| w[1].reading.nj > w[0].reading.nj).collect();
let n = advanced.len();
let live_count = advanced.iter().filter(|a| **a).count();
let consecutive = advanced.windows(2).any(|w| w[0] && w[1]);
if live_count < 5 || live_count * 5 < n * 4 || !consecutive {
return Err(Unmetered::NotLive);
}
let nj = e1.saturating_sub(e0);
let f = (nj + 500) / 1000;
if f == 0 {
return Err(Unmetered::Zero);
}
let f = u64::try_from(f).map_err(|_| Unmetered::Uncertain)?;
// The uncertainty, in nanojoules squared.
let sq = |x: f64| x * x;
// Interpolation: within the interval an end falls in, the counter's course is unknown,
// and the error is taken as rectangular over half that interval's energy either way.
let mut var = (sq(edge0 as f64) + sq(edge1 as f64)) / 12.0;
// The counter's lag at each end, rectangular over [0, L].
let lag = |i: usize| -> f64 {
match scope {
Scope::Task => {
// The energy a CPU-nanosecond costs, at its highest over the bracket's
// intervals, times a quantum, for each thread on a core at the read.
let per_ns = bracket
.windows(2)
.filter_map(|w| {
let c = w[1].reading.cpu_ns?.checked_sub(w[0].reading.cpu_ns?)?;
(c >= 1_000_000).then(|| (w[1].reading.nj - w[0].reading.nj) as f64 / c as f64)
})
.fold(nj as f64 / (to_us - from_us) as f64 / 1000.0, f64::max);
let running = samples[i].reading.running.max(samples[i.saturating_sub(1)].reading.running).max(1);
f64::from(running) * QUANTUM_NS as f64 * per_ns
}
Scope::Package => {
// The register's update period at the bracket's highest interval power.
let watts = bracket
.windows(2)
.map(|w| (w[1].reading.nj - w[0].reading.nj) as f64 / ((w[1].unix_us - w[0].unix_us).max(1) as f64 * 1000.0))
.fold(0.0, f64::max);
REGISTER_PERIOD_NS as f64 * watts
}
}
};
var += (sq(lag(i0)) + sq(lag(i1))) / 12.0;
if scope == Scope::Package {
// Two whole-microjoule register reads at each end (energy-measurement §2.3).
var += 2.0 * sq(1000.0 / 6f64.sqrt());
// A package counter may be filtered (§2.4): the noise INTEL-SA-00389's guidance
// states, under 2 % at 1 s intervals and 5–15 % at 100 ms, taken at its top.
let rel = if to_us - from_us >= 1_000_000 { 0.02 } else { 0.15 };
var += sq(rel * nj as f64);
}
// The figure's own rounding to whole microjoules.
var += sq(500.0 / 3f64.sqrt());
let ppm = (var.sqrt() * 1e6 / (f as f64 * 1000.0)).ceil();
let floor = RESOLUTION_FLOOR.div_ceil(f) as f64;
let ppm = ppm.max(floor);
if !ppm.is_finite() || ppm > f64::from(u32::MAX) || ppm < 1.0 {
return Err(Unmetered::Uncertain);
}
Ok(Figure { joules_micro: f, relative_ppm: ppm as u32, window_us: to_us - from_us, scope })
}
/// The sampler: a thread reading one counter, and the samples it keeps.
pub struct Meter {
scope: Scope,
samples: Arc<Mutex<VecDeque<Sample>>>,
stop: Arc<AtomicBool>,
}
impl Meter {
/// How long the samples reach back: a record signed later than this after its span
/// began carries the unmetered marker.
pub const KEEP: Duration = Duration::from_secs(4 * 3600);
/// Start sampling `counter` every `interval`.
pub fn start(mut counter: Box<dyn Counter>, interval: Duration) -> Meter {
let scope = counter.scope();
let samples = Arc::new(Mutex::new(VecDeque::new()));
let stop = Arc::new(AtomicBool::new(false));
let keep = (Self::KEEP.as_micros() / interval.as_micros().max(1)) as usize + 1;
let (s, halt) = (samples.clone(), stop.clone());
std::thread::Builder::new()
.name("relay-energy".into())
.spawn(move || {
while !halt.load(Ordering::Relaxed) {
if let Some(reading) = counter.read() {
let mut q = s.lock().unwrap();
if q.len() >= keep {
q.pop_front();
}
q.push_back(Sample { unix_us: now_unix_us(), reading });
}
std::thread::sleep(interval);
}
})
.expect("the energy sampler's thread starts");
Meter { scope, samples, stop }
}
/// The counter this host offers the relay: macOS's task counter, or, where `dedicated`
/// and readable, Linux's package counter. `None` elsewhere.
pub fn host(dedicated: bool) -> Option<Box<dyn Counter>> {
#[cfg(target_os = "macos")]
{
let _ = dedicated;
macos::TaskCounter::open().map(|c| Box::new(c) as Box<dyn Counter>)
}
#[cfg(target_os = "linux")]
{
if !dedicated {
return None;
}
powercap::PackageCounter::open_in(std::path::Path::new("/sys/class/powercap")).map(|c| Box::new(c) as Box<dyn Counter>)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = dedicated;
None
}
}
pub fn scope(&self) -> Scope {
self.scope
}
/// The figure for `[from_us, to_us)`, Unix microseconds.
pub fn figure(&self, from_us: u64, to_us: u64) -> Result<Figure, Unmetered> {
let q = self.samples.lock().unwrap();
let v: Vec<Sample> = q.iter().copied().collect();
figure(&v, from_us, to_us, self.scope)
}
/// The most recent sample's time, Unix microseconds, once there is one.
pub fn last_sample_us(&self) -> Option<u64> {
self.samples.lock().unwrap().back().map(|s| s.unix_us)
}
}
impl Drop for Meter {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
fn now_unix_us() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
use super::{Counter, Reading, Scope};
use std::os::raw::{c_int, c_void};
/// `RUSAGE_INFO_V6`, its size, and the offsets read: `ri_user_time`, `ri_system_time`
/// (Mach absolute time units) and `ri_energy_nj`, from `<sys/resource.h>` (the struct is
/// a 16-byte UUID then 56 `uint64_t`s; the same layout `jcp-energy` reads).
const FLAVOR: c_int = 6;
const SIZE: usize = 464;
const USER_TIME: usize = 16;
const SYSTEM_TIME: usize = 24;
const ENERGY_NJ: usize = 336;
unsafe extern "C" {
fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int;
fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *mut c_void, buffersize: c_int) -> c_int;
fn mach_timebase_info(info: *mut [u32; 2]) -> c_int;
fn getpid() -> c_int;
}
#[repr(C, align(8))]
struct Info([u8; SIZE]);
fn word(b: &[u8; SIZE], at: usize) -> u64 {
u64::from_ne_bytes(b[at..at + 8].try_into().expect("8 bytes"))
}
/// The relay process's own CPU energy counter.
pub struct TaskCounter {
/// Mach absolute time units to nanoseconds: numer / denom.
timebase: (u64, u64),
}
impl TaskCounter {
pub fn open() -> Option<TaskCounter> {
let mut tb = [0u32; 2];
if unsafe { mach_timebase_info(&mut tb) } != 0 || tb[1] == 0 {
return None;
}
let mut c = TaskCounter { timebase: (u64::from(tb[0]), u64::from(tb[1])) };
c.read()?;
Some(c)
}
/// `pti_numrunning` of `struct proc_taskinfo` (`PROC_PIDTASKINFO`, 4): 96 bytes, the
/// running-thread count at byte 88 after eleven 64-bit and integer fields.
fn running() -> u32 {
let mut b = [0u8; 96];
let n = unsafe { proc_pidinfo(getpid(), 4, 0, b.as_mut_ptr().cast(), 96) };
if n != 96 {
return 1;
}
i32::from_ne_bytes(b[88..92].try_into().expect("4 bytes")).max(1) as u32
}
}
impl Counter for TaskCounter {
fn scope(&self) -> Scope {
Scope::Task
}
fn read(&mut self) -> Option<Reading> {
let mut info = Info([0; SIZE]);
if unsafe { proc_pid_rusage(getpid(), FLAVOR, info.0.as_mut_ptr().cast()) } != 0 {
return None;
}
let mach = word(&info.0, USER_TIME).saturating_add(word(&info.0, SYSTEM_TIME));
let cpu_ns = (u128::from(mach) * u128::from(self.timebase.0) / u128::from(self.timebase.1)) as u64;
Some(Reading { nj: word(&info.0, ENERGY_NJ), cpu_ns: Some(cpu_ns), running: Self::running() })
}
}
}
/// The CPU packages' RAPL counters through powercap: built everywhere, so its tests run on
/// a tree made for them; read on Linux alone.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod powercap {
use super::{Counter, Reading, Scope};
use std::path::{Path, PathBuf};
/// The CPU packages' RAPL counters through powercap.
pub struct PackageCounter {
/// Per package: its `energy_uj`, its `max_energy_range_uj`, and its last raw value.
zones: Vec<(PathBuf, u64, u64)>,
/// The sum of every package's wrap-corrected advance since the counter opened, in µJ.
total_uj: u64,
}
fn read_u64(p: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(p).ok()?.trim().parse().ok()
}
impl PackageCounter {
/// Every top-level `intel-rapl:<n>` zone whose `name` is `package-<n>`, each readable
/// with a known range; none if any package cannot be read, so the figure never
/// leaves a package out.
pub fn open_in(root: &Path) -> Option<PackageCounter> {
let mut zones = Vec::new();
for e in std::fs::read_dir(root).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let Some(rest) = name.strip_prefix("intel-rapl:") else { continue };
if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let zone = std::fs::read_to_string(e.path().join("name")).ok()?;
let zone = zone.trim();
let Some(n) = zone.strip_prefix("package-") else { continue };
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let energy = e.path().join("energy_uj");
let range = read_u64(&e.path().join("max_energy_range_uj")).filter(|r| *r > 0)?;
let now = read_u64(&energy)?;
zones.push((energy, range, now));
}
if zones.is_empty() {
return None;
}
zones.sort();
Some(PackageCounter { zones, total_uj: 0 })
}
}
impl Counter for PackageCounter {
fn scope(&self) -> Scope {
Scope::Package
}
fn read(&mut self) -> Option<Reading> {
let mut add = 0u64;
let mut now = Vec::with_capacity(self.zones.len());
for (p, range, last) in &self.zones {
let v = read_u64(p)?;
if v > *range {
return None;
}
add += if v >= *last { v - last } else { range - last + v };
now.push(v);
}
for (z, v) in self.zones.iter_mut().zip(now) {
z.2 = v;
}
self.total_uj += add;
Some(Reading { nj: self.total_uj.saturating_mul(1000), cpu_ns: None, running: 1 })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn zone(root: &Path, dir: &str, name: &str, energy: u64, range: u64) {
let d = root.join(dir);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("name"), format!("{name}\n")).unwrap();
std::fs::write(d.join("energy_uj"), format!("{energy}\n")).unwrap();
std::fs::write(d.join("max_energy_range_uj"), format!("{range}\n")).unwrap();
}
/// Every package zone and nothing else is summed, each wrap corrected at its own
/// range; a tree with no package, or a package that cannot be read, opens nothing.
#[test]
fn packages_alone_each_wrap_corrected() {
let root = std::env::temp_dir().join(format!("relay-powercap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
zone(&root, "intel-rapl:0", "package-0", 1_000, 10_000);
zone(&root, "intel-rapl:0:0", "core", 5, 10_000);
zone(&root, "intel-rapl:1", "psys", 70, 10_000);
zone(&root, "intel-rapl:2", "package-1", 9_990, 10_000);
let mut c = PackageCounter::open_in(&root).expect("two packages");
assert_eq!(c.zones.len(), 2);
assert_eq!(c.read().unwrap().nj, 0);
std::fs::write(root.join("intel-rapl:0/energy_uj"), "1500\n").unwrap();
std::fs::write(root.join("intel-rapl:2/energy_uj"), "20\n").unwrap(); // wrapped: 10 + 20
std::fs::write(root.join("intel-rapl:1/energy_uj"), "99999\n").unwrap(); // psys: never read
assert_eq!(c.read().unwrap().nj, (500 + 30) * 1000);
std::fs::write(root.join("intel-rapl:0/energy_uj"), "20000\n").unwrap(); // past its range
assert_eq!(c.read(), None, "a value past its range is no reading");
std::fs::remove_file(root.join("intel-rapl:2/energy_uj")).unwrap();
assert!(PackageCounter::open_in(&root).is_none(), "a package that cannot be read: nothing opens");
let empty = root.join("empty");
std::fs::create_dir_all(empty.join("intel-rapl:1")).unwrap();
std::fs::write(empty.join("intel-rapl:1/name"), "psys\n").unwrap();
assert!(PackageCounter::open_in(&empty).is_none(), "no package zone");
let _ = std::fs::remove_dir_all(&root);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Samples every 100 ms from `t0`, the counter advancing by `step(i)` nJ before sample
/// `i`, with `cpu` ns of CPU per interval and `running` threads.
fn series(t0: u64, n: usize, step: impl Fn(usize) -> u64, cpu: u64, running: u32) -> Vec<Sample> {
let (mut nj, mut c) = (0u64, 0u64);
(0..n)
.map(|i| {
if i > 0 {
nj += step(i);
c += cpu;
}
Sample { unix_us: t0 + 100_000 * i as u64, reading: Reading { nj, cpu_ns: Some(c), running } }
})
.collect()
}
#[test]
fn a_steady_counter_gives_its_advance_with_an_uncertainty_over_the_floor() {
// 50 mJ an interval: 0.5 W over 2 s.
let s = series(1_000_000, 30, |_| 50_000_000, 20_000_000, 2);
let f = figure(&s, 1_000_000 + 250_000, 1_000_000 + 2_250_000, Scope::Task).unwrap();
assert_eq!(f.joules_micro, 1_000_000, "the interpolated advance over 2 s");
assert_eq!(f.window_us, 2_000_000);
assert!(f.joules_micro * u64::from(f.relative_ppm) >= RESOLUTION_FLOOR);
// Each end's interval energy (50 mJ) and two threads' quantum at 2.5 W/CPU-s each.
let want = ((2.0 * 5e7f64.powi(2) + 2.0 * (2.0 * 1e7 * 2.5f64).powi(2)) / 12.0 + (500.0f64 / 3f64.sqrt()).powi(2)).sqrt() * 1e6 / 1e9;
assert!((f64::from(f.relative_ppm) - want.ceil()).abs() <= 1.0, "{} against {}", f.relative_ppm, want.ceil());
assert_eq!(f.evidence().len(), 13);
}
#[test]
fn a_frozen_or_sparse_counter_is_not_live() {
let frozen = series(0, 30, |_| 0, 0, 1);
assert_eq!(figure(&frozen, 100_000, 2_100_000, Scope::Task), Err(Unmetered::NotLive));
// A counter refreshed every fifth interval: one advance in five.
let lumpy = series(0, 30, |i| if i % 5 == 0 { 250_000_000 } else { 0 }, 0, 1);
assert_eq!(figure(&lumpy, 100_000, 2_100_000, Scope::Task), Err(Unmetered::NotLive));
// Every other interval: two in four, under four in five.
let half = series(0, 30, |i| if i % 2 == 0 { 100_000_000 } else { 0 }, 0, 1);
assert_eq!(figure(&half, 100_000, 2_100_000, Scope::Task), Err(Unmetered::NotLive));
}
#[test]
fn a_span_the_samples_do_not_hold_or_too_short_has_no_figure() {
// 1 µJ (1 000 nJ) an interval.
let s = series(1_000_000, 10, |_| 1_000, 1_000_000, 1);
assert_eq!(figure(&s, 500_000, 1_600_000, Scope::Task), Err(Unmetered::NotSampled));
assert_eq!(figure(&s, 1_100_000, 2_000_000, Scope::Task), Err(Unmetered::NotSampled));
assert_eq!(figure(&s, 1_100_000, 1_300_000, Scope::Task), Err(Unmetered::SpanTooShort));
// 1 µJ an interval over 0.8 s: 8 µJ, live, and sealed with a declaration at the floor.
let f = figure(&s, 1_050_000, 1_850_000, Scope::Task).unwrap();
assert_eq!(f.joules_micro, 8);
assert!(8 * u64::from(f.relative_ppm) >= RESOLUTION_FLOOR);
// Less than half a microjoule in all: no figure, never a figure of zero.
let tiny = series(0, 12, |_| 40, 0, 1);
assert_eq!(figure(&tiny, 0, 1_000_000, Scope::Task), Err(Unmetered::Zero));
}
#[test]
fn a_package_figure_declares_the_filtering_noise_for_its_window() {
let s = series(0, 40, |_| 2_500_000_000, 0, 1); // 25 W
let long = figure(&s, 0, 2_000_000, Scope::Package).unwrap();
assert!(long.relative_ppm >= 20_000, "{}", long.relative_ppm);
let short = figure(&s, 0, 500_000, Scope::Package).unwrap();
assert!(short.relative_ppm >= 150_000, "{}", short.relative_ppm);
}
}

View file

@ -412,6 +412,62 @@ fn count(f: &mut RoomFloor, e: &Event) {
}
}
/// One record of `owner`'s events track that is not an audience input: counted, and, when it
/// is a schedule, a handover, a window or a commitment the floor honours (`room_key` for the
/// owner a room key, `direct` for it holding the direct power), held in the room's floor.
#[allow(clippy::too_many_arguments)]
fn statement(f: &mut RoomFloor, room: [u8; 32], owner: [u8; 32], e: &Event, rec: &[u8], room_key: bool, direct: bool) {
count(f, e);
let Some(magic) = (e.kind == KIND_META && e.payload.len() >= 4).then(|| &e.payload[..4]) else { return };
let m4: &[u8; 4] = magic.try_into().expect("4 bytes");
if ![SCHEDULE_MAGIC, HANDOVER_MAGIC, WINDOW_MAGIC, COMMIT_MAGIC].contains(&m4) || f.seen.contains(&e.digest) || !e.verifies(rec) {
return;
}
f.seen.insert(e.digest);
if magic == WINDOW_MAGIC {
if direct && let Some(w) = WindowRule::read(&e.payload, e.t) {
f.windows.entry(e.digest).or_insert(w);
}
return;
}
if magic == COMMIT_MAGIC {
let p = &e.payload;
if room_key && p.len() == 5 + 32 + 4 + 32 && p[4] == 1
&& let Some(w) = f.windows.get_mut(&<[u8; 32]>::try_from(&p[5..37]).expect("32"))
{
w.committed = true;
}
return;
}
if magic == SCHEDULE_MAGIC {
if let Some(s) = Schedule::read(&e.payload, e.digest)
&& direct
&& s.from >= e.t
{
tracing::info!(target: "joule_moq_relay::floor", room = %hex::encode(room), schedule = %hex::encode(e.digest), mode = s.mode, from = s.from, "a schedule is honoured");
f.schedules.push(s);
}
return;
}
let p = &e.payload;
if p.len() != 5 + 8 + 32 || p[4] != 1 {
return;
}
let at = u64::from_le_bytes(p[5..13].try_into().expect("8"));
let to: [u8; 32] = p[13..45].try_into().expect("32");
let Some(s) = f.schedule_at(at).cloned() else { return };
let Some((segment, turn)) = s.turn_at(at) else { return };
if at < e.t || !s.segments[segment].holders.contains(&to) {
return;
}
if !(direct || f.holder(&s, segment, turn, at) == owner) {
return;
}
f.order += 1;
let order = f.order;
f.handovers.push(Handover { schedule: s.digest, segment, turn, at, to, order });
}
/// Record a decision, forgetting every earlier one once a room holds its bound of them.
fn decide(decided: &mut HashMap<[u8; 32], Option<&'static str>>, digest: [u8; 32], v: Option<&'static str>) {
if decided.len() >= MAX_DECIDED {
@ -533,6 +589,37 @@ impl Floor {
let _ = self.events_step(ns, payload, directors, &[], 0, Instant::now(), &mut Pass::default());
}
/// One object of the events track `ns` that reached this relay over a hop link
/// (crate::cascade), from a relay that held it to §7.3 already. Its schedules, handovers,
/// windows and commitments go into its room's floor, as [`Floor::events_step`] takes
/// them, those of room keys and of a turn's holders honoured; its audience inputs were
/// decided, in their author's chain order, at the hop where their author publishes, and
/// are neither held nor counted here, so that every relay forwards the same inputs in the
/// same order (WAI co-performance §8).
pub fn events_observe(&self, ns: &TrackNamespace, payload: &[u8]) {
let (Some(room), Some(owner)) = (crate::dvr::room_of(ns), key_field(ns, 1)) else { return };
if !(ns.0.len() == 3 && ns.0[2] == b"events") {
return;
}
let Some(recs) = events::records(payload) else { return };
let room_key = self.room_keys.contains(&owner);
let mut rooms = self.rooms.lock().unwrap();
if !rooms.contains_key(&room) && rooms.len() >= MAX_ROOMS
&& let Some(old) = rooms.iter().min_by_key(|(_, f)| f.touched).map(|(r, _)| *r)
{
rooms.remove(&old);
}
let f = rooms.entry(room).or_default();
f.touched = Instant::now();
for (_, rec) in &recs {
let Some(e) = Event::read(rec).filter(|e| e.room == room && e.principal == owner) else { continue };
if e.kind == KIND_META && e.payload.get(..4) == Some(&INPUT_MAGIC[..]) {
continue;
}
statement(f, room, owner, &e, rec, room_key, room_key);
}
}
/// One object of the events track `ns`, before the relay forwards it: each schedule,
/// handover and window of it the relay honours, and each commitment of a room key, goes
/// into its room's floor; each audience input of it is held to §7.3, in its author's chain
@ -626,55 +713,7 @@ impl Floor {
moved |= !pass.verdicts.contains_key(&e.digest) && take_input(f, room, owner, e, rec, limit, grant, pass).is_some();
continue;
}
count(f, e);
let Some(magic) = (e.kind == KIND_META && e.payload.len() >= 4).then(|| &e.payload[..4]) else { continue };
let m4: &[u8; 4] = magic.try_into().expect("4 bytes");
if ![SCHEDULE_MAGIC, HANDOVER_MAGIC, WINDOW_MAGIC, COMMIT_MAGIC].contains(&m4) || f.seen.contains(&e.digest) || !e.verifies(rec) {
continue;
}
f.seen.insert(e.digest);
if magic == WINDOW_MAGIC {
if direct && let Some(w) = WindowRule::read(&e.payload, e.t) {
f.windows.entry(e.digest).or_insert(w);
}
continue;
}
if magic == COMMIT_MAGIC {
let p = &e.payload;
if room_key && p.len() == 5 + 32 + 4 + 32 && p[4] == 1
&& let Some(w) = f.windows.get_mut(&<[u8; 32]>::try_from(&p[5..37]).expect("32"))
{
w.committed = true;
}
continue;
}
if magic == SCHEDULE_MAGIC {
if let Some(s) = Schedule::read(&e.payload, e.digest)
&& direct
&& s.from >= e.t
{
tracing::info!(target: "joule_moq_relay::floor", room = %hex::encode(room), schedule = %hex::encode(e.digest), mode = s.mode, from = s.from, "a schedule is honoured");
f.schedules.push(s);
}
continue;
}
let p = &e.payload;
if p.len() != 5 + 8 + 32 || p[4] != 1 {
continue;
}
let at = u64::from_le_bytes(p[5..13].try_into().expect("8"));
let to: [u8; 32] = p[13..45].try_into().expect("32");
let Some(s) = f.schedule_at(at).cloned() else { continue };
let Some((segment, turn)) = s.turn_at(at) else { continue };
if at < e.t || !s.segments[segment].holders.contains(&to) {
continue;
}
if !(direct || f.holder(&s, segment, turn, at) == owner) {
continue;
}
f.order += 1;
let order = f.order;
f.handovers.push(Handover { schedule: s.digest, segment, turn, at, to, order });
statement(f, room, owner, e, rec, room_key, direct);
}
// The object's inputs still waiting, in their chain's order: one decided above may have
// been the turn another waited for.

View file

@ -18,13 +18,19 @@
//! - **Receipts at the door** ([`door`]): optional per namespace, with `joule-moq`'s
//! verifier.
//! - **Session summaries** ([`summary`]): bytes and objects per subscriber, signed by the
//! relay at session end; its own energy is reported as unmetered, since no meter backs
//! it.
//! relay at session end; a session's own energy is reported as unmetered, since sessions
//! run together in one process and a counter cannot tell their shares apart.
//! - **Its own energy** ([`energy`]): a counter the host keeps, sampled while the relay
//! runs: macOS's per-task CPU energy of the relay process, or, on a Linux host the
//! operator states runs the relay alone, the CPU packages' RAPL energy; `OnChipCounter`
//! with its scope and declared uncertainty, or the unmetered marker where it is not live
//! or not the room's.
//! - **Per-hop session records** ([`metering`]): WAI's `WMH1` record of a room
//! (`live-metering.md` §3), signed by the relay from its summaries: per track the
//! objects and bytes in and out and the drops by reason, its subscribers' time by layer
//! class, the events it delivered, and a ledger of the room grants its sessions ran under.
//! With a DVR store it signs version 2, with the store's figures (§3.9).
//! class, the events it delivered, a ledger of the room grants its sessions ran under, and
//! its own energy over the span where it served that room alone. With a DVR store it
//! signs version 2, with the store's figures (§3.9).
//! - **DVR** ([`dvr`]): a window of the tracks under each configured prefix, with their
//! receipts, on disk, served to FETCH below the live edge (WAI `live-recording.md` §8):
//! evicted oldest first by whole group, kept across a restart with every object checked
@ -35,16 +41,23 @@
//! events tracks; a media object published out of turn under a schedule that refuses is
//! not forwarded (WAI `co-performance.md` §6).
//!
//! - **Cascading** ([`cascade`]): relays that dial their upstreams (`[[upstream]]`) and route
//! the namespaces each is configured for there: a subscription or fetch no local publisher
//! serves goes upstream once, namespaces are published both ways, and a relay-id path
//! (`RELAY_PATH`, negotiated by the Setup Option `RELAY_ID`) keeps requests out of loops.
//!
//! [`server::RelayServer`] binds the listeners; [`client::Client`] is a small client for
//! the tests and for tools.
pub mod auth;
pub mod cache;
pub mod cascade;
pub mod cert;
pub mod client;
pub mod config;
pub mod door;
pub mod dvr;
pub mod energy;
pub mod events;
pub mod floor;
pub mod metering;

View file

@ -5,9 +5,13 @@
//! the room id), the objects and bytes in and out, the objects dropped by reason with those
//! whose receipts it forwarded; its subscriber sessions' time by layer class, from the
//! subscriptions each held; the events it delivered; and a ledger, one entry for each room
//! grant its sessions ran under, by the token each presented in SETUP. Its own energy is the
//! unmetered marker, since no meter backs this relay. It connects to no other hop, so every
//! session of it that subscribes is a subscriber session (§3.3 there).
//! grant its sessions ran under, by the token each presented in SETUP. Its own energy is read
//! from the counter the host keeps ([`crate::energy`]): `OnChipCounter` with its declared
//! uncertainty, where the counter was live across the record's span and the relay served
//! that room alone in it; the unmetered marker otherwise, never a figure of zero. A session
//! another relay holds with it (a hop link, [`crate::cascade`]) is counted in the track
//! entries and is neither a subscriber session nor in the ledger (§3.3 there); every other
//! session that subscribes is a subscriber session.
//!
//! A relay that keeps a DVR store signs version 2 of the record, which adds the store's
//! figures for the room over the span (§3.9): the objects and bytes it served from the
@ -200,7 +204,11 @@ pub struct HopRecord {
pub end: u64,
/// 0 with class tag 0: the unmetered marker.
pub joules: u64,
/// energy-measurement §2.7's tag: 0 (the unmetered marker) or 4 (`OnChipCounter`).
pub class_tag: u8,
/// The tag's evidence as §2.7 writes it: none for tag 0; for tag 4, `relative_ppm` u32
/// BE, `window_us` u64 BE and the filtering byte.
pub class_evidence: Vec<u8>,
pub entries: Vec<Entry>,
pub sessions: u32,
pub subscriber_us: [u64; CLASSES],
@ -224,6 +232,7 @@ impl HopRecord {
o.extend_from_slice(&self.end.to_le_bytes());
o.extend_from_slice(&self.joules.to_le_bytes());
o.push(self.class_tag);
o.extend_from_slice(&self.class_evidence);
o.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
for e in &self.entries {
o.push(e.kind);
@ -276,6 +285,15 @@ impl HopRecord {
self
}
/// The record with the relay's own energy over its span, `OnChipCounter` with its
/// evidence, signed again with `key`.
pub fn with_energy(mut self, f: &crate::energy::Figure, key: &SigningKey) -> HopRecord {
self.joules = f.joules_micro;
self.class_tag = 4;
self.class_evidence = f.evidence();
self.sign(key)
}
/// Check the signature under the key the record names.
pub fn verify(&self) -> Result<(), String> {
let key = VerifyingKey::from_bytes(&self.hop).map_err(|e| e.to_string())?;
@ -284,7 +302,8 @@ impl HopRecord {
key.verify(&m, &Signature::from_bytes(&self.sig)).map_err(|e| e.to_string())
}
/// Read a record this module wrote: an unmetered one, as the relay signs.
/// Read a record this module wrote: unmetered, or `OnChipCounter` with its evidence
/// (live-metering §3.6's rules on the class), as the relay signs.
pub fn decode(b: &[u8], ledger: &[u8]) -> Result<HopRecord, String> {
let mut p = 0usize;
let mut take = |n: usize| -> Result<&[u8], String> {
@ -305,9 +324,20 @@ impl HopRecord {
let role = take(1)?[0];
let (start, end, joules) = (u64_of(take(8)?), u64_of(take(8)?), u64_of(take(8)?));
let class_tag = take(1)?[0];
if class_tag != 0 {
return Err("this reader reads the unmetered marker alone".into());
}
let class_evidence = match class_tag {
0 if joules == 0 => Vec::new(),
4 if joules > 0 => {
let e = take(13)?.to_vec();
let ppm = u64::from(u32::from_be_bytes(e[..4].try_into().expect("4")));
let window = u64::from_be_bytes(e[4..12].try_into().expect("8"));
if ppm == 0 || window == 0 || e[12] > 2 || u128::from(joules) * u128::from(ppm) < u128::from(crate::energy::RESOLUTION_FLOOR) {
return Err("an OnChipCounter class whose evidence breaks live-metering §3.6".into());
}
e
}
0 | 4 => return Err("the tag is 0 exactly when the figure is 0".into()),
_ => return Err("this reader reads the unmetered marker or OnChipCounter, as this relay signs".into()),
};
let n = u16::from_le_bytes(take(2)?.try_into().expect("2")) as usize;
let mut entries = Vec::with_capacity(n);
for _ in 0..n {
@ -363,7 +393,7 @@ impl HopRecord {
}
})
.collect();
let r = HopRecord { room, hop, role, start, end, joules, class_tag, entries, sessions, subscriber_us, events, ledger, dvr, sig };
let r = HopRecord { room, hop, role, start, end, joules, class_tag, class_evidence, entries, sessions, subscriber_us, events, ledger, dvr, sig };
if r.ledger_root() != ledger_root {
return Err("the ledger is not the one the record signs".into());
}
@ -433,6 +463,17 @@ pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64,
e.dropped_duplicate += x.deduplicated;
bytes_in += x.object_bytes_received;
}
// A hop link (§3.3, crate::cascade): another relay's session. Its objects are in the
// track entries; it is not a subscriber session, and it runs under no grant of the
// room, so it is in no ledger entry: the principals whose objects it carries are
// metered at the hops that serve them.
if t.peer.is_some() {
if touched {
start = start.min(clock(s.opened_unix_ms * 1000));
end = end.max(clock((s.closed_unix_ms + 1) * 1000));
}
continue;
}
let spans: Vec<_> = t.subscriber.layer_spans.iter().filter(|x| x.room == room_hex).collect();
if !touched && spans.is_empty() {
continue;
@ -471,6 +512,7 @@ pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64,
end,
joules: 0,
class_tag: 0,
class_evidence: Vec::new(),
entries: entries.into_values().collect(),
sessions,
subscriber_us,
@ -501,6 +543,31 @@ mod tests {
assert_eq!(layer_class([format!("{room}-{owner}-events--0")].iter().map(String::as_str)), NO_MEDIA);
}
/// A record with the relay's measured energy carries tag 4 and its evidence, reads
/// back, and verifies; the reader refuses what live-metering §3.6 refuses of the class.
#[test]
fn a_measured_record_reads_back_and_a_bad_class_does_not() {
let key = SigningKey::from_bytes(&[3; 32]);
let rec = hop_record(&[], [9; 32], 0, &key, None);
assert_eq!((rec.joules, rec.class_tag, rec.class_evidence.len()), (0, 0, 0));
let f = crate::energy::Figure { joules_micro: 52_000, relative_ppm: 41_000, window_us: 2_000_000, scope: crate::energy::Scope::Task };
let m = rec.clone().with_energy(&f, &key);
m.verify().unwrap();
assert_eq!(HopRecord::decode(&m.to_bytes(), &m.ledger_bytes()).unwrap(), m);
assert_eq!(m.to_bytes().len(), rec.to_bytes().len() + 13);
let bad = |edit: &dyn Fn(&mut HopRecord)| {
let mut r = m.clone();
edit(&mut r);
HopRecord::decode(&r.sign(&key).to_bytes(), &m.ledger_bytes())
};
assert!(bad(&|r| r.class_evidence[..4].copy_from_slice(&5u32.to_be_bytes())).is_err(), "below the resolution floor");
assert!(bad(&|r| r.class_evidence[4..12].copy_from_slice(&0u64.to_be_bytes())).is_err(), "no window");
assert!(bad(&|r| r.class_evidence[12] = 3).is_err(), "a filtering byte past 2");
assert!(bad(&|r| r.joules = 0).is_err(), "a class beside zero");
assert!(bad(&|r| { r.class_tag = 1; r.class_evidence.clear(); }).is_err(), "HwShunt");
assert!(bad(&|r| { r.class_tag = 0; r.class_evidence.clear(); }).is_err(), "a figure with no class");
}
/// The root is RFC 6962's: two leaves are one node.
#[test]
fn the_ledger_root() {

View file

@ -70,6 +70,11 @@ pub struct SessionCtx {
pub ingress: Option<Mutex<Ingress>>,
/// The peer's data streams the relay is reading now.
pub streams_reading: AtomicU64,
/// The relay at the other end, when the session is a hop link ([`crate::cascade`]): the
/// relay id it declared and proved, or the upstream this relay dialed.
pub peer: Option<crate::cascade::Peer>,
/// The peer sent GOAWAY: the relay sends it no new request (-22 §9.2).
pub peer_goaway: AtomicBool,
}
/// A session's allowance of object bytes in (`limits.max_ingress_kbps`): a bucket that
@ -260,6 +265,9 @@ pub struct PublishedNs {
pub namespace: TrackNamespace,
pub session: u64,
pub id: u64,
/// The relays its PUBLISH_NAMESPACE passed through, the publishing peer last; empty for
/// a namespace a publisher's own session published here ([`crate::cascade`]).
pub path: Vec<[u8; 32]>,
}
pub enum NsEvent {
@ -276,6 +284,20 @@ pub struct NsSub {
pub type UpstreamWait = watch::Receiver<Option<Result<(), (u64, String)>>>;
/// Whether a track is routed to an upstream link ([`Relay::routes_for`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Routed {
/// A link that routes it can be asked.
Yes,
/// No link routes it.
No,
/// A link that routes it is down or going away.
Down,
/// Every link that routes it leads back along the request's relay path, or past the hop
/// limit.
Barred,
}
#[derive(Default)]
pub struct State {
pub sessions: HashMap<u64, Arc<SessionCtx>>,
@ -288,6 +310,9 @@ pub struct State {
/// Per upstream (session, Track Alias): the data streams whose reading has ended,
/// for a PUBLISH_DONE's Stream Count.
pub streams_ended: HashMap<(u64, u64), u64>,
/// The peer relays an upstream SUBSCRIBE still pending was sent to, by track: a request
/// whose path holds one of them does not wait on it ([`crate::cascade`]).
pub pending_via: HashMap<TrackKey, Vec<[u8; 32]>>,
}
/// How long the relay waits, after an upstream PUBLISH_DONE, for the data streams its
@ -315,6 +340,11 @@ pub struct Stats {
/// age-assurance §5.5).
pub age_refused: AtomicU64,
pub age_withheld: AtomicU64,
/// Requests forwarded on an upstream link and Established there, requests no upstream
/// answered (`no_upstream`), and requests refused for their relay path (crate::cascade).
pub forwarded_upstream: AtomicU64,
pub no_upstream: AtomicU64,
pub relay_loops: AtomicU64,
}
/// A snapshot of [`Stats`].
@ -335,6 +365,9 @@ pub struct RelayStats {
pub ratings_held: u64,
/// Items pushed to sessions' schedulers and not yet dispatched.
pub queued: u64,
pub forwarded_upstream: u64,
pub no_upstream: u64,
pub relay_loops: u64,
}
pub struct Inner {
@ -353,11 +386,18 @@ pub struct Inner {
pub budget: Mutex<Budget>,
pub ids: AtomicU64,
pub summaries: Mutex<Vec<SignedSessionSummary>>,
/// The sampler of the relay's own energy, where the host offers a counter and the
/// configuration reads it.
pub meter: Option<crate::energy::Meter>,
pub going_away: AtomicBool,
pub stats: Stats,
/// Woken when a publisher or a session appears or leaves.
pub change: Notify,
pub sessions_gone: Notify,
/// The upstream relays this one dials ([`crate::cascade`]), in the configuration's order.
pub links: Vec<Arc<crate::cascade::Link>>,
/// Woken when a namespace is published or withdrawn here, for the links' announcers.
pub ns_changed: Notify,
}
/// A MOQT relay. Cheap to clone; every clone is the same relay.
@ -422,6 +462,13 @@ impl Relay {
})?),
None => None,
};
let mut links = Vec::new();
for (i, u) in cfg.upstream.iter().enumerate() {
links.push(Arc::new(crate::cascade::Link::new(i, u)?));
}
if cfg.limits.max_relay_hops == 0 || cfg.limits.max_relay_hops >= joule_moq::draft22::params::MAX_RELAY_PATH {
return Err(format!("limits.max_relay_hops is {}; it is 1 to {}", cfg.limits.max_relay_hops, joule_moq::draft22::params::MAX_RELAY_PATH - 1));
}
let floor = match &cfg.floor {
Some(f) => {
let mut keys = f.keys()?;
@ -430,6 +477,10 @@ impl Relay {
}
None => None,
};
let meter = match cfg.metering.energy {
crate::config::EnergyMode::Off => None,
crate::config::EnergyMode::Auto => crate::energy::Meter::host(cfg.metering.dedicated_host).map(|c| crate::energy::Meter::start(c, crate::energy::INTERVAL)),
};
Ok(Relay {
inner: Arc::new(Inner {
cfg,
@ -444,14 +495,34 @@ impl Relay {
budget: Mutex::new(budget),
ids: AtomicU64::new(1),
summaries: Mutex::new(Vec::new()),
meter,
going_away: AtomicBool::new(false),
stats: Stats::default(),
change: Notify::new(),
sessions_gone: Notify::new(),
links,
ns_changed: Notify::new(),
}),
})
}
/// The relay's id among relays ([`crate::cascade`]): the public half of its summary key,
/// the key its per-hop records are signed with.
pub fn relay_id(&self) -> [u8; 32] {
self.inner.key.verifying_key().to_bytes()
}
/// The upstream relays this one dials.
pub fn links(&self) -> &[Arc<crate::cascade::Link>] {
&self.inner.links
}
/// The namespaces published here, each with its publishing session and its path, for
/// diagnosis.
pub fn namespaces(&self) -> Vec<(TrackNamespace, u64, Vec<[u8; 32]>)> {
self.inner.state.lock().unwrap().namespaces.iter().map(|n| (n.namespace.clone(), n.session, n.path.clone())).collect()
}
pub fn config(&self) -> &RelayConfig {
&self.inner.cfg
}
@ -521,11 +592,58 @@ impl Relay {
/// The relay's per-hop session record of `room` over the sessions that ended (WAI
/// live-metering §3), on the room clock of `t0_unix_us`, signed with the relay's key.
/// A relay that keeps a DVR store signs version 2, with the store's figures for the
/// room over the record's span (§3.9).
/// room over the record's span (§3.9). Its energy is [`Self::record_energy`]'s figure,
/// or the unmetered marker.
pub fn hop_record(&self, room: [u8; 32], t0_unix_us: u64) -> crate::metering::HopRecord {
let summaries: Vec<_> = self.summaries().into_iter().map(|s| s.summary).collect();
let figures = self.inner.dvr.as_ref().map(|d| move |from: u64, to: u64| d.figures(room, from, to));
crate::metering::hop_record(&summaries, room, t0_unix_us, &self.inner.key, figures.as_ref().map(|f| f as &dyn Fn(u64, u64) -> crate::dvr::RoomFigures))
let rec = crate::metering::hop_record(&summaries, room, t0_unix_us, &self.inner.key, figures.as_ref().map(|f| f as &dyn Fn(u64, u64) -> crate::dvr::RoomFigures));
match self.record_energy(&summaries, room, t0_unix_us + rec.start, t0_unix_us + rec.end) {
Ok(f) => rec.with_energy(&f, &self.inner.key),
Err(why) => {
tracing::info!(room = hex::encode(room), why = why.label(), "the room's record carries the unmetered marker");
rec
}
}
}
/// The relay's own energy over `[from_us, to_us)` (Unix microseconds), as a room's
/// record may carry it: the meter's figure ([`crate::energy`]), where the relay has a
/// meter and served that room alone across the span: every session that overlapped the
/// span has ended, and carried tracks of `room` and of no other room or none.
pub fn record_energy(&self, summaries: &[crate::summary::SessionSummary], room: [u8; 32], from_us: u64, to_us: u64) -> Result<crate::energy::Figure, crate::energy::Unmetered> {
let meter = self.inner.meter.as_ref().ok_or(crate::energy::Unmetered::NoCounter)?;
let room_hex = hex::encode(room);
let overlaps = |open_ms: u64, closed_ms: Option<u64>| open_ms * 1000 < to_us && closed_ms.is_none_or(|c| (c + 1) * 1000 > from_us);
let this_room_only = |s: &crate::summary::SessionSummary| {
let t = &s.tally;
let names = t.subscriber.tracks.keys().chain(t.publisher.tracks.keys());
let mut any = false;
for n in names {
match crate::metering::classify(n) {
Some((r, _)) if r == room => any = true,
_ => return false,
}
}
t.subscriber.layer_spans.iter().all(|x| x.room == room_hex) && (any || !t.subscriber.layer_spans.is_empty())
};
if summaries.iter().any(|s| overlaps(s.opened_unix_ms, Some(s.closed_unix_ms)) && !this_room_only(s)) {
return Err(crate::energy::Unmetered::Shared);
}
if self.inner.state.lock().unwrap().sessions.values().any(|c| overlaps(c.opened_unix_ms, None)) {
return Err(crate::energy::Unmetered::Shared);
}
meter.figure(from_us, to_us)
}
/// Where the relay reads its own energy: the counter's scope, or `None`.
pub fn energy_scope(&self) -> Option<crate::energy::Scope> {
self.inner.meter.as_ref().map(crate::energy::Meter::scope)
}
/// The time of the energy sampler's latest sample, Unix microseconds, once it has one.
pub fn energy_sampled_until(&self) -> Option<u64> {
self.inner.meter.as_ref().and_then(crate::energy::Meter::last_sample_us)
}
pub fn stats(&self) -> RelayStats {
@ -545,6 +663,9 @@ impl Relay {
age_withheld: s.age_withheld.load(Ordering::Relaxed),
ratings_held: self.inner.ratings.lock().unwrap().held() as u64,
queued: st.sessions.values().map(|c| c.sched.queued()).sum(),
forwarded_upstream: s.forwarded_upstream.load(Ordering::Relaxed),
no_upstream: s.no_upstream.load(Ordering::Relaxed),
relay_loops: s.relay_loops.load(Ordering::Relaxed),
}
}
@ -1007,6 +1128,7 @@ impl Relay {
end_down(sub, status);
}
self.inner.change.notify_waiters();
self.inner.ns_changed.notify_waiters();
}
/// Remove a downstream subscription (its subscriber cancelled it, or its session
@ -1071,7 +1193,10 @@ impl Relay {
let sessions: Vec<Arc<SessionCtx>> = self.inner.state.lock().unwrap().sessions.values().cloned().collect();
let uri = self.inner.cfg.goaway_uri.clone().into_bytes();
for ctx in &sessions {
crate::session::send_goaway(ctx, &uri, timeout.as_millis() as u64).await;
// A client's GOAWAY names no URI (§9.2): on a session this relay dialed, it is the
// client.
let uri: &[u8] = if ctx.peer.is_some_and(|p| p.link.is_some()) { &[] } else { &uri };
crate::session::send_goaway(ctx, uri, timeout.as_millis() as u64).await;
}
let deadline = Instant::now() + timeout;
if self.wait_sessions_gone(deadline).await {
@ -1104,10 +1229,49 @@ impl Relay {
/// The sessions that published a namespace `ns` falls under, other than `except`.
pub fn publishers_of(&self, st: &State, ns: &TrackNamespace, except: u64) -> Vec<Arc<SessionCtx>> {
let mut ids: Vec<u64> = st.namespaces.iter().filter(|n| n.session != except && prefix_matches(&n.namespace, ns)).map(|n| n.session).collect();
self.publishers_for(st, ns, except, &[])
}
/// [`Relay::publishers_of`] for a request that arrived with the relay path `path`
/// ([`crate::cascade`]): a peer relay on the path, one whose announcement passed through
/// a relay on the path, and a session that sent GOAWAY are left out.
pub fn publishers_for(&self, st: &State, ns: &TrackNamespace, except: u64, path: &[[u8; 32]]) -> Vec<Arc<SessionCtx>> {
let max = self.inner.cfg.limits.max_relay_hops;
let mut ids: Vec<u64> = st
.namespaces
.iter()
.filter(|n| n.session != except && prefix_matches(&n.namespace, ns) && !n.path.iter().any(|r| path.contains(r)))
.map(|n| n.session)
.collect();
ids.sort();
ids.dedup();
ids.iter().filter_map(|i| st.sessions.get(i).cloned()).collect()
ids.iter().filter_map(|i| st.sessions.get(i).cloned()).filter(|c| crate::cascade::may_forward(c, except, path, max)).collect()
}
/// The upstream links a request for a track under `ns`, arrived with `path` on the
/// session `except`, may be forwarded to, in the configuration's order (the failover
/// order): those that route `ns`, with a session open and not going away, whose relay is
/// not on the path. And, when there is none, why: no link routes `ns`; one that does is
/// down; or every one that does leads to a relay on the path or past the hop limit.
pub fn routes_for(&self, ns: &TrackNamespace, except: u64, path: &[[u8; 32]]) -> (Vec<Arc<SessionCtx>>, Routed) {
let max = self.inner.cfg.limits.max_relay_hops;
let (mut down, mut barred, mut out) = (false, false, Vec::new());
for l in self.inner.links.iter().filter(|l| l.routes(ns)) {
match l.session() {
// Back where the request came from: no route, and no loop either.
Some(c) if c.id == except => {}
Some(c) if crate::cascade::may_forward(&c, except, path, max) => out.push(c),
Some(_) => barred = true,
None => down = true,
}
}
let why = match (out.is_empty(), down, barred) {
(false, _, _) => Routed::Yes,
(true, true, _) => Routed::Down,
(true, false, true) => Routed::Barred,
(true, false, false) => Routed::No,
};
(out, why)
}
}

View file

@ -27,7 +27,10 @@ pub struct RelayServer {
websocket_task: Option<JoinHandle<()>>,
}
fn transport_config(cfg: &RelayConfig) -> quinn::TransportConfig {
/// The QUIC transport configuration of every connection the relay holds: the sessions it
/// accepts, and the links it dials to its upstreams (`crate::cascade`), so that what one
/// connection can leave the relay holding unread is bounded the same way on both.
pub(crate) fn transport_config(cfg: &RelayConfig) -> quinn::TransportConfig {
let mut t = quinn::TransportConfig::default();
t.max_concurrent_bidi_streams(quinn::VarInt::from_u64(cfg.limits.max_requests).unwrap_or(quinn::VarInt::MAX));
t.max_concurrent_uni_streams(quinn::VarInt::from_u64(cfg.limits.max_uni_streams).unwrap_or(quinn::VarInt::MAX));
@ -36,6 +39,9 @@ fn transport_config(cfg: &RelayConfig) -> quinn::TransportConfig {
}
t.keep_alive_interval(Some(Duration::from_millis((cfg.limits.idle_timeout_ms / 3).max(1_000))));
t.send_window(cfg.limits.quic_send_window_bytes.max(16 * 1024));
t.stream_receive_window(quinn::VarInt::from_u64(cfg.limits.quic_stream_window_bytes.max(64 * 1024)).unwrap_or(quinn::VarInt::MAX));
// What one session can leave unread across all its streams: never below a stream's window.
t.receive_window(quinn::VarInt::from_u64(cfg.limits.quic_connection_window_bytes.max(cfg.limits.quic_stream_window_bytes.max(64 * 1024))).unwrap_or(quinn::VarInt::MAX));
t
}
@ -69,6 +75,10 @@ impl RelayServer {
let mux = MuxConfig { max_bidi: cfg.limits.max_requests, max_uni: cfg.limits.max_uni_streams, max_buffered: cfg.limits.websocket_buffer_bytes, ..MuxConfig::default() };
websocket_task = Some(tokio::spawn(websocket_accept(relay.clone(), l, cfg.path.clone(), mux)));
}
// Each upstream link dials once the listeners are up (crate::cascade).
for l in relay.links() {
tokio::spawn(crate::cascade::run_link(relay.clone(), l.clone()));
}
Ok(RelayServer { relay, identity, quic_addr, websocket_addr, endpoint, quic_task, websocket_task })
}

View file

@ -4,9 +4,12 @@
//! The summary is relay-scoped telemetry (WAI `jwp-receipts.md` §4.2: "local to the relay
//! and MUST NOT be conflated with publisher-issued receipts"), signed by the relay's own
//! key. It counts bytes and objects. It states energy only as its acquisition class
//! allows: no meter backs this relay, so its own figure is the unmetered marker, 0 with
//! the class `unmetered`, which a reader must not present as zero joules (WAI
//! `energy-measurement.md` §2). Where the door verified an object's receipt, the
//! allows: a session's own share of the relay's energy is the unmetered marker, 0 with the
//! class `unmetered`, which a reader must not present as zero joules (WAI
//! `energy-measurement.md` §2). The relay's counter ([`crate::energy`]) reads the whole
//! process, and sessions run together in it, so no read of it is one session's; a room's
//! record carries the relay's figure where the relay served that room alone
//! ([`crate::relay::Relay::hop_record`]). Where the door verified an object's receipt, the
//! publisher's signed figure for producing that object is summed under the class it was
//! signed with, and labelled as the publisher's: it is not the relay's energy.
//!
@ -165,6 +168,10 @@ pub struct Tally {
/// The room grant the session's SETUP token maps to, where the configuration maps one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub room_grant: Option<RoomGrantRef>,
/// The relay at the other end, hex, when the session is a hop link
/// ([`crate::cascade`]): another relay, not a subscriber (WAI live-metering §3.3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer: Option<String>,
/// The subscriptions the session holds to each room's tracks, by room; not part of the
/// summary.
#[serde(skip)]
@ -243,7 +250,8 @@ impl Tally {
/// The relay's own energy for the session, as its acquisition class allows.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayEnergy {
/// `unmetered`: no meter backs the figure.
/// `unmetered`: a session's share of the relay process's energy is not read, since
/// sessions run together and the counter cannot tell their shares apart.
pub acquisition: String,
/// 0 is the unmetered marker, never a measurement of zero joules.
pub joules_micro: u64,

Some files were not shown because too many files have changed in this diff Show more