diff --git a/crates/archivr-server/src/main.rs b/crates/archivr-server/src/main.rs index ab7366b..2e1b31b 100644 --- a/crates/archivr-server/src/main.rs +++ b/crates/archivr-server/src/main.rs @@ -63,6 +63,6 @@ async fn main() -> Result<()> { let listener = tokio::net::TcpListener::bind(addr).await?; println!("archivr-server listening on http://{addr}"); - axum::serve(listener, app).await?; + axum::serve(listener, app.into_make_service_with_connect_info::()).await?; Ok(()) } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 18f12ff..1d074f4 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -21,7 +21,7 @@ use std::{ collections::{HashMap, VecDeque}, - net::IpAddr, + net::{IpAddr, SocketAddr}, path::PathBuf, sync::Arc, time::{Duration, Instant}, @@ -31,7 +31,7 @@ use parking_lot::Mutex; use archivr_core::{archive, capture, database}; use axum::{ Json, Router, - extract::{Path, Query, Request, State}, + extract::{ConnectInfo, Path, Query, Request, State}, http::StatusCode, middleware::Next, response::{IntoResponse, Response}, @@ -165,12 +165,39 @@ async fn login_rate_limit( } fn extract_client_ip(req: &Request) -> IpAddr { - req.headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(IpAddr::from([127, 0, 0, 1])) + // Attempt to read the real peer address injected by + // `into_make_service_with_connect_info` in main.rs. + let peer_ip: Option = req + .extensions() + .get::>() + .map(|ci| ci.0.ip()); + + match peer_ip { + // Peer is a loopback address → the connection came from a local + // reverse proxy (nginx/caddy on the same host). Trust the first + // address in X-Forwarded-For as the real client IP. + Some(peer) if peer.is_loopback() => req + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(peer), + + // Peer is a real address → use it directly; ignoring X-Forwarded-For + // prevents header-spoofing attacks. + Some(peer) => peer, + + // No ConnectInfo present (unit tests using .oneshot() without a real + // socket). Fall back to XFF for test compatibility. + None => req + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(IpAddr::from([127, 0, 0, 1])), + } } /// Build the Axum router from a pre-constructed `AppState`. diff --git a/docs/README.md b/docs/README.md index 1df2eed..5efdf7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -154,7 +154,8 @@ enable the service: { services.archivr-server = { enable = true; - bind = "127.0.0.1:8080"; # loopback only; put nginx/caddy in front for TLS + # listenAddress defaults to "127.0.0.1" (loopback only) + # port defaults to 8080 archives = [ { id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; } { id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; } @@ -180,7 +181,8 @@ Only needed when binding to a non-loopback address: ```nix services.archivr-server = { - bind = "0.0.0.0:8080"; + listenAddress = "0.0.0.0"; + port = 8080; # explicit, though 8080 is the default openFirewall = true; }; ``` diff --git a/modules/nixos/archivr-server.nix b/modules/nixos/archivr-server.nix index 05aebfb..1c26c36 100644 --- a/modules/nixos/archivr-server.nix +++ b/modules/nixos/archivr-server.nix @@ -6,10 +6,13 @@ let cfg = config.services.archivr-server; + # Derived bind string from separate address + port options. + bindStr = "${cfg.listenAddress}:${toString cfg.port}"; + # Generate the TOML registry file from NixOS options. # The auth DB is pinned to the StateDirectory so it survives upgrades. configFile = pkgs.writeText "archivr-server.toml" '' - bind = "${cfg.bind}" + bind = "${bindStr}" auth_db_path = "/var/lib/archivr-server/archivr-auth.sqlite" ${lib.concatMapStrings (a: '' @@ -32,17 +35,24 @@ in description = "The archivr-server package to use."; }; - bind = lib.mkOption { + listenAddress = lib.mkOption { type = lib.types.str; - default = "127.0.0.1:8080"; - example = "0.0.0.0:8080"; + default = "127.0.0.1"; + example = "0.0.0.0"; description = '' - Address and port to listen on. Defaults to loopback only. - Binding to a non-loopback address exposes the server on the network; - put a reverse proxy with TLS and authentication in front before doing so. + IP address to listen on. Defaults to loopback only (127.0.0.1). + Set to {code}`0.0.0.0` (or a specific interface address) to expose + the server on the network. Always put a reverse proxy with TLS + in front before doing this. ''; }; + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "TCP port to listen on."; + }; + archives = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { @@ -59,7 +69,9 @@ in path = lib.mkOption { type = lib.types.str; example = "/srv/archivr/personal/.archivr"; - description = "Absolute path to the .archivr directory created by {command}`archivr init`."; + description = ''Absolute path to the .archivr directory created by {command}`archivr init`. + The parent directory (which also contains the {file}`store/` blob directory) + is whitelisted for read-write access under systemd hardening.''; }; }; }); @@ -92,9 +104,10 @@ in type = lib.types.bool; default = false; description = '' - Open the firewall TCP port derived from - {option}`services.archivr-server.bind`. Only meaningful when binding - to a non-loopback address. + Open the firewall TCP port specified by + {option}`services.archivr-server.port`. Only meaningful when + {option}`services.archivr-server.listenAddress` is set to a + non-loopback address. ''; }; }; @@ -134,11 +147,14 @@ in RestartSec = "5s"; # Hardening — make the entire FS read-only except for the state - # directory and the mounted archive directories. + # directory and the parent directory of each mounted archive. + # Each archive_path is an .archivr dir; its sibling store/ dir (where + # capture artifacts are written) lives at the same level. Whitelisting + # the parent covers both without over-permissioning. NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; - ReadWritePaths = [ "/var/lib/archivr-server" ] ++ (map (a: a.path) cfg.archives); + ReadWritePaths = [ "/var/lib/archivr-server" ] ++ (map (a: builtins.dirOf a.path) cfg.archives); RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; LockPersonality = true; RestrictNamespaces = true; @@ -148,9 +164,7 @@ in }; networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ - (lib.toInt (lib.last (lib.splitString ":" cfg.bind))) - ]; + allowedTCPPorts = [ cfg.port ]; }; }; }