1412 lines
43 KiB
Rust
1412 lines
43 KiB
Rust
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<TerminalUi>,
|
|
_interrupted: Arc<AtomicBool>,
|
|
) -> 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<TerminalUi>,
|
|
interrupted: Arc<AtomicBool>,
|
|
) -> 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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct GlobalProgress {
|
|
completed_ips: usize,
|
|
total_ips: usize,
|
|
queue_remaining_ips: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RtspFetchResult {
|
|
checked_at: i64,
|
|
target_ip: String,
|
|
target_port: u16,
|
|
rtsp_path: String,
|
|
rtsp_url: String,
|
|
screenshot_path: Option<String>,
|
|
status: String,
|
|
error: Option<String>,
|
|
}
|
|
|
|
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<Option<u16>> {
|
|
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::<u16>()
|
|
.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<Option<String>> {
|
|
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<bool> {
|
|
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<GlobalProgress> {
|
|
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::<usize>().ok())
|
|
.collect::<Vec<_>>();
|
|
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<Option<RtspFetchResult>> {
|
|
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<Option<usize>> {
|
|
let output = redis_cli(redis_url, &["--raw", "GET", key]).await?;
|
|
let value = output.trim();
|
|
if value.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
value
|
|
.parse::<usize>()
|
|
.map(Some)
|
|
.with_context(|| format!("invalid Redis integer for {key}: {value}"))
|
|
}
|
|
|
|
async fn redis_list_len(redis_url: &str, key: &str) -> Result<usize> {
|
|
let output = redis_cli(redis_url, &["--raw", "LLEN", key]).await?;
|
|
output
|
|
.trim()
|
|
.parse::<usize>()
|
|
.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<RtspFetchResult> {
|
|
let parts = value.split('\t').collect::<Vec<_>>();
|
|
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::<i64>()
|
|
.with_context(|| format!("invalid checked_at: {}", parts[0]))?,
|
|
target_port: parts[1]
|
|
.parse::<u16>()
|
|
.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<Option<String>> {
|
|
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<String> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
let _ = redis_cli(redis_url, &sentinel_args).await?;
|
|
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> {
|
|
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<Vec<String>> {
|
|
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<String> {
|
|
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<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 = 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<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 {
|
|
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()]
|
|
);
|
|
}
|
|
}
|