diff --git a/README.md b/README.md index 37fa1ea..cf23a54 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,21 @@ A read-only Axum + Svelte sidecar for `itzg/docker-minecraft-server`. It streams The container runs as UID/GID `10001`, uses a read-only root filesystem, drops every capability, does not mount the Docker socket, and mounts the shared Minecraft volume at `/data:ro`. +The Compose example bind-mounts the host directory `log-viewer-config` and reads +`log-viewer-config/ip-whitelist.txt`. Put one IP address or CIDR on each line +(`#` comments are supported) to grant an explicit owner override. Mount the +directory rather than the individual file: editors commonly save by replacing a +file, and an individual Docker file bind mount remains attached to the replaced +inode. The file is read after normal player authorization fails on every access +attempt, so edits take effect without restarting the viewer. An unreadable or +malformed file grants no override. + +With `RUST_LOG=minecraft_log_viewer=debug,tower_http=info`, each API access logs +the effective client IP and authorization source. Override checks also log the +configured path, complete parsed IP/CIDR list, match/miss result, and read or +parse errors. The complete set of IPs authorized through successful joins and +the current Minecraft whitelist is logged as `allowed_player_ips`. + Example Caddy integration: ```caddyfile @@ -27,7 +42,7 @@ Only successful joins in the current `latest.log` count. Failed, banned, rejecte ## Configuration -Required: `PUBLIC_ORIGIN`. Paths default to `/data`, `/data/logs`, `/data/logs/latest.log`, and `/data/whitelist.json`. The Compose example documents proxy and redaction settings. Resource limits are configurable with `INITIAL_LOG_LINES`, `MAX_HISTORY_LINES_PER_REQUEST`, `ARCHIVE_CACHE_MAX_BYTES`, `ARCHIVE_CACHE_MAX_FILES`, `MAX_ARCHIVE_DECOMPRESSED_BYTES`, `WS_CLIENT_QUEUE_CAPACITY`, and `MAX_WS_CONNECTIONS`. +Required: `PUBLIC_ORIGIN`. Paths default to `/data`, `/data/logs`, `/data/logs/latest.log`, and `/data/whitelist.json`. Set optional `IP_WHITELIST_FILE` to a mounted owner-managed IP/CIDR file. The Compose example documents proxy and redaction settings. Resource limits are configurable with `INITIAL_LOG_LINES`, `MAX_HISTORY_LINES_PER_REQUEST`, `ARCHIVE_CACHE_MAX_BYTES`, `ARCHIVE_CACHE_MAX_FILES`, `MAX_ARCHIVE_DECOMPRESSED_BYTES`, `WS_CLIENT_QUEUE_CAPACITY`, and `MAX_WS_CONNECTIONS`. Malformed startup configuration or an unreadable initial whitelist fails closed. Later malformed whitelist updates retain the last valid snapshot. diff --git a/compose.example.yml b/compose.example.yml index 9566532..4f3b6da 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -22,12 +22,15 @@ services: - /tmp:size=16m,mode=1777 volumes: - minecraft-data:/data:ro + # Mount the directory so editor atomic saves are visible inside the container. + - ./log-viewer-config:/config:ro environment: LISTEN_ADDR: 0.0.0.0:8080 MC_DATA_DIR: /data MC_LOG_DIR: /data/logs MC_LATEST_LOG: /data/logs/latest.log MC_WHITELIST: /data/whitelist.json + IP_WHITELIST_FILE: /config/ip-whitelist.txt PUBLIC_ORIGIN: https://logs.example.com TRUST_PROXY: "true" TRUSTED_PROXY_CIDRS: 172.20.0.0/16 diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 239e2c7..932f149 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,7 +1,7 @@ -{#if state==='Access denied'}

Access denied

This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.

{:else}
{state}
{lines.length.toLocaleString()} lines retained
{#if loading}
Loading older logs…
{/if}{#each lines as line (line.id)}
{line.timestamp??''}{line.text}
{/each}
{#if unseen}{/if}
{/if} +{#if state==='Access denied'}

Access denied

This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.

{#if clientIp}
Your IP{#if showIp}{clientIp}{:else}{/if}
{/if}
{:else}
{state}
{lines.length.toLocaleString()} lines retained
{#if loading}
Loading older logs…
{/if}{#each lines as line (line.id)}
{line.timestamp??''}{line.text}
{/each}
{#if unseen}{/if}
{/if} diff --git a/frontend/src/style.css b/frontend/src/style.css index 93d56b0..6266965 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1 +1 @@ -:root{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#dce5df;background:#0b0e0c;font-synthesis:none}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{height:100dvh;display:flex;flex-direction:column;background:radial-gradient(circle at 80% -20%,#193226 0,transparent 38%),#0b0e0c}header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid #26322b;color:#8e9c93;font-size:12px}header div{display:flex;gap:9px;align-items:center;color:#dce5df}.dot{width:8px;height:8px;border-radius:50%;background:#b07847;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{flex:1;overflow:auto;padding:12px 0;scrollbar-color:#344139 transparent}.line{display:grid;grid-template-columns:88px minmax(max-content,1fr);gap:14px;padding:2px 18px;min-height:22px;line-height:18px;font-size:13px;white-space:pre}.line:hover{background:#ffffff08}.time{color:#60766a;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{position:fixed;right:22px;bottom:22px;border:1px solid #537962;border-radius:18px;background:#193226;color:#dce5df;padding:9px 14px;font:inherit;cursor:pointer}.denied{justify-content:center;align-items:center;padding:24px;text-align:center}.denied h1{font:500 28px system-ui;margin:0 0 12px}.denied p{font:14px/1.6 system-ui;color:#9ba9a0;max-width:580px}@media(max-width:600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}} +:root{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#dce5df;background:#0b0e0c;font-synthesis:none}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{height:100dvh;display:flex;flex-direction:column;background:radial-gradient(circle at 80% -20%,#193226 0,transparent 38%),#0b0e0c}header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid #26322b;color:#8e9c93;font-size:12px}header div{display:flex;gap:9px;align-items:center;color:#dce5df}.dot{width:8px;height:8px;border-radius:50%;background:#b07847;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{flex:1;overflow:auto;padding:12px 0;scrollbar-color:#344139 transparent}.line{display:grid;grid-template-columns:88px minmax(max-content,1fr);gap:14px;padding:2px 18px;min-height:22px;line-height:18px;font-size:13px;white-space:pre}.line:hover{background:#ffffff08}.time{color:#60766a;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{position:fixed;right:22px;bottom:22px;border:1px solid #537962;border-radius:18px;background:#193226;color:#dce5df;padding:9px 14px;font:inherit;cursor:pointer}.denied{justify-content:center;align-items:center;padding:24px;text-align:center}.denied-card{width:min(100%,620px);padding:40px;border:1px solid #29362f;border-radius:12px;background:#101512;box-shadow:0 20px 60px #0006}.denied-icon{display:grid;place-items:center;width:44px;height:44px;margin:0 auto 18px;border:1px solid #9a6337;border-radius:50%;color:#e5a364;font:600 22px system-ui;background:#9a633719}.denied h1{font:500 28px system-ui;margin:0 0 12px}.denied p{font:14px/1.6 system-ui;color:#9ba9a0;max-width:580px;margin:0 auto}.ip-row{display:flex;align-items:center;justify-content:space-between;min-height:54px;margin-top:28px;padding-top:20px;border-top:1px solid #29362f;color:#829188;font-size:12px;text-align:left}.ip-row code{color:#dce5df;font:13px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.ip-row .show-ip{position:static;border:0;border-radius:4px;background:transparent;color:#72a7e8;padding:6px 0;text-decoration:underline;text-underline-offset:3px}.ip-row .show-ip:hover{color:#9bc1f0}@media(max-width:600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}.denied-card{padding:30px 22px}} diff --git a/src/auth/ip_whitelist.rs b/src/auth/ip_whitelist.rs new file mode 100644 index 0000000..b464176 --- /dev/null +++ b/src/auth/ip_whitelist.rs @@ -0,0 +1,85 @@ +use ipnet::IpNet; +use std::{io, net::IpAddr, path::Path}; + +#[derive(Debug, PartialEq)] +pub struct CheckResult { + pub matched: bool, + pub entries: Vec, +} + +/// Reads and evaluates the owner-managed IP whitelist on every call so a +/// read-only bind mount can be updated without restarting the service. +pub fn check(path: &Path, ip: IpAddr) -> io::Result { + let contents = std::fs::read_to_string(path)?; + let mut networks = Vec::new(); + for (index, raw) in contents.lines().enumerate() { + let value = raw.split('#').next().unwrap_or("").trim(); + if value.is_empty() { + continue; + } + networks.push(parse_entry(value).map_err(|message| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("line {}: {message}", index + 1), + ) + })?); + } + Ok(CheckResult { + matched: networks.iter().any(|network| network.contains(&ip)), + entries: networks, + }) +} + +fn parse_entry(value: &str) -> Result { + if value.contains('/') { + value.parse::().map_err(|error| error.to_string()) + } else { + value + .parse::() + .map(IpNet::from) + .map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_addresses_cidrs_comments_and_live_edits() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ips.txt"); + std::fs::write( + &path, + "# owner overrides\n100.64.0.7\n192.0.2.0/24 # office\n", + ) + .unwrap(); + assert!(check(&path, "100.64.0.7".parse().unwrap()).unwrap().matched); + assert!(check(&path, "192.0.2.25".parse().unwrap()).unwrap().matched); + assert!( + !check(&path, "203.0.113.9".parse().unwrap()) + .unwrap() + .matched + ); + + std::fs::write(&path, "203.0.113.9\n").unwrap(); + assert!( + check(&path, "203.0.113.9".parse().unwrap()) + .unwrap() + .matched + ); + } + + #[test] + fn malformed_file_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ips.txt"); + std::fs::write(&path, "127.0.0.1\nnot-an-ip\n").unwrap(); + assert_eq!( + check(&path, "127.0.0.1".parse().unwrap()) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index a17deb1..c27bbc1 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,5 @@ pub mod client_ip; +pub mod ip_whitelist; pub mod join_tracker; pub mod whitelist; @@ -39,6 +40,20 @@ impl AuthorizationSnapshot { pub fn is_empty(&self) -> bool { self.by_ip.is_empty() } + pub fn allowed_ips(&self, whitelist: &Whitelist) -> Vec { + let mut ips = self + .by_ip + .iter() + .filter_map(|(ip, records)| { + records + .iter() + .any(|record| whitelist.contains(record.uuid.as_deref(), &record.name)) + .then_some(*ip) + }) + .collect::>(); + ips.sort(); + ips + } } #[derive(Clone)] @@ -110,6 +125,11 @@ impl AuthorizationService { pub fn rescan_count(&self) -> u64 { self.rescan_count.load(Ordering::Relaxed) } + pub async fn allowed_ips(&self) -> Vec { + let snapshot = self.snapshot.read().await; + let whitelist = self.whitelist.read().await; + snapshot.allowed_ips(&whitelist) + } async fn check(&self, ip: IpAddr) -> bool { let snapshot = self.snapshot.read().await; let whitelist = self.whitelist.read().await; diff --git a/src/config.rs b/src/config.rs index b2934b4..b770854 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ pub struct Config { pub log_dir: PathBuf, pub latest_log: PathBuf, pub whitelist: PathBuf, + pub ip_whitelist_file: Option, pub trust_proxy: bool, pub trusted_proxy_cidrs: Vec, pub client_ip_header: String, @@ -37,6 +38,9 @@ impl Config { let log_dir = path("MC_LOG_DIR", data_dir.join("logs")); let latest_log = path("MC_LATEST_LOG", log_dir.join("latest.log")); let whitelist = path("MC_WHITELIST", data_dir.join("whitelist.json")); + let ip_whitelist_file = env::var_os("IP_WHITELIST_FILE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); let trust_proxy = boolean("TRUST_PROXY", false)?; let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128") .into_iter() @@ -67,6 +71,7 @@ impl Config { log_dir, latest_log, whitelist, + ip_whitelist_file, trust_proxy, trusted_proxy_cidrs, client_ip_header: env::var("CLIENT_IP_HEADER") diff --git a/src/http/mod.rs b/src/http/mod.rs index afa4673..235ee9a 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,5 +1,5 @@ use crate::{ - auth::{client_ip::ClientIpPolicy, AuthorizationService}, + auth::{client_ip::ClientIpPolicy, ip_whitelist, AuthorizationService}, config::Config, logs::{follower::LiveMessage, index::HistoryStore}, }; @@ -142,17 +142,101 @@ async fn authorize( peer: SocketAddr, headers: &HeaderMap, ) -> Result { - validate_origin(&state.config.public_origin, headers) - .map_err(|_| error(StatusCode::FORBIDDEN, "origin_denied"))?; + validate_origin(&state.config.public_origin, headers).map_err(|_| { + tracing::debug!( + event = "authorization_decision", + result = "denied", + reason = "origin_denied", + peer_ip = %peer.ip() + ); + error(StatusCode::FORBIDDEN, "origin_denied") + })?; let ip = state .ip_policy .extract(peer.ip(), headers) - .map_err(|_| error(StatusCode::BAD_REQUEST, "invalid_client_ip"))?; + .map_err(|extract_error| { + tracing::debug!( + event = "authorization_decision", + result = "denied", + reason = "invalid_client_ip", + peer_ip = %peer.ip(), + error = %extract_error + ); + error(StatusCode::BAD_REQUEST, "invalid_client_ip") + })?; if state.auth.is_allowed(ip).await { - Ok(ip) - } else { - Err(error(StatusCode::FORBIDDEN, "access_denied")) + let allowed_player_ips = state.auth.allowed_ips().await; + tracing::debug!( + event = "authorization_decision", + result = "allowed", + source = "player_join", + client_ip = %ip, + peer_ip = %peer.ip(), + ?allowed_player_ips + ); + return Ok(ip); } + let allowed_player_ips = state.auth.allowed_ips().await; + if let Some(path) = state.config.ip_whitelist_file.clone() { + let log_path = path.clone(); + let result = tokio::task::spawn_blocking(move || ip_whitelist::check(&path, ip)).await; + match result { + Ok(Ok(check)) if check.matched => { + tracing::debug!( + event = "authorization_decision", + result = "allowed", + source = "ip_whitelist_file", + client_ip = %ip, + peer_ip = %peer.ip(), + path = %log_path.display(), + allowed_override_networks = ?check.entries, + ?allowed_player_ips + ); + return Ok(ip); + } + Ok(Ok(check)) => tracing::debug!( + event = "ip_whitelist_checked", + result = "no_match", + client_ip = %ip, + path = %log_path.display(), + allowed_override_networks = ?check.entries, + ?allowed_player_ips + ), + Ok(Err(error)) => tracing::warn!( + event = "ip_whitelist_read_failed", + client_ip = %ip, + path = %log_path.display(), + ?allowed_player_ips, + %error + ), + Err(error) => tracing::warn!( + event = "ip_whitelist_check_failed", + client_ip = %ip, + path = %log_path.display(), + ?allowed_player_ips, + %error + ), + } + } else { + tracing::debug!( + event = "ip_whitelist_disabled", + client_ip = %ip, + ?allowed_player_ips + ); + } + tracing::debug!( + event = "authorization_decision", + result = "denied", + reason = "no_authorization_match", + client_ip = %ip, + peer_ip = %peer.ip(), + ?allowed_player_ips + ); + Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"error":"access_denied", "client_ip":ip})), + ) + .into_response()) } fn validate_origin(expected: &str, headers: &HeaderMap) -> Result<(), ()> { if let Some(origin) = headers.get(header::ORIGIN) { diff --git a/src/main.rs b/src/main.rs index a4e8db8..45b2f99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,7 +62,17 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(config.listen_addr) .await .context("HTTP bind failed")?; - tracing::info!(event="application_startup",listen_addr=%config.listen_addr); + let ip_whitelist_file = config + .ip_whitelist_file + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "disabled".into()); + tracing::info!( + event = "application_startup", + listen_addr = %config.listen_addr, + trust_proxy = config.trust_proxy, + ip_whitelist_file + ); axum::serve( listener, http::router(state).into_make_service_with_connect_info::(), diff --git a/tests/http_security.rs b/tests/http_security.rs index 25d4b61..137226d 100644 --- a/tests/http_security.rs +++ b/tests/http_security.rs @@ -36,6 +36,7 @@ async fn app() -> ( log_dir: logs.clone(), latest_log: latest.clone(), whitelist: whitelist_path, + ip_whitelist_file: Some(dir.path().join("ip-whitelist.txt")), trust_proxy: true, trusted_proxy_cidrs: vec![ "10.0.0.0/8".parse().unwrap(), @@ -52,6 +53,7 @@ async fn app() -> ( max_ws_connections: 2, redact_player_ips: true, }); + std::fs::write(dir.path().join("ip-whitelist.txt"), "").unwrap(); let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap()); auth.rebuild().await.unwrap(); let history = HistoryStore::new(HistoryIndex::scan(&logs).unwrap(), 1024, 2, 4096, true); @@ -135,6 +137,39 @@ async fn untrusted_peer_cannot_spoof_an_authorized_forwarded_ip() { assert_eq!(response.status(), StatusCode::FORBIDDEN); } +#[tokio::test] +async fn denied_response_reports_the_ip_used_for_authorization() { + let (app, _dir, _tx) = app().await; + let mut request = request("127.0.0.1:5000"); + request + .headers_mut() + .insert("x-forwarded-for", "100.64.0.27".parse().unwrap()); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), 8192) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["client_ip"], + "100.64.0.27" + ); +} + +#[tokio::test] +async fn owner_ip_whitelist_is_a_hot_reloaded_final_fallback() { + let (app, dir, _tx) = app().await; + let peer = "100.64.0.27:5000"; + assert_eq!( + app.clone().oneshot(request(peer)).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + std::fs::write(dir.path().join("ip-whitelist.txt"), "100.64.0.27\n").unwrap(); + assert_eq!( + app.oneshot(request(peer)).await.unwrap().status(), + StatusCode::OK + ); +} + #[tokio::test] async fn unexpected_browser_origin_is_rejected() { let (app, _dir, _tx) = app().await;