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