Skip to main content

forge_core/config/
gateway.rs

1//! Gateway configuration for the HTTP listener, CORS, TLS, and request limits.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7use super::default_true;
8use super::types::{DurationStr, SizeStr};
9
10/// Gateway configuration.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[non_exhaustive]
13pub struct GatewayConfig {
14    /// HTTP port.
15    #[serde(default = "default_http_port")]
16    pub port: u16,
17
18    /// gRPC port for inter-node communication (reserved for future use).
19    ///
20    /// This port is registered in the cluster node info but a gRPC listener
21    /// is not yet started. It will be used for efficient binary inter-node
22    /// RPC in a future release.
23    #[serde(default = "default_grpc_port")]
24    pub grpc_port: u16,
25
26    /// Maximum concurrent connections.
27    #[serde(default = "default_max_connections")]
28    pub max_connections: usize,
29
30    /// Request timeout duration (e.g. "30s", "1m").
31    #[serde(default = "default_request_timeout")]
32    pub request_timeout: DurationStr,
33
34    /// Enable CORS handling.
35    #[serde(default = "default_cors_enabled")]
36    pub cors_enabled: bool,
37
38    /// Allowed CORS origins.
39    #[serde(default = "default_cors_origins")]
40    pub cors_origins: Vec<String>,
41
42    /// Routes excluded from request logs, metrics, and traces.
43    /// Defaults to `["/_api/health", "/_api/ready"]`. Set to `[]` to monitor everything.
44    #[serde(default = "default_quiet_paths")]
45    pub quiet_paths: Vec<String>,
46
47    /// Maximum request body size for multipart uploads (e.g. "100mb", "1gb"). Defaults to "20mb".
48    #[serde(default = "default_max_body_size")]
49    pub max_body_size: SizeStr,
50
51    /// Maximum JSON request body size for RPC endpoints (e.g. "1mb", "5mb"). Defaults to "1mb".
52    #[serde(default = "default_max_json_body_size")]
53    pub max_json_body_size: SizeStr,
54
55    /// Default per-file cap for multipart uploads (e.g. "10mb", "200mb").
56    /// Applies when a mutation does not declare its own `max_size`. Set to
57    /// the same value as `max_body_size` to disable the per-file guard.
58    /// Defaults to "10mb".
59    #[serde(default = "default_max_file_size")]
60    pub max_file_size: SizeStr,
61
62    /// TLS configuration for the gateway listener.
63    #[serde(default)]
64    pub tls: TlsConfig,
65
66    /// Maximum file fields in a single multipart upload.
67    #[serde(default = "default_max_multipart_fields")]
68    pub max_multipart_fields: usize,
69
70    /// Add standard security headers (X-Content-Type-Options, X-Frame-Options)
71    /// to all responses.
72    #[serde(default = "default_true")]
73    pub security_headers: bool,
74
75    /// Enable HTTP Strict Transport Security header. Off by default since
76    /// local development uses plain HTTP.
77    #[serde(default)]
78    pub hsts: bool,
79
80    /// IP ranges of trusted reverse proxies (e.g. `["10.0.0.0/8", "172.16.0.0/12"]`).
81    /// When set, `X-Forwarded-For` is only trusted if the connecting peer IP
82    /// matches one of these ranges. When empty (default), the peer socket IP
83    /// is always used and forwarding headers are ignored.
84    #[serde(default)]
85    pub trusted_proxies: Vec<String>,
86
87    /// Maximum number of background jobs a single mutation request may dispatch.
88    /// Prevents a single mutation from enqueuing an unbounded number of jobs and
89    /// exhausting the job table. Defaults to 10.
90    #[serde(default = "default_max_jobs_per_request")]
91    pub max_jobs_per_request: usize,
92
93    /// Maximum serialized response size in bytes. Responses exceeding this limit
94    /// are rejected before being written to the wire. Defaults to 10 MiB.
95    #[serde(default = "default_max_result_size_bytes")]
96    pub max_result_size_bytes: usize,
97
98    /// Maximum JSON nesting depth for incoming request bodies. Requests with
99    /// deeper nesting are rejected before deserialization to prevent stack
100    /// exhaustion. Defaults to 64.
101    #[serde(default = "default_max_json_depth")]
102    pub max_json_depth: usize,
103    // TODO(pre-1.0): per-route CSP overrides (item 40)
104}
105
106impl Default for GatewayConfig {
107    fn default() -> Self {
108        Self {
109            port: default_http_port(),
110            grpc_port: default_grpc_port(),
111            max_connections: default_max_connections(),
112            request_timeout: default_request_timeout(),
113            cors_enabled: default_cors_enabled(),
114            cors_origins: default_cors_origins(),
115            quiet_paths: default_quiet_paths(),
116            max_body_size: default_max_body_size(),
117            max_json_body_size: default_max_json_body_size(),
118            max_file_size: default_max_file_size(),
119            tls: TlsConfig::default(),
120            max_multipart_fields: default_max_multipart_fields(),
121            security_headers: true,
122            hsts: false,
123            trusted_proxies: Vec::new(),
124            max_jobs_per_request: default_max_jobs_per_request(),
125            max_result_size_bytes: default_max_result_size_bytes(),
126            max_json_depth: default_max_json_depth(),
127        }
128    }
129}
130
131/// TLS configuration for the gateway listener.
132///
133/// TLS is enabled when both `cert_path` and `key_path` are set. Leave both
134/// unset to serve plain HTTP. Setting only one is a configuration error.
135///
136/// Empty or whitespace-only strings normalize to unset at load time, so
137/// env-var-driven configs like `cert_path = "${FORGE_TLS_CERT_PATH-}"`
138/// treat an unset variable as "TLS off" instead of failing validation.
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct TlsConfig {
141    /// Path to a PEM-encoded certificate chain file.
142    #[serde(default, deserialize_with = "deserialize_optional_nonempty")]
143    pub cert_path: Option<String>,
144
145    /// Path to a PEM-encoded private key file.
146    #[serde(default, deserialize_with = "deserialize_optional_nonempty")]
147    pub key_path: Option<String>,
148}
149
150/// Deserialize an `Option<String>` treating empty / whitespace-only input as
151/// `None`. Lets env-var-substituted fields with an empty default fall through
152/// to "unset" semantics without tripping the half-set validator.
153fn deserialize_optional_nonempty<'de, D>(
154    deserializer: D,
155) -> std::result::Result<Option<String>, D::Error>
156where
157    D: serde::Deserializer<'de>,
158{
159    let opt: Option<String> = Option::deserialize(deserializer)?;
160    Ok(opt.filter(|s| !s.trim().is_empty()))
161}
162
163impl TlsConfig {
164    /// Return `true` when both `cert_path` and `key_path` are set.
165    pub fn is_enabled(&self) -> bool {
166        self.cert_path.is_some() && self.key_path.is_some()
167    }
168
169    /// Validate the TLS configuration: both paths or neither.
170    pub fn validate(&self) -> crate::Result<()> {
171        match (self.cert_path.as_deref(), self.key_path.as_deref()) {
172            (Some(_), Some(_)) | (None, None) => Ok(()),
173            (Some(_), None) => Err(crate::ForgeError::config(
174                "gateway.tls.cert_path is set but gateway.tls.key_path is missing. \
175                 Set both to enable TLS, or neither to serve plain HTTP.",
176            )),
177            (None, Some(_)) => Err(crate::ForgeError::config(
178                "gateway.tls.key_path is set but gateway.tls.cert_path is missing. \
179                 Set both to enable TLS, or neither to serve plain HTTP.",
180            )),
181        }
182    }
183}
184
185fn default_http_port() -> u16 {
186    9081
187}
188
189fn default_grpc_port() -> u16 {
190    9000
191}
192
193fn default_max_connections() -> usize {
194    4096
195}
196
197fn default_request_timeout() -> DurationStr {
198    DurationStr::new(Duration::from_secs(30))
199}
200
201fn default_cors_enabled() -> bool {
202    false
203}
204
205fn default_cors_origins() -> Vec<String> {
206    Vec::new()
207}
208
209fn default_quiet_paths() -> Vec<String> {
210    vec![
211        "/_api/health".to_string(),
212        "/_api/ready".to_string(),
213        "/_api/signal".to_string(),
214    ]
215}
216
217fn default_max_body_size() -> SizeStr {
218    SizeStr::new(20 * 1024 * 1024)
219}
220
221fn default_max_file_size() -> SizeStr {
222    SizeStr::new(10 * 1024 * 1024)
223}
224
225fn default_max_multipart_fields() -> usize {
226    20
227}
228
229fn default_max_jobs_per_request() -> usize {
230    10
231}
232
233fn default_max_result_size_bytes() -> usize {
234    10 * 1024 * 1024
235}
236
237fn default_max_json_body_size() -> SizeStr {
238    SizeStr::new(1024 * 1024)
239}
240
241fn default_max_json_depth() -> usize {
242    64
243}