Skip to content

video: support five streams and improve per-entry access controls - #43

Open
tridge wants to merge 10 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots
Open

video: support five streams and improve per-entry access controls#43
tridge wants to merge 10 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots

Conversation

@tridge

@tridge tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • expand per-entry video capacity from three streams to five while preserving the append-only keys.tdb layout
  • add a per-slot MAVLink-session publishing fallback for cameras that cannot present the configured publish password
  • expose the new slot policy in the web UI and improve password-field help rendering
  • ignore unsupported RTMP data streams instead of rejecting otherwise usable publishers
  • add per-entry Private, Login Required, and Public read-only log access with delete operations still restricted to owners/admins

Compatibility

Existing key records remain valid: slots four and five use fields appended after the established record layout, and the new access flags default existing entries to their previous private behavior.

Testing

  • make -j2
  • pytest -q tests/webadmin — 287 passed
  • pytest -q tests/test_video_schema.py tests/test_video_ports.py tests/test_video_rtsp.py — 84 passed

tridge added 8 commits August 18, 2026 13:28
An aircraft can carry more than three cameras, and one port carries one
stream, so the cap was the limit on how many a single entry could
proxy.

The three fields that hold per-slot state -- video_ports, video_flags
and video_rtmp_path -- all sit in the middle of the record, so none of
them could simply grow: every field after them would shift, every record
already on disk would be misparsed, and an older binary would read
garbage. The append-only contract at the top of keydb.h exists to make
that unnecessary, so slots 3 and 4 are carried in new fields appended
after reserved[], and accessors join the two halves. A record written
before they existed zero-extends into them, which reads as two unused
slots, so nothing needs converting and the live database keeps working.

video_flags could not be widened for the same reason, and it was already
full: three slot bytes plus the entry-wide byte is exactly 32 bits. A
fourth slot byte at shift 24 would have landed on the entry options --
which is what happened first time round, and is why there is now a test
that sets slots 3 and 4 to 0xFF and checks the audio flag survives.

The record grows 344 -> 456 bytes. Also fixes video_port_count()
tripping over a short list, which callers that build a KeyEntry by hand
were relying on not happening.

The README's video section had been pasted in three times: the edit that
added it replaced on "## Building", which matches three headings. Only
one copy remains.
Bit 3 of each slot's option byte, which was free, so no record growth
and no migration -- an existing record reads it clear, which is the
current behaviour.

It marks a slot whose publisher may be admitted by the entry's MAVLink
session even though a publish password is set. Some publishers cannot
present one: a camera speaking RTMP from its own firmware has nowhere
to put a credential unless its stream-key field tolerates a query, and
plain MPEG-TS over UDP never does. Without this an entry faced an
all-or-nothing choice between a password and those streams.
admit() gains the slot's session_ok bit. With it set and no credential
offered, admission falls through to the MAVLink-session path instead of
refusing; without it, nothing changes.

The fallback deliberately does not apply to a credential that was
offered and is wrong. Downgrading that to address matching would turn a
clear rejection into a silent weakening, so a typo cannot succeed on
the strength of the source address.

Five tests, of which two guard the behaviour being preserved: an
unflagged slot still refuses a session-only publisher, and a wrong
password is still refused on a flagged one. Verified RED by forcing the
bit false -- the three that assert the new path fail, the two guards
still pass.
Rendered in the existing per-slot options row and documented in the
template beside it, as the other three are. The forms.py tooltip check
exempts the per-slot booleans, so the guard that they really are
documented where they are rendered is extended to cover it.
Pasting into it with the tooltip showing wedges Chrome's renderer: the
tab stops responding to input entirely, and it does not recover. The
text it carried moves into the blurb above the form, which already
introduced the field, so nothing is lost.

The forms.py "every option is documented" guard exempts the field and
records why, so it cannot be quietly reinstated.
A tooltip over a password field wedges Chrome's renderer: the tab stops
accepting input and does not recover. Reproduced on the login form and
removed there; this covers the rest, which share the markup and so
presumably share the fault.

The help itself is worth keeping -- the publish-password text explains
that it replaces the address check, and the new-passphrase one that
blank means unchanged -- so the macro renders a password field's
description in flow as .field-hint rather than dropping it. The dotted
underline that advertises a tooltip goes with it, and aria-describedby
is now emitted only when there is something to point at.

Two guards: no password input on any page carries a .tip, and the text
still reaches the page. Both verified RED.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Deprecated — see below for the updated review after the harden-publish-session-fallback fix.

Previous review (2026-08-25, at head 1174f36)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Full report (with the other DevCallEU PRs): https://uav.tridgell.net/DevCallReviews/2026_08_26/devcall_pr_reviews.html

Reviewed at head 1174f36b03 — verdict: COMMENT. Careful, security-conscious PR; the 5-slot ABI split, per-slot isolation and log gate are all sound. My own read first cleared it APPROVE (the admit() decision itself is correct); an independent cold security pass flagged the credential-presence handling, which I traced against source. Two of the cold pass's claims I refuted on inspection (see below), so the confirmed set is narrower than a raw cold read would suggest.

ISSUE — a supplied-but-wrong publish password can be read as "absent" and take the address fallback. admit() is correct — it returns BAD_PASSWORD before any session_ok consideration when a credential is supplied — but "credential absent" is computed upstream as *password != '\0' on a bare string with no explicit present-bit, and four paths collapse a supplied-wrong value to empty on a session_ok+non-bidi slot:

  • RTSP reads the credential with a single MSG_PEEK that doesn't wait for a complete request line (video.cpp:639-660) — withhold ?pw=wrong from the first segment and the rest is spliced to ffmpeg with no re-auth;
  • the publish path matches only a literal ?pw= (video.cpp:647), so /cam?mode=x&pw=wrong reads as no-credential (the robust HttpRequest::query() is used only on the viewer path);
  • http_url_decode permits %00 (httpreq.cpp:142), so ?pw=%00wrong is NUL-first and tests as absent;
  • RTMP split_credential lets the last parameter win (videortmp.cpp:296), so FPV?pw=wrong&pw= arrives empty.

Not an access-control bypass — every vector needs the attacker already at the authorized session address on a flag-enabled, non-bidi slot, where credential-free publish is intended — but it means the README's "a typo cannot quietly succeed on the strength of the address" isn't honoured. A uniform fix: represent presence with an explicit flag (independent of the decoded value), require a complete bounded request line before deciding, reject embedded NUL, and use the robust query parser on the publish path too.

ISSUE — signed-session fallback can never succeed (fails closed). ConnEntry.authenticated is read by the bidi gate (videoauth.cpp:70,99) but never written non-zero — the conn_write sites (supportproxy.cpp:1103,1132) zero-init and never copy is_authenticated() — so a KEY_FLAG_BIDI_SIGN slot always returns UNAUTH on the session path. Safe, but it contradicts the documented intent (conntdb.h:94-96): session_ok on a signed entry won't work as described.

NOTE — stale comments: keydb_lib.py still says the record is 344 bytes and set_video_ports() says "up to 3"; the implementation is correct at 456 bytes / 5 slots.

Refuted from the cold pass and not issues: the session-name collision check is not still bounded to 3 (every video-slot loop uses MAX_VIDEO_PORTS=5; the only literal 3 is KEY_VIDEO_PORTS_INLINE, the on-disk struct split, correct by design); and the RTMP unsupported-stream change only alters the ffmpeg -map (nothing leaks our side).

Verified clean: append-only ABI (old 344-byte records zero-extend so the new flag defaults off on migration), per-slot/per-entry isolation, port allocation stops correctly at 65535, and the log gate re-validates the viewer each request with traversal closed. Please confirm CI green before merge given this touches an auth path.

Preserve the distinction between absent and supplied publish credentials across RTSP and RTMP parsing, and export signed MAVLink authentication state for bidi session fallback. Update stale five-slot schema documentation and add regression coverage.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/2026_08_26/devcall_pr_reviews.html#prSupportProxy_43

Reviewed at head 42209cf0c7. Verdict: REQUEST CHANGES.

Re-reviewed at the same head as my 2026-08-25 comment; the auth/ABI work verified clean again (credential-presence bits end-to-end, 456-byte split-record ABI offsets, per-slot session_ok, RTSP request guarding), but the must-fix from that round is still unaddressed, so the verdict stands.

Must fix:

  • session.cpp:76 — SESSION_EXTS is still .tlog .bin .v1.ts .v2.ts .v3.ts; slots 4/5 write .v4.ts/.v5.ts (videorec.cpp:126) via the same session_unique_basename(), so basename_free() reports a name free while that name's v4/v5 files exist. Impact refined from the last round: actual truncation/append corruption cannot occur — .tlog/.bin are still in the set, and videorec opens O_EXCL and retries on EEXIST — but the one-session-one-basename contract the function's own comment states is broken: a new tlog/bin/video session can silently share a basename with an unrelated existing v4/v5 recording, corrupting session identity for the web UI grouping and the -N suffix scheme. Add the two extensions (or derive the list from KEY_MAX_VIDEO_PORTS). (link)

Should fix:

  • webadmin/logs.py:543 — admin_play_mp4 sits behind require_log_read, so on a LOG_ACCESS_PUBLIC entry an anonymous client reaches _remux_response (logs.py:376), which spawns one ffmpeg per request with no concurrency cap. ffmpeg is killed when the generator closes, and the shipped deployment caps the web server at 4 worker threads (start_webadmin.sh:44), so this is bounded — but 4 parallel slow anonymous readers monopolise the entire web capacity (and each holds an ffmpeg). Cap concurrent remuxes (global or per-IP) or keep play.mp4 login-gated. (link)

Notes (non-blocking):

  • videortsp.cpp:256 — -map 0 became -map 0:v:0 (+ optional 0:a?). A publish with no video stream at all (audio-only RTMP/RTSP), which -map 0 previously carried, now hard-fails with "Stream map '0:v:0' matches no streams" — same 0-KiB-slot symptom this change fixes for data streams. Almost certainly acceptable for a video proxy; worth one line in the commit message or a nicer log. (link)
  • keydb.h:207 — video_rtmp_path_size() is added but has no callers (promote_pending uses sizeof(ke_.video_rtmp_path[0]) directly, video.cpp:979). Drop it or use it. (link)

Previous round triage: SESSION_EXTS BUG — still open (impact narrowed on re-analysis: .tlog/.bin remain in the set and videorec opens O_EXCL, so no file corruption — it breaks the one-session-one-basename contract instead; still worth the two-line fix, or derive the list from KEY_MAX_VIDEO_PORTS). Anonymous ffmpeg remux ISSUE — still open (bounded by the 4-thread web server, so it monopolises web capacity rather than exhausting processes). All previously-RESOLVED credential/RTSP/export items re-verified as fixed.

Reject duplicate RTMP connect properties, preserve the first credential across RTMP setup commands, and continue validating credentials on every RTSP request. Bound RTSP framing, reject ambiguous content lengths, and cover the reported downgrade cases with integration tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant