This commit is contained in:
2026-08-10 21:55:52 +08:00
commit bba448e329
41 changed files with 8173 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
#[test]
fn compose_is_a_hardened_read_only_sidecar() {
let compose = include_str!("../compose.example.yml");
assert!(compose.contains("minecraft-data:/data:ro"));
assert!(compose.contains("read_only: true"));
assert!(compose.contains("cap_drop: [ALL]"));
assert!(compose.contains("no-new-privileges:true"));
assert!(!compose.contains("docker.sock"));
assert!(!compose.contains("ports:"));
}
#[test]
fn runtime_has_no_node_and_runs_non_root() {
let dockerfile = include_str!("../Dockerfile");
let runtime = dockerfile
.split("FROM debian:bookworm-slim AS runtime")
.nth(1)
.unwrap();
assert!(!runtime.contains("npm "));
assert!(!runtime.contains("node "));
assert!(runtime.contains("USER 10001:10001"));
}
+171
View File
@@ -0,0 +1,171 @@
use axum::{
extract::ConnectInfo,
http::{header, Request, StatusCode},
};
use futures_util::StreamExt;
use ipnet::IpNet;
use minecraft_log_viewer::{
auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService},
config::Config,
http::{router, AppState},
logs::index::{HistoryIndex, HistoryStore},
};
use std::{
net::SocketAddr,
sync::{atomic::AtomicUsize, Arc},
};
use tokio::sync::broadcast;
use tower::ServiceExt;
async fn app() -> (
axum::Router,
tempfile::TempDir,
broadcast::Sender<minecraft_log_viewer::logs::follower::LiveMessage>,
) {
let dir = tempfile::tempdir().unwrap();
let logs = dir.path().join("logs");
std::fs::create_dir(&logs).unwrap();
let latest = logs.join("latest.log");
std::fs::write(&latest, "[12:00:00] Alex[/203.0.113.4:51234] logged in\n").unwrap();
let whitelist_path = dir.path().join("whitelist.json");
let whitelist_json = br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#;
std::fs::write(&whitelist_path, whitelist_json).unwrap();
let config = Arc::new(Config {
listen_addr: "127.0.0.1:0".parse().unwrap(),
data_dir: dir.path().to_path_buf(),
log_dir: logs.clone(),
latest_log: latest.clone(),
whitelist: whitelist_path,
trust_proxy: true,
trusted_proxy_cidrs: vec![
"10.0.0.0/8".parse().unwrap(),
"127.0.0.1/32".parse().unwrap(),
],
client_ip_header: "x-forwarded-for".into(),
public_origin: "https://logs.example".into(),
initial_log_lines: 100,
max_history_lines: 100,
archive_cache_max_bytes: 1024,
archive_cache_max_files: 2,
max_archive_bytes: 4096,
ws_queue_capacity: 8,
max_ws_connections: 2,
redact_player_ips: true,
});
let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap());
auth.rebuild().await.unwrap();
let history = HistoryStore::new(HistoryIndex::scan(&logs).unwrap(), 1024, 2, 4096, true);
let (live_tx, _) = broadcast::channel(8);
let state = AppState {
config,
auth,
history,
live_tx: live_tx.clone(),
ip_policy: ClientIpPolicy {
trust_proxy: true,
trusted_proxies: vec![
"10.0.0.0/8".parse::<IpNet>().unwrap(),
"127.0.0.1/32".parse().unwrap(),
],
header: "x-forwarded-for".into(),
},
ws_count: Arc::new(AtomicUsize::new(0)),
};
(router(state), dir, live_tx)
}
fn request(peer: &str) -> Request<axum::body::Body> {
let mut request = Request::builder()
.uri("/api/logs/recent")
.body(axum::body::Body::empty())
.unwrap();
request
.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
request
}
#[tokio::test]
async fn authorized_direct_ip_receives_redacted_logs() {
let (app, _dir, _tx) = app().await;
let response = app.oneshot(request("203.0.113.4:5000")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 8192)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("IP REDACTED"));
assert!(!text.contains("203.0.113.4"));
}
#[tokio::test]
async fn untrusted_peer_cannot_spoof_an_authorized_forwarded_ip() {
let (app, _dir, _tx) = app().await;
let mut request = request("192.0.2.9:5000");
request
.headers_mut()
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn unexpected_browser_origin_is_rejected() {
let (app, _dir, _tx) = app().await;
let mut request = request("203.0.113.4:5000");
request
.headers_mut()
.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn authorized_websocket_receives_ordered_batches() {
use minecraft_log_viewer::logs::{follower::LiveMessage, LogLine};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let (app, _dir, tx) = app().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
let mut request = format!("ws://{address}/api/live")
.into_client_request()
.unwrap();
request
.headers_mut()
.insert(header::ORIGIN, "https://logs.example".parse().unwrap());
request
.headers_mut()
.insert("x-forwarded-for", "203.0.113.4".parse().unwrap());
let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap();
let hello = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert!(hello.contains("hello"));
tx.send(LiveMessage::LogLines {
lines: vec![
LogLine {
id: "1".into(),
timestamp: None,
text: "first".into(),
source: "latest.log".into(),
},
LogLine {
id: "2".into(),
timestamp: None,
text: "second".into(),
source: "latest.log".into(),
},
],
})
.unwrap();
let batch = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert!(batch.find("first").unwrap() < batch.find("second").unwrap());
server.abort();
}
+12
View File
@@ -0,0 +1,12 @@
use minecraft_log_viewer::auth::join_tracker::JoinTracker;
#[test]
fn realistic_fixture_authorizes_only_successful_ipv4_and_ipv6_joins() {
let mut tracker = JoinTracker::default();
let records = include_str!("../fixtures/join_sequences.log")
.lines()
.filter_map(|line| tracker.push(line))
.collect::<Vec<_>>();
assert_eq!(records.len(), 2);
assert_eq!(records[0].name, "Alex");
assert_eq!(records[1].name, "Steve");
}