diff --git a/api/Cargo.toml b/api/Cargo.toml index 391b0d2..a619529 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -23,6 +23,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus flate2 = "1" sha2 = "0.10" hex = "0.4" +subtle = "2.5" # Constant-time comparison for security-sensitive operations # S3-compatible object storage (rust-s3 is lighter than AWS SDK) rust-s3 = { version = "0.35", default-features = false, features = ["tokio-rustls-tls"] } diff --git a/api/src/auth.rs b/api/src/auth.rs new file mode 100644 index 0000000..1fc24d1 --- /dev/null +++ b/api/src/auth.rs @@ -0,0 +1,127 @@ +//! Shared authentication utilities for API routes. +//! +//! This module provides common auth functions used across routes to ensure +//! consistent security practices like constant-time comparison. + +use axum::http::HeaderMap; +use sha2::Digest; +use subtle::ConstantTimeEq; + +/// Extracts and parses a Bearer token from the Authorization header. +/// +/// Returns `Some(token)` if a valid "Bearer " header is found, +/// `None` otherwise. +pub fn parse_bearer_token(headers: &HeaderMap) -> Option { + let auth = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .trim(); + let prefix = "bearer "; + if auth.len() <= prefix.len() { + return None; + } + if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + Some(auth[prefix.len()..].trim().to_string()) +} + +/// Computes SHA-256 hash of the input and returns it as a hex string. +pub fn sha256_hex(input: &str) -> String { + let mut h = sha2::Sha256::new(); + h.update(input.as_bytes()); + let out = h.finalize(); + hex::encode(out) +} + +/// Performs a constant-time comparison of two strings to prevent timing attacks. +/// +/// Returns `true` if the strings are equal, `false` otherwise. +/// The comparison time is constant regardless of how many characters match. +pub fn constant_time_eq(a: &str, b: &str) -> bool { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + + // Length check is unavoidable, but we still do a comparison + // to maintain constant time behavior + let len_match = a_bytes.len() == b_bytes.len(); + + if len_match { + a_bytes.ct_eq(b_bytes).into() + } else { + // Do a dummy comparison to maintain constant time + let dummy = vec![0u8; b_bytes.len()]; + let _ = dummy.as_slice().ct_eq(b_bytes); + false + } +} + +/// Validates a token hash against a stored hash using constant-time comparison. +/// +/// Returns `true` if the hashes match, `false` otherwise. +pub fn validate_token_hash(provided_hash: &str, stored_hash: &str) -> bool { + constant_time_eq(provided_hash, stored_hash) +} + +/// Extract server address for dashboard ping feature. +/// Priority: +/// 1. X-Server-Address header (explicit config from plugin) +/// 2. X-Forwarded-For header (if behind a proxy) +/// 3. X-Real-IP header (common proxy header) +pub fn extract_server_address(headers: &HeaderMap) -> Option { + // 1. Explicit header from plugin config takes priority + if let Some(addr) = headers + .get("x-server-address") + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + tracing::debug!(address = %addr, "using explicit X-Server-Address"); + return Some(addr); + } + + // 2. X-Forwarded-For (first IP in the chain, closest to client) + if let Some(forwarded) = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + { + // X-Forwarded-For can be comma-separated: "client, proxy1, proxy2" + if let Some(first_ip) = forwarded.split(',').next().map(|s| s.trim()) { + if !first_ip.is_empty() && !is_local_ip(first_ip) { + tracing::debug!(ip = %first_ip, "using X-Forwarded-For for server address"); + // Add default MC port + return Some(format!("{}:25565", first_ip)); + } + } + } + + // 3. X-Real-IP (single IP header, common with nginx) + if let Some(real_ip) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && !is_local_ip(s)) + { + tracing::debug!(ip = %real_ip, "using X-Real-IP for server address"); + return Some(format!("{}:25565", real_ip)); + } + + None +} + +/// Check if an IP string represents a local/loopback address. +fn is_local_ip(ip: &str) -> bool { + ip == "127.0.0.1" + || ip == "::1" + || ip == "localhost" + || ip.starts_with("10.") + || ip.starts_with("192.168.") + || ip.starts_with("172.16.") + || ip.starts_with("172.17.") + || ip.starts_with("172.18.") + || ip.starts_with("172.19.") + || ip.starts_with("172.2") + || ip.starts_with("172.30.") + || ip.starts_with("172.31.") +} diff --git a/api/src/config.rs b/api/src/config.rs index 42d00ac..7ca42c9 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -28,6 +28,9 @@ pub struct Config { pub local_store_dir: String, // CORS pub cors_allow_origins: Vec, + /// Explicitly opt-in to permissive CORS (for development only). + /// SECURITY: Must be explicitly set to true; defaults to false. + pub cors_permissive_dev: bool, } fn parse_bool_env(key: &str, default: bool) -> bool { @@ -111,7 +114,7 @@ impl Config { let local_store_dir = env::var("LOCAL_STORE_DIR").unwrap_or_else(|_| "./data/object_store".to_string()); - // Comma-separated list of allowed origins. Empty => permissive (dev) CORS. + // Comma-separated list of allowed origins. let cors_allow_origins = env::var("CORS_ALLOW_ORIGINS") .map(|v| { v.split(',') @@ -121,6 +124,9 @@ impl Config { }) .unwrap_or_default(); + // SECURITY: Permissive CORS must be explicitly enabled. Defaults to false. + let cors_permissive_dev = parse_bool_env("CORS_PERMISSIVE_DEV", false); + Self { host, port, @@ -144,6 +150,7 @@ impl Config { s3_secret_key, local_store_dir, cors_allow_origins, + cors_permissive_dev, } } } diff --git a/api/src/lib.rs b/api/src/lib.rs index a6ead20..d949266 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod builtin_modules; pub mod config; pub mod db; diff --git a/api/src/main.rs b/api/src/main.rs index b8de962..e9eb2a6 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -179,10 +179,28 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -/// Build a CORS layer. If `CORS_ALLOW_ORIGINS` is empty, fall back to permissive (dev). +/// Build a CORS layer. +/// SECURITY: Permissive CORS is only allowed when CORS_PERMISSIVE_DEV=true is explicitly set. +/// This prevents accidental permissive CORS in production. fn cors_layer(cfg: &Config) -> CorsLayer { if cfg.cors_allow_origins.is_empty() { - return CorsLayer::permissive(); + if cfg.cors_permissive_dev { + tracing::warn!( + "CORS_PERMISSIVE_DEV=true: using permissive CORS. DO NOT USE IN PRODUCTION!" + ); + return CorsLayer::permissive(); + } else { + // In production with no origins configured, use restrictive defaults + // This allows same-origin requests only + tracing::info!( + "CORS_ALLOW_ORIGINS is empty and CORS_PERMISSIVE_DEV is not set. \ + Using restrictive CORS (same-origin only). Set CORS_ALLOW_ORIGINS \ + or CORS_PERMISSIVE_DEV=true for cross-origin requests." + ); + return CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers([CONTENT_TYPE, AUTHORIZATION]); + } } let origins: Vec = cfg diff --git a/api/src/routes/callbacks.rs b/api/src/routes/callbacks.rs index c860a1b..50605a7 100644 --- a/api/src/routes/callbacks.rs +++ b/api/src/routes/callbacks.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::collections::HashSet; +use subtle::ConstantTimeEq; use uuid::Uuid; use crate::{error::ApiError, webhooks, AppState}; @@ -40,7 +41,28 @@ fn require_callback_auth(state: &AppState, headers: &HeaderMap) -> Result<(), Ap .and_then(|v| v.to_str().ok()) .unwrap_or(""); let expected = format!("Bearer {}", state.module_callback_token); - if state.module_callback_token.is_empty() || auth != expected { + + // Use constant-time comparison to prevent timing attacks + if state.module_callback_token.is_empty() { + return Err(ApiError::Unauthorized); + } + + let auth_bytes = auth.as_bytes(); + let expected_bytes = expected.as_bytes(); + + // Constant-time comparison: both length check and content check + // are done in a way that doesn't leak timing information + let len_match = auth_bytes.len() == expected_bytes.len(); + let content_match = if len_match { + auth_bytes.ct_eq(expected_bytes).into() + } else { + // Still do a comparison to maintain constant time even on length mismatch + let dummy = vec![0u8; expected_bytes.len()]; + let _ = dummy.as_slice().ct_eq(expected_bytes); + false + }; + + if !content_match { return Err(ApiError::Unauthorized); } Ok(()) diff --git a/api/src/routes/handshake.rs b/api/src/routes/handshake.rs index 4a9fbc1..6f2177c 100644 --- a/api/src/routes/handshake.rs +++ b/api/src/routes/handshake.rs @@ -5,7 +5,7 @@ use axum::{ }; use serde::Serialize; -use crate::{error::ApiError, AppState}; +use crate::{auth, error::ApiError, AppState}; #[derive(Debug, Serialize)] pub struct HandshakeResponse { @@ -15,40 +15,17 @@ pub struct HandshakeResponse { pub server_id: String, } -fn parse_bearer_token(headers: &HeaderMap) -> Option { - let auth = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .trim(); - let prefix = "bearer "; - if auth.len() <= prefix.len() { - return None; - } - if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) { - return None; - } - Some(auth[prefix.len()..].trim().to_string()) -} - -fn sha256_hex(input: &str) -> String { - use sha2::Digest; - let mut h = sha2::Sha256::new(); - h.update(input.as_bytes()); - let out = h.finalize(); - hex::encode(out) -} - /// POST /handshake /// /// Lightweight "hello" endpoint used by the plugin on startup. /// - Stores the server_id + token hash the first time we see a server. /// - Returns `waiting_for_registration` until the server is linked to an account (owner_user_id set). +/// - Optionally stores server address for dashboard ping feature (auto-detected or from X-Server-Address header). pub async fn handshake( State(state): State, headers: HeaderMap, ) -> Result<(StatusCode, Json), ApiError> { - let token = parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; + let token = auth::parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; let server_id = headers .get("x-server-id") @@ -66,7 +43,10 @@ pub async fn handshake( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let token_hash = sha256_hex(&token); + // Extract server address for ping feature (explicit header > forwarded-for > real-ip) + let server_address = auth::extract_server_address(&headers); + + let token_hash = auth::sha256_hex(&token); // Load or create server row. let row: Option<( @@ -94,17 +74,19 @@ pub async fn handshake( sqlx::query( r#" insert into public.servers - (id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at) + (id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at, callback_url) values - ($1, $2, now(), now(), $3, now()) + ($1, $2, now(), now(), $3, now(), $4) on conflict (id) do update set platform = coalesce(excluded.platform, servers.platform), - last_seen_at = now() + last_seen_at = now(), + callback_url = coalesce(excluded.callback_url, servers.callback_url) "#, ) .bind(&server_id) .bind(platform.as_deref()) .bind(&token_hash) + .bind(server_address.as_deref()) .execute(&state.db) .await .map_err(|e| { @@ -124,17 +106,26 @@ pub async fn handshake( Some((stored_hash_opt, owner_user_id, registered_at)) => { // Validate token FIRST before updating any state. // This prevents attackers from spoofing last_seen_at with invalid tokens. + // Uses constant-time comparison to prevent timing attacks. if let Some(stored_hash) = &stored_hash_opt { - if stored_hash != &token_hash { + if !auth::validate_token_hash(&token_hash, stored_hash) { return Err(ApiError::Unauthorized); } } - // Token is valid (or no token stored yet) - now bump last_seen_at. - let _ = sqlx::query("update public.servers set last_seen_at = now() where id = $1") - .bind(&server_id) - .execute(&state.db) - .await; + // Token is valid (or no token stored yet) - now bump last_seen_at and callback_url. + let _ = sqlx::query( + r#" + update public.servers + set last_seen_at = now(), + callback_url = coalesce($2, callback_url) + where id = $1 + "#, + ) + .bind(&server_id) + .bind(server_address.as_deref()) + .execute(&state.db) + .await; // If no token was stored, save this one. if stored_hash_opt.is_none() { diff --git a/api/src/routes/heartbeat.rs b/api/src/routes/heartbeat.rs index e29bb48..600be48 100644 --- a/api/src/routes/heartbeat.rs +++ b/api/src/routes/heartbeat.rs @@ -6,37 +6,13 @@ use axum::{extract::State, http::HeaderMap, Json}; use serde::Serialize; -use crate::{error::ApiError, AppState}; +use crate::{auth, error::ApiError, AppState}; #[derive(Serialize)] pub struct HeartbeatResponse { pub ok: bool, } -fn parse_bearer_token(headers: &HeaderMap) -> Option { - let auth = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .trim(); - let prefix = "bearer "; - if auth.len() <= prefix.len() { - return None; - } - if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) { - return None; - } - Some(auth[prefix.len()..].trim().to_string()) -} - -fn sha256_hex(input: &str) -> String { - use sha2::Digest; - let mut h = sha2::Sha256::new(); - h.update(input.as_bytes()); - let out = h.finalize(); - hex::encode(out) -} - /// POST /heartbeat /// /// Lightweight endpoint for plugin liveness. Updates last_seen_at without @@ -64,20 +40,19 @@ pub async fn heartbeat( } // Extract and validate token - let token = parse_bearer_token(&headers) + let token = auth::parse_bearer_token(&headers) .ok_or_else(|| ApiError::BadRequest("Authorization header is required".to_string()))?; if token.is_empty() { return Err(ApiError::Unauthorized); } - let token_hash = sha256_hex(&token); + let token_hash = auth::sha256_hex(&token); - // Verify token matches the server's registered token - let server_exists: Option<(String,)> = - sqlx::query_as("SELECT id FROM public.servers WHERE id = $1 AND auth_token_hash = $2") + // Fetch stored token hash for constant-time comparison + let stored_hash: Option<(Option,)> = + sqlx::query_as("SELECT auth_token_hash FROM public.servers WHERE id = $1") .bind(&server_id) - .bind(&token_hash) .fetch_optional(&state.db) .await .map_err(|e| { @@ -85,9 +60,21 @@ pub async fn heartbeat( ApiError::Internal })?; - if server_exists.is_none() { - // Either server doesn't exist or token doesn't match - return Err(ApiError::Unauthorized); + // Verify token using constant-time comparison + match stored_hash { + None => { + // Server doesn't exist + return Err(ApiError::Unauthorized); + } + Some((None,)) => { + // Server exists but has no token stored - reject + return Err(ApiError::Unauthorized); + } + Some((Some(stored),)) => { + if !auth::validate_token_hash(&token_hash, &stored) { + return Err(ApiError::Unauthorized); + } + } } // Update last_seen_at diff --git a/api/src/routes/ingest.rs b/api/src/routes/ingest.rs index 16b54ff..d96e6fe 100644 --- a/api/src/routes/ingest.rs +++ b/api/src/routes/ingest.rs @@ -12,7 +12,7 @@ use std::io::{BufRead, BufReader}; use uuid::Uuid; use crate::module_pipeline; -use crate::{error::ApiError, AppState}; +use crate::{auth, error::ApiError, AppState}; #[derive(Serialize)] pub struct IngestResponse { @@ -28,30 +28,6 @@ pub struct WaitingForRegistrationResponse { pub server_id: String, } -fn parse_bearer_token(headers: &HeaderMap) -> Option { - let auth = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .trim(); - let prefix = "bearer "; - if auth.len() <= prefix.len() { - return None; - } - if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) { - return None; - } - Some(auth[prefix.len()..].trim().to_string()) -} - -fn sha256_hex(input: &str) -> String { - use sha2::Digest; - let mut h = sha2::Sha256::new(); - h.update(input.as_bytes()); - let out = h.finalize(); - hex::encode(out) -} - /// POST /ingest /// /// Receives a gzipped NDJSON batch of packet records. @@ -93,8 +69,8 @@ pub async fn ingest( } // --- Auth (per-server token) --- - let token = parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; - let token_hash = sha256_hex(&token); + let token = auth::parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; + let token_hash = auth::sha256_hex(&token); // --- Optional metadata from headers --- let platform = headers @@ -102,6 +78,9 @@ pub async fn ingest( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Extract server address for ping feature (explicit header > forwarded-for > real-ip) + let server_address = auth::extract_server_address(&headers); + // --- Registration gate --- // We store the server + token hash the first time we see it, but we do not accept payloads // until the server is linked to a dashboard account (owner_user_id + registered_at). @@ -130,17 +109,19 @@ pub async fn ingest( sqlx::query( r#" insert into public.servers - (id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at) + (id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at, callback_url) values - ($1, $2, now(), now(), $3, now()) + ($1, $2, now(), now(), $3, now(), $4) on conflict (id) do update set platform = coalesce(excluded.platform, servers.platform), - last_seen_at = now() + last_seen_at = now(), + callback_url = coalesce(excluded.callback_url, servers.callback_url) "#, ) .bind(&server_id) .bind(platform.as_deref()) .bind(&token_hash) + .bind(server_address.as_deref()) .execute(&state.db) .await .map_err(|e| { @@ -161,17 +142,26 @@ pub async fn ingest( Some((stored_hash_opt, owner_user_id, registered_at)) => { // Validate token FIRST before updating any state. // This prevents attackers from spoofing last_seen_at with invalid tokens. + // Uses constant-time comparison to prevent timing attacks. if let Some(stored_hash) = &stored_hash_opt { - if stored_hash != &token_hash { + if !auth::validate_token_hash(&token_hash, stored_hash) { return Err(ApiError::Unauthorized); } } - // Token is valid (or no token stored yet) - now update last_seen_at. - let _ = sqlx::query("update public.servers set last_seen_at = now() where id = $1") - .bind(&server_id) - .execute(&state.db) - .await; + // Token is valid (or no token stored yet) - now update last_seen_at and callback_url. + let _ = sqlx::query( + r#" + update public.servers + set last_seen_at = now(), + callback_url = coalesce($2, callback_url) + where id = $1 + "#, + ) + .bind(&server_id) + .bind(server_address.as_deref()) + .execute(&state.db) + .await; // First time we see a token for an existing row: store it. if stored_hash_opt.is_none() { diff --git a/api/src/routes/observations.rs b/api/src/routes/observations.rs index bbf8069..5af784a 100644 --- a/api/src/routes/observations.rs +++ b/api/src/routes/observations.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{error::ApiError, AppState}; +use crate::{auth, error::ApiError, AppState}; #[derive(Debug, Deserialize)] pub struct CreateObservation { @@ -29,30 +29,6 @@ pub struct CreateObservationResponse { pub observation_id: Uuid, } -fn parse_bearer_token(headers: &HeaderMap) -> Option { - let auth = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .trim(); - let prefix = "bearer "; - if auth.len() <= prefix.len() { - return None; - } - if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) { - return None; - } - Some(auth[prefix.len()..].trim().to_string()) -} - -fn sha256_hex(input: &str) -> String { - use sha2::Digest; - let mut h = sha2::Sha256::new(); - h.update(input.as_bytes()); - let out = h.finalize(); - hex::encode(out) -} - /// POST /observations /// /// Creates a new cheat observation (recording) from the plugin. @@ -77,8 +53,8 @@ pub async fn create_observation( } // --- Auth (per-server token) --- - let token = parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; - let token_hash = sha256_hex(&token); + let token = auth::parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?; + let token_hash = auth::sha256_hex(&token); // --- Validate server is registered and token matches --- let row: Option<(Option, Option, Option>)> = sqlx::query_as( @@ -104,9 +80,9 @@ pub async fn create_observation( ))); } Some((stored_hash_opt, owner_user_id, registered_at)) => { - // Token must match + // Token must match (using constant-time comparison to prevent timing attacks) if let Some(stored_hash) = stored_hash_opt { - if stored_hash != token_hash { + if !auth::validate_token_hash(&token_hash, &stored_hash) { return Err(ApiError::Unauthorized); } } else { diff --git a/plugin/bukkit/src/main/java/md/thomas/asyncanticheat/bukkit/BukkitPacketCaptureListener.java b/plugin/bukkit/src/main/java/md/thomas/asyncanticheat/bukkit/BukkitPacketCaptureListener.java index f46fa5d..246e6a4 100644 --- a/plugin/bukkit/src/main/java/md/thomas/asyncanticheat/bukkit/BukkitPacketCaptureListener.java +++ b/plugin/bukkit/src/main/java/md/thomas/asyncanticheat/bukkit/BukkitPacketCaptureListener.java @@ -94,14 +94,16 @@ public void onPacketSend(PacketSendEvent event) { // but can be useful for context (e.g., teleports, entity positions). // For now, we capture minimal data to reduce bandwidth. final Player player = event.getPlayer(); - - // Check exemptions (same as serverbound) - // Note: We might want different rules for clientbound in the future - if (exemptionTracker.isExempt(player)) { + final String packetName = String.valueOf(event.getPacketType()); + + // IMPORTANT: Always allow PLAYER_ABILITIES packets through even for exempt players. + // This mirrors the serverbound handling - we need to capture when the server tells + // a player their flying ability state changes (allow_flying/flying flags). + // Without this, modules can't correlate flight permission changes with movement. + if (!"PLAYER_ABILITIES".equals(packetName) && exemptionTracker.isExempt(player)) { return; } - - final String packetName = String.valueOf(event.getPacketType()); + final Map fields = extractClientBoundFields(event); service.tryEnqueue(new PacketRecord( System.currentTimeMillis(), @@ -440,53 +442,54 @@ private static Map extractUseItem(@NotNull PacketReceiveEvent ev /** * Best-effort extraction for modern "use item on block" packets on newer Minecraft versions. - * We intentionally avoid directly referencing PacketEvents wrapper classes that may not exist - * in all supported builds, and instead extract by reflection. + * Tries WrapperPlayClientPlayerBlockPlacement first (1.12-1.18), then falls back to + * WrapperPlayClientUseItem for basic hand/sequence/rotation data (1.19+). */ @NotNull private static Map extractUseItemOnLike(@NotNull PacketReceiveEvent event) { final Map m = new HashMap<>(); + + // Try block placement wrapper first (works for 1.12-1.18 style packets) + boolean hasBlockData = false; try { - final Object wrapper = new WrapperPlayClientUseItem(event); - // Fallback: at least capture hand/sequence/yaw/pitch when available. - m.put("hand", ((WrapperPlayClientUseItem) wrapper).getHand().name()); - m.put("sequence", ((WrapperPlayClientUseItem) wrapper).getSequence()); - m.put("yaw", ((WrapperPlayClientUseItem) wrapper).getYaw()); - m.put("pitch", ((WrapperPlayClientUseItem) wrapper).getPitch()); - } catch (Throwable ignored) { - // ignore + final WrapperPlayClientPlayerBlockPlacement w = new WrapperPlayClientPlayerBlockPlacement(event); + final Vector3i pos = w.getBlockPosition(); + if (pos != null) { + m.put("x", pos.getX()); + m.put("y", pos.getY()); + m.put("z", pos.getZ()); + hasBlockData = true; + } + if (w.getFace() != null) { + m.put("face", w.getFace().name()); + } + if (w.getHand() != null) { + m.put("hand", w.getHand().name()); + } + if (w.getCursorPosition() != null) { + m.put("cursor_x", w.getCursorPosition().getX()); + m.put("cursor_y", w.getCursorPosition().getY()); + m.put("cursor_z", w.getCursorPosition().getZ()); + } + m.put("inside_block", w.getInsideBlock()); + m.put("sequence", w.getSequence()); + } catch (Throwable e) { + // Block placement wrapper not compatible - try use item wrapper below } - // Try extracting block interaction fields via reflection (position/face/cursor/inside_block) - try { - // Attempt to construct WrapperPlayClientPlayerBlockPlacement against this event if compatible. + // If no block data, try the use item wrapper for basic hand/rotation data + if (!hasBlockData) { try { - final WrapperPlayClientPlayerBlockPlacement w = new WrapperPlayClientPlayerBlockPlacement(event); - final Vector3i pos = w.getBlockPosition(); - if (pos != null) { - m.put("x", pos.getX()); - m.put("y", pos.getY()); - m.put("z", pos.getZ()); - } - if (w.getFace() != null) { - m.put("face", w.getFace().name()); - } - if (w.getHand() != null) { - m.put("hand", w.getHand().name()); - } - if (w.getCursorPosition() != null) { - m.put("cursor_x", w.getCursorPosition().getX()); - m.put("cursor_y", w.getCursorPosition().getY()); - m.put("cursor_z", w.getCursorPosition().getZ()); - } - m.put("inside_block", w.getInsideBlock()); - m.put("sequence", w.getSequence()); - } catch (Throwable ignored) { - // If wrapper construction isn't compatible, just keep partial fields. + final WrapperPlayClientUseItem wrapper = new WrapperPlayClientUseItem(event); + m.put("hand", wrapper.getHand().name()); + m.put("sequence", wrapper.getSequence()); + m.put("yaw", wrapper.getYaw()); + m.put("pitch", wrapper.getPitch()); + } catch (Throwable e) { + // Neither wrapper works - return what we have (possibly empty) } - } catch (Throwable ignored) { - // ignore } + return m; } diff --git a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatConfig.java b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatConfig.java index 85d0d02..052a044 100644 --- a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatConfig.java +++ b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatConfig.java @@ -27,6 +27,9 @@ public final class AsyncAnticheatConfig { private String apiUrl = DEFAULT_API_URL; private String apiToken = ""; private int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; + // Optional: server address for API ping feature (e.g., "play.myserver.com:25565") + // Leave empty for auto-detect (uses connection IP) + private String serverAddress = ""; // Dashboard linking private String dashboardUrl = DEFAULT_DASHBOARD_URL; @@ -92,6 +95,7 @@ private void loadFromMap(@NotNull Map data, @NotNull AcLogger lo apiUrl = getString(api, "url", apiUrl); apiToken = getString(api, "token", apiToken); timeoutSeconds = getInt(api, "timeout_seconds", timeoutSeconds); + serverAddress = getString(api, "server_address", serverAddress); } final Map dashboard = (Map) data.get("dashboard"); @@ -146,6 +150,9 @@ public void save(@NotNull File configFile, @NotNull AcLogger logger) { api.put("url", apiUrl); api.put("token", apiToken); api.put("timeout_seconds", timeoutSeconds); + // Optional: your server's public address for dashboard ping feature + // Leave empty for auto-detect, or set to "play.myserver.com:25565" + api.put("server_address", serverAddress); root.put("api", api); final Map dashboard = new LinkedHashMap<>(); @@ -256,6 +263,7 @@ private static List getStringList(Map map, String key, L @NotNull public String getApiUrl() { return apiUrl; } @NotNull public String getApiToken() { return apiToken; } public int getTimeoutSeconds() { return timeoutSeconds; } + @NotNull public String getServerAddress() { return serverAddress; } @NotNull public String getDashboardUrl() { return dashboardUrl; } @NotNull public String getSpoolDirName() { return spoolDirName; } diff --git a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatService.java b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatService.java index 8c37647..cbf27e5 100644 --- a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatService.java +++ b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/AsyncAnticheatService.java @@ -141,8 +141,10 @@ public boolean tryEnqueue(@NotNull PacketRecord record) { if (isDevMarker(record)) { return offerWithDropPolicy(record); } - if (Math.random() > config.getSampleRate()) { - return true; + // Sample rate: keep packets with probability = sampleRate + // If sampleRate is 0.5, we want to keep ~50% of packets + if (Math.random() >= config.getSampleRate()) { + return true; // Drop this packet (sampled out) } if (!PacketFilters.shouldCapture(config, record.getPacketName())) { return true; diff --git a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/DiskSpool.java b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/DiskSpool.java index 7766fd0..2bc30b9 100644 --- a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/DiskSpool.java +++ b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/DiskSpool.java @@ -44,8 +44,11 @@ File getSpoolDir() { File writeBatch(@NotNull List records, @NotNull String serverId, @NotNull String sessionId) { enforceMaxSize(); - final String name = "batch-" + Instant.now().toEpochMilli() + "-" + UUID.randomUUID() + ".ndjson.gz"; - final File out = new File(spoolDir, name); + // Write to a temp file first, then atomically rename to final name. + // This prevents corrupt/partial files from being picked up by the uploader. + final String baseName = "batch-" + Instant.now().toEpochMilli() + "-" + UUID.randomUUID(); + final File tempFile = new File(spoolDir, baseName + ".tmp"); + final File finalFile = new File(spoolDir, baseName + ".ndjson.gz"); // NOTE: Map.of rejects null values; PacketRecord fields may be null (e.g., Bungee can enqueue nulls). final Map meta = new HashMap<>(); @@ -54,7 +57,8 @@ File writeBatch(@NotNull List records, @NotNull String serverId, @ meta.put("created_at_ms", System.currentTimeMillis()); meta.put("event_count", records.size()); - try (FileOutputStream fos = new FileOutputStream(out); + boolean success = false; + try (FileOutputStream fos = new FileOutputStream(tempFile); GZIPOutputStream gzip = new GZIPOutputStream(fos); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(gzip, StandardCharsets.UTF_8))) { @@ -75,11 +79,37 @@ File writeBatch(@NotNull List records, @NotNull String serverId, @ writer.write("\n"); } writer.flush(); - return out; + success = true; } catch (Exception e) { - logger.error("[AsyncAnticheat] Failed to write spool batch: " + out.getAbsolutePath(), e); + logger.error("[AsyncAnticheat] Failed to write spool batch: " + tempFile.getAbsolutePath(), e); + } + + // Clean up temp file on failure, or rename to final name on success + if (!success) { + try { + Files.deleteIfExists(tempFile.toPath()); + } catch (Exception e) { + logger.warn("[AsyncAnticheat] Failed to delete temp file: " + tempFile.getAbsolutePath()); + } + return null; + } + + // Atomically rename temp file to final file + try { + if (tempFile.renameTo(finalFile)) { + return finalFile; + } else { + // Rename failed - try copy + delete as fallback + Files.copy(tempFile.toPath(), finalFile.toPath()); + Files.deleteIfExists(tempFile.toPath()); + return finalFile; + } + } catch (Exception e) { + logger.error("[AsyncAnticheat] Failed to finalize batch file: " + e.getMessage(), e); + // Clean up both files on failure try { - Files.deleteIfExists(out.toPath()); + Files.deleteIfExists(tempFile.toPath()); + Files.deleteIfExists(finalFile.toPath()); } catch (Exception ignored) {} return null; } diff --git a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/HttpUploader.java b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/HttpUploader.java index e4936e4..1717f72 100644 --- a/plugin/core/src/main/java/md/thomas/asyncanticheat/core/HttpUploader.java +++ b/plugin/core/src/main/java/md/thomas/asyncanticheat/core/HttpUploader.java @@ -86,13 +86,19 @@ void handshake() { } final String url = normalizeBaseUrl(config.getApiUrl()) + "/handshake"; - final HttpRequest req = HttpRequest.newBuilder() + final HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(config.getTimeoutSeconds())) .header("Authorization", "Bearer " + token) - .header("X-Server-Id", serverId) - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); + .header("X-Server-Id", serverId); + + // Send server address if configured (for dashboard ping feature) + final String serverAddr = config.getServerAddress(); + if (serverAddr != null && !serverAddr.isBlank()) { + reqBuilder.header("X-Server-Address", serverAddr.trim()); + } + + final HttpRequest req = reqBuilder.POST(HttpRequest.BodyPublishers.noBody()).build(); try { final HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); @@ -150,16 +156,22 @@ void uploadFile(@NotNull File file) { } final String url = normalizeBaseUrl(config.getApiUrl()) + "/ingest"; - final HttpRequest req = HttpRequest.newBuilder() + final HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(config.getTimeoutSeconds())) .header("Authorization", "Bearer " + token) .header("Content-Type", "application/x-ndjson") .header("Content-Encoding", "gzip") .header("X-Server-Id", serverId) - .header("X-Session-Id", sessionId) - .POST(HttpRequest.BodyPublishers.ofByteArray(body)) - .build(); + .header("X-Session-Id", sessionId); + + // Send server address if configured (for dashboard ping feature) + final String serverAddr = config.getServerAddress(); + if (serverAddr != null && !serverAddr.isBlank()) { + reqBuilder.header("X-Server-Address", serverAddr.trim()); + } + + final HttpRequest req = reqBuilder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); try { final HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); diff --git a/plugin/gradle.properties b/plugin/gradle.properties index a90b13b..00a0a4c 100644 --- a/plugin/gradle.properties +++ b/plugin/gradle.properties @@ -1,3 +1,3 @@ -pluginVersion=0.2.0 +pluginVersion=0.3.0 diff --git a/web/app/(dashboard)/dashboard/page.tsx b/web/app/(dashboard)/dashboard/page.tsx index 4b92ea6..0883dc8 100644 --- a/web/app/(dashboard)/dashboard/page.tsx +++ b/web/app/(dashboard)/dashboard/page.tsx @@ -597,19 +597,24 @@ function StatPanel({ suffix?: string; trend?: string; }) { + // Check if value is actually available (not placeholder) + const hasValue = value !== "—" && value !== null && value !== undefined; + return ( -
+
-

+

{label}

-

+

{value} - {suffix && ( + {suffix && hasValue && ( {suffix} )}

- {trend &&

{trend}

} + {trend && hasValue && ( +

{trend}

+ )}
); @@ -947,6 +952,9 @@ export default function DashboardPage() { // Build connection status list // Note: "API → Server" only shows if the server has a pingable address configured. // Without a callback_url, we can't TCP ping and showing red is misleading. + // When we have metrics but pluginLastSeenMs is -1, it means we couldn't reach the API + const apiReachable = connectionMetrics && connectionMetrics.apiLatencyMs > 0; + const connections = connectionMetrics ? [ { @@ -973,7 +981,9 @@ export default function DashboardPage() { lastSeenMs: connectionMetrics.pluginLastSeenMs, status: connectionMetrics.pluginOnline ? "excellent" - : "offline", + : apiReachable + ? "offline" + : "unknown", isLastSeen: true, }, ] @@ -1003,10 +1013,11 @@ export default function DashboardPage() { ? "bg-emerald-400" : conn.status === "good" ? "bg-amber-400" - : conn.status === "offline" || - conn.status === "unknown" - ? "bg-red-400" - : "bg-orange-400" + : conn.status === "unknown" + ? "bg-white/30" + : conn.status === "offline" + ? "bg-red-400" + : "bg-orange-400" )} />
@@ -1037,26 +1048,52 @@ export default function DashboardPage() {
All Systems - - {connectionMetrics?.pluginOnline && - connectionMetrics?.serverReachable - ? "Operational" - : connectionMetrics?.pluginOnline || - connectionMetrics?.serverReachable - ? "Partial" - : "Offline"} - + {(() => { + // Determine overall status based on available metrics + // If no server address is configured, don't require serverReachable + const hasServerAddress = !!connectionMetrics?.serverAddress; + const pluginOk = connectionMetrics?.pluginOnline ?? false; + const serverOk = connectionMetrics?.serverReachable ?? false; + // Check if we actually have metrics (API is reachable) + const apiOk = connectionMetrics && connectionMetrics.apiLatencyMs > 0; + + let status: "operational" | "partial" | "offline" | "unknown"; + if (!connectionMetrics || !apiOk) { + // No metrics available - API unreachable + status = "unknown"; + } else if (hasServerAddress) { + // Both plugin and server ping matter + if (pluginOk && serverOk) status = "operational"; + else if (pluginOk || serverOk) status = "partial"; + else status = "offline"; + } else { + // No server address configured - only plugin status matters + status = pluginOk ? "operational" : "offline"; + } + + return ( + + {status === "operational" + ? "Operational" + : status === "partial" + ? "Partial" + : status === "unknown" + ? "Unknown" + : "Offline"} + + ); + })()}
diff --git a/web/app/page.tsx b/web/app/page.tsx index 52e9701..d2d3b11 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -271,9 +271,11 @@ export default function HomePage() { dashboard. - AsyncAnticheat supports Paper, Spigot, BungeeCord, and Velocity. - The plugin uses PacketEvents for cross-platform packet - interception. + AsyncAnticheat supports all major server platforms including + Paper, Spigot, Purpur, Folia, and any proxy (BungeeCord, + Velocity, etc.). The plugin uses PacketEvents for cross-platform + packet interception. Note: the plugin must be installed on each + backend server. AsyncAnticheat ships with category modules for Combat (aim, diff --git a/web/content/configuration.mdx b/web/content/configuration.mdx index e2be6fd..0bd3bfd 100644 --- a/web/content/configuration.mdx +++ b/web/content/configuration.mdx @@ -18,6 +18,7 @@ api: url: "https://api.asyncanticheat.com" token: "your_api_token" timeout_seconds: 10 + server_address: "" # Optional: e.g., "play.myserver.com:25565" ``` | Option | Description | Default | @@ -25,6 +26,9 @@ api: | `url` | Base URL of your AsyncAnticheat API | Required | | `token` | Authentication token for API requests | Required | | `timeout_seconds` | Request timeout in seconds | `10` | +| `server_address` | Server address for dashboard ping (auto-detected if empty) | `""` | + +> **Note:** The `server_address` is automatically detected from your server's IP when the plugin connects to the API. Only set this manually if auto-detection doesn't work (e.g., behind NAT or using a different public address). ### Spool Settings @@ -127,8 +131,14 @@ OBJECT_STORE_CLEANUP_DRY_RUN="true" OBJECT_STORE_CLEANUP_INTERVAL_SECONDS="3600" OBJECT_STORE_TTL_DAYS="7" BATCH_INDEX_TTL_DAYS="7" + +# CORS +CORS_ALLOW_ORIGINS="https://asyncanticheat.com,https://dashboard.asyncanticheat.com" +CORS_PERMISSIVE_DEV="false" # Only set true for local development ``` +> **Security Note:** `CORS_PERMISSIVE_DEV` must be explicitly set to `true` to enable permissive CORS. This prevents accidental exposure in production. For production, always use `CORS_ALLOW_ORIGINS` to specify allowed domains. + ## Server-Specific Configuration ### Per-Server Settings diff --git a/web/content/dashboard/index.mdx b/web/content/dashboard/index.mdx index a1f1001..98c1479 100644 --- a/web/content/dashboard/index.mdx +++ b/web/content/dashboard/index.mdx @@ -50,9 +50,36 @@ Monitor your infrastructure health: | Status | Meaning | |--------|---------| | **Dashboard → API** | Latency from your browser to our API | -| **API → Server** | Ping from API to your Minecraft server | +| **API → Server** | TCP ping from API to your Minecraft server (optional) | | **Plugin Status** | Time since last data from your plugin | +#### How Plugin Status Works + +The plugin sends data to the API whenever there's activity. The "Plugin Status" shows how long ago the last data was received. A plugin is considered **online** if data was received within the last 2 hours. + +> **Note:** If your server has no players, the plugin may only send keep-alive pings infrequently. This is normal—the plugin batches packets and only uploads when there's meaningful data. + +#### API → Server Ping + +The "API → Server" connection status shows TCP ping results from our API to your Minecraft server (default port 25565). + +**Automatic Detection**: The server address is automatically detected from your plugin's IP address when it connects to the API. This happens automatically—no configuration needed for most setups. + +**Manual Override**: If auto-detection doesn't work (e.g., your server is behind NAT or uses a different public address), you can set the address explicitly in your plugin's `config.yml`: + +```yaml +api: + url: https://api.asyncanticheat.com + token: your-token-here + server_address: play.yourserver.com:25565 # Optional override +``` + +The priority order is: +1. Explicit `server_address` in config (if set) +2. Auto-detected from plugin's connection IP + +If no address is configured or detected, this metric is hidden and doesn't affect your overall status. + ## Navigation