use crate::{ config::Config, db::Database, terminal::{RedisWorkerScreenState, ScreenState, TerminalUi}, }; use anyhow::{anyhow, Context, Result}; use std::{ collections::HashSet, fmt::Write as _, fs, path::{Path, PathBuf}, sync::atomic::{AtomicBool, Ordering}, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, process::Command, time::{sleep, timeout}, }; const DEFAULT_DONE_SUFFIX: &str = ":done"; const DEFAULT_SENTINEL: &str = "__CAMFINDER_DONE__"; const GLOBAL_PROGRESS_REFRESH: Duration = Duration::from_secs(5); const UI_REFRESH: Duration = Duration::from_millis(250); const RESULT_QUEUE_SUFFIX: &str = ":results"; pub async fn run_redis_coordinator( config: Config, ui: Arc, _interrupted: Arc, ) -> Result<()> { let ips = load_ip_list(&config.redis_input_csv)?; if ips.is_empty() { return Err(anyhow!( "nenhum IP encontrado em {}", config.redis_input_csv )); } let database = Database::open(&config.database_url).await?; let done_key = format!("{}{}", config.redis_queue_name, DEFAULT_DONE_SUFFIX); let result_queue = result_queue_name(&config.redis_queue_name); let total_key = total_ips_key(&config.redis_queue_name); let completed_key = completed_ips_key(&config.redis_queue_name); clear_queue( &config.redis_url, &config.redis_queue_name, &result_queue, &done_key, &total_key, &completed_key, ) .await?; set_redis_key(&config.redis_url, &total_key, ips.len()).await?; set_redis_key(&config.redis_url, &completed_key, 0).await?; enqueue_ips( &config.redis_url, &config.redis_queue_name, &ips, config.redis_queue_block_size.max(1), config.redis_worker_count.max(1), ) .await?; mark_done(&config.redis_url, &done_key).await?; if ui.is_enabled() { ui.print_banner(); ui.render(&ScreenState { current_ip: format!("fila Redis: {}", config.redis_queue_name), tested: ips.len(), total: ips.len(), }); ui.finish(); } else { println!( "Fila Redis alimentada com {} IPs em {}. Workers esperados: {}", ips.len(), config.redis_queue_name, config.redis_worker_count.max(1) ); } consume_result_queue( &config.redis_url, &config.redis_queue_name, &result_queue, &completed_key, ips.len(), &database, ) .await?; Ok(()) } pub async fn run_redis_worker( config: Config, ui: Arc, interrupted: Arc, ) -> Result<()> { ensure_snapshot_dir(&config.snapshot_dir)?; let done_key = format!("{}{}", config.redis_queue_name, DEFAULT_DONE_SUFFIX); let result_queue = result_queue_name(&config.redis_queue_name); let total_key = total_ips_key(&config.redis_queue_name); let completed_key = completed_ips_key(&config.redis_queue_name); ensure_total_ips_key(&config.redis_url, &total_key, &config.redis_input_csv).await?; let started_at = Instant::now(); let mut processed = 0usize; let mut captured = 0usize; let paths = normalize_paths(&config.rtsp_paths); let credentials = load_rtsp_credentials(Path::new("/app/main_ids.json"))?; let worker_name = format!("{}-{}", config.worker_name, std::process::id()); let mut global_stats = read_global_progress( &config.redis_url, &config.redis_queue_name, &total_key, &completed_key, ) .await .unwrap_or_default(); let mut last_global_refresh = Instant::now(); let mut last_ui_render = Instant::now() .checked_sub(UI_REFRESH) .unwrap_or_else(Instant::now); if ui.is_enabled() { render_worker_progress( &ui, &worker_name, &config.redis_queue_name, "aguardando bloco", "", "iniciando", processed, captured, &global_stats, 0, 0, started_at, ); } else { println!( "worker pronto para {} paths, {} credenciais e fila {}", paths.len(), credentials.len(), config.redis_queue_name ); } loop { if interrupted.load(Ordering::SeqCst) { break; } match pop_queue_item(&config.redis_url, &config.redis_queue_name).await? { Some(item) if item == DEFAULT_SENTINEL => break, Some(block) => { let block_ips = parse_ip_block(&block); let block_total = block_ips .len() .saturating_mul(paths.len()) .saturating_mul(credentials.len()); let mut block_done = 0usize; for ip in block_ips { for rtsp_path in &paths { if interrupted.load(Ordering::SeqCst) { break; } let mut path_captured = false; for credential in &credentials { if interrupted.load(Ordering::SeqCst) { break; } let attempt = build_rtsp_url( &ip, config.port, &rtsp_path, credential.username.as_deref(), credential.password.as_deref(), ); let checked_at = timestamp_millis(); let probe = probe_rtsp(&attempt, config.capture_timeout_ms).await; let stop_path_after_attempt = probe.is_dead_path(); let (status, screenshot_path, error, attempt_captured) = if probe .is_connectable() { let output_path = build_snapshot_path( &config.snapshot_dir, &ip, &rtsp_path, checked_at, ); let outcome = capture_feed(&attempt, &output_path, config.capture_timeout_ms) .await; match outcome { Ok(()) => ( "captured".to_string(), Some(output_path.to_string_lossy().to_string()), None, true, ), Err(err) => { let _ = fs::remove_file(&output_path); ("failed".to_string(), None, Some(err.to_string()), false) } } } else { ( probe.status, None, probe.error.map(|error| error.to_string()), false, ) }; push_fetch_result( &config.redis_url, &result_queue, &RtspFetchResult { checked_at, target_ip: ip.clone(), target_port: config.port, rtsp_path: rtsp_path.clone(), rtsp_url: attempt.clone(), screenshot_path: screenshot_path.clone(), status, error, }, ) .await?; processed += 1; block_done = block_done.saturating_add(1); let output_label = screenshot_path.unwrap_or_else(|| "sem imagem".to_string()); if ui.is_enabled() { let now = Instant::now(); if now.duration_since(last_global_refresh) >= GLOBAL_PROGRESS_REFRESH { if let Ok(stats) = read_global_progress( &config.redis_url, &config.redis_queue_name, &total_key, &completed_key, ) .await { global_stats = stats; } last_global_refresh = now; } if now.duration_since(last_ui_render) >= UI_REFRESH { render_worker_progress( &ui, &worker_name, &config.redis_queue_name, &ip, rtsp_path, &format!( "{} -> {}", redact_rtsp_url(&attempt), output_label ), processed, captured, &global_stats, block_done, block_total, started_at, ); last_ui_render = now; } } else { println!("{} -> {}", redact_rtsp_url(&attempt), output_label); } if attempt_captured { captured += 1; path_captured = true; break; } if stop_path_after_attempt { break; } } if path_captured { continue; } } increment_redis_key(&config.redis_url, &completed_key).await?; global_stats.completed_ips = global_stats.completed_ips.saturating_add(1); } if ui.is_enabled() { render_worker_progress( &ui, &worker_name, &config.redis_queue_name, "aguardando bloco", "", "bloco finalizado", processed, captured, &global_stats, block_total, block_total, started_at, ); } } None => { if ui.is_enabled() { render_worker_progress( &ui, &worker_name, &config.redis_queue_name, "fila vazia", "", "aguardando done", processed, captured, &global_stats, 0, 0, started_at, ); } if redis_key_exists(&config.redis_url, &done_key).await? { break; } sleep(Duration::from_secs(1)).await; } } } let elapsed = started_at.elapsed().as_secs_f64(); if ui.is_enabled() { render_worker_progress( &ui, &worker_name, &config.redis_queue_name, "finalizado", "", &format!("tentativas {}, capturas {}", processed, captured), processed, captured, &global_stats, 1, 1, started_at, ); ui.finish(); } else { println!( "worker finalizado em {:.2}s, tentativas {}, capturas {}", elapsed, processed, captured ); } Ok(()) } async fn capture_feed(url: &str, output_path: &Path, timeout_ms: u64) -> Result<()> { let mut command = Command::new("ffmpeg"); command .kill_on_drop(true) .arg("-hide_banner") .arg("-loglevel") .arg("error") .arg("-fflags") .arg("nobuffer") .arg("-flags") .arg("low_delay") .arg("-probesize") .arg("32") .arg("-analyzeduration") .arg("0") .arg("-rtsp_transport") .arg("tcp") .arg("-i") .arg(url) .arg("-frames:v") .arg("1") .arg("-skip_frame") .arg("nokey") .arg("-an") .arg("-sn") .arg("-dn") .arg("-y") .arg(output_path.as_os_str()); let output = timeout(Duration::from_millis(timeout_ms), async { command.output().await }) .await .map_err(|_| anyhow!("ffmpeg timeout for {}", redact_rtsp_url(url)))??; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if output_path.exists() { let _ = fs::remove_file(output_path); } Err(anyhow!( "ffmpeg failed for {}: {}", redact_rtsp_url(url), if stderr.is_empty() { "sem stderr".to_string() } else { stderr } )) } async fn probe_rtsp(url: &str, timeout_ms: u64) -> RtspProbe { let host = extract_host(url); let connect_result = timeout( Duration::from_millis(timeout_ms), tokio::net::TcpStream::connect(host), ) .await; let mut stream = match connect_result { Ok(Ok(stream)) => stream, Ok(Err(err)) => { return RtspProbe { status: "connect_error".to_string(), error: Some(err.to_string()), }; } Err(_) => { return RtspProbe { status: "timeout".to_string(), error: Some("connect timeout".to_string()), }; } }; let request = format!( "DESCRIBE {url} RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: CamFinder/0.1\r\nAccept: application/sdp\r\n\r\n" ); match timeout( Duration::from_millis(timeout_ms), stream.write_all(request.as_bytes()), ) .await { Ok(Ok(())) => {} Ok(Err(err)) => { return RtspProbe { status: "write_error".to_string(), error: Some(err.to_string()), }; } Err(_) => { return RtspProbe { status: "timeout".to_string(), error: Some("write timeout".to_string()), }; } } let mut buf = vec![0u8; 1024]; let read_result = timeout(Duration::from_millis(timeout_ms), stream.read(&mut buf)).await; let bytes_read = match read_result { Ok(Ok(0)) => { return RtspProbe { status: "no_response".to_string(), error: Some("empty response".to_string()), }; } Ok(Ok(n)) => n, Ok(Err(err)) => { return RtspProbe { status: "read_error".to_string(), error: Some(err.to_string()), }; } Err(_) => { return RtspProbe { status: "timeout".to_string(), error: Some("read timeout".to_string()), }; } }; match parse_rtsp_status(&buf[..bytes_read]) { Ok(Some(200)) => RtspProbe { status: "ok".to_string(), error: None, }, Ok(Some(401)) | Ok(Some(403)) => RtspProbe { status: "auth_required".to_string(), error: None, }, Ok(Some(code)) => RtspProbe { status: format!("rtsp_{code}"), error: None, }, Ok(None) => RtspProbe { status: "bad_response".to_string(), error: Some("missing RTSP status line".to_string()), }, Err(err) => RtspProbe { status: "bad_response".to_string(), error: Some(err.to_string()), }, } } #[derive(Debug, Clone)] struct RtspProbe { status: String, error: Option, } #[derive(Debug, Clone, Default)] struct GlobalProgress { completed_ips: usize, total_ips: usize, queue_remaining_ips: Option, } #[derive(Debug, Clone)] struct RtspFetchResult { checked_at: i64, target_ip: String, target_port: u16, rtsp_path: String, rtsp_url: String, screenshot_path: Option, status: String, error: Option, } impl RtspProbe { fn is_connectable(&self) -> bool { matches!( self.status.as_str(), "ok" | "auth_required" | "rtsp_200" | "rtsp_401" | "rtsp_403" ) } fn is_dead_path(&self) -> bool { matches!( self.status.as_str(), "rtsp_400" | "rtsp_404" | "rtsp_451" | "bad_response" ) } } fn parse_rtsp_status(response: &[u8]) -> Result> { let response = std::str::from_utf8(response).context("RTSP response is not valid UTF-8")?; let first_line = response.lines().next().unwrap_or("").trim(); if !first_line.starts_with("RTSP/1.") { return Ok(None); } let mut parts = first_line.split_whitespace(); let _version = parts.next(); let code = parts .next() .ok_or_else(|| anyhow!("RTSP response missing status code"))?; let code = code .parse::() .with_context(|| format!("invalid RTSP status code: {code}"))?; Ok(Some(code)) } fn extract_host(url: &str) -> String { let rest = url.strip_prefix("rtsp://").unwrap_or(url); let authority = rest.split('/').next().unwrap_or(rest); authority .rsplit_once('@') .map(|(_, host)| host) .unwrap_or(authority) .to_string() } async fn pop_queue_item(redis_url: &str, queue_name: &str) -> Result> { let output = redis_cli(redis_url, &["--raw", "LPOP", queue_name]).await?; let value = output.trim(); if value.is_empty() { return Ok(None); } Ok(Some(value.to_string())) } async fn redis_key_exists(redis_url: &str, key: &str) -> Result { let output = redis_cli(redis_url, &["--raw", "EXISTS", key]).await?; Ok(output.trim() == "1") } async fn clear_queue( redis_url: &str, queue_name: &str, result_queue: &str, done_key: &str, total_key: &str, completed_key: &str, ) -> Result<()> { let _ = redis_cli( redis_url, &[ "DEL", queue_name, result_queue, done_key, total_key, completed_key, ], ) .await?; Ok(()) } async fn mark_done(redis_url: &str, done_key: &str) -> Result<()> { let _ = redis_cli(redis_url, &["SET", done_key, "1"]).await?; Ok(()) } async fn set_redis_key(redis_url: &str, key: &str, value: usize) -> Result<()> { let value = value.to_string(); let _ = redis_cli(redis_url, &["SET", key, &value]).await?; Ok(()) } async fn set_redis_key_if_missing(redis_url: &str, key: &str, value: usize) -> Result<()> { let value = value.to_string(); let _ = redis_cli(redis_url, &["SETNX", key, &value]).await?; Ok(()) } async fn increment_redis_key(redis_url: &str, key: &str) -> Result<()> { let _ = redis_cli(redis_url, &["INCR", key]).await?; Ok(()) } async fn ensure_total_ips_key(redis_url: &str, total_key: &str, input_csv: &str) -> Result<()> { let total = load_ip_list(input_csv)?.len(); set_redis_key_if_missing(redis_url, total_key, total).await } async fn read_global_progress( redis_url: &str, queue_name: &str, total_key: &str, completed_key: &str, ) -> Result { let script = r#" local queue = KEYS[1] local total_key = KEYS[2] local completed_key = KEYS[3] local total = tonumber(redis.call('GET', total_key) or '0') local completed = tonumber(redis.call('GET', completed_key) or '0') local n = redis.call('LLEN', queue) local remaining = 0 for i = 0, n - 1 do local item = redis.call('LINDEX', queue, i) if item ~= '__CAMFINDER_DONE__' then for _ in string.gmatch(item, '[^\n]+') do remaining = remaining + 1 end end end return {completed, total, remaining} "#; let output = redis_cli( redis_url, &[ "--raw", "EVAL", script, "3", queue_name, total_key, completed_key, ], ) .await?; let values = output .lines() .filter_map(|line| line.trim().parse::().ok()) .collect::>(); Ok(GlobalProgress { completed_ips: values.get(0).copied().unwrap_or_default(), total_ips: values.get(1).copied().unwrap_or_default(), queue_remaining_ips: values.get(2).copied(), }) } fn total_ips_key(queue_name: &str) -> String { format!("{queue_name}:total_ips") } fn completed_ips_key(queue_name: &str) -> String { format!("{queue_name}:completed_ips") } fn result_queue_name(queue_name: &str) -> String { format!("{queue_name}{RESULT_QUEUE_SUFFIX}") } async fn push_fetch_result( redis_url: &str, result_queue: &str, result: &RtspFetchResult, ) -> Result<()> { let serialized = serialize_fetch_result(result); let _ = redis_cli(redis_url, &["RPUSH", result_queue, &serialized]).await?; Ok(()) } async fn pop_fetch_result(redis_url: &str, result_queue: &str) -> Result> { let output = redis_cli(redis_url, &["--raw", "LPOP", result_queue]).await?; let value = output.trim_end_matches('\n'); if value.is_empty() { return Ok(None); } deserialize_fetch_result(value).map(Some) } async fn consume_result_queue( redis_url: &str, queue_name: &str, result_queue: &str, completed_key: &str, total_ips: usize, database: &Database, ) -> Result<()> { let mut inserted = 0usize; loop { let mut drained_any = false; while let Some(result) = pop_fetch_result(redis_url, result_queue).await? { database .insert_rtsp_fetch_result( result.checked_at, &result.target_ip, result.target_port, &result.rtsp_path, &result.rtsp_url, result.screenshot_path.as_deref(), &result.status, result.error.as_deref(), ) .await?; inserted += 1; drained_any = true; } let completed = redis_usize(redis_url, completed_key) .await? .unwrap_or_default(); let pending_results = redis_list_len(redis_url, result_queue).await?; if completed >= total_ips && pending_results == 0 { println!( "writer finalizado: {} resultados gravados em Postgres para {}", inserted, queue_name ); break; } if !drained_any { sleep(Duration::from_millis(500)).await; } } Ok(()) } async fn redis_usize(redis_url: &str, key: &str) -> Result> { let output = redis_cli(redis_url, &["--raw", "GET", key]).await?; let value = output.trim(); if value.is_empty() { return Ok(None); } value .parse::() .map(Some) .with_context(|| format!("invalid Redis integer for {key}: {value}")) } async fn redis_list_len(redis_url: &str, key: &str) -> Result { let output = redis_cli(redis_url, &["--raw", "LLEN", key]).await?; output .trim() .parse::() .with_context(|| format!("invalid Redis LLEN for {key}")) } fn serialize_fetch_result(result: &RtspFetchResult) -> String { [ result.checked_at.to_string(), result.target_port.to_string(), encode_field(&result.target_ip), encode_field(&result.rtsp_path), encode_field(&result.rtsp_url), encode_optional_field(result.screenshot_path.as_deref()), encode_field(&result.status), encode_optional_field(result.error.as_deref()), ] .join("\t") } fn deserialize_fetch_result(value: &str) -> Result { let parts = value.split('\t').collect::>(); if parts.len() != 8 { return Err(anyhow!( "invalid RTSP fetch result field count: expected 8, got {}", parts.len() )); } Ok(RtspFetchResult { checked_at: parts[0] .parse::() .with_context(|| format!("invalid checked_at: {}", parts[0]))?, target_port: parts[1] .parse::() .with_context(|| format!("invalid target_port: {}", parts[1]))?, target_ip: decode_field(parts[2])?, rtsp_path: decode_field(parts[3])?, rtsp_url: decode_field(parts[4])?, screenshot_path: decode_optional_field(parts[5])?, status: decode_field(parts[6])?, error: decode_optional_field(parts[7])?, }) } fn encode_optional_field(value: Option<&str>) -> String { value.map(encode_field).unwrap_or_default() } fn decode_optional_field(value: &str) -> Result> { if value.is_empty() { Ok(None) } else { decode_field(value).map(Some) } } fn encode_field(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b'/' | b':' | b'@' => { encoded.push(*byte as char) } _ => { let _ = write!(&mut encoded, "%{byte:02X}"); } } } encoded } fn decode_field(value: &str) -> Result { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0usize; while index < bytes.len() { if bytes[index] == b'%' { if index + 2 >= bytes.len() { return Err(anyhow!("invalid percent escape in Redis result field")); } let hex = std::str::from_utf8(&bytes[index + 1..index + 3]) .context("invalid percent escape UTF-8")?; let value = u8::from_str_radix(hex, 16) .with_context(|| format!("invalid percent escape: %{hex}"))?; decoded.push(value); index += 3; } else { decoded.push(bytes[index]); index += 1; } } String::from_utf8(decoded).context("decoded Redis result field is not valid UTF-8") } fn render_worker_progress( ui: &TerminalUi, worker_name: &str, queue_name: &str, current_ip: &str, current_path: &str, current_status: &str, processed: usize, captured: usize, global_stats: &GlobalProgress, block_done: usize, block_total: usize, started_at: Instant, ) { ui.render_redis_worker(&RedisWorkerScreenState { worker_name: worker_name.to_string(), queue_name: queue_name.to_string(), current_ip: current_ip.to_string(), current_path: current_path.to_string(), current_status: current_status.to_string(), processed, captured, global_done: global_stats.completed_ips, global_total: global_stats.total_ips, queue_remaining: global_stats.queue_remaining_ips, block_done, block_total, elapsed: started_at.elapsed(), }); } async fn enqueue_ips( redis_url: &str, queue_name: &str, ips: &[String], block_size: usize, worker_count: usize, ) -> Result<()> { for chunk in ips.chunks(block_size.max(1)) { let mut args = Vec::with_capacity(chunk.len() + 2); args.push("RPUSH".to_string()); args.push(queue_name.to_string()); args.push(chunk.join("\n")); let args_ref = args.iter().map(String::as_str).collect::>(); let _ = redis_cli(redis_url, &args_ref).await?; } let mut sentinels = Vec::with_capacity(worker_count + 2); sentinels.push("RPUSH".to_string()); sentinels.push(queue_name.to_string()); sentinels.extend(std::iter::repeat_with(|| DEFAULT_SENTINEL.to_string()).take(worker_count)); let sentinel_args = sentinels.iter().map(String::as_str).collect::>(); let _ = redis_cli(redis_url, &sentinel_args).await?; Ok(()) } fn parse_ip_block(block: &str) -> Vec { block .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(strip_quotes) .filter(|line| !line.is_empty()) .filter(|line| !is_header(line)) .map(ToString::to_string) .collect() } async fn redis_cli(redis_url: &str, args: &[&str]) -> Result { let mut command = Command::new("redis-cli"); command.kill_on_drop(true).arg("-u").arg(redis_url); for arg in args { command.arg(arg); } let output = timeout(Duration::from_secs(15), async { command.output().await }) .await .context("redis-cli timeout")??; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(anyhow!( "redis-cli failed{}", if stderr.is_empty() { String::new() } else { format!(": {stderr}") } )); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn load_ip_list(input: &str) -> Result> { let content = match fs::read_to_string(input) { Ok(content) => content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { include_str!("../camfinder-open-rtsp.csv").to_string() } Err(err) => { return Err(anyhow!(err)).with_context(|| format!("failed to read input CSV {input}")); } }; let mut ips = Vec::new(); for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some(first) = line.split(',').next() else { continue; }; let value = strip_quotes(first.trim()); if value.is_empty() || is_header(value) { continue; } ips.push(value.to_string()); } Ok(ips) } fn normalize_paths(paths: &[String]) -> Vec { paths .iter() .map(|path| path.trim()) .filter(|path| !path.is_empty()) .map(|path| { if path.starts_with('/') { path.to_string() } else { format!("/{path}") } }) .collect() } fn build_rtsp_url( ip: &str, port: u16, path: &str, username: Option<&str>, password: Option<&str>, ) -> String { let auth = build_rtsp_auth_prefix(username, password); format!("rtsp://{auth}{ip}:{port}{path}") } fn build_snapshot_path(base_dir: &Path, ip: &str, rtsp_path: &str, checked_at: i64) -> PathBuf { let ip_component = sanitize_component(ip); let path_component = sanitize_component(rtsp_path); base_dir.join(format!("{ip_component}_{checked_at}_{path_component}.png")) } fn ensure_snapshot_dir(snapshot_dir: &Path) -> Result<()> { fs::create_dir_all(snapshot_dir) .with_context(|| format!("failed to create snapshot dir {}", snapshot_dir.display())) } fn strip_quotes(value: &str) -> &str { value .strip_prefix('"') .and_then(|value| value.strip_suffix('"')) .or_else(|| { value .strip_prefix('\'') .and_then(|value| value.strip_suffix('\'')) }) .unwrap_or(value) } fn is_header(value: &str) -> bool { matches!( value.to_ascii_lowercase().as_str(), "ip" | "host" | "hostname" | "address" | "target" | "camera" ) } fn sanitize_component(value: &str) -> String { value .chars() .map(|ch| match ch { 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch, _ => '_', }) .collect() } #[derive(Debug, Clone, Eq, PartialEq, Hash)] struct RtspCredential { username: Option, password: Option, } impl RtspCredential { fn anonymous() -> Self { Self { username: None, password: None, } } fn from_parts(username: Option<&str>, password: Option<&str>) -> Self { Self { username: username .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), password: password .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), } } } fn load_rtsp_credentials(path: &Path) -> Result> { let content = match fs::read_to_string(path) { Ok(content) => content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { include_str!("../main_ids.json").to_string() } Err(err) => { return Err(anyhow!(err)).with_context(|| { format!("failed to read RTSP credential file {}", path.display()) }); } }; let usernames = parse_json_string_array(&content, "usernames")?; let passwords = parse_json_string_array(&content, "passwords")?; Ok(build_rtsp_credentials(&usernames, &passwords)) } fn build_rtsp_credentials(usernames: &[String], passwords: &[String]) -> Vec { let mut credentials = Vec::new(); let mut seen = HashSet::new(); push_credential(&mut credentials, &mut seen, RtspCredential::anonymous()); for username in usernames.iter().filter(|value| !value.trim().is_empty()) { push_credential( &mut credentials, &mut seen, RtspCredential::from_parts(Some(username), None), ); } for password in passwords.iter().filter(|value| !value.trim().is_empty()) { push_credential( &mut credentials, &mut seen, RtspCredential::from_parts(None, Some(password)), ); } for username in usernames.iter().filter(|value| !value.trim().is_empty()) { for password in passwords.iter().filter(|value| !value.trim().is_empty()) { push_credential( &mut credentials, &mut seen, RtspCredential::from_parts(Some(username), Some(password)), ); } } credentials } fn push_credential( credentials: &mut Vec, seen: &mut HashSet, credential: RtspCredential, ) { if seen.insert(credential.clone()) { credentials.push(credential); } } fn parse_json_string_array(input: &str, key: &str) -> Result> { let marker = format!("\"{key}\""); let Some(key_pos) = input.find(&marker) else { return Err(anyhow!("missing {key} field in RTSP credential file")); }; let after_key = &input[key_pos + marker.len()..]; let Some(array_start) = after_key.find('[') else { return Err(anyhow!("missing {key} array in RTSP credential file")); }; let array_start = key_pos + marker.len() + array_start; let array_end = find_matching_bracket(input, array_start)?; let array_body = &input[array_start + 1..array_end]; parse_json_string_values(array_body) } fn find_matching_bracket(input: &str, open_index: usize) -> Result { let mut in_string = false; let mut escape = false; for (index, ch) in input[open_index..].char_indices() { let index = open_index + index; if escape { escape = false; continue; } match ch { '\\' if in_string => escape = true, '"' => in_string = !in_string, '[' if !in_string => {} ']' if !in_string => return Ok(index), _ => {} } } Err(anyhow!("unterminated JSON array in RTSP credential file")) } fn parse_json_string_values(input: &str) -> Result> { let mut values = Vec::new(); let mut index = 0; let bytes = input.as_bytes(); while index < bytes.len() { skip_json_whitespace(input, &mut index); if index >= bytes.len() { break; } if bytes[index] == b',' { index += 1; continue; } if bytes[index] != b'"' { return Err(anyhow!("expected JSON string in RTSP credential file")); } index += 1; let mut value = String::new(); while index < bytes.len() { let ch = input[index..] .chars() .next() .ok_or_else(|| anyhow!("invalid UTF-8 in RTSP credential file"))?; index += ch.len_utf8(); match ch { '"' => break, '\\' => { let esc = input[index..] .chars() .next() .ok_or_else(|| anyhow!("unterminated escape in RTSP credential file"))?; index += esc.len_utf8(); match esc { '"' | '\\' | '/' => value.push(esc), 'b' => value.push('\u{0008}'), 'f' => value.push('\u{000C}'), 'n' => value.push('\n'), 'r' => value.push('\r'), 't' => value.push('\t'), other => { return Err(anyhow!( "unsupported JSON escape \\{other} in RTSP credential file" )); } } } other => value.push(other), } } values.push(value); skip_json_whitespace(input, &mut index); if index < bytes.len() && bytes[index] == b',' { index += 1; } } Ok(values) } fn skip_json_whitespace(input: &str, index: &mut usize) { let bytes = input.as_bytes(); while *index < bytes.len() && bytes[*index].is_ascii_whitespace() { *index += 1; } } fn build_rtsp_auth_prefix(username: Option<&str>, password: Option<&str>) -> String { match (username, password) { (None, None) => String::new(), (Some(username), Some(password)) => { format!( "{}:{}@", encode_rtsp_userinfo(username), encode_rtsp_userinfo(password) ) } (Some(username), None) => format!("{}@", encode_rtsp_userinfo(username)), (None, Some(password)) => format!(":{}@", encode_rtsp_userinfo(password)), } } fn encode_rtsp_userinfo(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { encoded.push(*byte as char) } _ => { let _ = write!(&mut encoded, "%{byte:02X}"); } } } encoded } fn redact_rtsp_url(url: &str) -> String { let Some(rest) = url.strip_prefix("rtsp://") else { return url.to_string(); }; let Some((authority, path)) = rest.split_once('/') else { return url.to_string(); }; let Some((userinfo, host)) = authority.rsplit_once('@') else { return url.to_string(); }; let redacted_userinfo = match userinfo.split_once(':') { Some((user, _)) if !user.is_empty() => format!("{user}:***"), Some(_) => "***".to_string(), None if !userinfo.is_empty() => format!("{userinfo}:***"), None => "***".to_string(), }; format!("rtsp://{}@{}/{}", redacted_userinfo, host, path) } fn timestamp_millis() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis() as i64) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn builds_credential_combinations_in_useful_order() { let usernames = vec!["".to_string(), "admin".to_string()]; let passwords = vec!["".to_string(), "1234".to_string()]; let credentials = build_rtsp_credentials(&usernames, &passwords); assert_eq!( credentials, vec![ RtspCredential::anonymous(), RtspCredential::from_parts(Some("admin"), None), RtspCredential::from_parts(None, Some("1234")), RtspCredential::from_parts(Some("admin"), Some("1234")), ] ); } #[test] fn builds_and_redacts_rtsp_urls_with_auth() { let url = build_rtsp_url("192.168.1.10", 554, "/stream", Some("admin"), Some("1234")); assert_eq!(url, "rtsp://admin:1234@192.168.1.10:554/stream"); assert_eq!( redact_rtsp_url(&url), "rtsp://admin:***@192.168.1.10:554/stream" ); assert_eq!(extract_host(&url), "192.168.1.10:554"); } #[test] fn parses_json_arrays_with_strings() { let input = r#" { "usernames": ["", "admin"], "passwords": ["", "1234"] } "#; assert_eq!( parse_json_string_array(input, "usernames").unwrap(), vec!["".to_string(), "admin".to_string()] ); assert_eq!( parse_json_string_array(input, "passwords").unwrap(), vec!["".to_string(), "1234".to_string()] ); } #[test] fn parses_queue_blocks_into_ips() { let block = "192.168.0.10\n192.168.0.11\n\n# ignored\ncamera"; assert_eq!( parse_ip_block(block), vec!["192.168.0.10".to_string(), "192.168.0.11".to_string()] ); } }