ip whitelist
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
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;
|
||||
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>}
|
||||
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){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 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()}
|
||||
@@ -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()}
|
||||
onMount(()=>{initial().then(()=>connect()).catch(()=>{});return()=>{stopped=true;socket?.close()}})
|
||||
</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 @@
|
||||
: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}}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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)]
|
||||
@@ -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<IpAddr> {
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct Config {
|
||||
pub log_dir: PathBuf,
|
||||
pub latest_log: PathBuf,
|
||||
pub whitelist: PathBuf,
|
||||
pub ip_whitelist_file: Option<PathBuf>,
|
||||
pub trust_proxy: bool,
|
||||
pub trusted_proxy_cidrs: Vec<IpNet>,
|
||||
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")
|
||||
|
||||
+91
-7
@@ -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<IpAddr, Response> {
|
||||
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) {
|
||||
|
||||
+11
-1
@@ -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::<std::net::SocketAddr>(),
|
||||
|
||||
@@ -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::<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]
|
||||
async fn unexpected_browser_origin_is_rejected() {
|
||||
let (app, _dir, _tx) = app().await;
|
||||
|
||||
Reference in New Issue
Block a user