diff --git a/CONTEXT.md b/CONTEXT.md index 4332cf690..3988dde71 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -151,6 +151,18 @@ The absolute budget from socket acceptance until the peer proves itself: 10 s on even when its blocking task has not started. Proof completion clears it before post-auth traffic. _Avoid_: pre-auth read deadline, read timeout, idle timeout (for this limit) +**Logon Limiter**: +The Gateway's in-memory caps on the logon port: three attempts per connection, ten failed logons +per address per minute, eight open logon connections per address, and a 200 ms pause before a +failed proof is answered. A refusal closes the socket. Per gateway process; a restart forgets it. +_Avoid_: rate limiter, throttle, brute-force lockout + +**Session Expiry**: +The one hour a `game_session` row stays valid after its logon. The world handshake refuses an +expired row like an absent one, and the Module's `reap_sessions` deletes it. Every logon rewrites +the row, so a returning Account always starts a fresh hour. +_Avoid_: session timeout, TTL (in prose) + ### Sharding and transfer **Transfer**: diff --git a/docs/quickstart.md b/docs/quickstart.md index 3123379be..ca57fd8c5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -473,8 +473,8 @@ Rules `--lan` enforces for you: - A **running** gateway is never silently rebound — switching modes is refused with the `dev down` to run first, rather than reported as "already up". -This is a LAN convenience for a contributor fixture, **not a deployment mode**: no rate limiting, no -TLS, 2004-era password hashing, and a fixture account whose password is written in this document. +This is a LAN convenience for a contributor fixture, **not a deployment mode**: no TLS, 2004-era +password hashing, and a fixture account whose password is written in this document. Only put it on a network you trust, and read the next section. --- @@ -497,8 +497,10 @@ are concrete, not precautionary: identity gate at all (`debug_set_health(any_guid, 0)`, `set_level`, `kill_nearest`, `teleport`, …). One anonymous call owns any character. This build is for the loopback harness; it must never reach a reachable node. -- **No rate limiting, no TLS, no brute-force lockout.** SRP6 proof attempts are unlimited, and the - logon tier has no per-IP cap. That is acceptable on loopback and nowhere else. +- **No TLS.** The logon port has the Logon Limiter (three attempts per connection, ten failed + logons per address per minute, eight open logon connections per address). The stored `game_session` + key expires one hour after logon and prevents later world handshakes. Existing World Sessions + continue. The SRP6 exchange itself is 2004-era and unencrypted. If you ever want to run this for real players, understand that the localhost → public-VPS delta is real hardening work (a plain build, a firewalled `:3000`, DoS guards, the realm address, launch diff --git a/docs/schema.md b/docs/schema.md index eb1dde228..13e50dadf 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -239,8 +239,10 @@ holds the Account's Alpha Test Tools authority. Existing Accounts receive that a end-appended default. New Accounts copy `game_alpha_test_tools_enrollment`, which starts enabled; changing enrollment never changes an existing Account. `game_session` holds the SRP6 session key **K**, which is what makes gateways stateless: the logon -flow writes it, and any world gateway can read it to complete the handshake. `game_operator` is a -singleton holding the one trusted operator identity, captured (not derived) by `claim_operator`. +flow writes it, and any world gateway can read it to complete the handshake. A row is valid for +one hour from its logon (`expires_at`): the handshake refuses an expired row like an absent one, +and `reap_sessions` deletes expired rows every five minutes. `game_operator` is a singleton +holding the one trusted operator identity, captured (not derived) by `claim_operator`. On a realm-core deployment these live on realm-core, and the world shards' copies are downgraded to a **write-through cache that is never read for auth**: a world shard's own `game_account`/ @@ -401,11 +403,11 @@ Two constraints survive the removal and bind any filter added later. ## 6. Scheduled tables -**23 scheduled tables** drive every periodic and deferred effect in the game. Nothing on a gateway +**24 scheduled tables** drive every periodic and deferred effect in the game. Nothing on a gateway timer decides gameplay. Recount and re-list them with: ```bash -grep -rn 'scheduled(' module/src --include='*.rs' # 23 tables plus 4 comment lines, 2026-09-03 +grep -rn 'scheduled(' module/src --include='*.rs' # 24 tables plus 4 comment lines, 2026-09-03 ``` | Scheduled table | Reducer | Cadence | Where | @@ -424,6 +426,7 @@ grep -rn 'scheduled(' module/src --include='*.rs' # 23 tables plus 4 comment l | `game_pet_care_schedule` | `tick_pet_care` | 7.5 s | `creatures/pet_care.rs:18` | | `game_gateway_lease_reaper_schedule` | `reap_gateway_leases` | 15 s | `gw.rs:71` | | `game_instance_reaper_schedule` | `reap_instances` | 60 s | `instance.rs:312` | +| `game_session_reaper_schedule` | `reap_sessions` | 5 min, armed lazily by `establish_session` | `auth.rs:127` | | `game_weather_schedule` | `tick_weather` | 10 min | `weather.rs:153` | | `game_pending_cast` | `fire_pending_cast` | one-shot at cast completion | `spell/tables.rs:643` | | `game_pending_spell_impact` | `fire_spell_impact` | one-shot at projectile landing | `spell/tables.rs:689` | @@ -435,7 +438,7 @@ grep -rn 'scheduled(' module/src --include='*.rs' # 23 tables plus 4 comment l | `game_creature_ai_relay_arrival` | `resume_relay_arrival` | one-shot on movement completion | `creatures/eventai/relay.rs:309` | The interval rows are inserted by `init` (`seed_scheduler_arming`, `module/src/seed.rs`), except the -transfer and mail-escrow reapers, which their own entry points arm lazily and idempotently. +transfer, mail-escrow and session reapers, which their own entry points arm lazily and idempotently. Scheduled reducers self-gate on `ctx.sender() == ctx.database_identity()` so they cannot be driven externally. diff --git a/gateway/src/logon/limiter.rs b/gateway/src/logon/limiter.rs new file mode 100644 index 000000000..a63fb969b --- /dev/null +++ b/gateway/src/logon/limiter.rs @@ -0,0 +1,383 @@ +//! Logon Limiter: the caps on SRP6 proof attempts, per connection and per peer address. +//! +//! Every challenge and every proof costs the gateway a modular exponentiation, and a wrong +//! password was free to retry without bound. The limiter is in-memory and per gateway process; +//! nothing here is durable, so a restart forgives every address. + +use std::collections::HashMap; +use std::fmt; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Logon attempts one connection may start. The classic client's own retry ceiling. +pub const ATTEMPTS_PER_CONNECTION: u32 = 3; +/// Failed logons one address may make per window; the rest of the window is refused. +pub const FAILURES_PER_WINDOW: u32 = 10; +pub const FAILURE_WINDOW: Duration = Duration::from_secs(60); +/// Open logon connections one address may hold at once. +pub const CONNECTIONS_PER_ADDRESS: usize = 8; +/// Pause before answering a failed proof, so a guess costs wall-clock time as well as a modexp. +pub const PROOF_FAILURE_DELAY: Duration = Duration::from_millis(200); + +const PRUNE_INTERVAL: Duration = Duration::from_secs(1); + +/// Why the limiter closed or refused a connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogonRefusal { + /// The address already holds `CONNECTIONS_PER_ADDRESS` open logon connections. + TooManyConnections, + /// The address failed `FAILURES_PER_WINDOW` logons inside the current window. + TooManyFailures, + /// This connection has used its `ATTEMPTS_PER_CONNECTION`. + AttemptsExhausted, +} + +impl fmt::Display for LogonRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooManyConnections => write!( + f, + "address holds {CONNECTIONS_PER_ADDRESS} open logon connections" + ), + Self::TooManyFailures => write!( + f, + "address failed {FAILURES_PER_WINDOW} logons within {}s", + FAILURE_WINDOW.as_secs() + ), + Self::AttemptsExhausted => { + write!( + f, + "connection used its {ATTEMPTS_PER_CONNECTION} logon attempts" + ) + } + } + } +} + +impl std::error::Error for LogonRefusal {} + +#[derive(Default)] +struct AddressRecord { + open_connections: usize, + /// Start of the failure window; `None` until the first failure. + window_started: Option, + failures: u32, +} + +impl AddressRecord { + /// Drop a window that has run out, so the next failure opens a fresh one. + fn roll_window(&mut self, now: Instant) { + if self + .window_started + .is_some_and(|started| now.duration_since(started) >= FAILURE_WINDOW) + { + self.window_started = None; + self.failures = 0; + } + } + + fn refuses(&mut self, now: Instant) -> bool { + self.roll_window(now); + self.failures >= FAILURES_PER_WINDOW + } + + fn record_failure(&mut self, now: Instant) { + self.roll_window(now); + self.window_started.get_or_insert(now); + self.failures = self.failures.saturating_add(1); + } + + /// Nothing left to remember: no open connection and no live window. + fn is_idle(&mut self, now: Instant) -> bool { + self.roll_window(now); + self.open_connections == 0 && self.window_started.is_none() + } +} + +#[derive(Default)] +struct TrackedAddresses { + records: HashMap, + last_pruned: Option, +} + +/// Shared across every logon connection of one listener. +#[derive(Default)] +pub struct LogonLimiter { + addresses: Mutex, +} + +impl LogonLimiter { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Admit one connection from `ip`, or refuse it before it costs a thread. Check this address + /// every time; prune idle addresses on the first admission at least one second after the last + /// scan. Admissions between scans only look up this address under the shared lock. + pub fn admit( + self: &Arc, + ip: IpAddr, + now: Instant, + ) -> Result { + let mut addresses = self.lock(); + if addresses + .last_pruned + .is_none_or(|last| now.duration_since(last) >= PRUNE_INTERVAL) + { + addresses.records.retain(|_, record| !record.is_idle(now)); + addresses.last_pruned = Some(now); + } + let record = addresses.records.entry(ip).or_default(); + if record.open_connections >= CONNECTIONS_PER_ADDRESS { + return Err(LogonRefusal::TooManyConnections); + } + if record.refuses(now) { + return Err(LogonRefusal::TooManyFailures); + } + record.open_connections += 1; + Ok(LogonConnection { + limiter: Arc::clone(self), + ip, + attempts: 0, + }) + } + + fn refuses(&self, ip: IpAddr, now: Instant) -> bool { + self.lock() + .records + .get_mut(&ip) + .is_some_and(|record| record.refuses(now)) + } + + fn record_failure(&self, ip: IpAddr, now: Instant) { + self.lock() + .records + .entry(ip) + .or_default() + .record_failure(now); + } + + fn release(&self, ip: IpAddr) { + if let Some(record) = self.lock().records.get_mut(&ip) { + record.open_connections = record.open_connections.saturating_sub(1); + } + } + + /// A poisoned lock only means a connection task panicked mid-update; the counts are still usable. + fn lock(&self) -> std::sync::MutexGuard<'_, TrackedAddresses> { + self.addresses + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + #[cfg(test)] + pub fn tracked_addresses(&self) -> usize { + self.lock().records.len() + } +} + +/// One admitted connection's share of the limits. Dropping it frees its address slot. +pub struct LogonConnection { + limiter: Arc, + ip: IpAddr, + attempts: u32, +} + +impl LogonConnection { + /// Called on every challenge, before any SRP math. Refuses once this connection or its + /// address is out of attempts. + pub fn start_attempt(&mut self, now: Instant) -> Result<(), LogonRefusal> { + if self.attempts >= ATTEMPTS_PER_CONNECTION { + return Err(LogonRefusal::AttemptsExhausted); + } + if self.limiter.refuses(self.ip, now) { + return Err(LogonRefusal::TooManyFailures); + } + self.attempts += 1; + Ok(()) + } + + /// Called after a refused challenge or a failed proof. Counts it against the address, and + /// returns the refusal that closes this connection once its attempts are spent. + pub fn record_failure(&mut self, now: Instant) -> Result<(), LogonRefusal> { + self.limiter.record_failure(self.ip, now); + if self.attempts >= ATTEMPTS_PER_CONNECTION { + return Err(LogonRefusal::AttemptsExhausted); + } + Ok(()) + } +} + +impl Drop for LogonConnection { + fn drop(&mut self) { + self.limiter.release(self.ip); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn ip(last: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 0, 0, last)) + } + + #[test] + fn a_connection_gets_three_attempts_then_closes_on_its_third_failure() { + let limiter = LogonLimiter::new(); + let now = Instant::now(); + let mut connection = limiter.admit(ip(1), now).unwrap(); + for _ in 0..ATTEMPTS_PER_CONNECTION - 1 { + connection.start_attempt(now).unwrap(); + connection.record_failure(now).unwrap(); + } + connection.start_attempt(now).unwrap(); + assert_eq!( + connection.record_failure(now), + Err(LogonRefusal::AttemptsExhausted) + ); + assert_eq!( + connection.start_attempt(now), + Err(LogonRefusal::AttemptsExhausted) + ); + } + + #[test] + fn an_address_is_refused_after_ten_failures_until_the_window_ends() { + let limiter = LogonLimiter::new(); + let start = Instant::now(); + for i in 0..FAILURES_PER_WINDOW { + let mut connection = limiter.admit(ip(1), start).unwrap(); + connection.start_attempt(start).unwrap(); + // Every attempt on a fresh connection is that connection's first, so none closes. + connection + .record_failure(start) + .unwrap_or_else(|_| panic!("failure {i} closed")); + } + assert_eq!( + limiter.admit(ip(1), start).err(), + Some(LogonRefusal::TooManyFailures), + "the eleventh attempt is refused at accept" + ); + assert!( + limiter.admit(ip(2), start).is_ok(), + "the window is per address" + ); + + let later = start + FAILURE_WINDOW - Duration::from_millis(1); + assert_eq!( + limiter.admit(ip(1), later).err(), + Some(LogonRefusal::TooManyFailures) + ); + let window_over = start + FAILURE_WINDOW; + assert!(limiter.admit(ip(1), window_over).is_ok()); + } + + #[test] + fn a_window_that_fills_mid_connection_refuses_the_next_challenge() { + let limiter = LogonLimiter::new(); + let now = Instant::now(); + let mut long_lived = limiter.admit(ip(1), now).unwrap(); + for _ in 0..FAILURES_PER_WINDOW { + let mut other = limiter.admit(ip(1), now).unwrap(); + other.start_attempt(now).unwrap(); + other.record_failure(now).unwrap(); + } + assert_eq!( + long_lived.start_attempt(now), + Err(LogonRefusal::TooManyFailures) + ); + } + + #[test] + fn an_address_holds_at_most_eight_open_connections() { + let limiter = LogonLimiter::new(); + let now = Instant::now(); + let held: Vec<_> = (0..CONNECTIONS_PER_ADDRESS) + .map(|_| limiter.admit(ip(1), now).unwrap()) + .collect(); + assert_eq!( + limiter.admit(ip(1), now).err(), + Some(LogonRefusal::TooManyConnections) + ); + assert!(limiter.admit(ip(2), now).is_ok()); + drop(held); + assert!( + limiter.admit(ip(1), now).is_ok(), + "closing a connection frees its slot" + ); + } + + #[test] + fn idle_addresses_are_pruned_at_the_interval_and_not_on_each_admission() { + let limiter = LogonLimiter::new(); + let start = Instant::now(); + drop(limiter.admit(ip(1), start).unwrap()); + drop( + limiter + .admit(ip(2), start + Duration::from_millis(999)) + .unwrap(), + ); + assert_eq!(limiter.tracked_addresses(), 2, "no second scan yet"); + + let _open = limiter + .admit(ip(3), start + Duration::from_secs(1)) + .unwrap(); + assert_eq!( + limiter.tracked_addresses(), + 1, + "both idle records are removed" + ); + } + + #[test] + fn address_windows_expire_even_between_cleanup_scans() { + let limiter = LogonLimiter::new(); + let start = Instant::now(); + let mut held = limiter.admit(ip(1), start).unwrap(); + for address in [ip(1), ip(2)] { + for _ in 0..FAILURES_PER_WINDOW { + let mut connection = limiter.admit(address, start).unwrap(); + connection.start_attempt(start).unwrap(); + connection.record_failure(start).unwrap(); + } + } + let before_expiry = start + FAILURE_WINDOW - Duration::from_millis(1); + drop(limiter.admit(ip(3), before_expiry).unwrap()); + assert_eq!( + limiter.admit(ip(2), before_expiry).err(), + Some(LogonRefusal::TooManyFailures) + ); + assert_eq!( + held.start_attempt(before_expiry), + Err(LogonRefusal::TooManyFailures) + ); + + let expired = start + FAILURE_WINDOW; + assert!(limiter.admit(ip(2), expired).is_ok()); + assert_eq!(held.start_attempt(expired), Ok(())); + assert_eq!(limiter.tracked_addresses(), 3, "no global cleanup was due"); + } + + #[test] + fn expired_failure_windows_are_pruned_on_the_next_cleanup() { + let limiter = LogonLimiter::new(); + let start = Instant::now(); + for last in 1..=50 { + let mut connection = limiter.admit(ip(last), start).unwrap(); + connection.start_attempt(start).unwrap(); + connection.record_failure(start).unwrap(); + } + assert_eq!(limiter.tracked_addresses(), 50, "live windows are kept"); + + let window_over = start + FAILURE_WINDOW; + let _open = limiter.admit(ip(200), window_over).unwrap(); + assert_eq!( + limiter.tracked_addresses(), + 1, + "only the address with an open connection remains" + ); + } +} diff --git a/gateway/src/logon/mod.rs b/gateway/src/logon/mod.rs index 3a5f2c9a4..7e1fcecb1 100644 --- a/gateway/src/logon/mod.rs +++ b/gateway/src/logon/mod.rs @@ -9,13 +9,17 @@ //! The only state this touches is the `game_account` read (salt/verifier) and the session write //! (K) — both via `LogonStore`. Everything else is per-connection handshake scratch. +pub mod limiter; + use crate::accept::{classify_accept_error, AcceptBackoff, AcceptOutcome}; use crate::read_deadline::{ is_io_deadline, DeadlineClock, IoDeadline, PreAuthDeadline, LOGON_AUTH_DEADLINE, }; use crate::{config::GatewayConfig, stdb::Coordinator}; use anyhow::{anyhow, Result}; +use limiter::{LogonConnection, LogonLimiter, PROOF_FAILURE_DELAY}; use std::io::{Read, Write}; +use std::time::Instant; use tokio::net::TcpListener; use wow_login_messages::all::CMD_AUTH_LOGON_CHALLENGE_Client; use wow_login_messages::errors::ExpectedOpcodeError; @@ -94,16 +98,25 @@ pub trait LogonStore: Send + Sync { /// Drive one logon connection to completion (challenge -> proof -> realm list). Returns when /// the client disconnects. Pure protocol logic; no async, no direct IO beyond `stream`. -/// One total pre-auth I/O budget covers every read and write until the proof succeeds. +/// One total pre-auth I/O budget covers every read and write until the proof succeeds, and a +/// fresh Logon Limiter connection caps the attempts. #[cfg(test)] pub fn handle_logon( stream: &mut S, store: &St, ) -> Result<()> { let mut deadline = PreAuthDeadline::after(LOGON_AUTH_DEADLINE); - handle_logon_with_deadline(stream, store, &mut deadline) + handle_logon_with_deadline(stream, store, &mut deadline, &mut loopback_connection()) +} + +#[cfg(test)] +fn loopback_connection() -> LogonConnection { + LogonLimiter::new() + .admit(std::net::Ipv4Addr::LOCALHOST.into(), Instant::now()) + .expect("a fresh limiter admits its first connection") } +/// A refused attempt ends the connection: the limiter's verdict is the error the listener logs. fn handle_logon_with_deadline< S: Read + Write + IoDeadline, St: LogonStore + ?Sized, @@ -112,6 +125,7 @@ fn handle_logon_with_deadline< stream: &mut S, store: &St, deadline: &mut PreAuthDeadline, + connection: &mut LogonConnection, ) -> Result<()> { // Per-connection scratch: the in-flight SRP proof + which account it is for (id AND the // normalized username — see `LogonStore::save_session`). `SrpProof` is consumed by @@ -139,12 +153,16 @@ fn handle_logon_with_deadline< "no logon packet before the total pre-auth deadline" )); } + Err(ExpectedOpcodeError::Io(e)) => { + return Err(anyhow::Error::new(e).context("logon read failed")); + } Err(e) => return Err(anyhow!("logon read error: {e}")), }; match msg { ClientOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE(c) => { - pending = handle_challenge(&mut deadline.io(stream), store, &c)?; + connection.start_attempt(Instant::now())?; + pending = handle_challenge(&mut deadline.io(stream), store, &c, connection)?; } ClientOpcodeMessage::CMD_AUTH_LOGON_PROOF(p) => { let Some((proof, account_id, username)) = pending.take() else { @@ -156,12 +174,12 @@ fn handle_logon_with_deadline< proof, account_id, &username, - p.client_public_key, - p.client_proof, + &p, + connection, )? .map(|id| (id, username)); + // The realm-list phase after a successful proof has no pre-auth deadline. if authenticated.is_some() { - // Proven. The realm-list phase that follows has no pre-auth deadline. deadline.finish(stream)?; } } @@ -199,17 +217,22 @@ fn handle_challenge( stream: &mut S, store: &St, c: &CMD_AUTH_LOGON_CHALLENGE_Client, + connection: &mut LogonConnection, ) -> Result> { + let mut refuse = |reply: CMD_AUTH_LOGON_CHALLENGE_Server| -> Result<_> { + let attempt = connection.record_failure(Instant::now()); + reply.write(&mut *stream)?; + attempt?; + Ok(None) + }; let username = c.account_name.to_uppercase(); let account = match store.account(&username)? { Some(a) if a.banned => { - CMD_AUTH_LOGON_CHALLENGE_Server::FailBanned.write(&mut *stream)?; - return Ok(None); + return refuse(CMD_AUTH_LOGON_CHALLENGE_Server::FailBanned); } Some(a) => a, None => { - CMD_AUTH_LOGON_CHALLENGE_Server::FailUnknownAccount.write(&mut *stream)?; - return Ok(None); + return refuse(CMD_AUTH_LOGON_CHALLENGE_Server::FailUnknownAccount); } }; @@ -227,8 +250,7 @@ fn handle_challenge( "logon: account {} ({username}) has no SRP6 verifier (not provisioned) — rejecting", account.id ); - CMD_AUTH_LOGON_CHALLENGE_Server::FailUnknownAccount.write(&mut *stream)?; - return Ok(None); + return refuse(CMD_AUTH_LOGON_CHALLENGE_Server::FailUnknownAccount); } let ns = NormalizedString::new(&username).map_err(|e| anyhow!("bad username: {e}"))?; let proof = SrpVerifier::from_database_values(ns, account.verifier, account.salt).into_proof(); @@ -255,24 +277,20 @@ fn handle_proof( proof: SrpProof, account_id: u64, username: &str, - client_public_key: [u8; 32], - client_proof: [u8; 20], + packet: &wow_login_messages::version_3::CMD_AUTH_LOGON_PROOF_Client, + connection: &mut LogonConnection, ) -> Result> { - let client_public_key = match PublicKey::from_le_bytes(client_public_key) { - Ok(k) => k, - Err(_) => { - CMD_AUTH_LOGON_PROOF_Server::FailIncorrectPassword.write(&mut *stream)?; - return Ok(None); - } - }; - - let (server, server_proof) = match proof.into_server(client_public_key, client_proof) { - Ok(v) => v, - Err(_) => { - // Proof mismatch == wrong password. Common; not an error. - CMD_AUTH_LOGON_PROOF_Server::FailIncorrectPassword.write(&mut *stream)?; - return Ok(None); - } + let verified = PublicKey::from_le_bytes(packet.client_public_key) + .ok() + .and_then(|key| proof.into_server(key, packet.client_proof).ok()); + let Some((server, server_proof)) = verified else { + // A failed reply must still spend the address budget. Deliver the final permitted + // failure reply before ending the connection. + let attempt = connection.record_failure(Instant::now()); + std::thread::sleep(PROOF_FAILURE_DELAY); + CMD_AUTH_LOGON_PROOF_Server::FailIncorrectPassword.write(&mut *stream)?; + attempt?; + return Ok(None); }; let session_key = *server.session_key(); @@ -317,6 +335,7 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { // for the policy and for the errno that actually killed the gateway on 2026-08-07. let mut backoff = AcceptBackoff::new(); let mut capacity_was_full = false; + let limiter = LogonLimiter::new(); loop { let (sock, peer) = match listener.accept().await { Ok(pair) => { @@ -346,6 +365,14 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { } }, }; + // Refused before the socket costs a deadline, a permit or a thread. + let mut connection = match limiter.admit(peer.ip(), Instant::now()) { + Ok(connection) => connection, + Err(refusal) => { + log::debug!("logon: refused {peer}: {refusal}"); + continue; + } + }; // The budget begins at accept, before this socket can wait for a blocking-pool thread. let mut deadline = PreAuthDeadline::after(LOGON_AUTH_DEADLINE); let Some(task_permit) = cfg.blocking_task_capacity.try_admit() else { @@ -383,8 +410,22 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { let _task_permit = task_permit; let store = CoordinatorStore::new(coord); let mut s = std_sock; - if let Err(e) = handle_logon_with_deadline(&mut s, &store, &mut deadline) { - log::warn!("logon session {peer} ended: {e:#}"); + if let Err(e) = + handle_logon_with_deadline(&mut s, &store, &mut deadline, &mut connection) + { + let disconnected = e.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + ) + }); + if e.is::() || disconnected { + log::debug!("logon connection {peer} ended: {e:#}"); + } else { + log::warn!("logon connection {peer} ended: {e:#}"); + } } }); } @@ -1752,6 +1793,267 @@ mod tests { drop(client); } + /// One failed logon attempt: a challenge for `username`, then a proof for `wrong_password`, + /// answered with FailIncorrectPassword. + fn fail_proof(client: &mut UnixStream, username: &str, wrong_password: &str) { + challenge_client(username).write(&mut *client).unwrap(); + let (g, n, s_salt, server_pubkey) = match ServerOpcodeMessage::read(&mut *client).unwrap() { + ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( + CMD_AUTH_LOGON_CHALLENGE_Server::Success { + generator, + large_safe_prime, + salt, + server_public_key, + .. + }, + ) => (generator, large_safe_prime, salt, server_public_key), + other => panic!("expected challenge success, got {other:?}"), + }; + let n: [u8; 32] = n.try_into().unwrap(); + let challenge = SrpClientChallenge::new( + ns(username), + ns(wrong_password), + g[0], + n, + PublicKey::from_le_bytes(server_pubkey).unwrap(), + s_salt, + ); + wow_login_messages::version_3::CMD_AUTH_LOGON_PROOF_Client { + client_public_key: *challenge.client_public_key(), + client_proof: *challenge.client_proof(), + crc_hash: [0u8; 20], + telemetry_keys: vec![], + security_flag: + wow_login_messages::version_3::CMD_AUTH_LOGON_PROOF_Client_SecurityFlag::None, + } + .write(&mut *client) + .unwrap(); + match ServerOpcodeMessage::read(&mut *client).unwrap() { + ServerOpcodeMessage::CMD_AUTH_LOGON_PROOF( + CMD_AUTH_LOGON_PROOF_Server::FailIncorrectPassword, + ) => {} + other => panic!("expected FailIncorrectPassword, got {other:?}"), + } + } + + fn socket_is_closed(client: &mut UnixStream) -> bool { + let mut byte = [0u8]; + matches!(client.read(&mut byte), Ok(0)) + } + + #[test] + fn the_third_failed_proof_closes_the_connection() { + let store = provisioned_store("TEST", "PASSWORD", false); + let (mut client, server_end) = UnixStream::pair().unwrap(); + let server = std::thread::spawn(move || { + let mut s = server_end; + handle_logon(&mut s, &store) + }); + + for _ in 0..limiter::ATTEMPTS_PER_CONNECTION { + fail_proof(&mut client, "TEST", "WRONGPASS"); + } + + assert!( + socket_is_closed(&mut client), + "the client still saw its last FailIncorrectPassword, then lost the socket" + ); + let err = server + .join() + .unwrap() + .expect_err("the handler ends with the limiter's verdict"); + assert!(err.to_string().contains("logon attempts"), "{err:#}"); + } + + #[test] + fn the_third_refused_challenge_is_answered_before_the_connection_closes() { + let store = provisioned_store("TEST", "PASSWORD", false); + let (mut client, mut stream) = UnixStream::pair().unwrap(); + let server = std::thread::spawn(move || handle_logon(&mut stream, &store)); + for _ in 0..limiter::ATTEMPTS_PER_CONNECTION { + challenge_client("UNKNOWN").write(&mut client).unwrap(); + assert!(matches!( + ServerOpcodeMessage::read(&mut client).unwrap(), + ServerOpcodeMessage::CMD_AUTH_LOGON_CHALLENGE( + CMD_AUTH_LOGON_CHALLENGE_Server::FailUnknownAccount + ) + )); + } + assert!(socket_is_closed(&mut client)); + let error = server.join().unwrap().unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&limiter::LogonRefusal::AttemptsExhausted) + ); + } + + struct FailedReply { + input: std::io::Cursor>, + opcode: u8, + } + + impl Read for FailedReply { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + self.input.read(bytes) + } + } + + impl Write for FailedReply { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.first() == Some(&self.opcode) { + return Err(std::io::ErrorKind::BrokenPipe.into()); + } + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl IoDeadline for FailedReply { + fn set_read_timeout(&self, _: Option) -> std::io::Result<()> { + Ok(()) + } + + fn set_write_timeout(&self, _: Option) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn failed_proof_replies_still_spend_the_address_budget() { + let store = provisioned_store("TEST", "PASSWORD", false); + let limiter = LogonLimiter::new(); + let peer = std::net::Ipv4Addr::LOCALHOST.into(); + let mut packets = Vec::new(); + challenge_client("TEST").write(&mut packets).unwrap(); + wow_login_messages::version_3::CMD_AUTH_LOGON_PROOF_Client { + client_public_key: [0; 32], + client_proof: [0; 20], + crc_hash: [0; 20], + telemetry_keys: vec![], + security_flag: + wow_login_messages::version_3::CMD_AUTH_LOGON_PROOF_Client_SecurityFlag::None, + } + .write(&mut packets) + .unwrap(); + + for _ in 0..limiter::FAILURES_PER_WINDOW { + let mut connection = limiter.admit(peer, Instant::now()).unwrap(); + let mut stream = FailedReply { + input: std::io::Cursor::new(packets.clone()), + opcode: 1, + }; + let error = handle_logon_with_deadline( + &mut stream, + &store, + &mut PreAuthDeadline::after(LOGON_AUTH_DEADLINE), + &mut connection, + ) + .expect_err("the failed proof reply cannot be delivered"); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + std::io::ErrorKind::BrokenPipe + ); + } + assert!(matches!( + limiter.admit(peer, Instant::now()), + Err(limiter::LogonRefusal::TooManyFailures) + )); + assert!(store.saved.lock().unwrap().is_none()); + } + + #[test] + fn failed_challenge_replies_still_spend_the_address_budget() { + let mut unprovisioned = provisioned_store("TEST", "PASSWORD", false); + unprovisioned.account.verifier = [0; 32]; + for (username, store) in [ + ("UNKNOWN", provisioned_store("TEST", "PASSWORD", false)), + ("TEST", provisioned_store("TEST", "PASSWORD", true)), + ("TEST", unprovisioned), + ] { + let limiter = LogonLimiter::new(); + let peer = std::net::Ipv4Addr::LOCALHOST.into(); + let mut packet = Vec::new(); + challenge_client(username).write(&mut packet).unwrap(); + for _ in 0..limiter::FAILURES_PER_WINDOW { + let mut connection = limiter.admit(peer, Instant::now()).unwrap(); + let mut stream = FailedReply { + input: std::io::Cursor::new(packet.clone()), + opcode: 0, + }; + let error = handle_logon_with_deadline( + &mut stream, + &store, + &mut PreAuthDeadline::after(LOGON_AUTH_DEADLINE), + &mut connection, + ) + .expect_err("the challenge refusal cannot be delivered"); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + std::io::ErrorKind::BrokenPipe + ); + } + assert!(matches!( + limiter.admit(peer, Instant::now()), + Err(limiter::LogonRefusal::TooManyFailures) + )); + } + } + + #[test] + fn a_failed_proof_is_answered_after_the_failure_delay() { + let store = provisioned_store("TEST", "PASSWORD", false); + let (mut client, server_end) = UnixStream::pair().unwrap(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let _ = handle_logon(&mut s, &store); + }); + + let started = Instant::now(); + fail_proof(&mut client, "TEST", "WRONGPASS"); + assert!( + started.elapsed() >= PROOF_FAILURE_DELAY, + "a wrong proof was answered in {:?}", + started.elapsed() + ); + + drop(client); + server.join().unwrap(); + } + + #[test] + fn an_address_that_exhausted_its_window_is_closed_at_the_next_challenge() { + let store = provisioned_store("TEST", "PASSWORD", false); + let limiter = LogonLimiter::new(); + let peer: std::net::IpAddr = std::net::Ipv4Addr::new(10, 0, 0, 1).into(); + let now = Instant::now(); + let mut connection = limiter.admit(peer, now).unwrap(); + for _ in 0..limiter::FAILURES_PER_WINDOW { + let mut other = limiter.admit(peer, now).unwrap(); + other.start_attempt(now).unwrap(); + other.record_failure(now).unwrap(); + } + let (mut client, server_end) = UnixStream::pair().unwrap(); + let server = std::thread::spawn(move || { + let mut s = server_end; + let mut deadline = PreAuthDeadline::after(LOGON_AUTH_DEADLINE); + handle_logon_with_deadline(&mut s, &store, &mut deadline, &mut connection) + }); + + challenge_client("TEST").write(&mut client).unwrap(); + assert!( + socket_is_closed(&mut client), + "no challenge reply: the connection is closed before any SRP math" + ); + let err = server.join().unwrap().expect_err("the limiter's verdict"); + assert_eq!( + err.downcast_ref::(), + Some(&limiter::LogonRefusal::TooManyFailures) + ); + } + /// Client side of challenge -> proof for a provisioned account; asserts the proof succeeds. fn prove(client: &mut UnixStream, username: &str, password: &str) { challenge_client(username).write(&mut *client).unwrap(); @@ -1802,7 +2104,7 @@ mod tests { let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); let server = std::thread::spawn(move || { let mut s = server_end; - handle_logon_with_deadline(&mut s, &store, &mut deadline) + handle_logon_with_deadline(&mut s, &store, &mut deadline, &mut loopback_connection()) }); let err = server @@ -1821,12 +2123,16 @@ mod tests { ); } + /// The uncapped pre-auth request (an unauthenticated realm list gets an empty reply) must + /// still share the one total deadline; challenges are capped by the Logon Limiter instead. #[test] - fn repeated_challenges_cannot_extend_the_pre_auth_deadline() { + fn repeated_pre_auth_requests_cannot_extend_the_pre_auth_deadline() { let store = provisioned_store("TEST", "PASSWORD", false); let mut input = Vec::new(); for _ in 0..64 { - challenge_client("TEST").write(&mut input).unwrap(); + wow_login_messages::version_8::CMD_REALM_LIST_Client {} + .write(&mut input) + .unwrap(); } let start = Instant::now(); let clock = ManualClock(Rc::new(Cell::new(start))); @@ -1841,8 +2147,13 @@ mod tests { write_timeout_calls: Rc::new(Cell::new(0)), }; - let error = handle_logon_with_deadline(&mut stream, &store, &mut deadline) - .expect_err("complete challenge retries must share one total deadline"); + let error = handle_logon_with_deadline( + &mut stream, + &store, + &mut deadline, + &mut loopback_connection(), + ) + .expect_err("complete pre-auth requests must share one total deadline"); assert!( error.to_string().contains("pre-auth I/O deadline"), @@ -1850,7 +2161,7 @@ mod tests { ); assert!( !stream.output.is_empty(), - "at least one complete challenge must make progress before the deadline" + "at least one complete request must make progress before the deadline" ); } @@ -1873,8 +2184,13 @@ mod tests { write_timeout_calls: write_timeout_calls.clone(), }; - let error = handle_logon_with_deadline(&mut stream, &store, &mut deadline) - .expect_err("the challenge reply must not write past the total deadline"); + let error = handle_logon_with_deadline( + &mut stream, + &store, + &mut deadline, + &mut loopback_connection(), + ) + .expect_err("the challenge reply must not write past the total deadline"); assert_eq!( write_timeout_calls.get(), @@ -1900,7 +2216,7 @@ mod tests { let mut deadline = PreAuthDeadline::after(Duration::from_millis(200)); let server = std::thread::spawn(move || { let mut s = server_end; - handle_logon_with_deadline(&mut s, &store, &mut deadline) + handle_logon_with_deadline(&mut s, &store, &mut deadline, &mut loopback_connection()) }); prove(&mut client, "TEST", "PASSWORD"); diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index 32be0976c..2b2c3043f 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -37,6 +37,26 @@ use crate::config::{HomeShard, ShardMap}; use crate::stdb::{AccountRow, RealmRow}; use crate::world::WorldSession; +/// The `game_session` row as the world handshake reads it: K and the end of its validity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SessionKey { + pub key: [u8; 40], + pub expires_at_micros: i64, +} + +/// Expiry includes the deadline itself, as in the Module reaper. +pub(crate) fn session_expired(now_micros: i64, expires_at_micros: i64) -> bool { + expires_at_micros <= now_micros +} + +fn now_micros() -> i64 { + let micros = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_micros(); + i64::try_from(micros).unwrap_or(i64::MAX) +} + /// A SpacetimeDB database handle, reduced to exactly what the realm-core split touches. /// /// Implemented by [`crate::stdb::Coordinator`] (production) and [`fake::Handle`] (tests). Every @@ -59,7 +79,7 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { // --- auth reads fn account_by_username(&self, username: &str) -> Result>; - fn session_key(&self, account_id: u64) -> Result>; + fn session_key(&self, account_id: u64) -> Result>; fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]>; fn character_count(&self, account_id: u64) -> Result; fn realm(&self) -> Result; @@ -169,9 +189,14 @@ pub(crate) fn lookup_session( // (subscribe to the account row, disconnect on `banned`), not a one-shot check at handshake // time. Adding one here would also change unconfigured-realm-core behavior, which the split // promises it will not. Ceiling: a ban does not take effect until the player's next logon. - let Some(session_key) = realm_core.session_key(authoritative.id)? else { + let Some(session) = realm_core.session_key(authoritative.id)? else { return Ok(None); }; + // Expired is refused exactly like absent: the client has to log on again. + if session_expired(now_micros(), session.expires_at_micros) { + log::info!("account {account_name}: session expired; refusing the world handshake"); + return Ok(None); + } let Some(local) = db.account_by_username(account_name)? else { // `Ok(None)` (reject the handshake), NOT the id from realm-core: that id names whichever // account this world shard happened to issue it to. Unreachable with realm-core @@ -187,7 +212,7 @@ pub(crate) fn lookup_session( }; Ok(Some(WorldSession { account_id: local.id, - session_key, + session_key: session.key, })) } @@ -500,6 +525,9 @@ pub(crate) mod fake { use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; + /// A session expiry no test clock reaches. + pub(crate) const NEVER: i64 = i64::MAX; + /// One "database". #[derive(Default)] pub(crate) struct Db { @@ -507,8 +535,8 @@ pub(crate) mod fake { pub accounts: Mutex>, // Test-double storage: each map mirrors one module TABLE's columns, so the tuple is the row shape. #[allow(clippy::type_complexity)] - /// `game_session`: account_id -> (K, bound identity). - pub sessions: Mutex>, + /// `game_session`: account_id -> (K, bound identity, expires_at micros). + pub sessions: Mutex>, // Test-double storage: each map mirrors one module TABLE's columns, so the tuple is the row shape. #[allow(clippy::type_complexity)] /// `game_character`: guid -> (map, instance). Also the character COUNT per account. @@ -580,7 +608,6 @@ pub(crate) mod fake { .get(name) .expect("no such database in the fake realm") } - } /// Build a realm. `dbs` are the database names (the first is the default world shard), `rules` @@ -660,7 +687,7 @@ pub(crate) mod fake { db.note(&format!("account_by_username({username})")); Ok(db.accounts.lock().unwrap().get(username).cloned()) } - fn session_key(&self, account_id: u64) -> Result> { + fn session_key(&self, account_id: u64) -> Result> { let db = self.store(); db.note(&format!("session_key({account_id})")); Ok(db @@ -668,7 +695,10 @@ pub(crate) mod fake { .lock() .unwrap() .get(&account_id) - .map(|(k, _)| *k)) + .map(|(key, _, expires_at_micros)| SessionKey { + key: *key, + expires_at_micros: *expires_at_micros, + })) } fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { let db = self.store(); @@ -726,7 +756,7 @@ pub(crate) mod fake { db.sessions .lock() .unwrap() - .insert(account_id, (*session_key, bound_identity)); + .insert(account_id, (*session_key, bound_identity, NEVER)); Ok(()) } fn request_gm_command( @@ -811,7 +841,7 @@ pub(crate) mod fake { /// regression the realm-core auth split was built to prevent. #[cfg(test)] mod tests { - use super::fake::{account, realm, realm_with_dead_core}; + use super::fake::{account, realm, realm_with_dead_core, NEVER}; use super::*; use crate::logon::{CoordinatorStore, LogonStore}; @@ -919,8 +949,7 @@ mod tests { .expect("lookup succeeds") .expect("the account exists"); assert_eq!( - a.salt, - [0xAA; 32], + a.salt, [0xAA; 32], "the logon challenge was answered with the WORLD shard's salt. `game_account` on a \ world shard is a write-through CACHE — refreshed at logon and \ never authoritative — so authenticating against it means a password rotation or a ban \ @@ -1008,13 +1037,23 @@ mod tests { .expect("both writes land"); assert_eq!( - h.db_at(CORE).sessions.lock().unwrap().get(&9).map(|(k, _)| *k), + h.db_at(CORE) + .sessions + .lock() + .unwrap() + .get(&9) + .map(|(k, _, _)| *k), Some(K), "realm-core's `game_session` must be written under REALM-CORE's id (9) — it is the row \ every world gateway later reads to complete a handshake" ); assert_eq!( - h.db_at(WORLD).sessions.lock().unwrap().get(&3).map(|(k, _)| *k), + h.db_at(WORLD) + .sessions + .lock() + .unwrap() + .get(&3) + .map(|(k, _, _)| *k), Some(K), "the world shard's write-through cache write is missing, or landed under the wrong id. \ Its real job is binding `game_character.owner_identity` for this account's characters \ @@ -1133,12 +1172,12 @@ mod tests { .sessions .lock() .unwrap() - .insert(9, (K, [0x5A; 32])); + .insert(9, (K, [0x5A; 32], NEVER)); h.db_at(WORLD) .sessions .lock() .unwrap() - .insert(3, ([0xEE; 40], [0x5A; 32])); + .insert(3, ([0xEE; 40], [0x5A; 32], NEVER)); let s = lookup_session(&h, USER) .expect("handshake lookup") @@ -1161,6 +1200,49 @@ mod tests { ); } + #[test] + fn an_expired_session_is_refused_like_an_absent_one() { + let h = split_realm(); + // Expired one microsecond after the epoch: no clock this test runs under is earlier. + h.db_at(CORE) + .sessions + .lock() + .unwrap() + .insert(9, (K, [0x5A; 32], 1)); + assert!( + lookup_session(&h, USER) + .expect("handshake lookup") + .is_none(), + "an expired session row must refuse the world handshake" + ); + } + + #[test] + fn a_new_logon_replaces_an_expired_session_for_the_world_handshake() { + let h = split_realm(); + h.db_at(CORE) + .sessions + .lock() + .unwrap() + .insert(9, (K, [0x5A; 32], 1)); + assert!(lookup_session(&h, USER).unwrap().is_none()); + + let fresh_key = [0xA7; 40]; + CoordinatorStore::new(h.clone()) + .save_session(9, USER, &fresh_key, [0x5A; 32]) + .unwrap(); + let session = lookup_session(&h, USER).unwrap().unwrap(); + assert_eq!(session.session_key, fresh_key); + assert_eq!(session.account_id, 3); + } + + #[test] + fn a_session_expires_at_its_deadline_and_not_before() { + assert!(!session_expired(999, 1_000)); + assert!(session_expired(1_000, 1_000)); + assert!(session_expired(1_001, 1_000)); + } + #[test] fn the_world_handshake_fails_closed_when_realm_core_is_unreachable() { let h = realm_with_dead_core(&[WORLD, CORE], "", CORE); @@ -1173,7 +1255,7 @@ mod tests { .sessions .lock() .unwrap() - .insert(3, ([0xEE; 40], [0x5A; 32])); + .insert(3, ([0xEE; 40], [0x5A; 32], NEVER)); assert!( lookup_session(&h, USER).is_err(), "with realm-core down the handshake must be refused, not served from the world DB's \ @@ -1193,7 +1275,7 @@ mod tests { .sessions .lock() .unwrap() - .insert(3, (K, [0x5A; 32])); + .insert(3, (K, [0x5A; 32], NEVER)); let s = lookup_session(&h, USER) .expect("lookup") .expect("a live session"); @@ -1788,7 +1870,7 @@ mod tests { fn world_shards(&self) -> Vec<(String, Coordinator)> { self.world_shards() } \ fn account_by_username(&self, username: &str) -> Result> { \ self.account_by_username(username) } \ - fn session_key(&self, account_id: u64) -> Result> { \ + fn session_key(&self, account_id: u64) -> Result> { \ self.session_key(account_id) } \ fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { \ self.bound_identity(account_id) } \ diff --git a/gateway/src/stdb/bindings/creature_ai_state_type.rs b/gateway/src/stdb/bindings/creature_ai_state_type.rs index eea60008c..a5c7f15ba 100644 --- a/gateway/src/stdb/bindings/creature_ai_state_type.rs +++ b/gateway/src/stdb/bindings/creature_ai_state_type.rs @@ -67,6 +67,7 @@ impl __sdk::__query_builder::HasCols for CreatureAiState { /// /// Provides typed access to indexed columns for query building. pub struct CreatureAiStateIxCols { + pub active_object: __sdk::__query_builder::IxCol, pub creature_guid: __sdk::__query_builder::IxCol, } @@ -74,6 +75,7 @@ impl __sdk::__query_builder::HasIxCols for CreatureAiState { type IxCols = CreatureAiStateIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { CreatureAiStateIxCols { + active_object: __sdk::__query_builder::IxCol::new(table_name, "active_object"), creature_guid: __sdk::__query_builder::IxCol::new(table_name, "creature_guid"), } } diff --git a/gateway/src/stdb/bindings/debug_expire_session_reducer.rs b/gateway/src/stdb/bindings/debug_expire_session_reducer.rs new file mode 100644 index 000000000..0c588297a --- /dev/null +++ b/gateway/src/stdb/bindings/debug_expire_session_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugExpireSessionArgs { + pub account_id: u64, +} + +impl From for super::Reducer { + fn from(args: DebugExpireSessionArgs) -> Self { + Self::DebugExpireSession { + account_id: args.account_id, + } + } +} + +impl __sdk::InModule for DebugExpireSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_expire_session`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_expire_session { + /// Request that the remote module invoke the reducer `debug_expire_session` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_expire_session:debug_expire_session_then`] to run a callback after the reducer completes. + fn debug_expire_session(&self, account_id: u64) -> __sdk::Result<()> { + self.debug_expire_session_then(account_id, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_expire_session` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_expire_session_then( + &self, + account_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_expire_session for super::RemoteReducers { + fn debug_expire_session_then( + &self, + account_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugExpireSessionArgs { account_id }, callback) + } +} diff --git a/gateway/src/stdb/bindings/game_session_reaper_schedule_table.rs b/gateway/src/stdb/bindings/game_session_reaper_schedule_table.rs new file mode 100644 index 000000000..b1b8679a5 --- /dev/null +++ b/gateway/src/stdb/bindings/game_session_reaper_schedule_table.rs @@ -0,0 +1,231 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::session_reaper_schedule_type::SessionReaperSchedule; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_session_reaper_schedule`. +/// +/// Obtain a handle from the [`GameSessionReaperScheduleTableAccess::game_session_reaper_schedule`] method on [`super::RemoteTables`], +/// like `ctx.db.game_session_reaper_schedule()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_session_reaper_schedule().on_insert(...)`. +pub struct GameSessionReaperScheduleTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_session_reaper_schedule`. +pub struct GameSessionReaperScheduleTableAccessor; + +impl __sdk::TableAccessor for GameSessionReaperScheduleTableAccessor { + type Row = SessionReaperSchedule; + type Handle<'db> = GameSessionReaperScheduleTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_session_reaper_schedule() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_session_reaper_schedule`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameSessionReaperScheduleTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameSessionReaperScheduleTableHandle`], which mediates access to the table `game_session_reaper_schedule`. + fn game_session_reaper_schedule(&self) -> GameSessionReaperScheduleTableHandle<'_>; +} + +impl GameSessionReaperScheduleTableAccess for super::RemoteTables { + fn game_session_reaper_schedule(&self) -> GameSessionReaperScheduleTableHandle<'_> { + GameSessionReaperScheduleTableHandle { + imp: self + .imp + .get_table::("game_session_reaper_schedule"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameSessionReaperScheduleInsertCallbackId(__sdk::CallbackId); +pub struct GameSessionReaperScheduleDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameSessionReaperScheduleTableHandle<'ctx> { + type Row = SessionReaperSchedule; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameSessionReaperScheduleTableHandle<'ctx> { + type Row = SessionReaperSchedule; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameSessionReaperScheduleInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleInsertCallbackId { + GameSessionReaperScheduleInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameSessionReaperScheduleInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameSessionReaperScheduleDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleDeleteCallbackId { + GameSessionReaperScheduleDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameSessionReaperScheduleDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameSessionReaperScheduleTableHandle<'ctx> { + type InsertCallbackId = GameSessionReaperScheduleInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleInsertCallbackId { + GameSessionReaperScheduleInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameSessionReaperScheduleInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameSessionReaperScheduleTableHandle<'ctx> { + type DeleteCallbackId = GameSessionReaperScheduleDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleDeleteCallbackId { + GameSessionReaperScheduleDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameSessionReaperScheduleDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameSessionReaperScheduleUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameSessionReaperScheduleTableHandle<'ctx> { + type UpdateCallbackId = GameSessionReaperScheduleUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleUpdateCallbackId { + GameSessionReaperScheduleUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameSessionReaperScheduleUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameSessionReaperScheduleTableHandle<'ctx> { + type UpdateCallbackId = GameSessionReaperScheduleUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameSessionReaperScheduleUpdateCallbackId { + GameSessionReaperScheduleUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameSessionReaperScheduleUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `scheduled_id` unique index on the table `game_session_reaper_schedule`, +/// which allows point queries on the field of the same name +/// via the [`GameSessionReaperScheduleScheduledIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_session_reaper_schedule().scheduled_id().find(...)`. +pub struct GameSessionReaperScheduleScheduledIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameSessionReaperScheduleTableHandle<'ctx> { + /// Get a handle on the `scheduled_id` unique index on the table `game_session_reaper_schedule`. + pub fn scheduled_id(&self) -> GameSessionReaperScheduleScheduledIdUnique<'ctx> { + GameSessionReaperScheduleScheduledIdUnique { + imp: self.imp.get_unique_constraint::("scheduled_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameSessionReaperScheduleScheduledIdUnique<'ctx> { + /// Find the subscribed row whose `scheduled_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("game_session_reaper_schedule"); + _table.add_unique_constraint::("scheduled_id", |row| &row.scheduled_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `SessionReaperSchedule`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_session_reaper_scheduleQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `SessionReaperSchedule`. + fn game_session_reaper_schedule(&self) -> __sdk::__query_builder::Table; +} + +impl game_session_reaper_scheduleQueryTableAccess for __sdk::QueryTableAccessor { + fn game_session_reaper_schedule(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_session_reaper_schedule") + } +} diff --git a/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs b/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs index c6c29f4c6..63a8254a5 100644 --- a/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs +++ b/gateway/src/stdb/bindings/gw_auction_release_listing_hold_reducer.rs @@ -35,7 +35,11 @@ pub trait gw_auction_release_listing_hold { /// The reducer will run asynchronously in the future, /// and this method provides no way to listen for its completion status. /// /// Use [`gw_auction_release_listing_hold:gw_auction_release_listing_hold_then`] to run a callback after the reducer completes. - fn gw_auction_release_listing_hold(&self, operation_id: u64, seller_guid: u64) -> __sdk::Result<()> { + fn gw_auction_release_listing_hold( + &self, + operation_id: u64, + seller_guid: u64, + ) -> __sdk::Result<()> { self.gw_auction_release_listing_hold_then(operation_id, seller_guid, |_, _| {}) } diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 40e61ab7b..2dd266c44 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -157,6 +157,7 @@ pub mod debug_equip_item_reducer; pub mod debug_equip_offhand_reducer; pub mod debug_equip_weapon_reducer; pub mod debug_expire_quest_reducer; +pub mod debug_expire_session_reducer; pub mod debug_explore_at_reducer; pub mod debug_fill_aura_slots_reducer; pub mod debug_fish_reducer; @@ -501,6 +502,7 @@ pub mod game_resurrect_request_table; pub mod game_roll_event_table; pub mod game_school_lockout_table; pub mod game_script_table; +pub mod game_session_reaper_schedule_table; pub mod game_session_table; pub mod game_shard_load_table; pub mod game_shard_load_total_table; @@ -811,6 +813,7 @@ pub mod reap_gateway_leases_reducer; pub mod reap_instances_reducer; pub mod reap_mail_escrows_reducer; pub mod reap_movement_events_reducer; +pub mod reap_sessions_reducer; pub mod reap_transfers_reducer; pub mod receive_ai_event_condition_type; pub mod receive_emote_condition_type; @@ -875,6 +878,7 @@ pub mod scale_selected_threat_instruction_type; pub mod school_lockout_type; pub mod script_type; pub mod server_config_type; +pub mod session_reaper_schedule_type; pub mod session_type; pub mod set_alpha_test_tools_enrollment_reducer; pub mod set_character_shard_reducer; @@ -1120,6 +1124,7 @@ pub use debug_equip_item_reducer::debug_equip_item; pub use debug_equip_offhand_reducer::debug_equip_offhand; pub use debug_equip_weapon_reducer::debug_equip_weapon; pub use debug_expire_quest_reducer::debug_expire_quest; +pub use debug_expire_session_reducer::debug_expire_session; pub use debug_explore_at_reducer::debug_explore_at; pub use debug_fill_aura_slots_reducer::debug_fill_aura_slots; pub use debug_fish_reducer::debug_fish; @@ -1464,6 +1469,7 @@ pub use game_resurrect_request_table::*; pub use game_roll_event_table::*; pub use game_school_lockout_table::*; pub use game_script_table::*; +pub use game_session_reaper_schedule_table::*; pub use game_session_table::*; pub use game_shard_load_table::*; pub use game_shard_load_total_table::*; @@ -1774,6 +1780,7 @@ pub use reap_gateway_leases_reducer::reap_gateway_leases; pub use reap_instances_reducer::reap_instances; pub use reap_mail_escrows_reducer::reap_mail_escrows; pub use reap_movement_events_reducer::reap_movement_events; +pub use reap_sessions_reducer::reap_sessions; pub use reap_transfers_reducer::reap_transfers; pub use receive_ai_event_condition_type::ReceiveAiEventCondition; pub use receive_emote_condition_type::ReceiveEmoteCondition; @@ -1838,6 +1845,7 @@ pub use scale_selected_threat_instruction_type::ScaleSelectedThreatInstruction; pub use school_lockout_type::SchoolLockout; pub use script_type::Script; pub use server_config_type::ServerConfig; +pub use session_reaper_schedule_type::SessionReaperSchedule; pub use session_type::Session; pub use set_alpha_test_tools_enrollment_reducer::set_alpha_test_tools_enrollment; pub use set_character_shard_reducer::set_character_shard; @@ -2221,6 +2229,9 @@ pub enum Reducer { character_guid: u64, quest_entry: u32, }, + DebugExpireSession { + account_id: u64, + }, DebugExploreAt { guid: u64, map_id: u32, @@ -3416,6 +3427,9 @@ pub enum Reducer { ReapMovementEvents { schedule: EventReaperSchedule, }, + ReapSessions { + schedule: SessionReaperSchedule, + }, ReapTransfers { schedule: TransferReaperSchedule, }, @@ -3604,6 +3618,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugEquipOffhand { .. } => "debug_equip_offhand", Reducer::DebugEquipWeapon { .. } => "debug_equip_weapon", Reducer::DebugExpireQuest { .. } => "debug_expire_quest", + Reducer::DebugExpireSession { .. } => "debug_expire_session", Reducer::DebugExploreAt { .. } => "debug_explore_at", Reducer::DebugFillAuraSlots { .. } => "debug_fill_aura_slots", Reducer::DebugFish { .. } => "debug_fish", @@ -3905,6 +3920,7 @@ impl __sdk::Reducer for Reducer { Reducer::ReapInstances { .. } => "reap_instances", Reducer::ReapMailEscrows { .. } => "reap_mail_escrows", Reducer::ReapMovementEvents { .. } => "reap_movement_events", + Reducer::ReapSessions { .. } => "reap_sessions", Reducer::ReapTransfers { .. } => "reap_transfers", Reducer::RecordRegionLoad { .. } => "record_region_load", Reducer::RecordShardLoad { .. } => "record_shard_load", @@ -4444,6 +4460,11 @@ Reducer::DebugCheckRestAt{ } => __sats::bsatn::to_vec(&debug_expire_quest_reducer::DebugExpireQuestArgs { character_guid: character_guid.clone(), quest_entry: quest_entry.clone(), +}), + Reducer::DebugExpireSession{ + account_id, +} => __sats::bsatn::to_vec(&debug_expire_session_reducer::DebugExpireSessionArgs { + account_id: account_id.clone(), }), Reducer::DebugExploreAt{ guid, @@ -6582,6 +6603,11 @@ Reducer::PrepareVmapNavCoverage{ schedule, } => __sats::bsatn::to_vec(&reap_movement_events_reducer::ReapMovementEventsArgs { schedule: schedule.clone(), +}), + Reducer::ReapSessions{ + schedule, +} => __sats::bsatn::to_vec(&reap_sessions_reducer::ReapSessionsArgs { + schedule: schedule.clone(), }), Reducer::ReapTransfers{ schedule, @@ -6973,6 +6999,7 @@ pub struct DbUpdate { game_school_lockout: __sdk::TableUpdate, game_script: __sdk::TableUpdate