init
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user