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, ) { 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, ip_whitelist_file: Some(dir.path().join("ip-whitelist.txt")), 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, }); std::fs::write(dir.path().join("ip-whitelist.txt"), "").unwrap(); 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::().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 { let mut request = Request::builder() .uri("/api/logs/recent") .body(axum::body::Body::empty()) .unwrap(); request .extensions_mut() .insert(ConnectInfo(peer.parse::().unwrap())); request } #[tokio::test] async fn frontend_security_policy_allows_its_external_assets() { let (app, _dir, _tx) = app().await; let mut request = Request::builder() .uri("/") .body(axum::body::Body::empty()) .unwrap(); request.extensions_mut().insert(ConnectInfo( "203.0.113.4:5000".parse::().unwrap(), )); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_SECURITY_POLICY], "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self'; script-src 'self'; frame-ancestors 'none'" ); let body = axum::body::to_bytes(response.into_body(), 8192) .await .unwrap(); let html = String::from_utf8(body.to_vec()).unwrap(); assert!(html.contains("rel=\"stylesheet\"")); assert!(!html.contains("(&body).unwrap()["client_ip"], "100.64.0.27" ); } #[tokio::test] async fn owner_ip_whitelist_is_a_hot_reloaded_final_fallback() { let (app, dir, _tx) = app().await; let peer = "100.64.0.27:5000"; assert_eq!( app.clone().oneshot(request(peer)).await.unwrap().status(), StatusCode::FORBIDDEN ); std::fs::write(dir.path().join("ip-whitelist.txt"), "100.64.0.27\n").unwrap(); assert_eq!( app.oneshot(request(peer)).await.unwrap().status(), StatusCode::OK ); } #[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::(), ) .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(); }