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 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
: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 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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user