diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index edc3fae..d86016d 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -32,8 +32,10 @@ public final class Bridge { public static CompletableFuture login(UUID uuid, String name, SocketAddress address) { Integration current = integration; if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE); - String ip = address instanceof InetSocketAddress socket && socket.getAddress() != null - ? socket.getAddress().getHostAddress() : null; + String ip = null; + if (address instanceof InetSocketAddress socket) { + ip = socket.getAddress() != null ? socket.getAddress().getHostAddress() : socket.getHostString(); + } return current.login(uuid, name, ip); } diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs index d75fe66..2a88cf0 100644 --- a/launcher/src-tauri/src/accounts.rs +++ b/launcher/src-tauri/src/accounts.rs @@ -259,9 +259,16 @@ pub fn panel_token(state: &AppState) -> Option { let panel = state.panel_url()?; let accounts = state.accounts.read().unwrap(); let active = accounts.active()?; - if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) { + if active.kind != "panel" { return None; } + if let Some(active_url) = &active.panel_url { + let a = active_url.trim_end_matches('/'); + let p = panel.trim_end_matches('/'); + if !a.eq_ignore_ascii_case(p) { + return None; + } + } state.secrets.get(&active.id).panel_token } diff --git a/launcher/src-tauri/src/telemetry.rs b/launcher/src-tauri/src/telemetry.rs index c742d2c..489bd6a 100644 --- a/launcher/src-tauri/src/telemetry.rs +++ b/launcher/src-tauri/src/telemetry.rs @@ -9,23 +9,40 @@ use std::time::Duration; pub struct Recorder { http: reqwest::Client, panel: String, - token: String, + token: Option, + username: Option, } pub fn capture(state: &AppState) -> Option { - Some(Recorder { http: state.http.clone(), panel: state.panel_url()?, token: accounts::panel_token(state)? }) + let panel = state.panel_url()?; + let accounts = state.accounts.read().unwrap(); + let active = accounts.active()?; + let token = accounts::panel_token(state); + Some(Recorder { + http: state.http.clone(), + panel, + token, + username: Some(active.username.clone()), + }) } impl Recorder { pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> { - self.http + let mut req = self + .http .post(format!("{}/api/v1/launcher/events", self.panel)) - .bearer_auth(&self.token) - .timeout(Duration::from_secs(5)) - .json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: None }) - .send() - .await? - .error_for_status()?; + .timeout(Duration::from_secs(5)); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + req.json(&LaunchEvent { + kind: kind.into(), + instance_id: instance.into(), + username: self.username.clone(), + }) + .send() + .await? + .error_for_status()?; Ok(()) } diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 6cd9745..a40312b 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -215,9 +215,22 @@ const MIGRATIONS: &[&str] = &[ WHEN NEW.uuid <> '' BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END; "#, - // 5: instance logo url. + // 5: instance logo url and flexible launcher sessions. r#" ALTER TABLE instances ADD COLUMN logo_url TEXT; + CREATE TABLE launcher_sessions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + username TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO launcher_sessions_new (user_id, ip, created_at) + SELECT user_id, ip, created_at FROM launcher_sessions; + DROP TABLE launcher_sessions; + ALTER TABLE launcher_sessions_new RENAME TO launcher_sessions; + CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at); + CREATE INDEX launcher_sessions_ip ON launcher_sessions(ip, created_at); "#, ]; diff --git a/panel/server/src/routes/public.rs b/panel/server/src/routes/public.rs index 672afb8..e084b5a 100644 --- a/panel/server/src/routes/public.rs +++ b/panel/server/src/routes/public.rs @@ -168,9 +168,10 @@ pub async fn event( // Remember where signed-in players launch from, so game servers can // require "joined through the launcher" (see game server settings). if let (Some(ip), "launch") = (&ip, kind) { - sqlx::query("INSERT INTO launcher_sessions (user_id, ip, created_at) VALUES (?, ?, ?)") + sqlx::query("INSERT INTO launcher_sessions (user_id, ip, username, created_at) VALUES (?, ?, ?, ?)") .bind(user.id) .bind(ip) + .bind(&user.username) .bind(crate::db::now()) .execute(&state.db) .await?; diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index ab754ef..b474288 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -219,6 +219,93 @@ pub async fn login( Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?)) } +fn canonical_ip(ip: std::net::IpAddr) -> std::net::IpAddr { + match ip { + std::net::IpAddr::V4(v4) => std::net::IpAddr::V4(v4), + std::net::IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + std::net::IpAddr::V4(v4) + } else { + std::net::IpAddr::V6(v6) + } + } + } +} + +fn is_private_or_local(ip: &std::net::IpAddr) -> bool { + match canonical_ip(*ip) { + std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), + std::net::IpAddr::V6(v6) => v6.is_loopback(), + } +} + +pub fn ips_match(sess_str: &str, req_str: &str) -> bool { + let s = sess_str.trim(); + let r = req_str.trim(); + if s.eq_ignore_ascii_case(r) { + return true; + } + let (Ok(ip_a), Ok(ip_b)) = (s.parse::(), r.parse::()) else { + return false; + }; + let a = canonical_ip(ip_a); + let b = canonical_ip(ip_b); + if a == b { + return true; + } + if a.is_loopback() && b.is_loopback() { + return true; + } + if is_private_or_local(&a) && is_private_or_local(&b) { + return true; + } + if is_private_or_local(&a) { + return true; + } + match (a, b) { + (std::net::IpAddr::V4(v4_a), std::net::IpAddr::V4(v4_b)) => { + v4_a.octets()[0..3] == v4_b.octets()[0..3] + } + (std::net::IpAddr::V6(v6_a), std::net::IpAddr::V6(v6_b)) => { + v6_a.segments()[0..4] == v6_b.segments()[0..4] + } + _ => false, + } +} + +async fn verify_launcher_ip( + state: &AppState, + user_id: Option, + req_ip: Option<&str>, + since: &str, +) -> AppResult { + let req_ip = match req_ip { + Some(ip) if !ip.trim().is_empty() => ip.trim(), + _ => return Ok(false), + }; + let sessions: Vec = match user_id { + Some(uid) => { + sqlx::query_scalar("SELECT ip FROM launcher_sessions WHERE user_id = ? AND created_at >= ? ORDER BY id DESC LIMIT 50") + .bind(uid) + .bind(since) + .fetch_all(&state.db) + .await? + } + None => { + sqlx::query_scalar("SELECT ip FROM launcher_sessions WHERE user_id IS NULL AND created_at >= ? ORDER BY id DESC LIMIT 50") + .bind(since) + .fetch_all(&state.db) + .await? + } + }; + for sess_ip in sessions { + if ips_match(&sess_ip, req_ip) { + return Ok(true); + } + } + Ok(false) +} + async fn check_login( state: &AppState, server: &ServerRow, @@ -227,11 +314,18 @@ async fn check_login( brand: &str, ) -> AppResult { let Some(user) = user else { - return Ok(if server.access == "all" && !server.require_launcher { - LoginVerdict { allowed: true, message: None, account: None } + if server.access == "all" { + if server.require_launcher { + let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS)); + let launched = verify_launcher_ip(state, None, ip, &since).await?; + if !launched { + return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher."))); + } + } + return Ok(LoginVerdict { allowed: true, message: None, account: None }); } else { - LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher.")) - }); + return Ok(LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher."))); + } }; match user.status.as_str() { "active" => {} @@ -253,17 +347,7 @@ async fn check_login( } if server.require_launcher { let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS)); - let launched: bool = match ip { - Some(ip) => { - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM launcher_sessions WHERE user_id = ? AND ip = ? AND created_at >= ?)") - .bind(user.id) - .bind(ip.trim()) - .bind(&since) - .fetch_one(&state.db) - .await? - } - None => true, - }; + let launched = verify_launcher_ip(state, Some(user.id), ip, &since).await?; if !launched { return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher."))); } @@ -683,3 +767,35 @@ pub async fn live_summary(state: &AppState) -> AppResult { "servers": servers.into_iter().map(view).collect::>(), })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ips_match() { + // Exact match + assert!(ips_match("1.2.3.4", "1.2.3.4")); + assert!(ips_match("203.0.113.9", "203.0.113.9")); + + // Loopback IPv4 & IPv6 + assert!(ips_match("127.0.0.1", "::1")); + assert!(ips_match("::1", "127.0.0.1")); + assert!(ips_match("127.0.0.1", "127.0.0.1")); + + // IPv4-mapped IPv6 + assert!(ips_match("::ffff:192.168.1.10", "192.168.1.10")); + assert!(ips_match("192.168.1.10", "::ffff:192.168.1.10")); + + // Docker bridge / private gateway recorded + assert!(ips_match("172.18.0.1", "192.168.1.50")); + assert!(ips_match("10.0.0.1", "10.0.0.2")); + + // Subnet /24 match + assert!(ips_match("203.0.113.5", "203.0.113.9")); + + // Different public networks do not match + assert!(!ips_match("198.51.100.1", "203.0.113.9")); + assert!(!ips_match("8.8.8.8", "1.1.1.1")); + } +}