Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c447b9b
video: allow five streams per entry, not three
tridge Aug 18, 2026
ab8b1b0
keydb: add a per-slot VIDEO_SLOT_SESSION_OK flag
tridge Aug 19, 2026
7dfe9ba
video: admit a flagged slot on the MAVLink session despite a password
tridge Aug 19, 2026
0cc3553
webadmin: per-slot "MAVLink publish" checkbox
tridge Aug 19, 2026
2175f44
webadmin: drop the tooltip from the login passphrase field
tridge Aug 19, 2026
348b1c5
webadmin: no hover tooltip on any password field
tridge Aug 19, 2026
a5c1e73
video: ignore unsupported RTMP data streams
tridge Aug 19, 2026
1174f36
webadmin: add configurable log access
tridge Aug 24, 2026
a9ab58d
video: harden publish session fallback
tridge Aug 25, 2026
42209cf
video: close remaining credential downgrade paths
tridge Aug 26, 2026
0da52aa
video: add open_publish slot option
tridge Sep 7, 2026
4e53ec8
webadmin: open publish checkbox per video slot
tridge Sep 7, 2026
a24fc2e
video: accept bare RTP/H.264 and RTP/HEVC over UDP
tridge Sep 7, 2026
d9dd7eb
session: check every video slot's extension when picking a basename
tridge Sep 7, 2026
95bed93
keydb: drop unused video_rtmp_path_size()
tridge Sep 7, 2026
81632ed
webadmin: cap concurrent anonymous play.mp4 remuxes
tridge Sep 7, 2026
8e398f3
video: note that an audio-only publish fails at the stream map
tridge Sep 7, 2026
c621f90
webadmin: release the remux permit and ffmpeg on HEAD requests
tridge Sep 7, 2026
e3d76be
video: tear down a dead RTP backend, and fail its start if it dies
tridge Sep 7, 2026
bfd398d
build: session.o depends on keydb.h
tridge Sep 7, 2026
16f59e1
video: say when a wrong publish password is judged
tridge Sep 7, 2026
418a9b3
video: stop a held partial request line from spinning the child
tridge Sep 7, 2026
5de48f3
webadmin: keep next through the login form
tridge Sep 7, 2026
2a2646c
tests: wait for the video child's ready line before reading its pid
tridge Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ util.o: util.cpp util.h
keydb.o: keydb.cpp keydb.h
conntdb.o: conntdb.cpp conntdb.h
tlog.o: tlog.cpp tlog.h session.h
session.o: session.cpp session.h
session.o: session.cpp session.h keydb.h
binlog.o: binlog.cpp binlog.h session.h mavlink.h util.h cleanup.h $(MAVLINK_DIR)/protocol.h
cleanup.o: cleanup.cpp cleanup.h keydb.h
websocket.o: websocket.cpp websocket.h util.h
Expand Down
162 changes: 55 additions & 107 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Video is deliberately independent of the MAVLink session: it survives a
telemetry dropout, and with a publish password it needs no MAVLink at
all.

**Ports.** Up to three per entry, allocated by an admin from the web UI
**Ports.** Up to five per entry, allocated by an admin from the web UI
(suggested from 40001). Each carries one stream, on TCP and UDP.
**These are public listening ports and must be open in the firewall.**

Expand All @@ -54,9 +54,18 @@ all.
| Transport | Credential |
|---|---|
| MPEG-TS over UDP | none possible — see below |
| RTP/H.264 or RTP/HEVC over UDP | none possible — see below |
| RTSP | `?pw=` on the request URI |
| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` |

Bare RTP over UDP (`rtph264pay ! udpsink`, `rtph265pay ! udpsink`,
`ffmpeg -f rtp`) is accepted on the same port as MPEG-TS: the proxy
recognises the RTP header, works out the codec from the first NAL
units, and muxes the stream to MPEG-TS through the same ffmpeg backend
RTSP uses. Parameter sets have to be in-band, since there is no SDP
from the sender to carry them — `config-interval=1` on the GStreamer
payloaders, `-bsf:v h264_mp4toannexb` or `dump_extra` with ffmpeg.

Plain MPEG-TS over UDP has nowhere to carry a password, so it is
admitted on the MAVLink-session path only: a publisher is accepted when
a MAVLink session for the entry was seen from the same address within
Expand All @@ -65,6 +74,36 @@ side, so a scanner between flights can become the authorised address and
the aircraft's video is then refused until the grace expires. **Entries
used for video should set `bidi_sign` or a publish password.**

A publish password normally *replaces* the MAVLink-session check rather
than adding to it — an operator sets one precisely so address matching
is not the gate. Some publishers cannot present one at all, though: a
camera speaking RTMP from its own firmware has nowhere to put it unless
its stream-key field tolerates a query, and plain UDP never does. The
per-slot **MAVLink publish** option lets one slot fall back to the
session check while the rest of the entry stays password-only:

```bash
./keydb.py videoflag 11024 0 session_ok
```

It is opt-in and per slot, so enabling it on the camera's slot does not
weaken the others. A password that *is* supplied and is wrong is still
refused — the fallback applies only when none was offered, so a typo
cannot quietly succeed on the strength of the address.

Some publishers have neither: no password and no MAVLink session through
this proxy, e.g. a camera on its own link while telemetry goes elsewhere.
The per-slot **open publish** option admits a publisher that offers no
credential with no check at all:

```bash
./keydb.py videoflag 11024 0 open_publish
```

Anyone who can reach the port can then publish to that slot, so it is
off by default. If the entry has a publish password, one that *is*
supplied and is wrong is still refused.

**Watching.** In the browser from the web UI, or outside it with the
`ffplay`/`vlc` command the page offers. The browser player needs H.264:
Chrome and Firefox will not decode HEVC in Media Source Extensions on
Expand Down Expand Up @@ -122,59 +161,6 @@ source venv/bin/activate
pip install pymavlink
```

### Video

Optional, off unless an entry has it enabled. A user points a camera at
one of their entry's video ports and any number of ground stations can
watch, with the same NAT traversal and per-entry credentials the MAVLink
side already provides. Recordings land beside the tlogs under
`logs/<port2>/<date>/` and are covered by the same retention.

Video is deliberately independent of the MAVLink session: it survives a
telemetry dropout, and with a publish password it needs no MAVLink at
all.

**Ports.** Up to three per entry, allocated by an admin from the web UI
(suggested from 40001). Each carries one stream, on TCP and UDP.
**These are public listening ports and must be open in the firewall.**

**Publishing.**

| Transport | Credential |
|---|---|
| MPEG-TS over UDP | none possible — see below |
| RTSP | `?pw=` on the request URI |
| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` |

Plain MPEG-TS over UDP has nowhere to carry a password, so it is
admitted on the MAVLink-session path only: a publisher is accepted when
a MAVLink session for the entry was seen from the same address within
the grace window. On a non-bidi entry *any* datagram latches the user
side, so a scanner between flights can become the authorised address and
the aircraft's video is then refused until the grace expires. **Entries
used for video should set `bidi_sign` or a publish password.**

**Watching.** In the browser from the web UI, or outside it with the
`ffplay`/`vlc` command the page offers. The browser player needs H.264:
Chrome and Firefox will not decode HEVC in Media Source Extensions on
desktop Linux, and nothing here transcodes.

**Disk.** Video has its own budget, separate from telemetry, so a busy
camera can never evict a user's tlogs. Set it per entry in the web UI;
a free-space floor stops recording before the disk fills.

**Log rotation.** The daemon's own log is not rotated by default.
Install the supplied config once, as root:

```bash
sudo install -m 644 scripts/supportproxy.logrotate \
/etc/logrotate.d/supportproxy
```

It uses `copytruncate`, which is required rather than preferred when the
daemon's stdout is a file systemd holds open — see the comments in that
file.

## Building SupportProxy

```bash
Expand Down Expand Up @@ -317,59 +303,6 @@ netstat -ln | grep ":1000[0-9]"

SupportProxy can also be run using Docker for easier deployment and management.

### Video

Optional, off unless an entry has it enabled. A user points a camera at
one of their entry's video ports and any number of ground stations can
watch, with the same NAT traversal and per-entry credentials the MAVLink
side already provides. Recordings land beside the tlogs under
`logs/<port2>/<date>/` and are covered by the same retention.

Video is deliberately independent of the MAVLink session: it survives a
telemetry dropout, and with a publish password it needs no MAVLink at
all.

**Ports.** Up to three per entry, allocated by an admin from the web UI
(suggested from 40001). Each carries one stream, on TCP and UDP.
**These are public listening ports and must be open in the firewall.**

**Publishing.**

| Transport | Credential |
|---|---|
| MPEG-TS over UDP | none possible — see below |
| RTSP | `?pw=` on the request URI |
| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` |

Plain MPEG-TS over UDP has nowhere to carry a password, so it is
admitted on the MAVLink-session path only: a publisher is accepted when
a MAVLink session for the entry was seen from the same address within
the grace window. On a non-bidi entry *any* datagram latches the user
side, so a scanner between flights can become the authorised address and
the aircraft's video is then refused until the grace expires. **Entries
used for video should set `bidi_sign` or a publish password.**

**Watching.** In the browser from the web UI, or outside it with the
`ffplay`/`vlc` command the page offers. The browser player needs H.264:
Chrome and Firefox will not decode HEVC in Media Source Extensions on
desktop Linux, and nothing here transcodes.

**Disk.** Video has its own budget, separate from telemetry, so a busy
camera can never evict a user's tlogs. Set it per entry in the web UI;
a free-space floor stops recording before the disk fills.

**Log rotation.** The daemon's own log is not rotated by default.
Install the supplied config once, as root:

```bash
sudo install -m 644 scripts/supportproxy.logrotate \
/etc/logrotate.d/supportproxy
```

It uses `copytruncate`, which is required rather than preferred when the
daemon's stdout is a file systemd holds open — see the comments in that
file.

## Building the Docker Image

```bash
Expand Down Expand Up @@ -503,6 +436,21 @@ proxy restart.
- Everyone else gets the self-service UI (rename their own entry, rotate
their own passphrase, reset their signing timestamp).

### Log access

Each entry's edit page has a **Log access** setting:

- **Private** (the default): only the entry owner and server admins can read
its logs.
- **Login Required**: any user with a valid SupportProxy login can browse and
download them.
- **Public**: anyone with the `/admin/logs/<port2>/...` URL can browse and
download them without logging in.

Shared access is read-only. Deleting individual recordings or a whole day
continues to require either the entry owner's `/me/logs/` view or a server
admin. Log responses remain non-cacheable even when the entry is Public.

### Install dependencies

```bash
Expand Down
1 change: 1 addition & 0 deletions conntdb.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
#define CONN_APP_HTTP 3
#define CONN_APP_SRT 4
#define CONN_APP_RTMP 5
#define CONN_APP_RTP 6 // bare RTP over UDP

/*
Video rows live in a conn_index range disjoint from the MAVLink ones
Expand Down
2 changes: 2 additions & 0 deletions conntdb_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
CONN_APP_HTTP = 3
CONN_APP_SRT = 4
CONN_APP_RTMP = 5
CONN_APP_RTP = 6

APP_NAMES = {
CONN_APP_MAVLINK: 'mavlink',
Expand All @@ -81,6 +82,7 @@
CONN_APP_HTTP: 'http',
CONN_APP_SRT: 'srt',
CONN_APP_RTMP: 'rtmp',
CONN_APP_RTP: 'rtp',
}

# Video rows occupy a conn_index range disjoint from the MAVLink ones so
Expand Down
72 changes: 58 additions & 14 deletions httpreq.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,26 +110,41 @@ std::string HttpRequest::header(const char *name) const
return "";
}

std::string HttpRequest::query(const char *name) const
size_t HttpRequest::header_count(const char *name) const
{
const std::string want = name;
const std::string want = lower(name);
size_t count = 0;
size_t pos = 0;
while (pos <= query_.size()) {
size_t amp = query_.find('&', pos);
if (amp == std::string::npos) {
amp = query_.size();
while (pos < headers_.size()) {
size_t eol = headers_.find('\n', pos);
if (eol == std::string::npos) {
eol = headers_.size();
}
const std::string kv = query_.substr(pos, amp - pos);
const size_t eq = kv.find('=');
if (eq != std::string::npos && kv.compare(0, eq, want) == 0) {
return http_url_decode(kv.substr(eq + 1));
std::string line = headers_.substr(pos, eol - pos);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (amp == query_.size()) {
break;
const size_t colon = line.find(':');
if (colon != std::string::npos) {
std::string field = line.substr(0, colon);
const size_t end = field.find_last_not_of(" \t");
if (end != std::string::npos) {
field.resize(end + 1);
}
if (lower(field) == want) {
count++;
}
}
pos = amp + 1;
pos = eol + 1;
}
return "";
return count;
}

std::string HttpRequest::query(const char *name) const
{
std::string value;
(void)http_query_value(target_, name, value);
return value;
}

std::string http_url_decode(const std::string &s)
Expand All @@ -152,6 +167,35 @@ std::string http_url_decode(const std::string &s)
return o;
}

bool http_query_value(const std::string &target, const char *name,
std::string &value)
{
value.clear();
const size_t q = target.find('?');
if (q == std::string::npos) {
return false;
}
const std::string want = name;
size_t pos = q + 1;
while (pos <= target.size()) {
size_t amp = target.find('&', pos);
if (amp == std::string::npos) {
amp = target.size();
}
const std::string kv = target.substr(pos, amp - pos);
const size_t eq = kv.find('=');
if (eq != std::string::npos && kv.compare(0, eq, want) == 0) {
value = http_url_decode(kv.substr(eq + 1));
return true; // first value wins; duplicates cannot erase it
}
if (amp == target.size()) {
break;
}
pos = amp + 1;
}
return false;
}

std::string http_basic_password(const std::string &authorization)
{
const std::string prefix = "Basic ";
Expand Down
13 changes: 13 additions & 0 deletions httpreq.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class HttpRequest {
// Header lookup, case-insensitive. Empty string when absent.
std::string header(const char *name) const;

// Number of occurrences of a header, case-insensitive. Security
// parsers use this to reject ambiguous framing fields.
size_t header_count(const char *name) const;

// Query parameter from the request target. Empty when absent.
std::string query(const char *name) const;

Expand All @@ -55,6 +59,15 @@ class HttpRequest {
// escapes are left as-is rather than silently dropped.
std::string http_url_decode(const std::string &s);

/*
Fetch the first named query parameter from a request target. The boolean
distinguishes an absent parameter from one explicitly supplied with an
empty value; callers making access-control decisions must not collapse the
two. `value` is percent-decoded when present.
*/
bool http_query_value(const std::string &target, const char *name,
std::string &value);

/*
A request target with credential query values replaced.

Expand Down
Loading
Loading