Skip to main content

reinhardt_websockets/
settings.rs

1//! Settings-first configuration fragments for WebSocket support.
2//!
3//! These fragments are the settings-first configuration entry points for the
4//! WebSocket layer. Each public, independently loadable fragment maps to a
5//! `[ws_*]` TOML section and can be composed into a project's settings with the
6//! `#[settings]` macro. Conversions into the deprecated compatibility
7//! `XxxConfig` types are provided for the migration window; new code should
8//! prefer the fragments and the `create_*_from_settings` constructors.
9
10#![allow(deprecated)] // Conversions target legacy config types during the compatibility window.
11
12use std::time::Duration;
13
14use reinhardt_core::macros::settings;
15use serde::{Deserialize, Serialize};
16
17use crate::connection::ConnectionConfig;
18use crate::origin::{OriginPolicy, OriginValidationConfig};
19use crate::reconnection::ReconnectionConfig;
20use crate::throttling::WebSocketRateLimitConfig;
21
22#[cfg(feature = "redis-channel")]
23use crate::redis_channel::RedisConfig;
24
25// --- defaults -------------------------------------------------------------
26
27fn default_idle_timeout_secs() -> u64 {
28	300
29}
30fn default_handshake_timeout_secs() -> u64 {
31	10
32}
33fn default_cleanup_interval_secs() -> u64 {
34	30
35}
36fn default_reconnect_max_attempts() -> Option<u32> {
37	Some(10)
38}
39fn default_reconnect_initial_delay_secs() -> u64 {
40	1
41}
42fn default_reconnect_max_delay_secs() -> u64 {
43	300
44}
45fn default_reconnect_backoff_multiplier() -> f64 {
46	2.0
47}
48fn default_reconnect_jitter_factor() -> f64 {
49	0.1
50}
51fn default_reject_missing_origin() -> bool {
52	true
53}
54fn default_rate_max_connections_per_window() -> usize {
55	20
56}
57fn default_rate_connection_window_secs() -> u64 {
58	60
59}
60fn default_rate_max_concurrent_connections_per_ip() -> usize {
61	10
62}
63fn default_rate_max_messages_per_window() -> usize {
64	100
65}
66fn default_rate_message_window_secs() -> u64 {
67	60
68}
69
70#[cfg(feature = "redis-channel")]
71fn default_redis_url() -> String {
72	"redis://127.0.0.1:6379".to_string()
73}
74#[cfg(feature = "redis-channel")]
75fn default_redis_channel_prefix() -> String {
76	"ws:channel:".to_string()
77}
78#[cfg(feature = "redis-channel")]
79fn default_redis_group_prefix() -> String {
80	"ws:group:".to_string()
81}
82#[cfg(feature = "redis-channel")]
83fn default_redis_message_expiry() -> u64 {
84	60
85}
86#[cfg(feature = "redis-channel")]
87fn default_redis_require_auth() -> bool {
88	true
89}
90
91// --- connection -----------------------------------------------------------
92
93/// WebSocket connection settings fragment.
94///
95/// Maps to the `[ws_connection]` section.
96#[settings(fragment = true, section = "ws_connection")]
97#[non_exhaustive]
98#[derive(Clone, Debug, Serialize, Deserialize)]
99pub struct ConnectionSettings {
100	/// Maximum duration a connection can be idle before being closed, in seconds.
101	#[serde(default = "default_idle_timeout_secs")]
102	pub idle_timeout_secs: u64,
103	/// Maximum duration for the WebSocket handshake to complete, in seconds.
104	#[serde(default = "default_handshake_timeout_secs")]
105	pub handshake_timeout_secs: u64,
106	/// Interval for checking idle connections, in seconds.
107	#[serde(default = "default_cleanup_interval_secs")]
108	pub cleanup_interval_secs: u64,
109	/// Maximum number of concurrent connections allowed (None for unlimited).
110	#[serde(default)]
111	pub max_connections: Option<usize>,
112}
113
114impl Default for ConnectionSettings {
115	fn default() -> Self {
116		Self {
117			idle_timeout_secs: default_idle_timeout_secs(),
118			handshake_timeout_secs: default_handshake_timeout_secs(),
119			cleanup_interval_secs: default_cleanup_interval_secs(),
120			max_connections: None,
121		}
122	}
123}
124
125impl From<&ConnectionSettings> for ConnectionConfig {
126	fn from(settings: &ConnectionSettings) -> Self {
127		// `ConnectionConfig` has private fields, so rebuild through its builder.
128		ConnectionConfig::new()
129			.with_idle_timeout(Duration::from_secs(settings.idle_timeout_secs))
130			.with_handshake_timeout(Duration::from_secs(settings.handshake_timeout_secs))
131			.with_cleanup_interval(Duration::from_secs(settings.cleanup_interval_secs))
132			.with_max_connections(settings.max_connections)
133	}
134}
135
136/// Build a [`ConnectionConfig`] from a [`ConnectionSettings`] fragment.
137pub fn create_connection_config_from_settings(settings: &ConnectionSettings) -> ConnectionConfig {
138	ConnectionConfig::from(settings)
139}
140
141// --- reconnection ---------------------------------------------------------
142
143/// WebSocket reconnection settings fragment.
144///
145/// Maps to the `[ws_reconnection]` section.
146#[settings(fragment = true, section = "ws_reconnection")]
147#[non_exhaustive]
148#[derive(Clone, Debug, Serialize, Deserialize)]
149pub struct ReconnectionSettings {
150	/// Maximum number of reconnection attempts (None for unlimited).
151	#[serde(default = "default_reconnect_max_attempts")]
152	pub max_attempts: Option<u32>,
153	/// Initial reconnection delay, in seconds.
154	#[serde(default = "default_reconnect_initial_delay_secs")]
155	pub initial_delay_secs: u64,
156	/// Maximum reconnection delay, in seconds.
157	#[serde(default = "default_reconnect_max_delay_secs")]
158	pub max_delay_secs: u64,
159	/// Backoff multiplier for exponential backoff.
160	#[serde(default = "default_reconnect_backoff_multiplier")]
161	pub backoff_multiplier: f64,
162	/// Jitter factor applied to each delay (0.1 = 10%).
163	#[serde(default = "default_reconnect_jitter_factor")]
164	pub jitter_factor: f64,
165}
166
167impl Default for ReconnectionSettings {
168	fn default() -> Self {
169		Self {
170			max_attempts: default_reconnect_max_attempts(),
171			initial_delay_secs: default_reconnect_initial_delay_secs(),
172			max_delay_secs: default_reconnect_max_delay_secs(),
173			backoff_multiplier: default_reconnect_backoff_multiplier(),
174			jitter_factor: default_reconnect_jitter_factor(),
175		}
176	}
177}
178
179impl From<&ReconnectionSettings> for ReconnectionConfig {
180	fn from(settings: &ReconnectionSettings) -> Self {
181		// `ReconnectionConfig` fields are public, so build it directly.
182		ReconnectionConfig {
183			max_attempts: settings.max_attempts,
184			initial_delay: Duration::from_secs(settings.initial_delay_secs),
185			max_delay: Duration::from_secs(settings.max_delay_secs),
186			backoff_multiplier: settings.backoff_multiplier,
187			jitter_factor: settings.jitter_factor,
188		}
189	}
190}
191
192/// Build a [`ReconnectionConfig`] from a [`ReconnectionSettings`] fragment.
193pub fn create_reconnection_config_from_settings(
194	settings: &ReconnectionSettings,
195) -> ReconnectionConfig {
196	ReconnectionConfig::from(settings)
197}
198
199// --- origin validation ----------------------------------------------------
200
201/// Origin validation policy as a serializable value object.
202///
203/// This mirrors [`OriginPolicy`] but is `Serialize`/`Deserialize` so it can be
204/// loaded from settings. It is nested under [`OriginValidationSettings`].
205#[derive(Clone, Debug, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case", tag = "mode", content = "origins")]
207pub enum OriginPolicySettings {
208	/// Allow only specific origins.
209	AllowList(Vec<String>),
210	/// Allow all origins (disables validation; not recommended for production).
211	AllowAll,
212}
213
214impl Default for OriginPolicySettings {
215	fn default() -> Self {
216		// Mirror `OriginValidationConfig::default()`: an empty allow-list that
217		// rejects every origin until explicitly configured.
218		OriginPolicySettings::AllowList(Vec::new())
219	}
220}
221
222impl From<&OriginPolicySettings> for OriginPolicy {
223	fn from(settings: &OriginPolicySettings) -> Self {
224		match settings {
225			OriginPolicySettings::AllowList(origins) => OriginPolicy::AllowList(origins.clone()),
226			OriginPolicySettings::AllowAll => OriginPolicy::AllowAll,
227		}
228	}
229}
230
231/// Origin validation settings fragment.
232///
233/// Maps to the `[ws_origin]` section and controls Cross-Site WebSocket
234/// Hijacking (CSWSH) protection during the handshake.
235#[settings(fragment = true, section = "ws_origin")]
236#[non_exhaustive]
237#[derive(Clone, Debug, Serialize, Deserialize)]
238pub struct OriginValidationSettings {
239	/// The origin policy to apply.
240	#[serde(default)]
241	pub policy: OriginPolicySettings,
242	/// Whether to reject connections with a missing Origin header.
243	#[serde(default = "default_reject_missing_origin")]
244	pub reject_missing_origin: bool,
245}
246
247impl Default for OriginValidationSettings {
248	fn default() -> Self {
249		Self {
250			policy: OriginPolicySettings::default(),
251			reject_missing_origin: default_reject_missing_origin(),
252		}
253	}
254}
255
256impl From<&OriginValidationSettings> for OriginValidationConfig {
257	fn from(settings: &OriginValidationSettings) -> Self {
258		OriginValidationConfig {
259			policy: OriginPolicy::from(&settings.policy),
260			reject_missing_origin: settings.reject_missing_origin,
261		}
262	}
263}
264
265/// Build an [`OriginValidationConfig`] from an [`OriginValidationSettings`] fragment.
266pub fn create_origin_validation_config_from_settings(
267	settings: &OriginValidationSettings,
268) -> OriginValidationConfig {
269	OriginValidationConfig::from(settings)
270}
271
272// --- rate limiting --------------------------------------------------------
273
274/// WebSocket rate limit settings fragment.
275///
276/// Maps to the `[ws_rate_limit]` section.
277#[settings(fragment = true, section = "ws_rate_limit")]
278#[non_exhaustive]
279#[derive(Clone, Debug, Serialize, Deserialize)]
280pub struct RateLimitSettings {
281	/// Maximum new connections per IP within the connection window.
282	#[serde(default = "default_rate_max_connections_per_window")]
283	pub max_connections_per_window: usize,
284	/// Time window for connection rate limiting, in seconds.
285	#[serde(default = "default_rate_connection_window_secs")]
286	pub connection_window_secs: u64,
287	/// Maximum concurrent connections per IP.
288	#[serde(default = "default_rate_max_concurrent_connections_per_ip")]
289	pub max_concurrent_connections_per_ip: usize,
290	/// Maximum messages per connection within the message window.
291	#[serde(default = "default_rate_max_messages_per_window")]
292	pub max_messages_per_window: usize,
293	/// Time window for message rate limiting, in seconds.
294	#[serde(default = "default_rate_message_window_secs")]
295	pub message_window_secs: u64,
296}
297
298impl Default for RateLimitSettings {
299	fn default() -> Self {
300		Self {
301			max_connections_per_window: default_rate_max_connections_per_window(),
302			connection_window_secs: default_rate_connection_window_secs(),
303			max_concurrent_connections_per_ip: default_rate_max_concurrent_connections_per_ip(),
304			max_messages_per_window: default_rate_max_messages_per_window(),
305			message_window_secs: default_rate_message_window_secs(),
306		}
307	}
308}
309
310impl From<&RateLimitSettings> for WebSocketRateLimitConfig {
311	fn from(settings: &RateLimitSettings) -> Self {
312		// `WebSocketRateLimitConfig` has private fields, so rebuild through its builder.
313		WebSocketRateLimitConfig::default()
314			.with_max_connections_per_window(settings.max_connections_per_window)
315			.with_connection_window(Duration::from_secs(settings.connection_window_secs))
316			.with_max_concurrent_connections_per_ip(settings.max_concurrent_connections_per_ip)
317			.with_max_messages_per_window(settings.max_messages_per_window)
318			.with_message_window(Duration::from_secs(settings.message_window_secs))
319	}
320}
321
322/// Build a [`WebSocketRateLimitConfig`] from a [`RateLimitSettings`] fragment.
323pub fn create_rate_limit_config_from_settings(
324	settings: &RateLimitSettings,
325) -> WebSocketRateLimitConfig {
326	WebSocketRateLimitConfig::from(settings)
327}
328
329// --- redis channel layer --------------------------------------------------
330
331/// Redis channel layer settings fragment.
332///
333/// Maps to the `[ws_redis]` section. Requires the `redis-channel` feature.
334#[cfg(feature = "redis-channel")]
335#[settings(fragment = true, section = "ws_redis")]
336#[non_exhaustive]
337#[derive(Clone, Debug, Serialize, Deserialize)]
338pub struct RedisChannelSettings {
339	/// Redis connection URL.
340	#[serde(default = "default_redis_url")]
341	pub url: String,
342	/// Channel key prefix.
343	#[serde(default = "default_redis_channel_prefix")]
344	pub channel_prefix: String,
345	/// Group key prefix.
346	#[serde(default = "default_redis_group_prefix")]
347	pub group_prefix: String,
348	/// Message expiry time, in seconds.
349	#[serde(default = "default_redis_message_expiry")]
350	pub message_expiry: u64,
351	/// Redis password for authentication.
352	#[serde(default)]
353	pub password: Option<String>,
354	/// Redis username for authentication (Redis 6+ ACL).
355	#[serde(default)]
356	pub username: Option<String>,
357	/// Enable TLS for secure connection.
358	#[serde(default)]
359	pub tls: bool,
360	/// Require authentication (warns if disabled without credentials).
361	#[serde(default = "default_redis_require_auth")]
362	pub require_auth: bool,
363}
364
365#[cfg(feature = "redis-channel")]
366impl Default for RedisChannelSettings {
367	fn default() -> Self {
368		Self {
369			url: default_redis_url(),
370			channel_prefix: default_redis_channel_prefix(),
371			group_prefix: default_redis_group_prefix(),
372			message_expiry: default_redis_message_expiry(),
373			password: None,
374			username: None,
375			tls: false,
376			require_auth: default_redis_require_auth(),
377		}
378	}
379}
380
381#[cfg(feature = "redis-channel")]
382impl From<&RedisChannelSettings> for RedisConfig {
383	fn from(settings: &RedisChannelSettings) -> Self {
384		// `RedisConfig` fields are public, so build it directly.
385		RedisConfig {
386			url: settings.url.clone(),
387			channel_prefix: settings.channel_prefix.clone(),
388			group_prefix: settings.group_prefix.clone(),
389			message_expiry: settings.message_expiry,
390			password: settings.password.clone(),
391			username: settings.username.clone(),
392			tls: settings.tls,
393			require_auth: settings.require_auth,
394		}
395	}
396}
397
398/// Build a [`RedisConfig`] from a [`RedisChannelSettings`] fragment.
399#[cfg(feature = "redis-channel")]
400pub fn create_redis_config_from_settings(settings: &RedisChannelSettings) -> RedisConfig {
401	RedisConfig::from(settings)
402}
403
404#[cfg(test)]
405mod tests {
406	use super::*;
407
408	#[test]
409	fn connection_settings_default_converts() {
410		let settings = ConnectionSettings::default();
411		let config = ConnectionConfig::from(&settings);
412		assert_eq!(config.idle_timeout(), Duration::from_secs(300));
413		assert_eq!(config.handshake_timeout(), Duration::from_secs(10));
414		assert_eq!(config.cleanup_interval(), Duration::from_secs(30));
415		assert_eq!(config.max_connections(), None);
416	}
417
418	#[test]
419	fn reconnection_settings_default_converts() {
420		let settings = ReconnectionSettings::default();
421		let config = ReconnectionConfig::from(&settings);
422		assert_eq!(config.max_attempts, Some(10));
423		assert_eq!(config.initial_delay, Duration::from_secs(1));
424		assert_eq!(config.max_delay, Duration::from_secs(300));
425		assert_eq!(config.backoff_multiplier, 2.0);
426		assert_eq!(config.jitter_factor, 0.1);
427	}
428
429	#[test]
430	fn origin_settings_default_rejects_all() {
431		let settings = OriginValidationSettings::default();
432		let config = OriginValidationConfig::from(&settings);
433		assert!(config.reject_missing_origin);
434		assert!(matches!(config.policy, OriginPolicy::AllowList(ref v) if v.is_empty()));
435	}
436
437	#[test]
438	fn origin_settings_allow_list_roundtrips() {
439		let settings = OriginValidationSettings {
440			policy: OriginPolicySettings::AllowList(vec!["https://example.com".to_string()]),
441			reject_missing_origin: false,
442		};
443		let config = OriginValidationConfig::from(&settings);
444		assert!(!config.reject_missing_origin);
445		match config.policy {
446			OriginPolicy::AllowList(origins) => {
447				assert_eq!(origins, vec!["https://example.com".to_string()]);
448			}
449			OriginPolicy::AllowAll => panic!("expected AllowList"),
450		}
451	}
452
453	#[test]
454	fn rate_limit_settings_default_converts() {
455		let settings = RateLimitSettings::default();
456		let config = WebSocketRateLimitConfig::from(&settings);
457		assert_eq!(config.max_connections_per_window(), 20);
458		assert_eq!(config.connection_window(), Duration::from_secs(60));
459		assert_eq!(config.max_concurrent_connections_per_ip(), 10);
460		assert_eq!(config.max_messages_per_window(), 100);
461		assert_eq!(config.message_window(), Duration::from_secs(60));
462	}
463
464	#[test]
465	fn origin_policy_settings_serde_roundtrip() {
466		let settings = OriginValidationSettings::default();
467		let json = serde_json::to_string(&settings).expect("serialize");
468		let parsed: OriginValidationSettings = serde_json::from_str(&json).expect("deserialize");
469		assert_eq!(parsed.reject_missing_origin, settings.reject_missing_origin);
470	}
471
472	#[cfg(feature = "redis-channel")]
473	#[test]
474	fn redis_settings_default_converts() {
475		let settings = RedisChannelSettings::default();
476		let config = RedisConfig::from(&settings);
477		assert_eq!(config.url, "redis://127.0.0.1:6379");
478		assert_eq!(config.channel_prefix, "ws:channel:");
479		assert_eq!(config.group_prefix, "ws:group:");
480		assert_eq!(config.message_expiry, 60);
481		assert!(config.password.is_none());
482		assert!(config.username.is_none());
483		assert!(!config.tls);
484		assert!(config.require_auth);
485	}
486}