diff --git a/ADVANCED.md b/ADVANCED.md index 2b6b4e3..6d0672f 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -205,11 +205,44 @@ Set this as the fpcalc path in Picard's *Options → Fingerprinting*. Run `dupso For headless servers (NAS, Raspberry Pi, OpenMediaVault), dupsonic includes a built-in web interface: ```bash -dupsonic serve # http://0.0.0.0:8080 -dupsonic serve --bind 127.0.0.1:3000 # custom address/port +dupsonic serve # http://127.0.0.1:8080 (localhost only) +dupsonic serve --bind 0.0.0.0:8080 # expose on all interfaces +dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24 # LAN only ``` -Features: +### Access control + +By default, the server binds to **localhost only** (`127.0.0.1:8080`). To expose it on the network, use `--bind`: + +```bash +dupsonic serve --bind 0.0.0.0:8080 +``` + +To restrict which IPs can connect, use `--allow-ip` (repeatable, supports CIDR notation for both IPv4 and IPv6): + +```bash +dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24 +dupsonic serve --bind 0.0.0.0:8080 --allow-ip 10.0.0.5 --allow-ip 10.0.0.6 +dupsonic serve --bind [::]:8080 --allow-ip fd00::/8 --allow-ip ::1 +``` + +When `--allow-ip` is set, connections from non-matching IPs receive HTTP 403 Forbidden. When not set, all connections to the bound address are permitted. + +Environment variables are also supported: + +```bash +export DUPSONIC_BIND=0.0.0.0:8080 +export DUPSONIC_ALLOW_IP=192.168.1.0/24,10.0.0.0/8 +dupsonic serve +``` + +| Flag | Env var | Default | Description | +|------|---------|---------|-------------| +| `--bind` | `DUPSONIC_BIND` | `127.0.0.1:8080` | Address and port to listen on | +| `--allow-ip` | `DUPSONIC_ALLOW_IP` | *(none — allow all)* | Comma-separated IPs or CIDR ranges to allow | + +### Features + - **Status dashboard** — file count, scan state - **Scan** — enter a path or re-scan stored paths (pre-filled from previous scans) - **Find duplicates** — view all groups with similarity %, classification badges, quality details (format, sample rate, bit depth, bitrate, size) diff --git a/Cargo.toml b/Cargo.toml index e983873..0e68c0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,8 +62,12 @@ ureq = { version = "3.0", features = ["json"] } axum = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } +# IP access control (CIDR parsing) +ipnet = "2.12" + # System trash (for web UI delete action) trash = "5" [dev-dependencies] tempfile = "3.10" +tower = "0.5" diff --git a/README.md b/README.md index d908d0d..0145440 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,11 @@ sudo mv dupsonic /usr/local/bin/ Existing tools fail at cross-format duplicate detection: -- **Czkawka, dupeGuru** — compare metadata or file hashes only. Same song in FLAC and MP3? Not detected. -- **Duplicate Cleaner** — claims audio comparison but [struggles with cross-format matching](https://community.metabrainz.org/t/extremely-large-music-collection-needs-advice-on-what-dedupe-program-to-use/608781). +- **[Czkawka](https://github.com/qarmin/czkawka), [dupeGuru](https://github.com/arsenetar/dupeguru)** — compare metadata or file hashes only. Same song in FLAC and MP3? Not detected. +- **[Duplicate Cleaner](https://www.duplicatecleaner.com/)** — claims audio comparison but [struggles with cross-format matching](https://community.metabrainz.org/t/extremely-large-music-collection-needs-advice-on-what-dedupe-program-to-use/608781). - **Manual comparison** — impossible with 10k+ files. -dupsonic fingerprints the actual audio using [Chromaprint](https://acoustid.org/chromaprint) (the same technology behind MusicBrainz Picard) and compares fingerprints to find duplicates. +dupsonic fingerprints the actual audio using [Chromaprint](https://acoustid.org/chromaprint) (the same technology behind [MusicBrainz Picard](https://picard.musicbrainz.org/)) and compares fingerprints to find duplicates. ## Supported formats @@ -102,16 +102,18 @@ Designed for 100k+ file collections: parallel scanning, incremental cache, LSH-b For headless servers (NAS, Raspberry Pi), dupsonic includes a built-in web interface: ```bash -dupsonic serve # http://0.0.0.0:8080 +dupsonic serve # http://127.0.0.1:8080 (localhost) +dupsonic serve --bind 0.0.0.0:8080 # expose on network +dupsonic serve --bind 0.0.0.0:8080 --allow-ip 192.168.1.0/24 # restrict to LAN ``` Open from any browser on your network. Features: scan, find duplicates, view quality details, delete (moves to system trash with undo), exclude. -See [ADVANCED.md](ADVANCED.md#web-ui) for configuration options. +See [ADVANCED.md](ADVANCED.md#web-ui) for access control options (`--allow-ip`, `DUPSONIC_BIND`, `DUPSONIC_ALLOW_IP`). ## Similar projects -**[soundalike](https://codeberg.org/derat/soundalike)** by Daniel Erat — a mature Go tool using Chromaprint. Lightweight, has built-in move/delete commands. Requires external `fpcalc`, defaults to 15s fingerprints, no MusicBrainz integration. +**[soundalike](https://codeberg.org/derat/soundalike)** by Daniel Erat — a mature Go tool using Chromaprint. Lightweight, has built-in move/delete commands. Requires external `fpcalc`, defaults to 15s fingerprints, no [MusicBrainz](https://musicbrainz.org/) integration. ## Contributing diff --git a/src/main.rs b/src/main.rs index 12bf446..35e971c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,8 +168,14 @@ enum Commands { /// Start web UI server Serve { /// Address to bind to - #[arg(short, long, default_value = "0.0.0.0:8080")] + #[arg(short, long, default_value = "127.0.0.1:8080", env = "DUPSONIC_BIND")] bind: String, + + /// Allow connections from these IP addresses or CIDR ranges (repeatable). + /// When set, only matching IPs can access the server; others get 403. + /// Supports IPv4 and IPv6. Examples: 192.168.1.0/24, 10.0.0.5, ::1 + #[arg(long, env = "DUPSONIC_ALLOW_IP", value_delimiter = ',')] + allow_ip: Vec, }, } @@ -435,9 +441,24 @@ fn main() -> Result<()> { clap_complete::generate(shell, &mut cmd, "dupsonic", &mut std::io::stdout()); return Ok(()); } - Commands::Serve { bind } => { + Commands::Serve { bind, allow_ip } => { + // Parse --allow-ip values into IpNet (single IPs become /32 or /128) + let allowed_ips: Vec = allow_ip + .iter() + .map(|s| { + s.parse::().unwrap_or_else(|_| { + // Try parsing as a single IP address (no prefix) + let addr: std::net::IpAddr = s.parse().unwrap_or_else(|_| { + eprintln!("Invalid IP or CIDR: {}", s); + std::process::exit(1); + }); + ipnet::IpNet::from(addr) + }) + }) + .collect(); + let rt = tokio::runtime::Runtime::new()?; - rt.block_on(dupsonic::web::serve(db, db_path, &bind))?; + rt.block_on(dupsonic::web::serve(db, db_path, &bind, &allowed_ips))?; } } diff --git a/src/web.rs b/src/web.rs index db7ff9b..16c628a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -3,12 +3,15 @@ //! Provides a single-page web interface for scanning, viewing duplicates, //! and acting on them. Designed for headless servers (NAS, Raspberry Pi). -use axum::extract::{Query, State}; +use axum::extract::{ConnectInfo, Query, State}; use axum::http::{header, StatusCode}; -use axum::response::{Html, IntoResponse, Json}; +use axum::middleware::{self, Next}; +use axum::response::{Html, IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::Router; +use ipnet::IpNet; use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -41,7 +44,12 @@ impl Default for ScanStatus { } /// Start the web server. -pub async fn serve(db: Database, db_path: PathBuf, bind: &str) -> anyhow::Result<()> { +pub async fn serve( + db: Database, + db_path: PathBuf, + bind: &str, + allowed_ips: &[IpNet], +) -> anyhow::Result<()> { let state = Arc::new(AppState { db, db_path, @@ -49,6 +57,9 @@ pub async fn serve(db: Database, db_path: PathBuf, bind: &str) -> anyhow::Result dupes: Mutex::new(Vec::new()), }); + let allowed_ips: Arc> = Arc::new(allowed_ips.to_vec()); + let allowed_ips_for_middleware = allowed_ips.clone(); + let app = Router::new() .route("/", get(index_html)) .route("/api/status", get(api_status)) @@ -56,15 +67,34 @@ pub async fn serve(db: Database, db_path: PathBuf, bind: &str) -> anyhow::Result .route("/api/dupes", get(api_dupes)) .route("/api/action", post(api_action)) .route("/api/restore", post(api_restore)) + .layer(middleware::from_fn(move |req, next| { + let allowed = allowed_ips_for_middleware.clone(); + ip_access_control(req, next, allowed) + })) .with_state(state); let listener = tokio::net::TcpListener::bind(bind).await?; eprintln!("Web UI available at http://{}", bind); + if allowed_ips.is_empty() { + eprintln!("Access: unrestricted (use --allow-ip to restrict)"); + } else { + eprintln!( + "Access restricted to: {}", + allowed_ips + .iter() + .map(|ip| ip.to_string()) + .collect::>() + .join(", ") + ); + } eprintln!("Press Ctrl+C to stop."); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; Ok(()) } @@ -76,6 +106,48 @@ async fn shutdown_signal() { eprintln!("\nShutting down."); } +/// Middleware that restricts access based on client IP address. +/// If the allow list is empty, all connections are permitted. +/// Handles IPv4-mapped IPv6 addresses (::ffff:x.x.x.x). +async fn ip_access_control( + req: axum::extract::Request, + next: Next, + allowed_ips: Arc>, +) -> Response { + // If no restrictions configured, allow all + if allowed_ips.is_empty() { + return next.run(req).await; + } + + // Extract the client IP from ConnectInfo + let client_ip = req + .extensions() + .get::>() + .map(|ci| ci.0.ip()); + + let Some(client_ip) = client_ip else { + return (StatusCode::FORBIDDEN, "Access denied").into_response(); + }; + + // Normalize IPv4-mapped IPv6 addresses (::ffff:192.168.1.1 → 192.168.1.1) + let normalized_ip = match client_ip { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => client_ip, + }, + _ => client_ip, + }; + + // Check if the client IP is in any of the allowed ranges + let allowed = allowed_ips.iter().any(|net| net.contains(&normalized_ip)); + + if allowed { + next.run(req).await + } else { + (StatusCode::FORBIDDEN, "Access denied").into_response() + } +} + // --- API Endpoints --- async fn api_status(State(state): State>) -> Json { @@ -400,3 +472,140 @@ async fn index_html() -> impl IntoResponse { Html(include_str!("web_ui.html")), ) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use tower::ServiceExt; + + /// Build a minimal router with the IP middleware for testing. + fn test_app(allowed_ips: Vec) -> Router { + let allowed = Arc::new(allowed_ips); + + Router::new() + .route("/", get(|| async { "ok" })) + .layer(middleware::from_fn(move |req, next| { + let allowed = allowed.clone(); + ip_access_control(req, next, allowed) + })) + } + + /// Create a request with ConnectInfo set to the given IP. + fn request_from_ip(ip: IpAddr) -> Request { + let mut req = Request::builder().uri("/").body(Body::empty()).unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::new(ip, 12345))); + req + } + + #[tokio::test] + async fn test_empty_allow_list_permits_all() { + let app = test_app(vec![]); + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_allowed_ip_permitted() { + let allowed: IpNet = "192.168.1.0/24".parse().unwrap(); + let app = test_app(vec![allowed]); + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_denied_ip_gets_403() { + let allowed: IpNet = "192.168.1.0/24".parse().unwrap(); + let app = test_app(vec![allowed]); + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_exact_ip_allowed() { + // Single IP (parsed as /32) + let allowed: IpNet = "10.0.0.5/32".parse().unwrap(); + let app = test_app(vec![allowed]); + + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_exact_ip_denied_other() { + let allowed: IpNet = "10.0.0.5/32".parse().unwrap(); + let app = test_app(vec![allowed]); + + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 6))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_ipv6_allowed() { + let allowed: IpNet = "fd00::/8".parse().unwrap(); + let app = test_app(vec![allowed]); + + let req = request_from_ip(IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_ipv6_denied() { + let allowed: IpNet = "fd00::/8".parse().unwrap(); + let app = test_app(vec![allowed]); + + let req = request_from_ip(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_ipv4_mapped_ipv6_normalized() { + // Client connects as ::ffff:192.168.1.50 (IPv4-mapped IPv6) + // Allow list has 192.168.1.0/24 (IPv4) + let allowed: IpNet = "192.168.1.0/24".parse().unwrap(); + let app = test_app(vec![allowed]); + + // ::ffff:192.168.1.50 + let v4_mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0132); + let req = request_from_ip(IpAddr::V6(v4_mapped)); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_multiple_ranges_any_match() { + let allowed = vec![ + "10.0.0.0/8".parse::().unwrap(), + "172.16.0.0/12".parse::().unwrap(), + "192.168.0.0/16".parse::().unwrap(), + ]; + let app = test_app(allowed); + + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(172, 20, 5, 1))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_multiple_ranges_none_match() { + let allowed = vec![ + "10.0.0.0/8".parse::().unwrap(), + "192.168.0.0/16".parse::().unwrap(), + ]; + let app = test_app(allowed); + + let req = request_from_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } +}