Skip to main content

appcore_gateway/
config.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: config.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/26 08:53:09 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/02 12:48:56 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Gateway configuration definitions.
12
13use appcore_contracts::ProviderConfig;
14use std::collections::BTreeMap;
15use std::net::SocketAddr;
16use std::time::Duration;
17
18/// Deployment adapter key that enables the Runtime-owned Gateway service.
19pub const GATEWAY_ADAPTER_NAME: &str = "gateway";
20/// Provider identity implemented by this crate.
21pub const GATEWAY_PROVIDER_ID: &str = "appcore-gateway";
22
23/// Maximum decoded WebSocket message or frame size.
24pub const MAX_GATEWAY_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
25/// Maximum JSON body size for the mesh relay's byte-array encoding.
26pub const MAX_GATEWAY_HTTP_BODY_BYTES: usize = MAX_GATEWAY_MESSAGE_BYTES * 4 + 65_536;
27/// Maximum capabilities accepted from one worker connection.
28pub const MAX_GATEWAY_CAPABILITIES: usize = 64;
29/// Maximum active workers retained in one tenant partition.
30pub const MAX_GATEWAY_WORKERS_PER_TENANT: usize = 1_024;
31/// Maximum active clients retained in one tenant partition.
32pub const MAX_GATEWAY_CLIENTS_PER_TENANT: usize = 4_096;
33/// Maximum simultaneous connections accepted by one Gateway process.
34pub const MAX_GATEWAY_CONNECTIONS: usize = 8_192;
35/// Maximum pending worker requests retained in one tenant partition.
36pub const MAX_GATEWAY_PENDING_PER_TENANT: usize = 2_048;
37/// Maximum tenant partitions retained by one Gateway process.
38pub const MAX_GATEWAY_TENANTS: usize = 1_024;
39/// Maximum timeout accepted from an untrusted relay request.
40pub const MAX_GATEWAY_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
41
42/// Configuration options for the AppCore Gateway service.
43///
44/// The `domain_suffix` field **must** be set explicitly by the deployment.
45/// AppCore is a generic Runtime and does not assume any specific domain.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct GatewayConfig {
48    /// Server IP and port to bind to.
49    pub bind_address: SocketAddr,
50
51    /// Domain suffix used to resolve tenant IDs from incoming `Host` headers.
52    ///
53    /// The deployer sets this to the domain that owns the gateway
54    /// (e.g., `gateway.example.com`). An incoming request to
55    /// `tenant-a.gateway.example.com` resolves to `TenantId("tenant-a")`.
56    ///
57    /// This value is deployment-specific and has no default.
58    pub domain_suffix: String,
59
60    /// Whether the gateway enforces token authentication for connections.
61    require_auth: bool,
62
63    /// Interval at which the gateway expects worker heartbeats.
64    pub heartbeat_interval: Duration,
65
66    /// Maximum time window allowed without any heartbeat before pruning a connection.
67    pub heartbeat_timeout: Duration,
68}
69
70impl GatewayConfig {
71    /// Creates a configuration with all required fields.
72    pub fn new(bind_address: SocketAddr, domain_suffix: impl Into<String>) -> Self {
73        Self {
74            bind_address,
75            domain_suffix: domain_suffix.into(),
76            require_auth: true,
77            heartbeat_interval: Duration::from_secs(30),
78            heartbeat_timeout: Duration::from_secs(90),
79        }
80    }
81
82    /// Reports whether connection and mesh-relay authentication is required.
83    pub fn requires_authentication(&self) -> bool {
84        self.require_auth
85    }
86
87    /// Parses the bounded, non-secret Gateway settings selected by a
88    /// deployment adapter.
89    ///
90    /// Supported settings are `bind_address`, `domain_suffix`,
91    /// `heartbeat_interval_ms`, and `heartbeat_timeout_ms`. Authentication is
92    /// intentionally not configurable through a deployment setting and
93    /// remains enabled.
94    pub fn from_provider_config(provider: &ProviderConfig) -> crate::GatewayResult<Self> {
95        validate_provider_shape(provider)?;
96        let settings = provider.settings();
97        let bind_address = required_setting(settings, "bind_address")?
98            .parse::<SocketAddr>()
99            .map_err(|error| {
100                crate::GatewayError::Config(format!("invalid bind_address: {error}"))
101            })?;
102        let domain_suffix = required_setting(settings, "domain_suffix")?.to_string();
103        let mut config = Self::new(bind_address, domain_suffix);
104        config.heartbeat_interval =
105            duration_setting(settings, "heartbeat_interval_ms", config.heartbeat_interval)?;
106        config.heartbeat_timeout =
107            duration_setting(settings, "heartbeat_timeout_ms", config.heartbeat_timeout)?;
108        config.validate()?;
109        Ok(config)
110    }
111
112    /// Explicitly disables authentication for a loopback-only local test.
113    pub fn insecure_local_for_testing(mut self) -> Result<Self, crate::error::GatewayError> {
114        if !self.bind_address.ip().is_loopback() {
115            return Err(crate::error::GatewayError::Config(
116                "insecure gateway mode requires a loopback bind address".to_string(),
117            ));
118        }
119        self.require_auth = false;
120        Ok(self)
121    }
122
123    /// Validates the configuration bounds.
124    pub fn validate(&self) -> Result<(), crate::error::GatewayError> {
125        if !valid_domain_suffix(&self.domain_suffix) {
126            return Err(crate::error::GatewayError::Config(
127                "domain_suffix must be an explicit valid DNS suffix".to_string(),
128            ));
129        }
130        if self.heartbeat_interval.is_zero() {
131            return Err(crate::error::GatewayError::Config(
132                "heartbeat_interval must be greater than zero".to_string(),
133            ));
134        }
135        if self.heartbeat_timeout <= self.heartbeat_interval {
136            return Err(crate::error::GatewayError::Config(
137                "heartbeat_timeout must be strictly greater than heartbeat_interval".to_string(),
138            ));
139        }
140        if !self.require_auth && !self.bind_address.ip().is_loopback() {
141            return Err(crate::error::GatewayError::Config(
142                "gateway authentication cannot be disabled on a non-loopback bind address"
143                    .to_string(),
144            ));
145        }
146        Ok(())
147    }
148}
149
150fn validate_provider_shape(provider: &ProviderConfig) -> crate::GatewayResult<()> {
151    if provider.provider_id().as_str() != GATEWAY_PROVIDER_ID {
152        return Err(crate::GatewayError::Config(format!(
153            "gateway adapter requires provider_id={GATEWAY_PROVIDER_ID}"
154        )));
155    }
156    if provider.endpoint().is_some() {
157        return Err(crate::GatewayError::Config(
158            "gateway adapter does not accept a provider endpoint".to_string(),
159        ));
160    }
161    if !provider.secret_refs().is_empty() {
162        return Err(crate::GatewayError::Config(
163            "gateway adapter reuses Runtime security and accepts no secret refs".to_string(),
164        ));
165    }
166    const SETTINGS: [&str; 4] = [
167        "bind_address",
168        "domain_suffix",
169        "heartbeat_interval_ms",
170        "heartbeat_timeout_ms",
171    ];
172    if let Some(name) = provider
173        .settings()
174        .keys()
175        .find(|name| !SETTINGS.contains(&name.as_str()))
176    {
177        return Err(crate::GatewayError::Config(format!(
178            "unsupported gateway setting: {name}"
179        )));
180    }
181    Ok(())
182}
183
184fn required_setting<'a>(
185    settings: &'a BTreeMap<String, String>,
186    name: &'static str,
187) -> crate::GatewayResult<&'a str> {
188    settings
189        .get(name)
190        .map(String::as_str)
191        .filter(|value| !value.is_empty())
192        .ok_or_else(|| crate::GatewayError::Config(format!("gateway requires {name}")))
193}
194
195fn duration_setting(
196    settings: &BTreeMap<String, String>,
197    name: &'static str,
198    default: Duration,
199) -> crate::GatewayResult<Duration> {
200    let Some(value) = settings.get(name) else {
201        return Ok(default);
202    };
203    value
204        .parse::<u64>()
205        .map(Duration::from_millis)
206        .map_err(|_| crate::GatewayError::Config(format!("{name} must be a u64")))
207}
208
209fn valid_domain_suffix(value: &str) -> bool {
210    if value.is_empty() || value.len() > 253 || value != value.trim() {
211        return false;
212    }
213    value.split('.').all(|label| {
214        !label.is_empty()
215            && label.len() <= 63
216            && !label.starts_with('-')
217            && !label.ends_with('-')
218            && label
219                .bytes()
220                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
221    })
222}