diff --git a/API.md b/API.md index 4c8b738..b8a3340 100644 --- a/API.md +++ b/API.md @@ -2,6 +2,18 @@ This document provides detailed information about the Save-Rust API endpoints, including request/response schemas and error handling. +## Transport + +Save-Rust always serves its API over a Unix domain socket. + +Localhost TCP on `127.0.0.1:8080` is disabled by default. To enable it explicitly, set both `SAVE_ENABLE_TCP=1` and `SAVE_API_TOKEN=`. + +When TCP is enabled, requests to `/api/*` must include: + +```http +Authorization: Bearer +``` + ## Table of Contents - [General Endpoints](#general-endpoints) - [Groups Endpoints](#groups-endpoints) @@ -491,4 +503,4 @@ Error Response (404 Not Found): "status": "error", "error": "Group, repository, or file not found: [detailed error message]" } -``` \ No newline at end of file +``` diff --git a/Cargo.lock b/Cargo.lock index fb2ea49..fc4bbf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "iroh" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -3176,7 +3176,7 @@ dependencies = [ [[package]] name = "iroh-base" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "aead", "anyhow", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "iroh-blobs" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -3259,7 +3259,7 @@ dependencies = [ [[package]] name = "iroh-docs" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -3297,7 +3297,7 @@ dependencies = [ [[package]] name = "iroh-gossip" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "iroh-metrics" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "erased_set", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "iroh-net" version = "0.24.0" -source = "git+https://github.com/tripledoublev/iroh.git?branch=relax-hickory#b3f52f6d8bba462eebd7e3991946d844c9a75039" +source = "git+https://github.com/tripledoublev/iroh.git?rev=b3f52f6d8bba462eebd7e3991946d844c9a75039#b3f52f6d8bba462eebd7e3991946d844c9a75039" dependencies = [ "anyhow", "backoff", @@ -5750,7 +5750,7 @@ dependencies = [ [[package]] name = "save" -version = "0.2.0" +version = "0.2.1" dependencies = [ "actix-web", "anyhow", @@ -5781,8 +5781,8 @@ dependencies = [ [[package]] name = "save-dweb-backend" -version = "0.3.3" -source = "git+https://github.com/OpenArchive/save-dweb-backend?tag=v0.3.3#1eb5f492eee2c50b5e9782f69c58205a89a04233" +version = "0.3.4" +source = "git+https://github.com/OpenArchive/save-dweb-backend?tag=v0.3.4#e997584efa0a6a2ad5af116045508e8b7eef75c6" dependencies = [ "anyhow", "async-stream", @@ -7474,8 +7474,8 @@ dependencies = [ [[package]] name = "veilid-iroh-blobs" -version = "0.3.1" -source = "git+https://github.com/RangerMauve/veilid-iroh-blobs?tag=v0.3.1#e17c546c278266c1108d1ca756c9e31e03432b18" +version = "0.3.2" +source = "git+https://github.com/RangerMauve/veilid-iroh-blobs?tag=v0.3.2#9c58b16fde7c2a881dc057e0556b1ae66a51a2c2" dependencies = [ "anyhow", "bytes 1.11.1", diff --git a/Cargo.toml b/Cargo.toml index cfe869e..a3fd286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ ios = [] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -save-dweb-backend = { git = "https://github.com/OpenArchive/save-dweb-backend", tag = "v0.3.3" } +save-dweb-backend = { git = "https://github.com/OpenArchive/save-dweb-backend", tag = "v0.3.4" } tokio = { version = "^1.43", default-features = false, features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -63,10 +63,10 @@ serial_test = "2.0" # Patch iroh crates to relax hickory-resolver pin for veilid-core 0.5.2 compat. # All iroh workspace crates must come from the same source to avoid duplicate type errors. [patch.crates-io] -iroh = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-net = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-base = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-metrics = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-blobs = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-docs = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } -iroh-gossip = { git = "https://github.com/tripledoublev/iroh.git", branch = "relax-hickory" } +iroh = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-net = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-base = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-metrics = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-blobs = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-docs = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } +iroh-gossip = { git = "https://github.com/tripledoublev/iroh.git", rev = "b3f52f6d8bba462eebd7e3991946d844c9a75039" } diff --git a/README.md b/README.md index 6ebcac8..cd9b29b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,20 @@ The project temporarily patches `veilid-core` `v0.5.1` via the `[patch."https:// # API Documentation -The Save-Rust API provides HTTP endpoints for managing groups, repositories, and media files. For detailed API documentation including request/response schemas and error handling, please see [API.md](API.md). +The Save-Rust API provides endpoints for managing groups, repositories, and media files. For detailed API documentation including request/response schemas and error handling, please see [API.md](API.md). + +## Transport + +The service always listens on a Unix domain socket. + +Localhost TCP on `127.0.0.1:8080` is disabled by default. To enable it explicitly, set: + +- `SAVE_ENABLE_TCP=1` +- `SAVE_API_TOKEN=` + +When TCP is enabled, requests under `/api` must include: + +- `Authorization: Bearer ` ## Available Endpoints @@ -64,4 +77,4 @@ Base path: `/api/groups/{group_id}/repos/{repo_id}/media` * `GET /{file_name}` - Downloads a specific file from a repository. * `DELETE /{file_name}` - Deletes a specific file from a repository. -For detailed information about request/response formats, error handling, and examples, please refer to the [API Documentation](API.md). \ No newline at end of file +For detailed information about request/response formats, error handling, and examples, please refer to the [API Documentation](API.md). diff --git a/src/bin/server.rs b/src/bin/server.rs index 9eb4503..60e1d02 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -4,8 +4,8 @@ //! cargo run --bin save-server [-- ] //! //! The server listens on: -//! - HTTP: http://0.0.0.0:8080 //! - Unix socket: /save-server.sock +//! - Optional HTTP: http://127.0.0.1:8080 when SAVE_ENABLE_TCP=1 and SAVE_API_TOKEN is set //! //! Set RUST_LOG to control log verbosity, e.g.: //! RUST_LOG=debug cargo run --bin save-server @@ -35,7 +35,11 @@ async fn main() -> anyhow::Result<()> { println!("save-server v{}", env!("CARGO_PKG_VERSION")); println!(" Data directory: {base_dir}"); println!(" Unix socket: {socket_path}"); - println!(" HTTP: http://127.0.0.1:8080"); + println!(" HTTP: disabled by default"); + if std::env::var("SAVE_ENABLE_TCP").ok().as_deref() == Some("1") { + println!(" HTTP: http://127.0.0.1:8080"); + println!(" Auth: Authorization: Bearer $SAVE_API_TOKEN"); + } save::server::start(&base_dir, &socket_path).await } diff --git a/src/lib.rs b/src/lib.rs index f971951..20b2c35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,16 +20,17 @@ pub mod utils; #[cfg(test)] mod tests { use std::env; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use super::*; - use actix_web::{test, web, App}; + use actix_web::{http::header, middleware, test, web, App, HttpResponse}; use anyhow::Result; use models::{RequestName, RequestUrl, SnowbirdFile, SnowbirdGroup, SnowbirdRepo}; use save_dweb_backend::{common::DHTEntity, constants::TEST_GROUP_NAME}; use serde::{Deserialize, Serialize}; use serde_json::json; - use server::{status, health, set_backend, clear_backend}; + use server::{clear_backend, health, require_tcp_api_token, set_backend, status, TcpAuthConfig}; use tmpdir::TmpDir; use base64_url::base64; use base64_url::base64::Engine; @@ -319,6 +320,102 @@ mod tests { Ok(()) } + + #[actix_web::test] + async fn test_api_middleware_allows_requests_without_peer_addr() { + let app = test::init_service( + App::new() + .wrap(middleware::from_fn(require_tcp_api_token)) + .service( + web::scope("/api").route( + "/probe", + web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }), + ), + ), + ) + .await; + + let req = test::TestRequest::get().uri("/api/probe").to_request(); + let resp = test::call_service(&app, req).await; + + assert!(resp.status().is_success()); + } + + #[actix_web::test] + async fn test_api_middleware_rejects_tcp_requests_without_token() { + let app = test::init_service( + App::new() + .app_data(web::Data::new(TcpAuthConfig { + token: Arc::new("secret-token".to_string()), + })) + .wrap(middleware::from_fn(require_tcp_api_token)) + .service( + web::scope("/api").route( + "/probe", + web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }), + ), + ), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/probe") + .peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678)) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED); + } + + #[actix_web::test] + async fn test_api_middleware_allows_tcp_requests_with_token() { + let app = test::init_service( + App::new() + .app_data(web::Data::new(TcpAuthConfig { + token: Arc::new("secret-token".to_string()), + })) + .wrap(middleware::from_fn(require_tcp_api_token)) + .service( + web::scope("/api").route( + "/probe", + web::get().to(|| async { HttpResponse::Ok().json(json!({ "status": "ok" })) }), + ), + ), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/probe") + .peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678)) + .insert_header((header::AUTHORIZATION, "Bearer secret-token")) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert!(resp.status().is_success()); + } + + #[actix_web::test] + async fn test_non_api_routes_do_not_require_tcp_token() { + let app = test::init_service( + App::new() + .app_data(web::Data::new(TcpAuthConfig { + token: Arc::new("secret-token".to_string()), + })) + .wrap(middleware::from_fn(require_tcp_api_token)) + .service(status) + .service(health), + ) + .await; + + let req = test::TestRequest::get() + .uri("/status") + .peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 45678)) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert!(resp.status().is_success()); + } + #[actix_web::test] #[serial] async fn test_upload_list_delete() -> Result<()> { diff --git a/src/server.rs b/src/server.rs index 562ab32..18f3e3e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,7 +6,7 @@ use crate::logging::android_log; use crate::repos; use crate::{log_debug, log_error, log_info}; use actix_web::{get, post}; -use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use actix_web::{body::BoxBody, dev::ServiceRequest, middleware, web, App, HttpResponse, HttpServer, Responder}; use anyhow::{anyhow, Context, Result}; use num_cpus; use once_cell::sync::OnceCell; @@ -128,6 +128,58 @@ struct JoinGroupRequest { uri: String } +#[derive(Clone)] +pub(crate) struct TcpAuthConfig { + pub(crate) token: Arc, +} + +fn env_var_is_truthy(name: &str) -> bool { + env::var(name) + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false) +} + +pub(crate) async fn require_tcp_api_token( + req: ServiceRequest, + next: middleware::Next, +) -> Result, actix_web::Error> { + let needs_tcp_auth = req.peer_addr().is_some() && req.path().starts_with("/api"); + + if !needs_tcp_auth { + return next.call(req).await; + } + + let auth = req + .app_data::>() + .map(|cfg| cfg.token.clone()); + + let Some(expected_token) = auth else { + return Ok(req.into_response( + HttpResponse::InternalServerError().json(json!({ + "status": "error", + "error": "TCP API authentication is not configured" + })) + )); + }; + + let provided = req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + + if provided == Some(expected_token.as_str()) { + return next.call(req).await; + } + + Ok(req.into_response( + HttpResponse::Unauthorized().json(json!({ + "status": "error", + "error": "Missing or invalid API token" + })) + )) +} + #[post("memberships")] async fn join_group(body: web::Json) -> AppResult { let join_request_data = body.into_inner(); @@ -182,6 +234,17 @@ pub async fn start(backend_base_directory: &str, server_socket_path: &str) -> an let lan_address = Ipv4Addr::LOCALHOST; // 127.0.0.1 let lan_port = 8080; + let enable_tcp = env_var_is_truthy("SAVE_ENABLE_TCP"); + let tcp_auth = if enable_tcp { + let token = env::var("SAVE_API_TOKEN").context( + "SAVE_API_TOKEN must be set when SAVE_ENABLE_TCP is enabled" + )?; + Some(web::Data::new(TcpAuthConfig { + token: Arc::new(token), + })) + } else { + None + }; panic::set_hook(Box::new(|panic_info| { log_error!(TAG, "Panic occurred: {:?}", panic_info); @@ -230,8 +293,9 @@ pub async fn start(backend_base_directory: &str, server_socket_path: &str) -> an let web_server = HttpServer::new(move || { let app_start = Instant::now(); - let app = App::new() + let mut app = App::new() .wrap(RouteDumper::new(actix_log)) + .wrap(middleware::from_fn(require_tcp_api_token)) .service(status) .service(health) .service(health_ready) @@ -240,11 +304,22 @@ pub async fn start(backend_base_directory: &str, server_socket_path: &str) -> an .service(join_group) .service(groups::scope()) ); + + if let Some(tcp_auth) = tcp_auth.clone() { + app = app.app_data(tcp_auth); + } log_perf("Web server app created", app_start.elapsed()); app }) - .bind_uds(server_socket_path)? - .bind((lan_address, lan_port))? + .bind_uds(server_socket_path)?; + + let web_server = if enable_tcp { + log_info!(TAG, "TCP API enabled on {}:{} with bearer token auth", lan_address, lan_port); + web_server.bind((lan_address, lan_port))? + } else { + log_info!(TAG, "TCP API disabled; serving API on Unix domain socket only"); + web_server + } .disable_signals() .workers(worker_count);