ip whitelist

This commit is contained in:
2026-08-11 02:02:39 +08:00
parent 6e0b0c92d1
commit b62c74b883
10 changed files with 270 additions and 13 deletions
+16 -1
View File
@@ -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 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: Example Caddy integration:
```caddyfile ```caddyfile
@@ -27,7 +42,7 @@ Only successful joins in the current `latest.log` count. Failed, banned, rejecte
## Configuration ## 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. Malformed startup configuration or an unreadable initial whitelist fails closed. Later malformed whitelist updates retain the last valid snapshot.
+3
View File
@@ -22,12 +22,15 @@ services:
- /tmp:size=16m,mode=1777 - /tmp:size=16m,mode=1777
volumes: volumes:
- minecraft-data:/data:ro - minecraft-data:/data:ro
# Mount the directory so editor atomic saves are visible inside the container.
- ./log-viewer-config:/config:ro
environment: environment:
LISTEN_ADDR: 0.0.0.0:8080 LISTEN_ADDR: 0.0.0.0:8080
MC_DATA_DIR: /data MC_DATA_DIR: /data
MC_LOG_DIR: /data/logs MC_LOG_DIR: /data/logs
MC_LATEST_LOG: /data/logs/latest.log MC_LATEST_LOG: /data/logs/latest.log
MC_WHITELIST: /data/whitelist.json MC_WHITELIST: /data/whitelist.json
IP_WHITELIST_FILE: /config/ip-whitelist.txt
PUBLIC_ORIGIN: https://logs.example.com PUBLIC_ORIGIN: https://logs.example.com
TRUST_PROXY: "true" TRUST_PROXY: "true"
TRUSTED_PROXY_CIDRS: 172.20.0.0/16 TRUSTED_PROXY_CIDRS: 172.20.0.0/16
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from 'svelte';import {backoff,mergeLines,type LogLine,type Page} from './lib'; import {onMount} from 'svelte';import {backoff,mergeLines,type LogLine,type Page} from './lib';
let lines:LogLine[]=[];let before:string|null=null;let hasMore=true;let state:'Connecting'|'Live'|'Reconnecting'|'Disconnected'|'Access denied'='Connecting';let viewport:HTMLDivElement;let atBottom=true;let unseen=0;let loading=false;let socket:WebSocket|null=null;let stopped=false; let lines:LogLine[]=[];let before:string|null=null;let hasMore=true;let state:'Connecting'|'Live'|'Reconnecting'|'Disconnected'|'Access denied'='Connecting';let viewport:HTMLDivElement;let atBottom=true;let unseen=0;let loading=false;let socket:WebSocket|null=null;let stopped=false;let clientIp:string|null=null;let showIp=false;
async function fetchPage(cursor:string|null,limit=1000){const endpoint=cursor?`/api/logs/history?before=${encodeURIComponent(cursor)}&limit=${limit}`:`/api/logs/recent?limit=${limit}`;const response=await fetch(endpoint);if(response.status===403){state='Access denied';throw new Error('denied')}if(!response.ok)throw new Error('history');return response.json() as Promise<Page>} async function fetchPage(cursor:string|null,limit=1000){const endpoint=cursor?`/api/logs/history?before=${encodeURIComponent(cursor)}&limit=${limit}`:`/api/logs/recent?limit=${limit}`;const response=await fetch(endpoint);if(response.status===403){const denial=await response.json().catch(()=>null) as {client_ip?:string}|null;clientIp=denial?.client_ip??null;state='Access denied';throw new Error('denied')}if(!response.ok)throw new Error('history');return response.json() as Promise<Page>}
async function initial(){const page=await fetchPage(null);lines=mergeLines([],page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(scrollLive);} async function initial(){const page=await fetchPage(null);lines=mergeLines([],page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(scrollLive);}
async function older(){if(loading||!hasMore||!before)return;loading=true;const oldHeight=viewport.scrollHeight;try{const page=await fetchPage(before);const ids=new Set(lines.map(x=>x.id));lines=[...page.lines.filter(x=>!ids.has(x.id)),...lines];before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>viewport.scrollTop+=viewport.scrollHeight-oldHeight);}finally{loading=false}} async function older(){if(loading||!hasMore||!before)return;loading=true;const oldHeight=viewport.scrollHeight;try{const page=await fetchPage(before);const ids=new Set(lines.map(x=>x.id));lines=[...page.lines.filter(x=>!ids.has(x.id)),...lines];before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>viewport.scrollTop+=viewport.scrollHeight-oldHeight);}finally{loading=false}}
function connect(attempt=0){if(stopped||state==='Access denied')return;state=attempt?'Reconnecting':'Connecting';const scheme=location.protocol==='https:'?'wss':'ws';socket=new WebSocket(`${scheme}://${location.host}/api/live`);socket.onopen=async()=>{state='Live';try{const gap=await fetchPage(null,1000);lines=mergeLines(lines,gap.lines);if(atBottom)requestAnimationFrame(scrollLive)}catch{}};socket.onmessage=(event)=>{const message=JSON.parse(event.data);if(message.type==='log_lines'){lines=mergeLines(lines,message.lines);if(atBottom)requestAnimationFrame(scrollLive);else unseen+=message.lines.length}if(message.type==='resync_required')socket?.close()};socket.onclose=()=>{if(stopped||state==='Access denied')return;state='Reconnecting';setTimeout(()=>connect(attempt+1),backoff(attempt))};socket.onerror=()=>socket?.close()} function connect(attempt=0){if(stopped||state==='Access denied')return;state=attempt?'Reconnecting':'Connecting';const scheme=location.protocol==='https:'?'wss':'ws';socket=new WebSocket(`${scheme}://${location.host}/api/live`);socket.onopen=async()=>{state='Live';try{const gap=await fetchPage(null,1000);lines=mergeLines(lines,gap.lines);if(atBottom)requestAnimationFrame(scrollLive)}catch{}};socket.onmessage=(event)=>{const message=JSON.parse(event.data);if(message.type==='log_lines'){lines=mergeLines(lines,message.lines);if(atBottom)requestAnimationFrame(scrollLive);else unseen+=message.lines.length}if(message.type==='resync_required')socket?.close()};socket.onclose=()=>{if(stopped||state==='Access denied')return;state='Reconnecting';setTimeout(()=>connect(attempt+1),backoff(attempt))};socket.onerror=()=>socket?.close()}
@@ -9,4 +9,4 @@ function scrollLive(){viewport?.scrollTo({top:viewport.scrollHeight});unseen=0}
function scroll(){atBottom=viewport.scrollHeight-viewport.scrollTop-viewport.clientHeight<40;if(atBottom)unseen=0;if(viewport.scrollTop<200)older()} function scroll(){atBottom=viewport.scrollHeight-viewport.scrollTop-viewport.clientHeight<40;if(atBottom)unseen=0;if(viewport.scrollTop<200)older()}
onMount(()=>{initial().then(()=>connect()).catch(()=>{});return()=>{stopped=true;socket?.close()}}) onMount(()=>{initial().then(()=>connect()).catch(()=>{});return()=>{stopped=true;socket?.close()}})
</script> </script>
{#if state==='Access denied'}<main class="denied"><h1>Access denied</h1><p>This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.</p></main>{:else}<main><header><div><span class:live={state==='Live'} class="dot"></span><strong>{state}</strong></div><span>{lines.length.toLocaleString()} lines retained</span></header><!-- svelte-ignore a11y_no_noninteractive_tabindex --><div class="log" bind:this={viewport} on:scroll={scroll} tabindex="0" role="log" aria-live="off" aria-label="Minecraft server log">{#if loading}<div class="loading">Loading older logs…</div>{/if}{#each lines as line (line.id)}<div class="line"><span class="time">{line.timestamp??''}</span><span>{line.text}</span></div>{/each}</div>{#if unseen}<button on:click={scrollLive}>{unseen} new {unseen===1?'line':'lines'} ↓</button>{/if}</main>{/if} {#if state==='Access denied'}<main class="denied"><section class="denied-card"><div class="denied-icon" aria-hidden="true">!</div><h1>Access denied</h1><p>This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.</p>{#if clientIp}<div class="ip-row"><span>Your IP</span>{#if showIp}<code>{clientIp}</code>{:else}<button class="show-ip" on:click={()=>showIp=true}>Show IP</button>{/if}</div>{/if}</section></main>{:else}<main><header><div><span class:live={state==='Live'} class="dot"></span><strong>{state}</strong></div><span>{lines.length.toLocaleString()} lines retained</span></header><!-- svelte-ignore a11y_no_noninteractive_tabindex --><div class="log" bind:this={viewport} on:scroll={scroll} tabindex="0" role="log" aria-live="off" aria-label="Minecraft server log">{#if loading}<div class="loading">Loading older logs…</div>{/if}{#each lines as line (line.id)}<div class="line"><span class="time">{line.timestamp??''}</span><span>{line.text}</span></div>{/each}</div>{#if unseen}<button on:click={scrollLive}>{unseen} new {unseen===1?'line':'lines'} ↓</button>{/if}</main>{/if}
+1 -1
View File
@@ -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}}
+85
View File
@@ -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<IpNet>,
}
/// 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<CheckResult> {
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<IpNet, String> {
if value.contains('/') {
value.parse::<IpNet>().map_err(|error| error.to_string())
} else {
value
.parse::<IpAddr>()
.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
);
}
}
+20
View File
@@ -1,4 +1,5 @@
pub mod client_ip; pub mod client_ip;
pub mod ip_whitelist;
pub mod join_tracker; pub mod join_tracker;
pub mod whitelist; pub mod whitelist;
@@ -39,6 +40,20 @@ impl AuthorizationSnapshot {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.by_ip.is_empty() self.by_ip.is_empty()
} }
pub fn allowed_ips(&self, whitelist: &Whitelist) -> Vec<IpAddr> {
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::<Vec<_>>();
ips.sort();
ips
}
} }
#[derive(Clone)] #[derive(Clone)]
@@ -110,6 +125,11 @@ impl AuthorizationService {
pub fn rescan_count(&self) -> u64 { pub fn rescan_count(&self) -> u64 {
self.rescan_count.load(Ordering::Relaxed) self.rescan_count.load(Ordering::Relaxed)
} }
pub async fn allowed_ips(&self) -> Vec<IpAddr> {
let snapshot = self.snapshot.read().await;
let whitelist = self.whitelist.read().await;
snapshot.allowed_ips(&whitelist)
}
async fn check(&self, ip: IpAddr) -> bool { async fn check(&self, ip: IpAddr) -> bool {
let snapshot = self.snapshot.read().await; let snapshot = self.snapshot.read().await;
let whitelist = self.whitelist.read().await; let whitelist = self.whitelist.read().await;
+5
View File
@@ -9,6 +9,7 @@ pub struct Config {
pub log_dir: PathBuf, pub log_dir: PathBuf,
pub latest_log: PathBuf, pub latest_log: PathBuf,
pub whitelist: PathBuf, pub whitelist: PathBuf,
pub ip_whitelist_file: Option<PathBuf>,
pub trust_proxy: bool, pub trust_proxy: bool,
pub trusted_proxy_cidrs: Vec<IpNet>, pub trusted_proxy_cidrs: Vec<IpNet>,
pub client_ip_header: String, pub client_ip_header: String,
@@ -37,6 +38,9 @@ impl Config {
let log_dir = path("MC_LOG_DIR", data_dir.join("logs")); let log_dir = path("MC_LOG_DIR", data_dir.join("logs"));
let latest_log = path("MC_LATEST_LOG", log_dir.join("latest.log")); let latest_log = path("MC_LATEST_LOG", log_dir.join("latest.log"));
let whitelist = path("MC_WHITELIST", data_dir.join("whitelist.json")); 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 trust_proxy = boolean("TRUST_PROXY", false)?;
let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128") let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128")
.into_iter() .into_iter()
@@ -67,6 +71,7 @@ impl Config {
log_dir, log_dir,
latest_log, latest_log,
whitelist, whitelist,
ip_whitelist_file,
trust_proxy, trust_proxy,
trusted_proxy_cidrs, trusted_proxy_cidrs,
client_ip_header: env::var("CLIENT_IP_HEADER") client_ip_header: env::var("CLIENT_IP_HEADER")
+91 -7
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
auth::{client_ip::ClientIpPolicy, AuthorizationService}, auth::{client_ip::ClientIpPolicy, ip_whitelist, AuthorizationService},
config::Config, config::Config,
logs::{follower::LiveMessage, index::HistoryStore}, logs::{follower::LiveMessage, index::HistoryStore},
}; };
@@ -142,17 +142,101 @@ async fn authorize(
peer: SocketAddr, peer: SocketAddr,
headers: &HeaderMap, headers: &HeaderMap,
) -> Result<IpAddr, Response> { ) -> Result<IpAddr, Response> {
validate_origin(&state.config.public_origin, headers) validate_origin(&state.config.public_origin, headers).map_err(|_| {
.map_err(|_| error(StatusCode::FORBIDDEN, "origin_denied"))?; tracing::debug!(
event = "authorization_decision",
result = "denied",
reason = "origin_denied",
peer_ip = %peer.ip()
);
error(StatusCode::FORBIDDEN, "origin_denied")
})?;
let ip = state let ip = state
.ip_policy .ip_policy
.extract(peer.ip(), headers) .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 { if state.auth.is_allowed(ip).await {
Ok(ip) let allowed_player_ips = state.auth.allowed_ips().await;
} else { tracing::debug!(
Err(error(StatusCode::FORBIDDEN, "access_denied")) 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<(), ()> { fn validate_origin(expected: &str, headers: &HeaderMap) -> Result<(), ()> {
if let Some(origin) = headers.get(header::ORIGIN) { if let Some(origin) = headers.get(header::ORIGIN) {
+11 -1
View File
@@ -62,7 +62,17 @@ async fn main() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(config.listen_addr) let listener = tokio::net::TcpListener::bind(config.listen_addr)
.await .await
.context("HTTP bind failed")?; .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( axum::serve(
listener, listener,
http::router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(), http::router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
+35
View File
@@ -36,6 +36,7 @@ async fn app() -> (
log_dir: logs.clone(), log_dir: logs.clone(),
latest_log: latest.clone(), latest_log: latest.clone(),
whitelist: whitelist_path, whitelist: whitelist_path,
ip_whitelist_file: Some(dir.path().join("ip-whitelist.txt")),
trust_proxy: true, trust_proxy: true,
trusted_proxy_cidrs: vec![ trusted_proxy_cidrs: vec![
"10.0.0.0/8".parse().unwrap(), "10.0.0.0/8".parse().unwrap(),
@@ -52,6 +53,7 @@ async fn app() -> (
max_ws_connections: 2, max_ws_connections: 2,
redact_player_ips: true, redact_player_ips: true,
}); });
std::fs::write(dir.path().join("ip-whitelist.txt"), "").unwrap();
let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap()); let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap());
auth.rebuild().await.unwrap(); auth.rebuild().await.unwrap();
let history = HistoryStore::new(HistoryIndex::scan(&logs).unwrap(), 1024, 2, 4096, true); 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); 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::<serde_json::Value>(&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] #[tokio::test]
async fn unexpected_browser_origin_is_rejected() { async fn unexpected_browser_origin_is_rejected() {
let (app, _dir, _tx) = app().await; let (app, _dir, _tx) = app().await;