Fix launcher verification session check and socket IP resolution

This commit is contained in:
SCOPEDD committed 2026-09-29 02:03:08 -04:00
1 parent 522e3a5717
commit c7aa563dac
6 files changed
+182 -26

No files matched your search

@@ -32,8 +32,10 @@ public final class Bridge {
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) { public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
Integration current = integration; Integration current = integration;
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE); if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
String ip = address instanceof InetSocketAddress socket && socket.getAddress() != null String ip = null;
? socket.getAddress().getHostAddress() : null; if (address instanceof InetSocketAddress socket) {
ip = socket.getAddress() != null ? socket.getAddress().getHostAddress() : socket.getHostString();
}
return current.login(uuid, name, ip); return current.login(uuid, name, ip);
} }
+8 -1
View File
@@ -259,9 +259,16 @@ pub fn panel_token(state: &AppState) -> Option<String> {
let panel = state.panel_url()?; let panel = state.panel_url()?;
let accounts = state.accounts.read().unwrap(); let accounts = state.accounts.read().unwrap();
let active = accounts.active()?; let active = accounts.active()?;
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) { if active.kind != "panel" {
return None; 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 state.secrets.get(&active.id).panel_token
} }
+23 -6
View File
@@ -9,20 +9,37 @@ use std::time::Duration;
pub struct Recorder { pub struct Recorder {
http: reqwest::Client, http: reqwest::Client,
panel: String, panel: String,
token: String, token: Option<String>,
username: Option<String>,
} }
pub fn capture(state: &AppState) -> Option<Recorder> { pub fn capture(state: &AppState) -> Option<Recorder> {
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 { impl Recorder {
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> { 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)) .post(format!("{}/api/v1/launcher/events", self.panel))
.bearer_auth(&self.token) .timeout(Duration::from_secs(5));
.timeout(Duration::from_secs(5)) if let Some(token) = &self.token {
.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: None }) req = req.bearer_auth(token);
}
req.json(&LaunchEvent {
kind: kind.into(),
instance_id: instance.into(),
username: self.username.clone(),
})
.send() .send()
.await? .await?
.error_for_status()?; .error_for_status()?;
+14 -1
View File
@@ -215,9 +215,22 @@ const MIGRATIONS: &[&str] = &[
WHEN NEW.uuid <> '' WHEN NEW.uuid <> ''
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END; 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#" r#"
ALTER TABLE instances ADD COLUMN logo_url TEXT; 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);
"#, "#,
]; ];
+2 -1
View File
@@ -168,9 +168,10 @@ pub async fn event(
// Remember where signed-in players launch from, so game servers can // Remember where signed-in players launch from, so game servers can
// require "joined through the launcher" (see game server settings). // require "joined through the launcher" (see game server settings).
if let (Some(ip), "launch") = (&ip, kind) { 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(user.id)
.bind(ip) .bind(ip)
.bind(&user.username)
.bind(crate::db::now()) .bind(crate::db::now())
.execute(&state.db) .execute(&state.db)
.await?; .await?;
+131 -15
View File
@@ -219,6 +219,93 @@ pub async fn login(
Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?)) 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::<std::net::IpAddr>(), r.parse::<std::net::IpAddr>()) 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<i64>,
req_ip: Option<&str>,
since: &str,
) -> AppResult<bool> {
let req_ip = match req_ip {
Some(ip) if !ip.trim().is_empty() => ip.trim(),
_ => return Ok(false),
};
let sessions: Vec<String> = 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( async fn check_login(
state: &AppState, state: &AppState,
server: &ServerRow, server: &ServerRow,
@@ -227,11 +314,18 @@ async fn check_login(
brand: &str, brand: &str,
) -> AppResult<LoginVerdict> { ) -> AppResult<LoginVerdict> {
let Some(user) = user else { let Some(user) = user else {
return Ok(if server.access == "all" && !server.require_launcher { if server.access == "all" {
LoginVerdict { allowed: true, message: None, account: None } 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 { } 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() { match user.status.as_str() {
"active" => {} "active" => {}
@@ -253,17 +347,7 @@ async fn check_login(
} }
if server.require_launcher { if server.require_launcher {
let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS)); let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS));
let launched: bool = match ip { let launched = verify_launcher_ip(state, Some(user.id), ip, &since).await?;
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,
};
if !launched { if !launched {
return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher."))); return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher.")));
} }
@@ -683,3 +767,35 @@ pub async fn live_summary(state: &AppState) -> AppResult<Value> {
"servers": servers.into_iter().map(view).collect::<Vec<_>>(), "servers": servers.into_iter().map(view).collect::<Vec<_>>(),
})) }))
} }
#[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"));
}
}