Adds src/uplink/webrtc.rs: on a webrtc_offer from the cloud, POST the offer to go2rtc on 127.0.0.1 and send the answer back as webrtc_answer, or the reason as webrtc_error. No credential is involved, because the request goes to loopback and go2rtc exempts loopback from its API password. That is measured, and it is also why a probe from the gateway cannot be used to check the password is working - test from another host for that. Spawned rather than awaited in the select! loop: go2rtc gathers ICE candidates before answering, which includes a STUN round trip, so awaiting inline would stall telemetry and heartbeats for a whole farm while one person opened one camera. It rides the outbound queue added earlier for exactly this kind of out-of-band reply. The bind address is turned into a loopback URL by taking its port and refusing anything it cannot read a port out of - guessing 1984 would produce a gateway that silently signals to the wrong place when someone has moved it. Stream names are validated rather than percent-encoded, since a real one is a printer id and anything else is a bug or an attempt at one. camera.rs now states why there are no ice_servers and no TURN credentials on this side: the viewer's browser is pinned to relay-only, so it allocates the relay and go2rtc just sends to it. Only one side of a session needs one. That asymmetry is load-bearing, not tidy - go2rtc reads its config once at startup, so rotating short-lived credentials here would mean restarting it and dropping every viewer on a timer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
24 KiB
Rust
542 lines
24 KiB
Rust
//! Turns the printers' own camera URLs into a go2rtc configuration.
|
|
//!
|
|
//! `go2rtc.rs` has supervised a go2rtc process since before this module
|
|
//! existed, and go2rtc has never had anything to serve: nothing wrote its
|
|
//! config file. `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, which
|
|
//! did not exist in the repository or anywhere else. This writes it.
|
|
//!
|
|
//! ## Where the URL comes from
|
|
//!
|
|
//! Not from `printers.toml`. A printer reports its own camera URL in
|
|
//! `print.push_status`'s `ipcam` block, so asking an operator to copy it into
|
|
//! a config file would be asking them to keep a second copy of something the
|
|
//! machine already tells us - and to update it by hand when the printer's
|
|
//! address changes.
|
|
//!
|
|
//! ## What does not work, which is most printers
|
|
//!
|
|
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
|
|
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
|
|
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
|
|
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
|
|
//! crate does not speak, so those printers get no stream here and say so
|
|
//! rather than advertising one that will not play.
|
|
//!
|
|
//! That is not just an inference from the missing field - it is confirmed at
|
|
//! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P
|
|
//! *refuses* 322 and listens on 6000. So a P1P has no RTSP endpoint to
|
|
//! restream, and no amount of guessing a URL would produce one.
|
|
//!
|
|
//! That is also why this module reads the *reported* URL and never builds one
|
|
//! from the printer's IP: the fact that a printer has a camera and the fact
|
|
//! that the camera has an RTSP endpoint are different facts, and only the
|
|
//! second one is useful.
|
|
//!
|
|
//! ## What is confirmed to work
|
|
//!
|
|
//! Measured end to end against a live H2C with go2rtc 1.9.14 (2026-10-18):
|
|
//! this module's generated config is enough for go2rtc to connect to
|
|
//! `rtsps://bblp:<access_code>@<ip>:322/streaming/live/1` and pull video -
|
|
//! 984,604 bytes over `rtsp+tcp`, no certificate complaint and no
|
|
//! authentication failure. The printer runs LIVE555 and sends **H.264 High
|
|
//! profile, level 4.1**, which a browser plays natively over WebRTC, so no
|
|
//! transcoding step (and no ffmpeg on the gateway) is needed. The
|
|
//! `rtspx://` prefix that go2rtc documents for skipping TLS verification is
|
|
//! not required here.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::Notify;
|
|
use tracing::{info, warn};
|
|
|
|
use crate::printer::{GenericPrinter, PrinterHandle};
|
|
use crate::uplink::SharedFleet;
|
|
|
|
/// How often to rebuild the configuration from what the printers report.
|
|
///
|
|
/// A camera URL changes when a printer's address changes, which is a DHCP
|
|
/// lease event, not something that needs watching closely. The first useful
|
|
/// rebuild is the one a few seconds after startup, once the first reports
|
|
/// have arrived - before that the fleet has nothing to say.
|
|
const REBUILD_INTERVAL: Duration = Duration::from_secs(30);
|
|
|
|
/// The username every Bambu uses for its own services - MQTT, FTPS, and the
|
|
/// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already
|
|
/// authenticate with.
|
|
const CAMERA_USER: &str = "bblp";
|
|
|
|
/// One printer's playable stream.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CameraSource {
|
|
/// Our own printer id, which doubles as the go2rtc stream name. Using it
|
|
/// for both means the cloud can name a stream without the gateway having
|
|
/// to report a mapping: `camera_stream` in telemetry is this id.
|
|
pub stream_name: String,
|
|
/// The full source URL, credentials included.
|
|
pub url: String,
|
|
}
|
|
|
|
/// Builds the source URL for one printer, or `None` if it has no stream.
|
|
///
|
|
/// The credentials go in the URL because that is the only place go2rtc takes
|
|
/// them for an RTSP source. The consequence is that the printer's access code
|
|
/// ends up in a file on disk - which is not a new exposure (`printers.toml`
|
|
/// already holds it, and that file is gitignored for this reason) but it does
|
|
/// mean the generated config deserves the same treatment, and must never be
|
|
/// logged.
|
|
pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<CameraSource> {
|
|
let PrinterHandle::Bambu(bambu) = printer else {
|
|
// Nothing else has a camera path here yet. A Prusa's is a separate
|
|
// product, and a Klipper camera is whatever the user configured.
|
|
return None;
|
|
};
|
|
|
|
let url = report_url?;
|
|
if url.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// `rtsps://1.2.3.4:322/streaming/live/1` -> `rtsps://bblp:code@1.2.3.4:322/...`
|
|
let with_credentials = match url.split_once("://") {
|
|
Some((scheme, rest)) => {
|
|
// A URL that already carries credentials is left alone rather
|
|
// than having a second set spliced in.
|
|
if rest.contains('@') {
|
|
url.to_string()
|
|
} else {
|
|
format!("{scheme}://{CAMERA_USER}:{}@{rest}", bambu.access_code)
|
|
}
|
|
}
|
|
// Not a URL shape this understands. Refused rather than guessed at:
|
|
// a malformed source makes go2rtc fail to load the whole file, which
|
|
// would take every *working* stream down with it.
|
|
None => {
|
|
warn!(
|
|
printer = %bambu.display_name(),
|
|
"the printer reported a camera URL with no scheme; ignoring it"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
Some(CameraSource {
|
|
stream_name: printer.base().id.clone(),
|
|
url: with_credentials,
|
|
})
|
|
}
|
|
|
|
/// Renders a go2rtc configuration.
|
|
///
|
|
/// Hand-written YAML rather than a serializer, for the same reason the
|
|
/// `.3mf` reader is hand-rolled: it is a fixed, tiny shape, and a dependency
|
|
/// for ten lines of output is a dependency to license and audit. The values
|
|
/// are quoted and the keys are our own printer ids, which `fleet.rs`
|
|
/// constrains.
|
|
///
|
|
/// `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.
|
|
/// 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\
|
|
# the next rebuild. Contains printer access codes - treat it as a\n\
|
|
# secret and keep it out of version control.\n",
|
|
);
|
|
|
|
// Sorted, so an unchanged fleet renders byte-identical output and the
|
|
// change detection below does not restart go2rtc on every rebuild.
|
|
let ordered: BTreeMap<&str, &str> = sources
|
|
.iter()
|
|
.map(|s| (s.stream_name.as_str(), s.url.as_str()))
|
|
.collect();
|
|
|
|
out.push_str("streams:\n");
|
|
if ordered.is_empty() {
|
|
// An empty mapping, written explicitly. `streams:` with nothing
|
|
// under it is a null in YAML, and go2rtc is entitled to treat that
|
|
// differently from an empty map.
|
|
out.push_str(" {}\n");
|
|
}
|
|
for (name, url) in &ordered {
|
|
out.push_str(&format!(" {name}: \"{url}\"\n"));
|
|
}
|
|
|
|
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.
|
|
//
|
|
// Leaving the key out does NOT leave the server off - go2rtc defaults to
|
|
// :8554. Measured against go2rtc 1.9.14 with a config that omitted it:
|
|
// `rtsp://<gateway>:8554/<printer>` answered a DESCRIBE carrying a real
|
|
// video SDP, with no credentials sent at all. Every printer's camera was
|
|
// republished to anything that could reach the port.
|
|
//
|
|
// Nothing needs it: a browser is served over WebRTC through the API
|
|
// listener above. An empty `listen` is how go2rtc is told not to bind.
|
|
out.push_str("rtsp:\n listen: \"\"\n");
|
|
|
|
// `webrtc:` is deliberately left alone, and with TURN in the picture that
|
|
// is now a decision worth stating rather than an omission.
|
|
//
|
|
// go2rtc keeps its default media port and its default candidate
|
|
// gathering, which produces a host candidate and - measured against
|
|
// 1.9.14 - a server-reflexive one it discovers by STUN on its own. That
|
|
// is everything this end needs. No `ice_servers` here, and in particular
|
|
// **no TURN credentials on the gateway**: the viewer's browser is pinned
|
|
// to relay-only, so the browser allocates the relay and go2rtc simply
|
|
// sends to it from its own candidate. Only one side of a WebRTC session
|
|
// has to hold a relay for the media to traverse one.
|
|
//
|
|
// That asymmetry is what makes this tractable at all, because go2rtc
|
|
// reads its config exactly once at startup: TURN credentials are
|
|
// short-lived, so rotating them here would mean restarting go2rtc and
|
|
// dropping every viewer on a timer. Keeping them browser-side moves the
|
|
// rotation to a request that is already per-viewer.
|
|
//
|
|
// This is not a second open stream server either way: a WebRTC session
|
|
// exists only after an SDP offer through the API, and that API now
|
|
// requires a password.
|
|
out
|
|
}
|
|
|
|
/// Collects every printer that currently has a playable stream.
|
|
pub async fn sources(fleet: &SharedFleet) -> Vec<CameraSource> {
|
|
let fleet = fleet.lock().await;
|
|
let mut found = Vec::new();
|
|
|
|
for printer in fleet.iter() {
|
|
let report_url = printer
|
|
.print_status()
|
|
.await
|
|
.and_then(|report| report.ipcam)
|
|
.and_then(|ipcam| ipcam.rtsp_url);
|
|
|
|
if let Some(source) = source_for(printer, report_url.as_deref()) {
|
|
found.push(source);
|
|
}
|
|
}
|
|
|
|
found
|
|
}
|
|
|
|
/// Rewrites the config whenever what the printers report changes, and asks
|
|
/// the watchdog to restart go2rtc when it does.
|
|
///
|
|
/// Restart rather than reload: go2rtc can reload some of its configuration
|
|
/// over its own API, but a restart is the behaviour we can be sure of, and
|
|
/// the watchdog was already built to restart it. A stream interruption at the
|
|
/// moment a printer's camera URL changes is acceptable; a stream that is
|
|
/// silently serving the old address is not.
|
|
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);
|
|
let mut last_written: Option<String> = None;
|
|
|
|
loop {
|
|
ticker.tick().await;
|
|
|
|
// Collected once and kept, so the count logged below describes the
|
|
// 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, &auth);
|
|
if last_written.as_deref() == Some(desired.as_str()) {
|
|
continue;
|
|
}
|
|
|
|
// Compare against the file too, not just our own memory of it: on
|
|
// startup `last_written` is `None`, and rewriting an identical file
|
|
// would restart go2rtc - and therefore drop every live viewer - for
|
|
// no reason, every time the daemon restarts.
|
|
if last_written.is_none()
|
|
&& let Ok(existing) = tokio::fs::read_to_string(&config_path).await
|
|
&& existing == desired
|
|
{
|
|
last_written = Some(desired);
|
|
continue;
|
|
}
|
|
|
|
match write_atomically(&config_path, &desired).await {
|
|
Ok(()) => {
|
|
// Counted from the sources, not from the rendered text. It
|
|
// used to be `matches("\n ").count() - 1`, which reported
|
|
// one stream for a config with none - the `{}` of an empty
|
|
// mapping indents exactly like a stream does. A log line
|
|
// that says a camera is being served when none is makes the
|
|
// one thing this task is for impossible to check.
|
|
let count = found.len();
|
|
// The content is never logged: it holds access codes.
|
|
info!(path = %config_path, streams = count, "wrote go2rtc config, restarting it");
|
|
last_written = Some(desired);
|
|
restart.notify_one();
|
|
}
|
|
Err(err) => warn!(path = %config_path, ?err, "could not write the go2rtc config"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Writes through a temporary file and renames.
|
|
///
|
|
/// go2rtc may be reading this file at any moment - the watchdog restarts it
|
|
/// on a five-second loop - and a partially written config is a config that
|
|
/// fails to parse, which takes every working stream down. A rename is atomic
|
|
/// on the same filesystem, so a reader sees either the old file or the new
|
|
/// one.
|
|
async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> {
|
|
let temp = format!("{path}.tmp");
|
|
tokio::fs::write(&temp, contents).await?;
|
|
tokio::fs::rename(&temp, path).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::printer::{BambuPrinter, BambuProtocolPreference, BambuTls};
|
|
|
|
fn printer(id: &str) -> PrinterHandle {
|
|
PrinterHandle::Bambu(BambuPrinter::new(
|
|
id,
|
|
"Test Printer",
|
|
"192.168.0.21",
|
|
"12345678",
|
|
"SN123",
|
|
BambuTls::Insecure,
|
|
BambuProtocolPreference::Auto,
|
|
))
|
|
}
|
|
|
|
/// 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() }
|
|
}
|
|
|
|
/// The URL shape confirmed from a real H2C's `ipcam` block.
|
|
#[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"))
|
|
.expect("an rtsps URL is playable");
|
|
assert_eq!(source.stream_name, "h2c");
|
|
assert_eq!(source.url, "rtsps://bblp:12345678@192.168.0.21:322/streaming/live/1");
|
|
}
|
|
|
|
/// What a P1P reports: an `ipcam` block that says a camera exists without
|
|
/// giving anything to connect to. Advertising a stream here would make a
|
|
/// UI offer a player that can never load.
|
|
#[test]
|
|
fn a_printer_that_reports_no_url_has_no_stream() {
|
|
assert_eq!(source_for(&printer("p1p"), None), None);
|
|
assert_eq!(source_for(&printer("p1p"), Some("")), None);
|
|
}
|
|
|
|
/// Splicing a second set of credentials into a URL that already has some
|
|
/// would produce `bblp:code@user:pass@host`, which is not a host.
|
|
#[test]
|
|
fn a_url_that_already_carries_credentials_is_left_alone() {
|
|
let source = source_for(&printer("h2c"), Some("rtsps://someone:else@10.0.0.5:322/live"))
|
|
.expect("still playable");
|
|
assert_eq!(source.url, "rtsps://someone:else@10.0.0.5:322/live");
|
|
}
|
|
|
|
/// Refused rather than guessed at, because one bad entry makes go2rtc
|
|
/// fail to load the whole file and takes every working stream with it.
|
|
#[test]
|
|
fn a_url_with_no_scheme_is_refused_rather_than_repaired() {
|
|
assert_eq!(source_for(&printer("h2c"), Some("192.168.0.21:322/streaming/live/1")), None);
|
|
}
|
|
|
|
/// `streams:` with nothing under it is YAML for null, not for an empty
|
|
/// map, and a gateway with no streamable camera is the normal state of a
|
|
/// 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", &[], &test_auth());
|
|
assert!(config.contains("streams:\n {}\n"), "got:\n{config}");
|
|
assert!(config.contains("listen: \":1984\""), "got:\n{config}");
|
|
}
|
|
|
|
/// The change detection in `run` compares rendered output, so an
|
|
/// unchanged fleet in a different order must render identically or
|
|
/// go2rtc gets restarted - dropping every live viewer - every 30s.
|
|
#[test]
|
|
fn the_same_fleet_in_a_different_order_renders_identically() {
|
|
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()], &test_auth()),
|
|
render_config(":1984", &[b, a], &test_auth()),
|
|
);
|
|
}
|
|
|
|
/// The config must *actively* close go2rtc's RTSP server.
|
|
///
|
|
/// This test used to assert the opposite thing - that the rendered
|
|
/// config contained no `rtsp:` key - and concluded from that that no
|
|
/// RTSP server was opened. It passed, and it was worthless: go2rtc
|
|
/// defaults to :8554 when the key is absent. Measured against go2rtc
|
|
/// 1.9.14, a config with no `rtsp:` block answered an unauthenticated
|
|
/// DESCRIBE on `rtsp://<gateway>:8554/h2c` with a real video SDP.
|
|
///
|
|
/// A test that asserts a key is missing cannot tell "off" from
|
|
/// "defaulted on". This one asserts the value that turns it off.
|
|
#[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], &test_auth());
|
|
assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}");
|
|
}
|
|
|
|
/// An empty fleet must still close it - a gateway with no cameras is the
|
|
/// 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", &[], &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", std::slice::from_ref(&source), &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}"
|
|
);
|
|
}
|
|
}
|