wsprism_gateway/config/
schema.rs

1//! Typed configuration schema for the gateway (`wsprism.yaml`).
2//!
3//! Unknown fields are rejected to avoid silently ignoring operator intent.
4
5use serde::Deserialize;
6use wsprism_core::error::{Result, WsPrismError};
7
8#[derive(Debug, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct GatewayConfig {
11    /// Schema version (must be 1).
12    pub version: u32,
13
14    #[serde(default)]
15    pub gateway: GatewaySection,
16
17    #[serde(default)]
18    pub tenants: Vec<TenantConfig>,
19}
20
21impl GatewayConfig {
22    pub fn validate(&self) -> Result<()> {
23        if self.version != 1 {
24            return Err(WsPrismError::UnsupportedVersion);
25        }
26        if self.tenants.is_empty() {
27            return Err(WsPrismError::BadRequest("tenants must not be empty".into()));
28        }
29
30        // Unique tenant ids
31        {
32            use std::collections::HashSet;
33            let mut seen = HashSet::new();
34            for t in &self.tenants {
35                if t.id.trim().is_empty() {
36                    return Err(WsPrismError::BadRequest("tenant.id must not be empty".into()));
37                }
38                if !seen.insert(t.id.clone()) {
39                    return Err(WsPrismError::BadRequest(format!("duplicate tenant id: {}", t.id)));
40                }
41                t.validate()?;
42            }
43        }
44
45        self.gateway.validate()?;
46        Ok(())
47    }
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct GatewaySection {
53    /// Listener address (host:port).
54    #[serde(default = "default_listen")]
55    pub listen: String,
56
57    /// WebSocket ping interval in milliseconds.
58    #[serde(default = "default_ping_interval_ms")]
59    pub ping_interval_ms: u64,
60
61    /// Idle timeout in milliseconds.
62    /// Connections with no activity beyond this are closed.
63    #[serde(default = "default_idle_timeout_ms")]
64    pub idle_timeout_ms: u64,
65
66    /// Outbound writer send timeout in milliseconds.
67    ///
68    /// If a client is slow and a write stalls longer than this, the session is
69    /// closed to protect tail latency.
70    #[serde(default = "default_writer_send_timeout_ms")]
71    pub writer_send_timeout_ms: u64,
72
73    /// Grace period (ms) after entering draining mode before process exits.
74    ///
75    /// During draining, readiness becomes 503 and new upgrades are rejected.
76    #[serde(default = "default_drain_grace_ms")]
77    pub drain_grace_ms: u64,
78
79    // Sprint 5: Handshake Defender Configuration
80    #[serde(default)]
81    pub handshake_limit: HandshakeConfig,
82}
83
84#[derive(Debug, Deserialize, Clone)]
85#[serde(deny_unknown_fields)]
86pub struct HandshakeConfig {
87    #[serde(default)]
88    pub enabled: bool,
89
90    /// Global limiter: burst capacity.
91    #[serde(default = "default_hs_global_burst")]
92    pub global_burst: u32,
93    /// Global limiter: refill rate per sec.
94    #[serde(default = "default_hs_global_rps")]
95    pub global_rps: u32,
96
97    /// Per-IP limiter: burst capacity.
98    #[serde(default = "default_hs_ip_burst")]
99    pub per_ip_burst: u32,
100    /// Per-IP limiter: refill rate per sec.
101    #[serde(default = "default_hs_ip_rps")]
102    pub per_ip_rps: u32,
103
104    /// Clean up per-ip map opportunistically when it grows too big.
105    #[serde(default = "default_hs_max_entries")]
106    pub max_ip_entries: usize,
107}
108
109impl Default for HandshakeConfig {
110    fn default() -> Self {
111        Self {
112            enabled: false,
113            global_burst: 200,
114            global_rps: 100,
115            per_ip_burst: 50,
116            per_ip_rps: 10,
117            max_ip_entries: 50_000,
118        }
119    }
120}
121
122// Handshake defaults
123fn default_hs_global_burst() -> u32 { 200 }
124fn default_hs_global_rps() -> u32 { 100 }
125fn default_hs_ip_burst() -> u32 { 50 }
126fn default_hs_ip_rps() -> u32 { 10 }
127fn default_hs_max_entries() -> usize { 50_000 }
128
129impl Default for GatewaySection {
130    fn default() -> Self {
131        Self {
132            listen: default_listen(),
133            ping_interval_ms: default_ping_interval_ms(),
134            idle_timeout_ms: default_idle_timeout_ms(),
135            writer_send_timeout_ms: default_writer_send_timeout_ms(),
136            drain_grace_ms: default_drain_grace_ms(),
137            handshake_limit: HandshakeConfig::default(),
138        }
139    }
140}
141
142impl GatewaySection {
143    pub fn validate(&self) -> Result<()> {
144        if !(5000..=120000).contains(&self.ping_interval_ms) {
145            return Err(WsPrismError::BadRequest(
146                "gateway.ping_interval_ms must be between 5000 and 120000".into(),
147            ));
148        }
149        if !(10000..=600000).contains(&self.idle_timeout_ms) {
150            return Err(WsPrismError::BadRequest(
151                "gateway.idle_timeout_ms must be between 10000 and 600000".into(),
152            ));
153        }
154        if self.idle_timeout_ms <= self.ping_interval_ms {
155            return Err(WsPrismError::BadRequest(
156                "gateway.idle_timeout_ms must be greater than ping_interval_ms".into(),
157            ));
158        }
159        if !(50..=60000).contains(&self.writer_send_timeout_ms) {
160            return Err(WsPrismError::BadRequest(
161                "gateway.writer_send_timeout_ms must be between 50 and 60000".into(),
162            ));
163        }
164        if self.drain_grace_ms > 600000 {
165            return Err(WsPrismError::BadRequest(
166                "gateway.drain_grace_ms must be <= 600000".into(),
167            ));
168        }
169        Ok(())
170    }
171}
172
173fn default_listen() -> String { "0.0.0.0:8080".into() }
174fn default_ping_interval_ms() -> u64 { 20000 }
175fn default_idle_timeout_ms() -> u64 { 60000 }
176fn default_writer_send_timeout_ms() -> u64 { 1500 }
177fn default_drain_grace_ms() -> u64 { 2000 }
178
179#[derive(Debug, Deserialize, Clone)]
180#[serde(deny_unknown_fields)]
181pub struct TenantConfig {
182    /// Tenant identifier (namespaces policy and runtime state).
183    pub id: String,
184
185    #[serde(default)]
186    pub limits: TenantLimits,
187
188    /// Sprint 2+: policy controls (strict by default).
189    #[serde(default)]
190    pub policy: TenantPolicy,
191}
192
193impl TenantConfig {
194    pub fn validate(&self) -> Result<()> {
195        if self.limits.max_frame_bytes == 0 {
196            return Err(WsPrismError::BadRequest("limits.max_frame_bytes must be > 0".into()));
197        }
198        self.policy.validate()?;
199        Ok(())
200    }
201}
202
203#[derive(Debug, Deserialize, Clone)]
204#[serde(deny_unknown_fields)]
205pub struct TenantLimits {
206/// Maximum allowed frame size for this tenant (bytes).
207#[serde(default = "default_max_frame_bytes")]
208pub max_frame_bytes: usize,
209
210// Sprint 5: Resource Governance
211/// Max concurrent sessions for the entire tenant. 0 = unlimited.
212/// Lock-free best-effort: under extreme contention a tiny overshoot is possible.
213#[serde(default)]
214pub max_sessions_total: u64,
215/// Max active rooms for the tenant. 0 = unlimited.
216/// A room counts as "active" if it has at least one session.
217#[serde(default)]
218pub max_rooms_total: u64,
219/// Max users allowed in a single room. 0 = unlimited.
220#[serde(default)]
221pub max_users_per_room: u64,
222/// Max unique rooms a single user can join. 0 = unlimited.
223#[serde(default)]
224pub max_rooms_per_user: u64,
225}
226
227impl Default for TenantLimits {
228    fn default() -> Self {
229        Self {
230            max_frame_bytes: 4096,
231            max_sessions_total: 0,
232            max_rooms_total: 0,
233            max_users_per_room: 0,
234            max_rooms_per_user: 0,
235        }
236    }
237}
238
239fn default_max_frame_bytes() -> usize { 4096 }
240
241#[derive(Debug, Deserialize, Clone, Copy)]
242#[serde(rename_all = "snake_case")]
243pub enum RateLimitScope {
244    Tenant,
245    Connection,
246    Both,
247}
248
249fn default_rate_limit_scope() -> RateLimitScope { RateLimitScope::Connection }
250
251#[derive(Debug, Deserialize, Clone, Copy)]
252#[serde(rename_all = "snake_case")]
253pub enum HotErrorMode {
254    SysError,
255    Silent,
256}
257
258fn default_hot_error_mode() -> HotErrorMode { HotErrorMode::SysError }
259
260#[derive(Debug, Deserialize, Clone, Copy)]
261#[serde(rename_all = "snake_case")]
262pub enum SessionMode {
263    Single,
264    Multi,
265}
266
267#[derive(Debug, Deserialize, Clone, Copy)]
268#[serde(rename_all = "snake_case")]
269pub enum OnExceed {
270    Deny,
271    KickOldest,
272}
273
274#[derive(Debug, Deserialize, Clone)]
275#[serde(deny_unknown_fields)]
276pub struct SessionPolicy {
277    #[serde(default = "default_session_mode")]
278    pub mode: SessionMode,
279
280    #[serde(default = "default_max_sessions_per_user")]
281    pub max_sessions_per_user: u32,
282
283    #[serde(default = "default_on_exceed")]
284    pub on_exceed: OnExceed,
285}
286
287fn default_session_mode() -> SessionMode { SessionMode::Multi }
288fn default_max_sessions_per_user() -> u32 { 4 }
289fn default_on_exceed() -> OnExceed { OnExceed::Deny }
290
291impl Default for SessionPolicy {
292    fn default() -> Self {
293        Self {
294            mode: default_session_mode(),
295            max_sessions_per_user: default_max_sessions_per_user(),
296            on_exceed: default_on_exceed(),
297        }
298    }
299}
300
301/// Tenant policy knobs.
302/// Defaults are STRICT (deny-by-default).
303#[derive(Debug, Deserialize, Clone)]
304#[serde(deny_unknown_fields)]
305pub struct TenantPolicy {
306    /// Tenant-level or connection-level inbound rate limit in requests per second.
307    #[serde(default = "default_rate_limit_rps")]
308    pub rate_limit_rps: u32,
309
310    /// Burst capacity for token bucket.
311    #[serde(default = "default_rate_limit_burst")]
312    pub rate_limit_burst: u32,
313
314    /// Where to apply rate limiting.
315    #[serde(default = "default_rate_limit_scope")]
316    pub rate_limit_scope: RateLimitScope,
317
318    /// Ext lane allowlist entries, like "svc:type"
319    #[serde(default = "default_ext_allowlist")]
320    pub ext_allowlist: Vec<String>,
321
322    /// Hot lane allowlist entries, like "sid:opcode"
323    #[serde(default)]
324    pub hot_allowlist: Vec<String>,
325
326    /// Session policy (1:1 / 1:N)
327    #[serde(default)]
328    pub sessions: SessionPolicy,
329
330    /// Hot lane error surface (sys.error vs silent)
331    #[serde(default = "default_hot_error_mode")]
332    pub hot_error_mode: HotErrorMode,
333
334    /// If true, hot lane requires active_room.
335    #[serde(default = "default_hot_requires_active_room")]
336    pub hot_requires_active_room: bool,
337}
338
339fn default_hot_requires_active_room() -> bool { true }
340
341impl Default for TenantPolicy {
342    fn default() -> Self {
343        Self {
344            rate_limit_rps: default_rate_limit_rps(),
345            rate_limit_burst: default_rate_limit_burst(),
346            rate_limit_scope: default_rate_limit_scope(),
347            ext_allowlist: default_ext_allowlist(),
348            hot_allowlist: Vec::new(),
349            sessions: SessionPolicy::default(),
350            hot_error_mode: default_hot_error_mode(),
351            hot_requires_active_room: default_hot_requires_active_room(),
352        }
353    }
354}
355
356impl TenantPolicy {
357    pub fn validate(&self) -> Result<()> {
358        if self.rate_limit_rps == 0 || self.rate_limit_burst == 0 {
359            return Err(WsPrismError::BadRequest(
360                "policy.rate_limit_rps and rate_limit_burst must be > 0".into(),
361            ));
362        }
363        // sessions policy sanity
364        match self.sessions.mode {
365            SessionMode::Single => {
366                if self.sessions.max_sessions_per_user != 1 {
367                    return Err(WsPrismError::BadRequest(
368                        "policy.sessions.mode=single requires max_sessions_per_user=1".into(),
369                    ));
370                }
371            }
372            SessionMode::Multi => {
373                if self.sessions.max_sessions_per_user == 0 {
374                    return Err(WsPrismError::BadRequest(
375                        "policy.sessions.max_sessions_per_user must be > 0".into(),
376                    ));
377                }
378            }
379        }
380        Ok(())
381    }
382}
383
384fn default_rate_limit_rps() -> u32 { 200 }
385fn default_rate_limit_burst() -> u32 { 400 }
386fn default_ext_allowlist() -> Vec<String> {
387    vec!["room:join".into(), "room:leave".into()]
388}