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:
+153
-7
@@ -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
@@ -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()) => {}
|
||||
|
||||
Reference in New Issue
Block a user