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) {
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);
}
+8 -1
View File
@@ -259,9 +259,16 @@ pub fn panel_token(state: &AppState) -> Option<String> {
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
}
+23 -6
View File
@@ -9,20 +9,37 @@ use std::time::Duration;
pub struct Recorder {
http: reqwest::Client,
panel: String,
token: String,
token: Option<String>,
username: Option<String>,
}
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 {
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 })
.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()?;
+14 -1
View File
@@ -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);
"#,
];
+2 -1
View File
@@ -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?;
+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?))
}
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(
state: &AppState,
server: &ServerRow,
@@ -227,11 +314,18 @@ async fn check_login(
brand: &str,
) -> AppResult<LoginVerdict> {
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<Value> {
"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"));
}
}