This commit is contained in:
2026-08-10 21:55:52 +08:00
commit bba448e329
41 changed files with 8173 additions and 0 deletions
+84
View File
@@ -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)
);
}
}
+109
View File
@@ -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
View File
@@ -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);
}
}
+75
View File
@@ -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
View File
@@ -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");
}
}
+45
View File
@@ -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
View File
@@ -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());
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod auth;
pub mod config;
pub mod frontend;
pub mod http;
pub mod logs;
+122
View File
@@ -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"]);
}
}
+46
View File
@@ -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());
}
}
+366
View File
@@ -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(&current);
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);
}
}
+281
View File
@@ -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"]
);
}
}
+32
View File
@@ -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())
}
+21
View File
@@ -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
View File
@@ -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(())
}