Require a password on go2rtc's API

The API listener is what serves WebRTC, and it defaults to :1984 on every
interface, so anyone who could reach the gateway could watch any printer
with a camera. The RTSP server was closed on 2026-10-18; this was the
other half and the one that actually carries video.

Measured against go2rtc 1.9.14, feeding it this module's own rendered
config: from a non-loopback address /api/webrtc, /api/ws, /api/streams
and the web UI all answer 401 without credentials and 200 with them,
while :8554 stays closed. go2rtc exempts loopback, which is worth
knowing twice - it is why a probe from the gateway still works without
credentials, and why such a probe cannot be used to confirm auth is on.

The password is generated once per run from /dev/urandom, or taken from
CONTINUUM_GO2RTC_PASSWORD. Once per run matters: the config is
re-rendered every 30s and compared byte-for-byte to decide whether to
restart go2rtc, so a per-render credential would restart it and drop
every viewer every half minute, with no symptom but dying video. A test
pins that.

It is never logged - it goes into the config file, which already holds
every printer's access code and is already documented as a secret, so
there is one place to protect rather than two. An operator-supplied
password is escaped for YAML, because a config go2rtc cannot parse means
a camera that silently never appears.

No opt-out flag. The alternative is an unauthenticated video feed of
someone's workshop, which is not a thing to leave behind a setting, and
nothing in this crate calls go2rtc's API so nothing internal breaks.

Note this does NOT let a browser play a stream directly - see the next
commit's notes and PLAN.md. go2rtc sends no CORS headers and 401s the
unauthenticated preflight, so the relay is required, not optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:00:51 +00:00
co-authored by Claude Opus 5
parent 0f57717378
commit 005b8a3337
3 changed files with 176 additions and 7 deletions
+153 -7
View File
@@ -137,7 +137,75 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<C
/// `listen` is explicit rather than left to go2rtc's defaults, because the
/// cloud is told this address and a default that changes between go2rtc
/// versions would change where the stream lives without anything saying so.
pub fn render_config(api_listen: &str, sources: &[CameraSource]) -> String {
/// Basic-auth credentials for go2rtc's API listener.
///
/// The API is what serves WebRTC, so without this anyone who can reach
/// `http://<gateway>:1984` can watch any printer with a camera - and the
/// listener defaults to `:1984`, every interface. Measured against go2rtc
/// 1.9.14, `username`/`password` under `api:` really does enforce it: from a
/// non-loopback address, no credentials and wrong credentials both get 401
/// and the correct ones get 200, on `/api/webrtc`, `/api/ws`,
/// `/api/frame.jpeg` and the web UI alike.
///
/// **Loopback is exempt**, which is a deliberate convenience of go2rtc's and
/// worth knowing twice over: it is why a probe from the gateway itself still
/// works with no credentials, and it is why a probe from the gateway itself
/// cannot be used to check that auth is on. Test from another host.
pub struct ApiAuth {
pub username: String,
pub password: String,
}
impl ApiAuth {
/// The operator's password if they set one, otherwise a fresh random one.
///
/// Generated once per run and then held, never re-derived: `render_config`
/// is called every `REBUILD_INTERVAL` and its output is compared
/// byte-for-byte to decide whether to restart go2rtc, so a password that
/// changed per call would restart it - and drop every live viewer - every
/// 30 seconds, forever.
///
/// A generated password is never logged. It is written into the config
/// file, which already holds every printer's access code and is already
/// documented as a secret, so that is where an operator reads it from -
/// one secret store rather than two.
pub fn new() -> anyhow::Result<Self> {
let password = match std::env::var("CONTINUUM_GO2RTC_PASSWORD") {
Ok(set) if !set.is_empty() => set,
// `||` semantics, not `??`: an empty value in a deployment
// template is "unset", not "the empty password".
_ => random_secret()?,
};
Ok(Self { username: "continuum".to_string(), password })
}
}
/// 32 bytes of kernel randomness, base64url so it is safe unquoted in YAML.
///
/// Read from `/dev/urandom` with the standard library rather than pulling in
/// a crate for it: the gateway is Linux, this is the one place that needs
/// randomness, and a new dependency would need a licence review (see
/// `continuum-common/docs/LICENSES.md`).
fn random_secret() -> anyhow::Result<String> {
use base64::Engine;
use std::io::Read;
let mut bytes = [0u8; 32];
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
}
/// Escapes a value for a YAML double-quoted scalar.
///
/// Only needed for an operator-supplied password - a generated one is
/// base64url and has nothing to escape - but a password containing a quote
/// would otherwise produce a config go2rtc cannot parse, and the symptom
/// would be a camera that silently never appears.
fn yaml_quoted(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn render_config(api_listen: &str, sources: &[CameraSource], auth: &ApiAuth) -> String {
let mut out = String::from(
"# Generated by continuum-proxy. Do not edit: it is rewritten from\n\
# what the printers themselves report, so an edit here is lost on\n\
@@ -164,6 +232,17 @@ pub fn render_config(api_listen: &str, sources: &[CameraSource]) -> String {
}
out.push_str(&format!("api:\n listen: \"{api_listen}\"\n"));
// Not optional, and there is no flag to turn it off. The alternative is
// an unauthenticated video feed of someone's workshop reachable by
// anything on their network, which is not a thing to leave behind a
// setting. Nothing in this crate calls go2rtc's API, so nothing here
// breaks; a browser cannot use these credentials directly either way -
// see the CORS note in the module docs.
out.push_str(&format!(
" username: \"{}\"\n password: \"{}\"\n",
yaml_quoted(&auth.username),
yaml_quoted(&auth.password),
));
// Closes go2rtc's own RTSP server, and this has to be explicit.
//
@@ -221,6 +300,7 @@ pub async fn run(
fleet: SharedFleet,
config_path: String,
api_listen: String,
auth: ApiAuth,
restart: std::sync::Arc<Notify>,
) {
let mut ticker = tokio::time::interval(REBUILD_INTERVAL);
@@ -233,7 +313,7 @@ pub async fn run(
// config actually written rather than a second, later reading of the
// fleet that could disagree with it.
let found = sources(&fleet).await;
let desired = render_config(&api_listen, &found);
let desired = render_config(&api_listen, &found, &auth);
if last_written.as_deref() == Some(desired.as_str()) {
continue;
}
@@ -300,6 +380,13 @@ mod tests {
}
/// The URL shape confirmed from a real H2C's `ipcam` block.
/// A fixed credential, so the byte-identical assertions below stay
/// meaningful. A real one is 32 random bytes and would make every
/// rendered config differ from every other.
fn test_auth() -> ApiAuth {
ApiAuth { username: "continuum".into(), password: "test-password".into() }
}
#[test]
fn a_reported_rtsps_url_gets_the_printer_credentials_spliced_in() {
let source = source_for(&printer("h2c"), Some("rtsps://192.168.0.21:322/streaming/live/1"))
@@ -338,7 +425,7 @@ mod tests {
/// P1-only farm - so this is the output that must be valid, not a corner.
#[test]
fn an_empty_fleet_renders_an_explicit_empty_map() {
let config = render_config(":1984", &[]);
let config = render_config(":1984", &[], &test_auth());
assert!(config.contains("streams:\n {}\n"), "got:\n{config}");
assert!(config.contains("listen: \":1984\""), "got:\n{config}");
}
@@ -351,8 +438,8 @@ mod tests {
let a = CameraSource { stream_name: "aaa".into(), url: "rtsps://one".into() };
let b = CameraSource { stream_name: "zzz".into(), url: "rtsps://two".into() };
assert_eq!(
render_config(":1984", &[a.clone(), b.clone()]),
render_config(":1984", &[b, a]),
render_config(":1984", &[a.clone(), b.clone()], &test_auth()),
render_config(":1984", &[b, a], &test_auth()),
);
}
@@ -370,7 +457,7 @@ mod tests {
#[test]
fn the_generated_config_closes_the_rtsp_server() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
let config = render_config(":1984", &[source]);
let config = render_config(":1984", &[source], &test_auth());
assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}");
}
@@ -378,6 +465,65 @@ mod tests {
/// common case, and it is the config most likely to be left running.
#[test]
fn an_empty_config_closes_the_rtsp_server_too() {
assert!(render_config(":1984", &[]).contains("rtsp:\n listen: \"\"\n"));
assert!(render_config(":1984", &[], &test_auth()).contains("rtsp:\n listen: \"\"\n"));
}
/// The API listener is what serves WebRTC, and it defaults to every
/// interface, so a config without this is a video feed of someone's
/// workshop open to their whole network.
///
/// Asserts the values, for the same reason as the RTSP test above: a
/// `username:` key with the wrong content would pass an existence check
/// and authenticate nobody.
#[test]
fn the_generated_config_demands_a_password_on_the_api() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
let config = render_config(":1984", &[source], &test_auth());
assert!(
config.contains(" username: \"continuum\"\n password: \"test-password\"\n"),
"got:\n{config}"
);
}
/// Even with no cameras. A gateway that acquires one later keeps the
/// config it was already running, and an unauthenticated listener with
/// no streams is one printer away from an unauthenticated listener with
/// one.
#[test]
fn an_empty_config_demands_a_password_too() {
assert!(render_config(":1984", &[], &test_auth()).contains(" password: \"test-password\"\n"));
}
/// Byte-identical output for the same inputs, which is what stops the
/// rebuild loop restarting go2rtc.
///
/// The credential is the live hazard here: it is generated once per run
/// precisely so this holds. Were it re-derived per render, every 30s tick
/// would see a changed config, restart go2rtc and drop every viewer -
/// forever, and the only symptom would be video that dies every half
/// minute.
#[test]
fn the_same_fleet_and_credential_render_identically() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
assert_eq!(
render_config(":1984", &[source.clone()], &test_auth()),
render_config(":1984", &[source], &test_auth()),
);
}
/// An operator-supplied password is not guaranteed to be YAML-safe, and
/// a config go2rtc cannot parse means a camera that silently never
/// appears - the hardest kind of failure to trace back to a password.
#[test]
fn an_awkward_operator_password_is_escaped() {
let auth = ApiAuth {
username: "continuum".into(),
password: r#"he said "hi"\ok"#.into(),
};
let config = render_config(":1984", &[], &auth);
assert!(
config.contains(r#" password: "he said \"hi\"\\ok""#),
"got:\n{config}"
);
}
}
+13
View File
@@ -6,6 +6,7 @@
// about the library's actual public API. One copy, no false warnings.
mod go2rtc;
use tracing::info;
use tracing_subscriber::EnvFilter;
use continuum_proxy::config::Config;
@@ -59,6 +60,17 @@ async fn main() -> anyhow::Result<()> {
// so in practice this runs until the process is killed.
let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new());
// Built once, here, rather than inside the rebuild loop: the config is
// re-rendered every 30s and compared byte-for-byte to decide whether to
// restart go2rtc, so a credential that changed per render would restart
// it - and drop every viewer - on every tick.
let camera_auth = camera::ApiAuth::new()?;
info!(
user = %camera_auth.username,
config = %config.go2rtc_config,
"go2rtc's API requires basic auth; the password is in its config file"
);
tokio::select! {
_ = uplink::run(config.clone(), fleet.clone()) => {}
_ = go2rtc::watchdog(
@@ -70,6 +82,7 @@ async fn main() -> anyhow::Result<()> {
fleet.clone(),
config.go2rtc_config.clone(),
config.go2rtc_api_listen.clone(),
camera_auth,
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.clone()) => {}