init
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
target
|
||||||
|
frontend/node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
PRD.md
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/frontend/node_modules
|
||||||
|
/frontend/.svelte-kit
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
Generated
+1718
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
|||||||
|
[package]
|
||||||
|
name = "minecraft-log-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
flate2 = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
http = "1"
|
||||||
|
ipnet = "2"
|
||||||
|
mime_guess = "2"
|
||||||
|
notify = "8"
|
||||||
|
once_cell = "1"
|
||||||
|
regex = "1"
|
||||||
|
rust-embed = "8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
tar = "0.4"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6", features = ["catch-panic", "limit", "request-id", "set-header", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tokio-tungstenite = "0.27"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
strip = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /build/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run check && npm test && npm run build
|
||||||
|
|
||||||
|
FROM rust:1.88-bookworm AS backend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY --from=frontend /build/frontend/dist ./frontend/dist
|
||||||
|
RUN cargo test --release && cargo build --release --bin minecraft-log-viewer
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system --gid 10001 viewer && useradd --system --uid 10001 --gid viewer --home-dir /nonexistent --shell /usr/sbin/nologin viewer
|
||||||
|
COPY --from=backend /build/target/release/minecraft-log-viewer /usr/local/bin/minecraft-log-viewer
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD ["curl","--fail","--silent","http://127.0.0.1:8080/api/status"]
|
||||||
|
ENTRYPOINT ["/usr/local/bin/minecraft-log-viewer"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Minecraft Server Live Log Viewer
|
||||||
|
|
||||||
|
A read-only Axum + Svelte sidecar for `itzg/docker-minecraft-server`. It streams `latest.log` using filesystem events, paginates plain/gzip/tar-gzip history, and permits an IP only after a currently whitelisted player successfully joined from that address.
|
||||||
|
|
||||||
|
## Run as a Docker sidecar
|
||||||
|
|
||||||
|
1. Copy `compose.example.yml` beside the Minecraft Compose project.
|
||||||
|
2. Replace `PUBLIC_ORIGIN`, `TRUSTED_PROXY_CIDRS`, and the external `caddy` network name.
|
||||||
|
3. Run `docker compose -f compose.example.yml up -d --build`.
|
||||||
|
4. Point Caddy at `log-viewer:8080`; do not publish the viewer port to the host.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Example Caddy integration:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
logs.example.com {
|
||||||
|
reverse_proxy log-viewer:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy supplies `X-Forwarded-For`; the service reads it only when the immediate peer belongs to `TRUSTED_PROXY_CIDRS`. Set this CIDR to the actual Caddy Docker network. Direct clients cannot spoof authorization using that header.
|
||||||
|
|
||||||
|
## Authorization lifetime
|
||||||
|
|
||||||
|
Only successful joins in the current `latest.log` count. Failed, banned, rejected, and archived attempts do not. Associations survive disconnects until the current log is replaced/truncated or the viewer restarts. A whitelist removal revokes access after the watched JSON file is reloaded. This is a lightweight IP-based privacy barrier, not account authentication; NATs, VPNs, dynamic addresses, and IPv6 privacy addresses limit its precision.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
Malformed startup configuration or an unreadable initial whitelist fails closed. Later malformed whitelist updates retain the last valid snapshot.
|
||||||
|
|
||||||
|
## Development and verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test
|
||||||
|
cd frontend && npm install && npm test && npm run check && npm run build
|
||||||
|
docker build -t minecraft-log-viewer .
|
||||||
|
```
|
||||||
|
|
||||||
|
No Node runtime, database, Redis service, recurring file poller, Docker API, RCON, or writable Minecraft mount is used in production.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
minecraft:
|
||||||
|
image: itzg/minecraft-server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
EULA: "TRUE"
|
||||||
|
ENABLE_WHITELIST: "TRUE"
|
||||||
|
volumes:
|
||||||
|
- minecraft-data:/data
|
||||||
|
|
||||||
|
log-viewer:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
minecraft:
|
||||||
|
condition: service_started
|
||||||
|
read_only: true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
volumes:
|
||||||
|
- minecraft-data:/data: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
|
||||||
|
PUBLIC_ORIGIN: https://logs.example.com
|
||||||
|
TRUST_PROXY: "true"
|
||||||
|
TRUSTED_PROXY_CIDRS: 172.20.0.0/16
|
||||||
|
CLIENT_IP_HEADER: X-Forwarded-For
|
||||||
|
REDACT_PLAYER_IPS: "true"
|
||||||
|
RUST_LOG: minecraft_log_viewer=info,tower_http=info
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- caddy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minecraft-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# success_ipv4
|
||||||
|
[12:00:00] [User Authenticator #1/INFO]: UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000
|
||||||
|
[12:00:01] [Server thread/INFO]: Alex[/203.0.113.4:51234] logged in with entity id 1
|
||||||
|
# rejected
|
||||||
|
[12:01:00] [Server thread/INFO]: Disconnecting Eve (/198.51.100.9:4000): You are not whitelisted on this server!
|
||||||
|
# banned
|
||||||
|
[12:02:00] [Server thread/INFO]: Disconnecting Mallory (/198.51.100.10:4001): You are banned from this server!
|
||||||
|
# success_ipv6
|
||||||
|
[12:03:00] [User Authenticator #2/INFO]: UUID of player Steve is 223e4567-e89b-12d3-a456-426614174000
|
||||||
|
[12:03:01] [Server thread/INFO]: Steve[/[2001:db8::5]:51234] logged in with entity id 2
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
:root{color:#dce5df;font-synthesis:none;background:#0b0e0c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{background:radial-gradient(circle at 80% -20%,#193226 0,#0000 38%),#0b0e0c;flex-direction:column;height:100dvh;display:flex}header{color:#8e9c93;border-bottom:1px solid #26322b;justify-content:space-between;align-items:center;height:52px;padding:0 18px;font-size:12px;display:flex}header div{color:#dce5df;align-items:center;gap:9px;display:flex}.dot{background:#b07847;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{scrollbar-color:#344139 transparent;flex:1;padding:12px 0;overflow:auto}.line{white-space:pre;grid-template-columns:88px minmax(max-content,1fr);gap:14px;min-height:22px;padding:2px 18px;font-size:13px;line-height:18px;display:grid}.line:hover{background:#ffffff08}.time{color:#60766a;-webkit-user-select:none;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{color:#dce5df;font:inherit;cursor:pointer;background:#193226;border:1px solid #537962;border-radius:18px;padding:9px 14px;position:fixed;bottom:22px;right:22px}.denied{text-align:center;justify-content:center;align-items:center;padding:24px}.denied h1{margin:0 0 12px;font:500 28px system-ui}.denied p{color:#9ba9a0;max-width:580px;font:14px/1.6 system-ui}@media (width<=600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}}
|
||||||
+2
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="dark"/><title>Minecraft server logs</title> <script type="module" crossorigin src="/assets/index-CFRqyw3R.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-A-Ec5phf.css">
|
||||||
|
</head><body><div id="app"></div></body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="dark"/><title>Minecraft server logs</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||||
Generated
+2326
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{"name":"minecraft-log-viewer-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"vite build","check":"svelte-check --tsconfig ./tsconfig.json","test":"vitest run"},"devDependencies":{"@sveltejs/vite-plugin-svelte":"latest","@testing-library/svelte":"latest","@types/node":"latest","jsdom":"latest","svelte":"latest","svelte-check":"latest","typescript":"latest","vite":"latest","vitest":"latest"}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<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>}
|
||||||
|
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()}
|
||||||
|
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}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {describe,expect,it} from 'vitest';import {backoff,mergeLines,type LogLine} from './lib';
|
||||||
|
const line=(id:string):LogLine=>({id,timestamp:null,text:id,source:'latest.log'});
|
||||||
|
describe('live/history reconciliation',()=>{it('deduplicates overlapping snapshots',()=>expect(mergeLines([line('a'),line('b')],[line('b'),line('c')]).map(x=>x.id)).toEqual(['a','b','c']));it('bounds retained client history',()=>expect(mergeLines([line('a'),line('b')],[line('c')],2).map(x=>x.id)).toEqual(['b','c']));it('caps reconnect backoff',()=>expect(backoff(99)).toBe(30000));});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type LogLine={id:string;timestamp:string|null;text:string;source:string};
|
||||||
|
export type Page={lines:LogLine[];next_before:string|null;has_more:boolean};
|
||||||
|
export function mergeLines(current:LogLine[],incoming:LogLine[],max=10000):LogLine[]{const seen=new Set(current.map(l=>l.id));const merged=[...current,...incoming.filter(l=>!seen.has(l.id))];return merged.slice(Math.max(0,merged.length-max));}
|
||||||
|
export function backoff(attempt:number):number{return Math.min(30000,500*2**Math.min(attempt,6));}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import './style.css';import App from './App.svelte';new App({target:document.getElementById('app')!});
|
||||||
@@ -0,0 +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}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"compilerOptions":{"target":"ESNext","lib":["ESNext","DOM"],"module":"ESNext","moduleResolution":"bundler","strict":true,"types":["vite/client","node"]},"include":["src/**/*.ts","src/**/*.svelte","vite.config.ts","vitest.config.ts"]}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
export default defineConfig({ plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } });
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
export default defineConfig({ plugins: [svelte()], test: { environment: 'jsdom' } });
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use http::HeaderMap;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq)]
|
||||||
|
pub enum ClientIpError {
|
||||||
|
#[error("forwarded client IP is malformed")]
|
||||||
|
MalformedForwardedIp,
|
||||||
|
#[error("forwarded client IP header is not valid text")]
|
||||||
|
InvalidHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientIpPolicy {
|
||||||
|
pub trust_proxy: bool,
|
||||||
|
pub trusted_proxies: Vec<IpNet>,
|
||||||
|
pub header: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientIpPolicy {
|
||||||
|
pub fn extract(&self, peer: IpAddr, headers: &HeaderMap) -> Result<IpAddr, ClientIpError> {
|
||||||
|
if !self.trust_proxy || !self.trusted_proxies.iter().any(|net| net.contains(&peer)) {
|
||||||
|
return Ok(normalize(peer));
|
||||||
|
}
|
||||||
|
let Some(value) = headers.get(&self.header) else {
|
||||||
|
return Ok(normalize(peer));
|
||||||
|
};
|
||||||
|
let value = value.to_str().map_err(|_| ClientIpError::InvalidHeader)?;
|
||||||
|
let first = value.split(',').next().unwrap_or("").trim();
|
||||||
|
first
|
||||||
|
.parse()
|
||||||
|
.map(normalize)
|
||||||
|
.map_err(|_| ClientIpError::MalformedForwardedIp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn normalize(ip: IpAddr) -> IpAddr {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V6(v6) => v6
|
||||||
|
.to_ipv4_mapped()
|
||||||
|
.map(IpAddr::V4)
|
||||||
|
.unwrap_or(IpAddr::V6(v6)),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn policy() -> ClientIpPolicy {
|
||||||
|
ClientIpPolicy {
|
||||||
|
trust_proxy: true,
|
||||||
|
trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()],
|
||||||
|
header: "x-forwarded-for".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn trusts_first_forwarded_ip_only_from_proxy() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-forwarded-for", "203.0.113.1, 10.0.0.2".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
policy().extract("10.1.2.3".parse().unwrap(), &h).unwrap(),
|
||||||
|
"203.0.113.1".parse::<IpAddr>().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ignores_spoofed_header_from_untrusted_peer() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-forwarded-for", "203.0.113.1".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
policy().extract("192.0.2.5".parse().unwrap(), &h).unwrap(),
|
||||||
|
"192.0.2.5".parse::<IpAddr>().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_trusted_header() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-forwarded-for", "not-ip".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
policy().extract("10.1.2.3".parse().unwrap(), &h),
|
||||||
|
Err(ClientIpError::MalformedForwardedIp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
|
static UUID: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
Regex::new(r"UUID of player (?P<name>[A-Za-z0-9_]{1,16}) is (?P<uuid>[0-9a-fA-F-]{32,36})")
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
static JOIN: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
Regex::new(r"(?P<name>[A-Za-z0-9_]{1,16})\[(?P<address>.+)\] logged in(?: with entity id)?")
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JoinRecord {
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub name: String,
|
||||||
|
pub uuid: Option<String>,
|
||||||
|
pub joined_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct JoinTracker {
|
||||||
|
pending_uuids: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JoinTracker {
|
||||||
|
pub fn push(&mut self, line: &str) -> Option<JoinRecord> {
|
||||||
|
if let Some(c) = UUID.captures(line) {
|
||||||
|
self.pending_uuids
|
||||||
|
.insert(c["name"].to_ascii_lowercase(), c["uuid"].to_owned());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let c = JOIN.captures(line)?;
|
||||||
|
let name = c["name"].to_owned();
|
||||||
|
let ip = parse_log_address(&c["address"])?;
|
||||||
|
let uuid = self.pending_uuids.remove(&name.to_ascii_lowercase());
|
||||||
|
Some(JoinRecord {
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
uuid,
|
||||||
|
joined_at: Utc::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.pending_uuids.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_log_address(raw: &str) -> Option<IpAddr> {
|
||||||
|
let raw = raw.trim().trim_start_matches('/');
|
||||||
|
raw.parse::<SocketAddr>()
|
||||||
|
.map(|s| s.ip())
|
||||||
|
.ok()
|
||||||
|
.or_else(|| raw.parse().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
let (ip, port) = raw.rsplit_once(':')?;
|
||||||
|
port.parse::<u16>().ok()?;
|
||||||
|
ip.parse().ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn successful_join_correlates_uuid() {
|
||||||
|
let mut p = JoinTracker::default();
|
||||||
|
assert!(p
|
||||||
|
.push("UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
.is_none());
|
||||||
|
let r = p
|
||||||
|
.push("Alex[/203.0.113.4:51234] logged in with entity id 1")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.ip, "203.0.113.4".parse::<IpAddr>().unwrap());
|
||||||
|
assert!(r.uuid.is_some());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejected_and_banned_lines_never_authorize() {
|
||||||
|
let mut p = JoinTracker::default();
|
||||||
|
assert!(p
|
||||||
|
.push("Alex (/203.0.113.4:1) lost connection: You are not whitelisted")
|
||||||
|
.is_none());
|
||||||
|
assert!(p.push("Disconnecting Alex: You are banned").is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn supports_ipv6() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_log_address("/[2001:db8::1]:25565").unwrap(),
|
||||||
|
"2001:db8::1".parse::<IpAddr>().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rotation_discards_candidate_uuid() {
|
||||||
|
let mut p = JoinTracker::default();
|
||||||
|
p.push("UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000");
|
||||||
|
p.clear();
|
||||||
|
assert!(p
|
||||||
|
.push("Alex[/203.0.113.4:1] logged in")
|
||||||
|
.unwrap()
|
||||||
|
.uuid
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
pub mod client_ip;
|
||||||
|
pub mod join_tracker;
|
||||||
|
pub mod whitelist;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
join_tracker::{JoinRecord, JoinTracker},
|
||||||
|
whitelist::Whitelist,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
net::IpAddr,
|
||||||
|
path::Path,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct AuthorizationSnapshot {
|
||||||
|
by_ip: HashMap<IpAddr, Vec<JoinRecord>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthorizationSnapshot {
|
||||||
|
pub fn observe(&mut self, record: JoinRecord) {
|
||||||
|
self.by_ip.entry(record.ip).or_default().push(record);
|
||||||
|
}
|
||||||
|
pub fn is_allowed(&self, ip: IpAddr, whitelist: &Whitelist) -> bool {
|
||||||
|
self.by_ip.get(&ip).is_some_and(|records| {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| whitelist.contains(r.uuid.as_deref(), &r.name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.by_ip.len()
|
||||||
|
}
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.by_ip.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthorizationService {
|
||||||
|
latest_log: Arc<std::path::PathBuf>,
|
||||||
|
whitelist: Arc<RwLock<Whitelist>>,
|
||||||
|
snapshot: Arc<RwLock<AuthorizationSnapshot>>,
|
||||||
|
tracker: Arc<Mutex<JoinTracker>>,
|
||||||
|
rescan: Arc<Mutex<()>>,
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
rescan_count: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthorizationService {
|
||||||
|
pub fn new(latest_log: std::path::PathBuf, whitelist: Whitelist) -> Self {
|
||||||
|
Self {
|
||||||
|
latest_log: Arc::new(latest_log),
|
||||||
|
whitelist: Arc::new(RwLock::new(whitelist)),
|
||||||
|
snapshot: Default::default(),
|
||||||
|
tracker: Arc::new(Mutex::new(JoinTracker::default())),
|
||||||
|
rescan: Default::default(),
|
||||||
|
generation: Default::default(),
|
||||||
|
rescan_count: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn replace_whitelist(&self, value: Whitelist) {
|
||||||
|
*self.whitelist.write().await = value;
|
||||||
|
}
|
||||||
|
pub async fn observe_line(&self, line: &str) {
|
||||||
|
if let Some(record) = self.tracker.lock().await.push(line) {
|
||||||
|
self.snapshot.write().await.observe(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn clear_for_rotation(&self) {
|
||||||
|
*self.snapshot.write().await = AuthorizationSnapshot::default();
|
||||||
|
self.tracker.lock().await.clear();
|
||||||
|
}
|
||||||
|
pub async fn is_allowed(&self, ip: IpAddr) -> bool {
|
||||||
|
if self.check(ip).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let observed_generation = self.generation.load(Ordering::Acquire);
|
||||||
|
let _guard = self.rescan.lock().await;
|
||||||
|
if self.check(ip).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if self.generation.load(Ordering::Acquire) != observed_generation {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let latest_log = self.latest_log.clone();
|
||||||
|
if let Ok(Ok(rebuilt)) = tokio::task::spawn_blocking(move || scan_latest(&latest_log)).await
|
||||||
|
{
|
||||||
|
*self.snapshot.write().await = rebuilt;
|
||||||
|
self.rescan_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.generation.fetch_add(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
self.check(ip).await
|
||||||
|
}
|
||||||
|
pub async fn rebuild(&self) -> std::io::Result<()> {
|
||||||
|
let latest_log = self.latest_log.clone();
|
||||||
|
let rebuilt = tokio::task::spawn_blocking(move || scan_latest(&latest_log))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)??;
|
||||||
|
*self.snapshot.write().await = rebuilt;
|
||||||
|
self.rescan_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.generation.fetch_add(1, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn rescan_count(&self) -> u64 {
|
||||||
|
self.rescan_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
async fn check(&self, ip: IpAddr) -> bool {
|
||||||
|
let snapshot = self.snapshot.read().await;
|
||||||
|
let whitelist = self.whitelist.read().await;
|
||||||
|
snapshot.is_allowed(ip, &whitelist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_latest(path: &Path) -> std::io::Result<AuthorizationSnapshot> {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let file = std::fs::File::open(path)?;
|
||||||
|
let mut tracker = JoinTracker::default();
|
||||||
|
let mut snapshot = AuthorizationSnapshot::default();
|
||||||
|
for line in std::io::BufReader::new(file).lines() {
|
||||||
|
if let Some(record) = tracker.push(&line?) {
|
||||||
|
snapshot.observe(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn archives_are_never_scanned_for_auth() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let latest = dir.path().join("latest.log");
|
||||||
|
std::fs::write(
|
||||||
|
&latest,
|
||||||
|
"[12:00:00] [Server thread/INFO]: Alex[/203.0.113.4:1234] logged in\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s = scan_latest(&latest).unwrap();
|
||||||
|
assert_eq!(s.len(), 1);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_misses_share_one_rescan() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let latest = dir.path().join("latest.log");
|
||||||
|
std::fs::write(&latest, "unrelated\n").unwrap();
|
||||||
|
let service = AuthorizationService::new(latest, Whitelist::default());
|
||||||
|
let before = service.rescan_count();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for suffix in 1..=16 {
|
||||||
|
let service = service.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
service
|
||||||
|
.is_allowed(format!("192.0.2.{suffix}").parse().unwrap())
|
||||||
|
.await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
assert!(!task.await.unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(service.rescan_count() - before, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::{collections::HashSet, path::Path};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Whitelist {
|
||||||
|
uuids: HashSet<String>,
|
||||||
|
names: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Entry {
|
||||||
|
uuid: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum WhitelistError {
|
||||||
|
#[error("cannot read whitelist: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("invalid whitelist JSON: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Whitelist {
|
||||||
|
pub fn parse(bytes: &[u8]) -> Result<Self, WhitelistError> {
|
||||||
|
let entries: Vec<Entry> = serde_json::from_slice(bytes)?;
|
||||||
|
Ok(Self {
|
||||||
|
uuids: entries.iter().map(|e| normalize_uuid(&e.uuid)).collect(),
|
||||||
|
names: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.name.to_ascii_lowercase())
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn load(path: &Path) -> Result<Self, WhitelistError> {
|
||||||
|
Self::parse(&std::fs::read(path)?)
|
||||||
|
}
|
||||||
|
pub fn contains(&self, uuid: Option<&str>, name: &str) -> bool {
|
||||||
|
uuid.map(|u| self.uuids.contains(&normalize_uuid(u)))
|
||||||
|
.unwrap_or_else(|| self.names.contains(&name.to_ascii_lowercase()))
|
||||||
|
}
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.uuids.len()
|
||||||
|
}
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.uuids.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn normalize_uuid(value: &str) -> String {
|
||||||
|
value.replace('-', "").to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn parses_structural_json_and_matches_uuid() {
|
||||||
|
let w =
|
||||||
|
Whitelist::parse(br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#)
|
||||||
|
.unwrap();
|
||||||
|
assert!(w.contains(Some("123e4567e89b12d3a456426614174000"), "renamed"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn uuid_takes_precedence_over_name() {
|
||||||
|
let w =
|
||||||
|
Whitelist::parse(br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!w.contains(Some("00000000-0000-0000-0000-000000000000"), "Alex"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_fails_closed() {
|
||||||
|
assert!(Whitelist::parse(b"not json").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
use ipnet::IpNet;
|
||||||
|
use std::{env, net::SocketAddr, path::PathBuf, str::FromStr};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub log_dir: PathBuf,
|
||||||
|
pub latest_log: PathBuf,
|
||||||
|
pub whitelist: PathBuf,
|
||||||
|
pub trust_proxy: bool,
|
||||||
|
pub trusted_proxy_cidrs: Vec<IpNet>,
|
||||||
|
pub client_ip_header: String,
|
||||||
|
pub public_origin: String,
|
||||||
|
pub initial_log_lines: usize,
|
||||||
|
pub max_history_lines: usize,
|
||||||
|
pub archive_cache_max_bytes: usize,
|
||||||
|
pub archive_cache_max_files: usize,
|
||||||
|
pub max_archive_bytes: usize,
|
||||||
|
pub ws_queue_capacity: usize,
|
||||||
|
pub max_ws_connections: usize,
|
||||||
|
pub redact_player_ips: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("invalid {name}: {message}")]
|
||||||
|
Invalid { name: &'static str, message: String },
|
||||||
|
#[error("required path is missing or unreadable: {0}")]
|
||||||
|
MissingPath(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let data_dir = path("MC_DATA_DIR", "/data");
|
||||||
|
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 trust_proxy = boolean("TRUST_PROXY", false)?;
|
||||||
|
let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128")
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| parse("TRUSTED_PROXY_CIDRS", &value))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
if trust_proxy && trusted_proxy_cidrs.is_empty() {
|
||||||
|
return Err(ConfigError::Invalid {
|
||||||
|
name: "TRUSTED_PROXY_CIDRS",
|
||||||
|
message: "must not be empty when TRUST_PROXY=true".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let public_origin = env::var("PUBLIC_ORIGIN").map_err(|_| ConfigError::Invalid {
|
||||||
|
name: "PUBLIC_ORIGIN",
|
||||||
|
message: "must be set, for example https://logs.example.com".into(),
|
||||||
|
})?;
|
||||||
|
if !(public_origin.starts_with("https://") || public_origin.starts_with("http://")) {
|
||||||
|
return Err(ConfigError::Invalid {
|
||||||
|
name: "PUBLIC_ORIGIN",
|
||||||
|
message: "must be an absolute HTTP(S) origin".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
listen_addr: parse(
|
||||||
|
"LISTEN_ADDR",
|
||||||
|
&env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()),
|
||||||
|
)?,
|
||||||
|
data_dir,
|
||||||
|
log_dir,
|
||||||
|
latest_log,
|
||||||
|
whitelist,
|
||||||
|
trust_proxy,
|
||||||
|
trusted_proxy_cidrs,
|
||||||
|
client_ip_header: env::var("CLIENT_IP_HEADER")
|
||||||
|
.unwrap_or_else(|_| "X-Forwarded-For".into()),
|
||||||
|
public_origin,
|
||||||
|
initial_log_lines: number("INITIAL_LOG_LINES", 1000, 1, 10_000)?,
|
||||||
|
max_history_lines: number("MAX_HISTORY_LINES_PER_REQUEST", 2000, 1, 10_000)?,
|
||||||
|
archive_cache_max_bytes: number(
|
||||||
|
"ARCHIVE_CACHE_MAX_BYTES",
|
||||||
|
64 * 1024 * 1024,
|
||||||
|
0,
|
||||||
|
usize::MAX,
|
||||||
|
)?,
|
||||||
|
archive_cache_max_files: number("ARCHIVE_CACHE_MAX_FILES", 8, 0, 1_000)?,
|
||||||
|
max_archive_bytes: number(
|
||||||
|
"MAX_ARCHIVE_DECOMPRESSED_BYTES",
|
||||||
|
64 * 1024 * 1024,
|
||||||
|
1024,
|
||||||
|
usize::MAX,
|
||||||
|
)?,
|
||||||
|
ws_queue_capacity: number("WS_CLIENT_QUEUE_CAPACITY", 256, 1, 65_536)?,
|
||||||
|
max_ws_connections: number("MAX_WS_CONNECTIONS", 64, 1, 100_000)?,
|
||||||
|
redact_player_ips: boolean("REDACT_PLAYER_IPS", true)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_paths(&self) -> Result<(), ConfigError> {
|
||||||
|
for path in [
|
||||||
|
&self.data_dir,
|
||||||
|
&self.log_dir,
|
||||||
|
&self.latest_log,
|
||||||
|
&self.whitelist,
|
||||||
|
] {
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(ConfigError::MissingPath(path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(key: &str, default: impl Into<PathBuf>) -> PathBuf {
|
||||||
|
env::var_os(key)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| default.into())
|
||||||
|
}
|
||||||
|
fn csv(key: &str, default: &str) -> Vec<String> {
|
||||||
|
env::var(key)
|
||||||
|
.unwrap_or_else(|_| default.into())
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
fn parse<T: FromStr>(name: &'static str, value: &str) -> Result<T, ConfigError>
|
||||||
|
where
|
||||||
|
T::Err: std::fmt::Display,
|
||||||
|
{
|
||||||
|
value.parse().map_err(|e: T::Err| ConfigError::Invalid {
|
||||||
|
name,
|
||||||
|
message: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn boolean(key: &'static str, default: bool) -> Result<bool, ConfigError> {
|
||||||
|
match env::var(key) {
|
||||||
|
Ok(v) if v.eq_ignore_ascii_case("true") || v == "1" => Ok(true),
|
||||||
|
Ok(v) if v.eq_ignore_ascii_case("false") || v == "0" => Ok(false),
|
||||||
|
Ok(v) => Err(ConfigError::Invalid {
|
||||||
|
name: key,
|
||||||
|
message: format!("expected true/false, got {v}"),
|
||||||
|
}),
|
||||||
|
Err(_) => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn number(key: &'static str, default: usize, min: usize, max: usize) -> Result<usize, ConfigError> {
|
||||||
|
let value = match env::var(key) {
|
||||||
|
Ok(v) => parse(key, &v)?,
|
||||||
|
Err(_) => default,
|
||||||
|
};
|
||||||
|
if !(min..=max).contains(&value) {
|
||||||
|
return Err(ConfigError::Invalid {
|
||||||
|
name: key,
|
||||||
|
message: format!("must be between {min} and {max}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn parses_boolean_values() {
|
||||||
|
std::env::set_var("TEST_BOOLEAN", "1");
|
||||||
|
assert!(boolean("TEST_BOOLEAN", false).unwrap());
|
||||||
|
std::env::remove_var("TEST_BOOLEAN");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_out_of_range_number() {
|
||||||
|
std::env::set_var("TEST_NUMBER", "0");
|
||||||
|
assert!(number("TEST_NUMBER", 3, 1, 4).is_err());
|
||||||
|
std::env::remove_var("TEST_NUMBER");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, Response, StatusCode},
|
||||||
|
};
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "frontend/dist/"]
|
||||||
|
struct Assets;
|
||||||
|
|
||||||
|
pub async fn serve(uri: axum::http::Uri) -> Response<Body> {
|
||||||
|
let path = uri.path().trim_start_matches('/');
|
||||||
|
let path = if path.is_empty() { "index.html" } else { path };
|
||||||
|
let (asset, content_path) = match Assets::get(path) {
|
||||||
|
Some(asset) => (Some(asset), path),
|
||||||
|
None => (Assets::get("index.html"), "index.html"),
|
||||||
|
};
|
||||||
|
match asset {
|
||||||
|
Some(file) => Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
mime_guess::from_path(content_path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.body(Body::from(file.data))
|
||||||
|
.unwrap(),
|
||||||
|
None => Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn embeds_spa() {
|
||||||
|
let r = serve("/missing/route".parse().unwrap()).await;
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
assert_eq!(r.headers()[header::CONTENT_TYPE], "text/html");
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
use crate::{
|
||||||
|
auth::{client_ip::ClientIpPolicy, AuthorizationService},
|
||||||
|
config::Config,
|
||||||
|
logs::{follower::LiveMessage, index::HistoryStore},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
ConnectInfo, Query, State,
|
||||||
|
},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tower_http::{
|
||||||
|
catch_panic::CatchPanicLayer, limit::RequestBodyLimitLayer, set_header::SetResponseHeaderLayer,
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub auth: AuthorizationService,
|
||||||
|
pub history: HistoryStore,
|
||||||
|
pub live_tx: broadcast::Sender<LiveMessage>,
|
||||||
|
pub ip_policy: ClientIpPolicy,
|
||||||
|
pub ws_count: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new().route("/api/status",get(status)).route("/api/logs/recent",get(recent)).route("/api/logs/history",get(history)).route("/api/live",get(websocket)).fallback(crate::frontend::serve).with_state(state).layer(RequestBodyLimitLayer::new(16*1024)).layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS,header::HeaderValue::from_static("nosniff"))).layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY,header::HeaderValue::from_static("no-referrer"))).layer(SetResponseHeaderLayer::if_not_present(header::CONTENT_SECURITY_POLICY,header::HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; style-src 'self'; script-src 'self'; frame-ancestors 'none'"))).layer(CatchPanicLayer::new()).layer(TraceLayer::new_for_http())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status() -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({"status":"ok"}))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PageQuery {
|
||||||
|
before: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
async fn recent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<PageQuery>,
|
||||||
|
) -> Response {
|
||||||
|
logs_response(state, peer, headers, None, q.limit).await
|
||||||
|
}
|
||||||
|
async fn history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<PageQuery>,
|
||||||
|
) -> Response {
|
||||||
|
logs_response(state, peer, headers, q.before.as_deref(), q.limit).await
|
||||||
|
}
|
||||||
|
async fn logs_response(
|
||||||
|
state: AppState,
|
||||||
|
peer: SocketAddr,
|
||||||
|
headers: HeaderMap,
|
||||||
|
before: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(r) = authorize(&state, peer, &headers).await {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let limit = limit
|
||||||
|
.unwrap_or(state.config.initial_log_lines)
|
||||||
|
.min(state.config.max_history_lines)
|
||||||
|
.max(1);
|
||||||
|
match state.history.page(before, limit).await {
|
||||||
|
Ok(page) => Json(page).into_response(),
|
||||||
|
Err(code) if code == "invalid_cursor" => error(StatusCode::BAD_REQUEST, "invalid_cursor"),
|
||||||
|
Err(code) if code == "stale_cursor" => error(StatusCode::CONFLICT, "stale_cursor"),
|
||||||
|
Err(_) => error(StatusCode::UNPROCESSABLE_ENTITY, "history_unavailable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn websocket(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(r) = authorize(&state, peer, &headers).await {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if state.ws_count.load(Ordering::Relaxed) >= state.config.max_ws_connections {
|
||||||
|
return error(StatusCode::SERVICE_UNAVAILABLE, "connection_limit");
|
||||||
|
}
|
||||||
|
state.ws_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
ws.on_upgrade(move |socket| serve_socket(socket, state))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
async fn serve_socket(mut socket: WebSocket, state: AppState) {
|
||||||
|
let _guard = WsGuard(state.ws_count.clone());
|
||||||
|
let mut rx = state.live_tx.subscribe();
|
||||||
|
if let Ok(hello) = serde_json::to_string(&LiveMessage::Hello) {
|
||||||
|
if socket.send(Message::Text(hello.into())).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&event) {
|
||||||
|
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
let _ = socket
|
||||||
|
.send(Message::Text(r#"{"type":"resync_required"}"#.into()))
|
||||||
|
.await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct WsGuard(Arc<AtomicUsize>);
|
||||||
|
impl Drop for WsGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorize(
|
||||||
|
state: &AppState,
|
||||||
|
peer: SocketAddr,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<IpAddr, Response> {
|
||||||
|
validate_origin(&state.config.public_origin, headers)
|
||||||
|
.map_err(|_| error(StatusCode::FORBIDDEN, "origin_denied"))?;
|
||||||
|
let ip = state
|
||||||
|
.ip_policy
|
||||||
|
.extract(peer.ip(), headers)
|
||||||
|
.map_err(|_| error(StatusCode::BAD_REQUEST, "invalid_client_ip"))?;
|
||||||
|
if state.auth.is_allowed(ip).await {
|
||||||
|
Ok(ip)
|
||||||
|
} else {
|
||||||
|
Err(error(StatusCode::FORBIDDEN, "access_denied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn validate_origin(expected: &str, headers: &HeaderMap) -> Result<(), ()> {
|
||||||
|
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||||
|
if origin.to_str().ok() != Some(expected) {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn error(status: StatusCode, code: &str) -> Response {
|
||||||
|
(status, Json(serde_json::json!({"error":code}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn rejects_cross_origin() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||||
|
assert!(validate_origin("https://logs.example", &h).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn accepts_matching_or_non_browser_origin() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
assert!(validate_origin("https://logs.example", &h).is_ok());
|
||||||
|
h.insert(header::ORIGIN, "https://logs.example".parse().unwrap());
|
||||||
|
assert!(validate_origin("https://logs.example", &h).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod frontend;
|
||||||
|
pub mod http;
|
||||||
|
pub mod logs;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{self, BufRead, BufReader, Read},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
use tar::Archive;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ArchiveKind {
|
||||||
|
Plain,
|
||||||
|
Gzip,
|
||||||
|
TarGzip,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ArchiveError {
|
||||||
|
#[error("archive I/O failed: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("archive decompressed size exceeds configured limit")]
|
||||||
|
TooLarge,
|
||||||
|
#[error("unsupported log archive type")]
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect(path: &Path) -> Option<ArchiveKind> {
|
||||||
|
let name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
|
||||||
|
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
||||||
|
Some(ArchiveKind::TarGzip)
|
||||||
|
} else if name.ends_with(".log.gz") || name.ends_with(".gz") {
|
||||||
|
Some(ArchiveKind::Gzip)
|
||||||
|
} else if name.ends_with(".log") {
|
||||||
|
Some(ArchiveKind::Plain)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_lines(path: &Path, max_bytes: usize) -> Result<Vec<String>, ArchiveError> {
|
||||||
|
match detect(path).ok_or(ArchiveError::Unsupported)? {
|
||||||
|
ArchiveKind::Plain => bounded_lines(File::open(path)?, max_bytes),
|
||||||
|
ArchiveKind::Gzip => bounded_lines(GzDecoder::new(File::open(path)?), max_bytes),
|
||||||
|
ArchiveKind::TarGzip => {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut used = 0usize;
|
||||||
|
let gz = GzDecoder::new(File::open(path)?);
|
||||||
|
let mut tar = Archive::new(gz);
|
||||||
|
for entry in tar.entries()? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.header().entry_type().is_file()
|
||||||
|
&& entry.path()?.to_string_lossy().ends_with(".log")
|
||||||
|
{
|
||||||
|
for line in bounded_lines(entry, max_bytes.saturating_sub(used))? {
|
||||||
|
used = used.saturating_add(line.len() + 1);
|
||||||
|
if used > max_bytes {
|
||||||
|
return Err(ArchiveError::TooLarge);
|
||||||
|
}
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bounded_lines(reader: impl Read, max_bytes: usize) -> Result<Vec<String>, ArchiveError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut used = 0usize;
|
||||||
|
for line in BufReader::new(reader).split(b'\n') {
|
||||||
|
let bytes = line?;
|
||||||
|
used = used.saturating_add(bytes.len() + 1);
|
||||||
|
if used > max_bytes {
|
||||||
|
return Err(ArchiveError::TooLarge);
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
String::from_utf8_lossy(&bytes)
|
||||||
|
.trim_end_matches('\r')
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use flate2::{write::GzEncoder, Compression};
|
||||||
|
use std::io::Write;
|
||||||
|
#[test]
|
||||||
|
fn detects_supported_extensions() {
|
||||||
|
assert_eq!(detect(Path::new("2026.log")), Some(ArchiveKind::Plain));
|
||||||
|
assert_eq!(detect(Path::new("a.tar.gz")), Some(ArchiveKind::TarGzip));
|
||||||
|
assert_eq!(detect(Path::new("x.zip")), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn gzip_is_lazy_and_bounded() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("x.log.gz");
|
||||||
|
let mut g = GzEncoder::new(File::create(&p).unwrap(), Compression::default());
|
||||||
|
g.write_all(b"one\ntwo\n").unwrap();
|
||||||
|
g.finish().unwrap();
|
||||||
|
assert_eq!(read_lines(&p, 100).unwrap()[0], "one");
|
||||||
|
assert!(matches!(read_lines(&p, 2), Err(ArchiveError::TooLarge)));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn tar_gzip_reads_log_members_without_extracting() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("bundle.tar.gz");
|
||||||
|
let gzip = GzEncoder::new(File::create(&p).unwrap(), Compression::default());
|
||||||
|
let mut archive = tar::Builder::new(gzip);
|
||||||
|
let content = b"inside\n";
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_mode(0o444);
|
||||||
|
header.set_cksum();
|
||||||
|
archive
|
||||||
|
.append_data(&mut header, "nested/server.log", &content[..])
|
||||||
|
.unwrap();
|
||||||
|
archive.into_inner().unwrap().finish().unwrap();
|
||||||
|
assert_eq!(read_lines(&p, 100).unwrap(), vec!["inside"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Cursor {
|
||||||
|
pub source: usize,
|
||||||
|
pub line: usize,
|
||||||
|
pub generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CursorError {
|
||||||
|
#[error("invalid cursor encoding")]
|
||||||
|
Encoding,
|
||||||
|
#[error("invalid cursor payload")]
|
||||||
|
Payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cursor {
|
||||||
|
pub fn encode(&self) -> String {
|
||||||
|
URL_SAFE_NO_PAD
|
||||||
|
.encode(serde_json::to_vec(self).expect("cursor serialization is infallible"))
|
||||||
|
}
|
||||||
|
pub fn decode(value: &str) -> Result<Self, CursorError> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|_| CursorError::Encoding)?;
|
||||||
|
serde_json::from_slice(&bytes).map_err(|_| CursorError::Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn round_trip_and_rejects_garbage() {
|
||||||
|
let c = Cursor {
|
||||||
|
source: 2,
|
||||||
|
line: 9,
|
||||||
|
generation: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(Cursor::decode(&c.encode()).unwrap(), c);
|
||||||
|
assert!(Cursor::decode("../secret").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
use crate::{
|
||||||
|
auth::AuthorizationService,
|
||||||
|
logs::{index::HistoryStore, stable_line_id, timestamp_from_line, LogLine},
|
||||||
|
};
|
||||||
|
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{Read, Seek, SeekFrom},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum LiveMessage {
|
||||||
|
Hello,
|
||||||
|
LogLines { lines: Vec<LogLine> },
|
||||||
|
Rotation,
|
||||||
|
HistoryChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct FileIdentity {
|
||||||
|
device: u64,
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn identity(meta: &std::fs::Metadata) -> FileIdentity {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
FileIdentity {
|
||||||
|
device: meta.dev(),
|
||||||
|
inode: meta.ino(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileFollower {
|
||||||
|
path: PathBuf,
|
||||||
|
file: Option<File>,
|
||||||
|
identity: Option<FileIdentity>,
|
||||||
|
offset: u64,
|
||||||
|
partial: Vec<u8>,
|
||||||
|
next_line: usize,
|
||||||
|
redact: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct FollowResult {
|
||||||
|
pub lines: Vec<LogLine>,
|
||||||
|
pub rotated: bool,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileFollower {
|
||||||
|
pub fn at_eof(path: PathBuf, redact: bool) -> std::io::Result<Self> {
|
||||||
|
let mut s = Self {
|
||||||
|
path,
|
||||||
|
file: None,
|
||||||
|
identity: None,
|
||||||
|
offset: 0,
|
||||||
|
partial: Vec::new(),
|
||||||
|
next_line: 0,
|
||||||
|
redact,
|
||||||
|
};
|
||||||
|
s.open(true)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
pub fn from_start(path: PathBuf, redact: bool) -> std::io::Result<Self> {
|
||||||
|
let mut s = Self {
|
||||||
|
path,
|
||||||
|
file: None,
|
||||||
|
identity: None,
|
||||||
|
offset: 0,
|
||||||
|
partial: Vec::new(),
|
||||||
|
next_line: 0,
|
||||||
|
redact,
|
||||||
|
};
|
||||||
|
s.open(false)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
fn open(&mut self, eof: bool) -> std::io::Result<()> {
|
||||||
|
let mut file = File::open(&self.path)?;
|
||||||
|
let meta = file.metadata()?;
|
||||||
|
self.identity = Some(identity(&meta));
|
||||||
|
self.partial.clear();
|
||||||
|
self.next_line = 0;
|
||||||
|
if eof {
|
||||||
|
let mut chunk = [0_u8; 8192];
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut chunk)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for byte in &chunk[..read] {
|
||||||
|
if *byte == b'\n' {
|
||||||
|
self.next_line += 1;
|
||||||
|
self.partial.clear();
|
||||||
|
} else {
|
||||||
|
self.partial.push(*byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.offset = meta.len();
|
||||||
|
} else {
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
self.file = Some(file);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn read_new(&mut self) -> std::io::Result<FollowResult> {
|
||||||
|
let mut result = FollowResult::default();
|
||||||
|
let current = match std::fs::metadata(&self.path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(result),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let current_id = identity(¤t);
|
||||||
|
if self.identity != Some(current_id) {
|
||||||
|
if let Some(old) = self.file.as_mut() {
|
||||||
|
let mut tail = Vec::new();
|
||||||
|
old.seek(SeekFrom::Start(self.offset))?;
|
||||||
|
old.read_to_end(&mut tail)?;
|
||||||
|
result.lines.extend(decode_lines(
|
||||||
|
&mut self.partial,
|
||||||
|
&tail,
|
||||||
|
self.identity.expect("open file identity"),
|
||||||
|
&mut self.next_line,
|
||||||
|
self.redact,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.open(false)?;
|
||||||
|
result.rotated = true;
|
||||||
|
} else if current.len() < self.offset {
|
||||||
|
self.offset = 0;
|
||||||
|
self.partial.clear();
|
||||||
|
self.next_line = 0;
|
||||||
|
result.truncated = true;
|
||||||
|
}
|
||||||
|
let file = self.file.as_mut().expect("opened follower");
|
||||||
|
file.seek(SeekFrom::Start(self.offset))?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
self.offset += bytes.len() as u64;
|
||||||
|
result.lines.extend(decode_lines(
|
||||||
|
&mut self.partial,
|
||||||
|
&bytes,
|
||||||
|
self.identity.expect("open file identity"),
|
||||||
|
&mut self.next_line,
|
||||||
|
self.redact,
|
||||||
|
));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn decode_lines(
|
||||||
|
partial: &mut Vec<u8>,
|
||||||
|
bytes: &[u8],
|
||||||
|
identity: FileIdentity,
|
||||||
|
next_line: &mut usize,
|
||||||
|
redact: bool,
|
||||||
|
) -> Vec<LogLine> {
|
||||||
|
partial.extend_from_slice(bytes);
|
||||||
|
let mut completed = Vec::new();
|
||||||
|
let mut consumed = 0;
|
||||||
|
for (i, b) in partial.iter().enumerate() {
|
||||||
|
if *b == b'\n' {
|
||||||
|
let raw = String::from_utf8_lossy(&partial[consumed..i])
|
||||||
|
.trim_end_matches('\r')
|
||||||
|
.to_owned();
|
||||||
|
let id = stable_line_id(identity.device, identity.inode, *next_line, &raw);
|
||||||
|
let text = if redact {
|
||||||
|
crate::logs::redact::redact_player_ips(&raw)
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
completed.push(LogLine {
|
||||||
|
id,
|
||||||
|
timestamp: timestamp_from_line(&text),
|
||||||
|
text,
|
||||||
|
source: "latest.log".into(),
|
||||||
|
});
|
||||||
|
*next_line += 1;
|
||||||
|
consumed = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partial.drain(..consumed);
|
||||||
|
completed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_watcher(
|
||||||
|
log_dir: PathBuf,
|
||||||
|
latest: PathBuf,
|
||||||
|
whitelist_path: PathBuf,
|
||||||
|
auth: AuthorizationService,
|
||||||
|
history: HistoryStore,
|
||||||
|
tx: broadcast::Sender<LiveMessage>,
|
||||||
|
redact: bool,
|
||||||
|
) -> notify::Result<RecommendedWatcher> {
|
||||||
|
// Open first, then register the directory watch, then recover bytes written
|
||||||
|
// between those two operations. Events queued after registration are harmless:
|
||||||
|
// the follower's byte offset makes the later read a no-op.
|
||||||
|
let mut follower = FileFollower::at_eof(latest.clone(), false).map_err(notify::Error::io)?;
|
||||||
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||||
|
let mut watcher = notify::recommended_watcher(move |event| {
|
||||||
|
let _ = event_tx.send(event);
|
||||||
|
})?;
|
||||||
|
watcher.watch(&log_dir, RecursiveMode::NonRecursive)?;
|
||||||
|
if whitelist_path.parent() != Some(log_dir.as_path()) {
|
||||||
|
if let Some(parent) = whitelist_path.parent() {
|
||||||
|
watcher.watch(parent, RecursiveMode::NonRecursive)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let startup_delta = follower.read_new();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(result) = startup_delta {
|
||||||
|
if !result.lines.is_empty() {
|
||||||
|
let mut lines = result.lines;
|
||||||
|
for line in &lines {
|
||||||
|
auth.observe_line(&line.text).await;
|
||||||
|
}
|
||||||
|
if redact {
|
||||||
|
for line in &mut lines {
|
||||||
|
line.text = crate::logs::redact::redact_player_ips(&line.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(LiveMessage::LogLines { lines });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some(event) = event_rx.recv().await {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
let touches_whitelist = event.paths.iter().any(|p| p == &whitelist_path);
|
||||||
|
let touches_logs = event
|
||||||
|
.paths
|
||||||
|
.iter()
|
||||||
|
.any(|p| p == &latest || p.parent() == Some(log_dir.as_path()));
|
||||||
|
if touches_whitelist {
|
||||||
|
match crate::auth::whitelist::Whitelist::load(&whitelist_path) {
|
||||||
|
Ok(w) => {
|
||||||
|
auth.replace_whitelist(w).await;
|
||||||
|
tracing::info!(event = "whitelist_changed")
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!(event="whitelist_reload_failed",error=%e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if touches_logs {
|
||||||
|
match follower.read_new() {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.rotated || result.truncated {
|
||||||
|
auth.clear_for_rotation().await;
|
||||||
|
let _ = auth.rebuild().await;
|
||||||
|
let _ = history.refresh(&log_dir).await;
|
||||||
|
let _ = tx.send(LiveMessage::Rotation);
|
||||||
|
}
|
||||||
|
if !result.lines.is_empty() {
|
||||||
|
let mut lines = result.lines;
|
||||||
|
for line in &lines {
|
||||||
|
auth.observe_line(&line.text).await;
|
||||||
|
}
|
||||||
|
if redact {
|
||||||
|
for line in &mut lines {
|
||||||
|
line.text =
|
||||||
|
crate::logs::redact::redact_player_ips(&line.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(LiveMessage::LogLines { lines });
|
||||||
|
} else {
|
||||||
|
let _ = history.refresh(&log_dir).await;
|
||||||
|
let _ = tx.send(LiveMessage::HistoryChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(event="watcher_recovery",error=%e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(event="watcher_overflow_recovery",error=%e);
|
||||||
|
let _ = auth.rebuild().await;
|
||||||
|
let _ = history.refresh(&log_dir).await;
|
||||||
|
let _ = follower.read_new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(watcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
#[test]
|
||||||
|
fn buffers_partial_and_handles_multiple_lines() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("latest.log");
|
||||||
|
std::fs::write(&p, b"").unwrap();
|
||||||
|
let mut f = FileFollower::from_start(p.clone(), false).unwrap();
|
||||||
|
std::fs::write(&p, b"one").unwrap();
|
||||||
|
assert!(f.read_new().unwrap().lines.is_empty());
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&p)
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b"\ntwo\n")
|
||||||
|
.unwrap();
|
||||||
|
let r = f.read_new().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["one", "two"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn detects_truncation() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("latest.log");
|
||||||
|
std::fs::write(&p, b"old\n").unwrap();
|
||||||
|
let mut f = FileFollower::from_start(p.clone(), false).unwrap();
|
||||||
|
assert_eq!(f.read_new().unwrap().lines.len(), 1);
|
||||||
|
std::fs::write(&p, b"n\n").unwrap();
|
||||||
|
let r = f.read_new().unwrap();
|
||||||
|
assert!(r.truncated);
|
||||||
|
assert_eq!(r.lines[0].text, "n");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn detects_file_replacement_and_reads_the_new_generation() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("latest.log");
|
||||||
|
std::fs::write(&p, b"old\n").unwrap();
|
||||||
|
let mut follower = FileFollower::at_eof(p.clone(), false).unwrap();
|
||||||
|
std::fs::rename(&p, d.path().join("previous.log")).unwrap();
|
||||||
|
std::fs::write(&p, b"new\n").unwrap();
|
||||||
|
let result = follower.read_new().unwrap();
|
||||||
|
assert!(result.rotated);
|
||||||
|
assert_eq!(result.lines.last().unwrap().text, "new");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn eof_startup_retains_an_existing_partial_line() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("latest.log");
|
||||||
|
std::fs::write(&p, b"complete\npartial").unwrap();
|
||||||
|
let mut follower = FileFollower::at_eof(p.clone(), false).unwrap();
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&p)
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b" rest\n")
|
||||||
|
.unwrap();
|
||||||
|
let result = follower.read_new().unwrap();
|
||||||
|
assert_eq!(result.lines[0].text, "partial rest");
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_and_recent_snapshots_share_line_ids() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let p = d.path().join("latest.log");
|
||||||
|
std::fs::write(&p, b"").unwrap();
|
||||||
|
let mut follower = FileFollower::from_start(p.clone(), false).unwrap();
|
||||||
|
std::fs::write(&p, b"same line\n").unwrap();
|
||||||
|
let live = follower.read_new().unwrap().lines.remove(0);
|
||||||
|
let index = crate::logs::index::HistoryIndex::scan(d.path()).unwrap();
|
||||||
|
let history = HistoryStore::new(index, 0, 0, 1024, false)
|
||||||
|
.page(None, 1)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.lines
|
||||||
|
.remove(0);
|
||||||
|
assert_eq!(live.id, history.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
use crate::logs::{archive, cursor::Cursor, stable_line_id, timestamp_from_line, LogLine};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Source {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub label: String,
|
||||||
|
pub modified_ms: u128,
|
||||||
|
pub compressed_size: u64,
|
||||||
|
pub device: u64,
|
||||||
|
pub inode: u64,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HistoryIndex {
|
||||||
|
pub sources: Vec<Source>,
|
||||||
|
pub generation: u64,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HistoryPage {
|
||||||
|
pub lines: Vec<LogLine>,
|
||||||
|
pub next_before: Option<String>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryIndex {
|
||||||
|
pub fn scan(dir: &Path) -> std::io::Result<Self> {
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !entry.file_type()?.is_file() || archive::detect(&path).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let meta = entry.metadata()?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
let (device, inode) = {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
(meta.dev(), meta.ino())
|
||||||
|
};
|
||||||
|
sources.push(Source {
|
||||||
|
label: entry.file_name().to_string_lossy().into_owned(),
|
||||||
|
path,
|
||||||
|
modified_ms: meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0),
|
||||||
|
compressed_size: meta.len(),
|
||||||
|
device,
|
||||||
|
inode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sources.sort_by(|a, b| {
|
||||||
|
(a.label == "latest.log")
|
||||||
|
.cmp(&(b.label == "latest.log"))
|
||||||
|
.then(filename_key(&a.label).cmp(&filename_key(&b.label)))
|
||||||
|
.then(a.modified_ms.cmp(&b.modified_ms))
|
||||||
|
.then(a.label.cmp(&b.label))
|
||||||
|
});
|
||||||
|
let generation = sources.iter().fold(0u64, |acc, s| {
|
||||||
|
acc.wrapping_mul(31)
|
||||||
|
.wrapping_add(s.modified_ms as u64)
|
||||||
|
.wrapping_add(s.compressed_size)
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
sources,
|
||||||
|
generation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn filename_key(name: &str) -> String {
|
||||||
|
name.chars().filter(|c| c.is_ascii_digit()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HistoryStore {
|
||||||
|
index: Arc<tokio::sync::RwLock<HistoryIndex>>,
|
||||||
|
cache: Arc<Mutex<ArchiveCache>>,
|
||||||
|
max_archive_bytes: usize,
|
||||||
|
redact: bool,
|
||||||
|
}
|
||||||
|
struct ArchiveCache {
|
||||||
|
max_bytes: usize,
|
||||||
|
max_files: usize,
|
||||||
|
bytes: usize,
|
||||||
|
items: HashMap<PathBuf, Arc<Vec<String>>>,
|
||||||
|
order: VecDeque<PathBuf>,
|
||||||
|
}
|
||||||
|
impl ArchiveCache {
|
||||||
|
fn new(max_bytes: usize, max_files: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
max_bytes,
|
||||||
|
max_files,
|
||||||
|
bytes: 0,
|
||||||
|
items: HashMap::new(),
|
||||||
|
order: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn get(&mut self, p: &Path) -> Option<Arc<Vec<String>>> {
|
||||||
|
self.items.get(p).cloned()
|
||||||
|
}
|
||||||
|
fn put(&mut self, p: PathBuf, v: Arc<Vec<String>>) {
|
||||||
|
if self.max_files == 0 || self.max_bytes == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let size: usize = v.iter().map(|x| x.len()).sum();
|
||||||
|
if size > self.max_bytes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while self.items.len() >= self.max_files || self.bytes + size > self.max_bytes {
|
||||||
|
if let Some(old) = self.order.pop_front() {
|
||||||
|
if let Some(lines) = self.items.remove(&old) {
|
||||||
|
self.bytes = self
|
||||||
|
.bytes
|
||||||
|
.saturating_sub(lines.iter().map(|x| x.len()).sum::<usize>());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bytes += size;
|
||||||
|
self.order.push_back(p.clone());
|
||||||
|
self.items.insert(p, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryStore {
|
||||||
|
pub fn new(
|
||||||
|
index: HistoryIndex,
|
||||||
|
cache_bytes: usize,
|
||||||
|
cache_files: usize,
|
||||||
|
max_archive_bytes: usize,
|
||||||
|
redact: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
index: Arc::new(tokio::sync::RwLock::new(index)),
|
||||||
|
cache: Arc::new(Mutex::new(ArchiveCache::new(cache_bytes, cache_files))),
|
||||||
|
max_archive_bytes,
|
||||||
|
redact,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn refresh(&self, dir: &Path) -> std::io::Result<()> {
|
||||||
|
*self.index.write().await = HistoryIndex::scan(dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub async fn page(&self, before: Option<&str>, limit: usize) -> Result<HistoryPage, String> {
|
||||||
|
let index = self.index.read().await.clone();
|
||||||
|
let mut pos = match before {
|
||||||
|
Some(v) => {
|
||||||
|
let c = Cursor::decode(v).map_err(|_| "invalid_cursor")?;
|
||||||
|
if c.generation != index.generation {
|
||||||
|
return Err("stale_cursor".into());
|
||||||
|
}
|
||||||
|
(c.source, c.line)
|
||||||
|
}
|
||||||
|
None => (index.sources.len(), usize::MAX),
|
||||||
|
};
|
||||||
|
let mut out_rev = Vec::new();
|
||||||
|
while pos.0 > 0 && out_rev.len() < limit {
|
||||||
|
let source_idx = pos.0 - 1;
|
||||||
|
let source = &index.sources[source_idx];
|
||||||
|
let owned_source = source.clone();
|
||||||
|
let store = self.clone();
|
||||||
|
let lines = tokio::task::spawn_blocking(move || store.load(&owned_source))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "archive_unavailable")?
|
||||||
|
.map_err(|_| "archive_unavailable")?;
|
||||||
|
let end = if pos.0 == source_idx + 1 && pos.1 != usize::MAX {
|
||||||
|
pos.1.min(lines.len())
|
||||||
|
} else {
|
||||||
|
lines.len()
|
||||||
|
};
|
||||||
|
for line_idx in (0..end).rev() {
|
||||||
|
if out_rev.len() == limit {
|
||||||
|
pos = (source_idx + 1, line_idx + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out_rev.push(make_line(source, line_idx, &lines[line_idx], self.redact));
|
||||||
|
pos = (source_idx + 1, line_idx);
|
||||||
|
}
|
||||||
|
if pos.1 == 0 {
|
||||||
|
pos = (source_idx, usize::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out_rev.reverse();
|
||||||
|
let has_more = pos.0 > 0;
|
||||||
|
let next_before = has_more.then(|| {
|
||||||
|
Cursor {
|
||||||
|
source: pos.0,
|
||||||
|
line: pos.1,
|
||||||
|
generation: index.generation,
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
});
|
||||||
|
Ok(HistoryPage {
|
||||||
|
lines: out_rev,
|
||||||
|
next_before,
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn load(&self, source: &Source) -> Result<Arc<Vec<String>>, archive::ArchiveError> {
|
||||||
|
if let Some(v) = self.cache.lock().unwrap().get(&source.path) {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
let v = Arc::new(archive::read_lines(&source.path, self.max_archive_bytes)?);
|
||||||
|
self.cache
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.put(source.path.clone(), v.clone());
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn make_line(source: &Source, index: usize, text: &str, redact: bool) -> LogLine {
|
||||||
|
let id = stable_line_id(source.device, source.inode, index, text);
|
||||||
|
let text = if redact {
|
||||||
|
crate::logs::redact::redact_player_ips(text)
|
||||||
|
} else {
|
||||||
|
text.to_owned()
|
||||||
|
};
|
||||||
|
LogLine {
|
||||||
|
id,
|
||||||
|
timestamp: timestamp_from_line(&text),
|
||||||
|
text,
|
||||||
|
source: source.label.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn deterministic_filename_order() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(d.path().join("2026-01-02.log"), "b\n").unwrap();
|
||||||
|
fs::write(d.path().join("2026-01-01.log"), "a\n").unwrap();
|
||||||
|
let i = HistoryIndex::scan(d.path()).unwrap();
|
||||||
|
assert_eq!(i.sources[0].label, "2026-01-01.log");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn latest_is_always_newest() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(d.path().join("latest.log"), "new\n").unwrap();
|
||||||
|
fs::write(d.path().join("2026-01-01.log"), "old\n").unwrap();
|
||||||
|
let i = HistoryIndex::scan(d.path()).unwrap();
|
||||||
|
assert_eq!(i.sources.last().unwrap().label, "latest.log");
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paginates_without_exposing_paths() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(d.path().join("latest.log"), "one\ntwo\nthree\n").unwrap();
|
||||||
|
let s = HistoryStore::new(HistoryIndex::scan(d.path()).unwrap(), 1024, 2, 1024, true);
|
||||||
|
let p = s.page(None, 2).await.unwrap();
|
||||||
|
assert_eq!(p.lines.len(), 2);
|
||||||
|
assert!(p.has_more);
|
||||||
|
assert!(!p.next_before.unwrap().contains("latest"));
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refresh_discovers_new_archives() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(d.path().join("latest.log"), "new\n").unwrap();
|
||||||
|
let store = HistoryStore::new(HistoryIndex::scan(d.path()).unwrap(), 0, 0, 1024, false);
|
||||||
|
fs::write(d.path().join("2026-01-01.log"), "old\n").unwrap();
|
||||||
|
store.refresh(d.path()).await.unwrap();
|
||||||
|
let page = store.page(None, 10).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.text.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["old", "new"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
pub mod archive;
|
||||||
|
pub mod cursor;
|
||||||
|
pub mod follower;
|
||||||
|
pub mod index;
|
||||||
|
pub mod redact;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LogLine {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: Option<String>,
|
||||||
|
pub text: String,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timestamp_from_line(line: &str) -> Option<String> {
|
||||||
|
let start = line.find('[')? + 1;
|
||||||
|
let end = line[start..].find(']')? + start;
|
||||||
|
let value = &line[start..end];
|
||||||
|
(value.len() >= 8).then(|| value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stable_line_id(device: u64, inode: u64, line_index: usize, text: &str) -> String {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(device.to_le_bytes());
|
||||||
|
hash.update(inode.to_le_bytes());
|
||||||
|
hash.update(line_index.to_le_bytes());
|
||||||
|
hash.update(text.as_bytes());
|
||||||
|
format!("{:x}", hash.finalize())
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
static PLAYER_ADDRESS: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"/\[?(?:[0-9a-fA-F:.]+)\]?:\d+").unwrap());
|
||||||
|
pub fn redact_player_ips(line: &str) -> String {
|
||||||
|
PLAYER_ADDRESS
|
||||||
|
.replace_all(line, "/[IP REDACTED]")
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn redacts_ipv4_and_ipv6() {
|
||||||
|
assert_eq!(
|
||||||
|
redact_player_ips("Alex[/203.0.113.1:123] logged in"),
|
||||||
|
"Alex[/[IP REDACTED]] logged in"
|
||||||
|
);
|
||||||
|
assert!(!redact_player_ips("Alex[/[2001:db8::1]:123] logged in").contains("2001:db8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use minecraft_log_viewer::{
|
||||||
|
auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService},
|
||||||
|
config::Config,
|
||||||
|
http::{self, AppState},
|
||||||
|
logs::{
|
||||||
|
follower,
|
||||||
|
index::{HistoryIndex, HistoryStore},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::sync::{atomic::AtomicUsize, Arc};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.json()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
let config = Arc::new(Config::from_env().context("configuration validation failed")?);
|
||||||
|
config
|
||||||
|
.validate_paths()
|
||||||
|
.context("filesystem validation failed")?;
|
||||||
|
let whitelist = Whitelist::load(&config.whitelist)
|
||||||
|
.context("initial whitelist load failed; refusing to start")?;
|
||||||
|
let auth = AuthorizationService::new(config.latest_log.clone(), whitelist);
|
||||||
|
auth.rebuild()
|
||||||
|
.await
|
||||||
|
.context("authorization reconstruction failed")?;
|
||||||
|
let index = HistoryIndex::scan(&config.log_dir).context("historical index scan failed")?;
|
||||||
|
let history = HistoryStore::new(
|
||||||
|
index,
|
||||||
|
config.archive_cache_max_bytes,
|
||||||
|
config.archive_cache_max_files,
|
||||||
|
config.max_archive_bytes,
|
||||||
|
config.redact_player_ips,
|
||||||
|
);
|
||||||
|
let (tx, _) = broadcast::channel(config.ws_queue_capacity);
|
||||||
|
let ip_policy = ClientIpPolicy {
|
||||||
|
trust_proxy: config.trust_proxy,
|
||||||
|
trusted_proxies: config.trusted_proxy_cidrs.clone(),
|
||||||
|
header: config.client_ip_header.clone(),
|
||||||
|
};
|
||||||
|
let state = AppState {
|
||||||
|
config: config.clone(),
|
||||||
|
auth: auth.clone(),
|
||||||
|
history: history.clone(),
|
||||||
|
live_tx: tx.clone(),
|
||||||
|
ip_policy,
|
||||||
|
ws_count: Arc::new(AtomicUsize::new(0)),
|
||||||
|
};
|
||||||
|
let _watcher = follower::spawn_watcher(
|
||||||
|
config.log_dir.clone(),
|
||||||
|
config.latest_log.clone(),
|
||||||
|
config.whitelist.clone(),
|
||||||
|
auth,
|
||||||
|
history,
|
||||||
|
tx,
|
||||||
|
config.redact_player_ips,
|
||||||
|
)
|
||||||
|
.context("filesystem watcher startup failed")?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(config.listen_addr)
|
||||||
|
.await
|
||||||
|
.context("HTTP bind failed")?;
|
||||||
|
tracing::info!(event="application_startup",listen_addr=%config.listen_addr);
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
http::router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("HTTP server failed")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#[test]
|
||||||
|
fn compose_is_a_hardened_read_only_sidecar() {
|
||||||
|
let compose = include_str!("../compose.example.yml");
|
||||||
|
assert!(compose.contains("minecraft-data:/data:ro"));
|
||||||
|
assert!(compose.contains("read_only: true"));
|
||||||
|
assert!(compose.contains("cap_drop: [ALL]"));
|
||||||
|
assert!(compose.contains("no-new-privileges:true"));
|
||||||
|
assert!(!compose.contains("docker.sock"));
|
||||||
|
assert!(!compose.contains("ports:"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn runtime_has_no_node_and_runs_non_root() {
|
||||||
|
let dockerfile = include_str!("../Dockerfile");
|
||||||
|
let runtime = dockerfile
|
||||||
|
.split("FROM debian:bookworm-slim AS runtime")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!runtime.contains("npm "));
|
||||||
|
assert!(!runtime.contains("node "));
|
||||||
|
assert!(runtime.contains("USER 10001:10001"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::ConnectInfo,
|
||||||
|
http::{header, Request, StatusCode},
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use minecraft_log_viewer::{
|
||||||
|
auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService},
|
||||||
|
config::Config,
|
||||||
|
http::{router, AppState},
|
||||||
|
logs::index::{HistoryIndex, HistoryStore},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::{atomic::AtomicUsize, Arc},
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn app() -> (
|
||||||
|
axum::Router,
|
||||||
|
tempfile::TempDir,
|
||||||
|
broadcast::Sender<minecraft_log_viewer::logs::follower::LiveMessage>,
|
||||||
|
) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let logs = dir.path().join("logs");
|
||||||
|
std::fs::create_dir(&logs).unwrap();
|
||||||
|
let latest = logs.join("latest.log");
|
||||||
|
std::fs::write(&latest, "[12:00:00] Alex[/203.0.113.4:51234] logged in\n").unwrap();
|
||||||
|
let whitelist_path = dir.path().join("whitelist.json");
|
||||||
|
let whitelist_json = br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#;
|
||||||
|
std::fs::write(&whitelist_path, whitelist_json).unwrap();
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
listen_addr: "127.0.0.1:0".parse().unwrap(),
|
||||||
|
data_dir: dir.path().to_path_buf(),
|
||||||
|
log_dir: logs.clone(),
|
||||||
|
latest_log: latest.clone(),
|
||||||
|
whitelist: whitelist_path,
|
||||||
|
trust_proxy: true,
|
||||||
|
trusted_proxy_cidrs: vec![
|
||||||
|
"10.0.0.0/8".parse().unwrap(),
|
||||||
|
"127.0.0.1/32".parse().unwrap(),
|
||||||
|
],
|
||||||
|
client_ip_header: "x-forwarded-for".into(),
|
||||||
|
public_origin: "https://logs.example".into(),
|
||||||
|
initial_log_lines: 100,
|
||||||
|
max_history_lines: 100,
|
||||||
|
archive_cache_max_bytes: 1024,
|
||||||
|
archive_cache_max_files: 2,
|
||||||
|
max_archive_bytes: 4096,
|
||||||
|
ws_queue_capacity: 8,
|
||||||
|
max_ws_connections: 2,
|
||||||
|
redact_player_ips: true,
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
let (live_tx, _) = broadcast::channel(8);
|
||||||
|
let state = AppState {
|
||||||
|
config,
|
||||||
|
auth,
|
||||||
|
history,
|
||||||
|
live_tx: live_tx.clone(),
|
||||||
|
ip_policy: ClientIpPolicy {
|
||||||
|
trust_proxy: true,
|
||||||
|
trusted_proxies: vec![
|
||||||
|
"10.0.0.0/8".parse::<IpNet>().unwrap(),
|
||||||
|
"127.0.0.1/32".parse().unwrap(),
|
||||||
|
],
|
||||||
|
header: "x-forwarded-for".into(),
|
||||||
|
},
|
||||||
|
ws_count: Arc::new(AtomicUsize::new(0)),
|
||||||
|
};
|
||||||
|
(router(state), dir, live_tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(peer: &str) -> Request<axum::body::Body> {
|
||||||
|
let mut request = Request::builder()
|
||||||
|
.uri("/api/logs/recent")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
.extensions_mut()
|
||||||
|
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_direct_ip_receives_redacted_logs() {
|
||||||
|
let (app, _dir, _tx) = app().await;
|
||||||
|
let response = app.oneshot(request("203.0.113.4:5000")).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), 8192)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(text.contains("IP REDACTED"));
|
||||||
|
assert!(!text.contains("203.0.113.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn untrusted_peer_cannot_spoof_an_authorized_forwarded_ip() {
|
||||||
|
let (app, _dir, _tx) = app().await;
|
||||||
|
let mut request = request("192.0.2.9:5000");
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
|
||||||
|
let response = app.oneshot(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unexpected_browser_origin_is_rejected() {
|
||||||
|
let (app, _dir, _tx) = app().await;
|
||||||
|
let mut request = request("203.0.113.4:5000");
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
|
||||||
|
let response = app.oneshot(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_websocket_receives_ordered_batches() {
|
||||||
|
use minecraft_log_viewer::logs::{follower::LiveMessage, LogLine};
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
|
||||||
|
let (app, _dir, tx) = app().await;
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let mut request = format!("ws://{address}/api/live")
|
||||||
|
.into_client_request()
|
||||||
|
.unwrap();
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ORIGIN, "https://logs.example".parse().unwrap());
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
|
||||||
|
let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap();
|
||||||
|
let hello = socket.next().await.unwrap().unwrap().into_text().unwrap();
|
||||||
|
assert!(hello.contains("hello"));
|
||||||
|
tx.send(LiveMessage::LogLines {
|
||||||
|
lines: vec![
|
||||||
|
LogLine {
|
||||||
|
id: "1".into(),
|
||||||
|
timestamp: None,
|
||||||
|
text: "first".into(),
|
||||||
|
source: "latest.log".into(),
|
||||||
|
},
|
||||||
|
LogLine {
|
||||||
|
id: "2".into(),
|
||||||
|
timestamp: None,
|
||||||
|
text: "second".into(),
|
||||||
|
source: "latest.log".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let batch = socket.next().await.unwrap().unwrap().into_text().unwrap();
|
||||||
|
assert!(batch.find("first").unwrap() < batch.find("second").unwrap());
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use minecraft_log_viewer::auth::join_tracker::JoinTracker;
|
||||||
|
#[test]
|
||||||
|
fn realistic_fixture_authorizes_only_successful_ipv4_and_ipv6_joins() {
|
||||||
|
let mut tracker = JoinTracker::default();
|
||||||
|
let records = include_str!("../fixtures/join_sequences.log")
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| tracker.push(line))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(records.len(), 2);
|
||||||
|
assert_eq!(records[0].name, "Alex");
|
||||||
|
assert_eq!(records[1].name, "Steve");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user