Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/cfg.rs

172 KiB, 1034 runs

created by r1870400018:971, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
2//! Anthropic Claude
3
4use crate::srv::{
5 constant,
6 publish::PublishConfig,
7};
8
9use oxedyne_fe2o3_core::{
10 prelude::*,
11 file::{
12 OsPath,
13 PathState,
14 },
15 map::MapMut,
16 path::{
17 NormalPath,
18 NormPathBuf,
19 },
20};
21use oxedyne_fe2o3_jdat::{
22 prelude::*,
23 cfg::Config,
24};
25use oxedyne_fe2o3_net::{
26 constant::SESSION_ID_KEY_LABEL,
27 dns::Fqdn,
28 sms::Provider as SmsProvider,
29 http::{
30 encoding,
31 fields::{
32 Cookie,
33 SetCookieAttributes,
34 SameSite,
35 },
36 fwd::ForwardedPolicy,
37 },
38};
39
40use std::{
41 collections::{
42 BTreeMap,
43 BTreeSet,
44 },
45 path::{
46 Path,
47 PathBuf,
48 },
49 time::Duration,
50};
51
52
53// ┌───────────────────────────────────────────────────────────────────────────┐
54// │ REDIRECT RULES │
55// └───────────────────────────────────────────────────────────────────────────┘
56
57/// How a redirect rule's `match_path` is tested against an incoming request path.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub enum RedirectMatch {
60 Exact, // e.g. `/admin`
61 Prefix, // any request whose path starts with `match_path`
62 All, // any path on the vhost, typically for a www -> canonical redirect
63}
64
65impl RedirectMatch {
66 pub fn from_str(s: &str) -> Outcome<Self> {
67 match s {
68 "exact" => Ok(Self::Exact),
69 "prefix" => Ok(Self::Prefix),
70 "all" => Ok(Self::All),
71 _ => Err(err!(
72 "Unknown redirect match kind '{}'. Valid values are: exact, prefix, all.", s;
73 Invalid, Input, String)),
74 }
75 }
76}
77
78/// A single redirect rule applied by a vhost before static file resolution.
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct RedirectRule {
81 pub match_kind: RedirectMatch,
82 pub match_path: String, // ignored when `match_kind` is `All`
83 // May contain the literal `{uri}`, which is replaced by the matched request path and query
84 // string at redirect time.
85 pub target: String,
86 pub status: u16, // normally 301 permanent or 302 temporary
87}
88
89impl RedirectRule {
90 pub fn resolve_target(&self, request_uri: &str) -> String {
91 if self.target.contains("{uri}") {
92 self.target.replace("{uri}", request_uri)
93 } else {
94 self.target.clone()
95 }
96 }
97
98 pub fn matches(&self, request_path: &str) -> bool {
99 match self.match_kind {
100 RedirectMatch::Exact => request_path == self.match_path,
101 RedirectMatch::Prefix => request_path.starts_with(&self.match_path),
102 RedirectMatch::All => true,
103 }
104 }
105
106 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
107 let match_kind_str = match m.get(&dat!("match_kind")) {
108 Some(Dat::Str(s)) => s.clone(),
109 _ => fmt!("all"),
110 };
111 let match_kind = res!(RedirectMatch::from_str(&match_kind_str));
112 let match_path = match m.get(&dat!("match_path")) {
113 Some(Dat::Str(s)) => s.clone(),
114 _ => String::new(),
115 };
116 let target = match m.get(&dat!("target")) {
117 Some(Dat::Str(s)) => s.clone(),
118 None => return Err(err!(
119 "RedirectRule: missing 'target' field.";
120 Invalid, Input, Missing)),
121 _ => return Err(err!(
122 "RedirectRule: 'target' field must be a string.";
123 Invalid, Input, Mismatch)),
124 };
125 let status = match m.get(&dat!("status")) {
126 Some(Dat::U16(n)) => *n,
127 Some(Dat::U32(n)) => *n as u16,
128 Some(Dat::U64(n)) => *n as u16,
129 _ => 301,
130 };
131 Ok(Self {
132 match_kind,
133 match_path,
134 target,
135 status,
136 })
137 }
138}
139
140
141// ┌───────────────────────────────────────────────────────────────────────────┐
142// │ API ROUTES │
143// └───────────────────────────────────────────────────────────────────────────┘
144
145/// An outbound API proxy route.
146///
147/// Maps a local POST path to an upstream HTTPS URL. Steel forwards the
148/// request body verbatim and injects the configured headers (typically
149/// containing secret credentials loaded from files at startup).
150///
151/// As an alternative to a remote upstream, a route may name an
152/// in-process `handler` registered by an `AppExtension`. In that case
153/// Steel dispatches the request to the registered `ApiHandler`
154/// instead of proxying. The two modes are mutually exclusive: a
155/// route either has `upstream*` set or `handler` set, never both.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct ApiRoute {
158 pub path: String, // e.g. `/api/payments/checkout`
159 pub upstream_host: Option<String>, // `None` when served by a handler
160 pub upstream_port: Option<u16>, // 443 for `https://`, 80 for `http://`
161 pub upstream_path: Option<String>, // e.g. `/v1/checkout/sessions`
162 // True when the upstream URL used `https://`: dispatch opens a TLS connection when it is set
163 // and a plain TCP connection otherwise. Defaults to true, so third-party API proxying keeps
164 // the pre-feature semantics; the `http://` form is reserved for loopback app binaries where
165 // TLS is unnecessary.
166 pub upstream_tls: bool,
167 pub headers: Vec<(String, String)>, // `{file:...}` expanded at load time
168 pub handler: Option<String>, // in-process; `None` for a proxy route
169 pub config: Vec<(String, String)>, // handler config, `{file:}`/`{env:}` resolved
170}
171
172impl ApiRoute {
173 /// Header values are stored as-is and may contain `{file:path}` placeholders. Call
174 /// `resolve_headers` with the app root to expand them before use.
175 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
176 // Path (required).
177 let path = match m.get(&dat!("path")) {
178 Some(Dat::Str(s)) => s.clone(),
179 _ => return Err(err!(
180 "ApiRoute: 'path' field is required and must be a string.";
181 Invalid, Input, Missing)),
182 };
183 // Either `upstream` (proxy) or `handler` (in-process). Not both.
184 let upstream_str = match m.get(&dat!("upstream")) {
185 Some(Dat::Str(s)) => Some(s.clone()),
186 None => None,
187 _ => return Err(err!(
188 "ApiRoute '{}': 'upstream' must be a string when present.", path;
189 Invalid, Input, Mismatch)),
190 };
191 let handler = match m.get(&dat!("handler")) {
192 Some(Dat::Str(s)) => Some(s.clone()),
193 None => None,
194 _ => return Err(err!(
195 "ApiRoute '{}': 'handler' must be a string when present.", path;
196 Invalid, Input, Mismatch)),
197 };
198 match (&upstream_str, &handler) {
199 (None, None) => return Err(err!(
200 "ApiRoute '{}': must specify either 'upstream' (for proxy \
201 routes) or 'handler' (for in-process routes).", path;
202 Invalid, Input, Missing)),
203 (Some(_), Some(_)) => return Err(err!(
204 "ApiRoute '{}': 'upstream' and 'handler' are mutually \
205 exclusive. A route is either a proxy or in-process, not \
206 both.", path;
207 Invalid, Input, Conflict)),
208 _ => {}
209 }
210 // Parse upstream URL into host, port, path, scheme (proxy mode only).
211 let (upstream_host, upstream_port, upstream_path, upstream_tls) = match upstream_str {
212 Some(url) => {
213 let (h, p, up, tls) = res!(Self::parse_upstream(&url));
214 (Some(h), Some(p), Some(up), tls)
215 }
216 None => (None, None, None, true),
217 };
218 // Headers (optional map of name -> value). Used in proxy mode for
219 // headers injected into the upstream request; empty for handler mode.
220 let headers = match m.get(&dat!("headers")) {
221 Some(Dat::Map(sub)) => {
222 let mut out = Vec::new();
223 for (k, v) in sub.iter() {
224 let name = match k {
225 Dat::Str(s) => s.clone(),
226 _ => return Err(err!(
227 "ApiRoute '{}': header names must be strings.", path;
228 Invalid, Input, Mismatch)),
229 };
230 let raw_val = match v {
231 Dat::Str(s) => s.clone(),
232 _ => return Err(err!(
233 "ApiRoute '{}': header values must be strings.", path;
234 Invalid, Input, Mismatch)),
235 };
236 out.push((name, raw_val));
237 }
238 out
239 }
240 None => Vec::new(),
241 _ => return Err(err!(
242 "ApiRoute '{}': 'headers' must be a map.", path;
243 Invalid, Input, Mismatch)),
244 };
245 // Handler-specific config (optional map). Only used in handler mode.
246 let config = match m.get(&dat!("config")) {
247 Some(Dat::Map(sub)) => {
248 let mut out = Vec::new();
249 for (k, v) in sub.iter() {
250 let name = match k {
251 Dat::Str(s) => s.clone(),
252 _ => continue,
253 };
254 let val = match v {
255 Dat::Str(s) => s.clone(),
256 _ => continue,
257 };
258 out.push((name, val));
259 }
260 out
261 }
262 None => Vec::new(),
263 _ => Vec::new(),
264 };
265 Ok(Self {
266 path,
267 upstream_host,
268 upstream_port,
269 upstream_path,
270 upstream_tls,
271 headers,
272 handler,
273 config,
274 })
275 }
276
277 pub fn get_config(&self, key: &str) -> Option<&str> {
278 self.config.iter()
279 .find(|(k, _)| k == key)
280 .map(|(_, v)| v.as_str())
281 }
282
283 /// Parse an upstream URL into `(host, port, path, tls)`. Accepts
284 /// both `https://` and `http://`; the former sets `tls = true` and
285 /// defaults the port to 443, the latter sets `tls = false` and
286 /// defaults the port to 80. Plain HTTP is intended for loopback
287 /// upstreams only -- a public API reached over HTTP is a separate
288 /// security mistake and Steel does not make it easier to do.
289 pub fn parse_upstream(url: &str) -> Outcome<(String, u16, String, bool)> {
290 let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("https://") {
291 (r, true, 443u16)
292 } else if let Some(r) = url.strip_prefix("http://") {
293 (r, false, 80u16)
294 } else {
295 return Err(err!(
296 "ApiRoute: upstream URL must start with 'https://' or 'http://'. \
297 Got: '{}'.", url;
298 Invalid, Input));
299 };
300 let (host_port, path) = match rest.find('/') {
301 Some(i) => (&rest[..i], &rest[i..]),
302 None => (rest, "/"),
303 };
304 let (host, port) = match host_port.rfind(':') {
305 Some(i) => {
306 let p: u16 = match host_port[i + 1..].parse() {
307 Ok(n) => n,
308 Err(_) => return Err(err!(
309 "ApiRoute: invalid port in upstream URL '{}'.", url;
310 Invalid, Input)),
311 };
312 (host_port[..i].to_string(), p)
313 }
314 None => (host_port.to_string(), default_port),
315 };
316 Ok((host, port, path.to_string(), tls))
317 }
318
319 /// Expand `{file:path}` and `{env:}` placeholders in all header
320 /// values by reading the referenced files relative to `root`. Used
321 /// in proxy mode for headers injected into the upstream request.
322 /// Handler-config values are resolved separately by
323 /// [`ApiRoute::resolve_config`]; a route with an in-process handler
324 /// should have both called at startup. Must be called once before
325 /// the route is dispatched.
326 pub fn resolve_headers(&mut self, root: &Path) -> Outcome<()> {
327 for (_name, value) in &mut self.headers {
328 *value = res!(Self::resolve_file_refs(value, root));
329 }
330 Ok(())
331 }
332
333 /// Expand `{file:path}` and `{env:}` placeholders in all
334 /// handler-config values by reading the referenced files relative
335 /// to `root`. Mirrors [`WebhookRoute::resolve_config`] so an
336 /// in-process API handler can read a resolved secret (e.g. a
337 /// Stripe key) out of its `config` map. Must be called once at
338 /// startup before the route is dispatched.
339 pub fn resolve_config(&mut self, root: &Path) -> Outcome<()> {
340 for (_name, value) in &mut self.config {
341 *value = res!(Self::resolve_file_refs(value, root));
342 }
343 Ok(())
344 }
345
346 /// Resolve `{file:path}`, optional `{file?:path}`, and `{env:VAR}` or
347 /// `{env:VAR:default}` placeholders in a config value.
348 ///
349 /// * `{file:path}` — replaced with the trimmed contents of the file,
350 /// resolved relative to `root`. Fails if the file cannot be read.
351 /// * `{file?:path}` — the optional form: replaced with the trimmed
352 /// contents when the file exists, and with the empty string when it
353 /// is absent. Any read error other than not-found — a present file
354 /// the process may not read, say — still fails, so an unreadable key
355 /// is never silently dropped.
356 /// * `{env:VAR}` — replaced with the value of environment variable
357 /// `VAR`. Fails if the variable is unset.
358 /// * `{env:VAR:default}` — replaced with the env var value, or
359 /// `default` if the variable is unset or empty.
360 ///
361 /// Env placeholders are resolved first so they may appear inside
362 /// `{file:...}` paths to parameterise file locations.
363 pub fn resolve_file_refs(value: &str, root: &Path) -> Outcome<String> {
364 // Pass 1: resolve all {env:} placeholders so env values can appear
365 // inside {file:} paths.
366 let intermediate = res!(Self::resolve_env_refs(value));
367 // Pass 2: resolve all {file:} placeholders.
368 Self::resolve_file_only(&intermediate, root)
369 }
370
371 /// Resolve only `{env:VAR[:default]}` placeholders.
372 fn resolve_env_refs(value: &str) -> Outcome<String> {
373 let mut result = value.to_string();
374 while let Some(start) = result.find("{env:") {
375 let end = match result[start..].find('}') {
376 Some(i) => start + i,
377 None => return Err(err!(
378 "Config: unclosed '{{env:' placeholder in value '{}'.", value;
379 Invalid, Input)),
380 };
381 let inner = result[start + 5..end].to_string();
382 let (var_name, default) = match inner.find(':') {
383 Some(i) => (&inner[..i], Some(&inner[i + 1..])),
384 None => (inner.as_str(), None),
385 };
386 let replacement = match std::env::var(var_name) {
387 Ok(v) if !v.is_empty() => v,
388 _ => match default {
389 Some(d) => d.to_string(),
390 None => return Err(err!(
391 "Config: environment variable '{}' is not set \
392 and '{{env:{}}}' has no default.",
393 var_name, inner;
394 Invalid, Input, Missing)),
395 },
396 };
397 result.replace_range(start..=end, &replacement);
398 }
399 Ok(result)
400 }
401
402 /// Resolve `{file:path}` and optional `{file?:path}` placeholders.
403 ///
404 /// `{file:path}` fails on any read error. `{file?:path}` resolves to
405 /// the empty string when the file is not found, but still fails on any
406 /// other read error — a present-but-unreadable key must not be silently
407 /// swallowed.
408 fn resolve_file_only(value: &str, root: &Path) -> Outcome<String> {
409 let mut result = value.to_string();
410 loop {
411 // Find the earliest of the two markers. The required '{file:' is
412 // not a substring of the optional '{file?:' — the sixth byte is
413 // ':' against '?' — so a plain search for one never matches the
414 // other, and the two positions can never coincide.
415 let required = result.find("{file:");
416 let optional = result.find("{file?:");
417 let (start, marker_len, is_optional) = match (required, optional) {
418 (None, None) => break,
419 (Some(r), None) => (r, 6, false),
420 (None, Some(o)) => (o, 7, true),
421 (Some(r), Some(o)) if o < r => (o, 7, true),
422 (Some(r), Some(_)) => (r, 6, false),
423 };
424 let opt_mark = if is_optional { "?" } else { "" };
425 let end = match result[start..].find('}') {
426 Some(i) => start + i,
427 None => return Err(err!(
428 "Config: unclosed '{{file{}:' placeholder in value '{}'.",
429 opt_mark, value;
430 Invalid, Input)),
431 };
432 let rel_path = result[start + marker_len..end].to_string();
433 let abs_path = root.join(&rel_path);
434 let content = match std::fs::read_to_string(&abs_path) {
435 Ok(s) => s.trim().to_string(),
436 // An absent optional file resolves to nothing. Only not-found
437 // is tolerated, so a permissions failure on a present file
438 // still errors below rather than yielding a silent empty key.
439 Err(e) if is_optional
440 && e.kind() == std::io::ErrorKind::NotFound => String::new(),
441 Err(e) => return Err(err!(e,
442 "Config: failed to read '{{file{}:{}}}' at '{:?}'.",
443 opt_mark, rel_path, abs_path;
444 IO, File, Read)),
445 };
446 result.replace_range(start..=end, &content);
447 }
448 Ok(result)
449 }
450}
451
452
453// ┌───────────────────────────────────────────────────────────────────────────┐
454// │ WEBHOOK ROUTES │
455// └───────────────────────────────────────────────────────────────────────────┘
456
457/// An incoming webhook route with a named handler.
458///
459/// When Steel receives a POST at the configured `path`, it dispatches to
460/// the handler identified by `handler`. The `config` map carries handler-
461/// specific settings (API keys, upstream URLs, identifiers, etc.) whose
462/// values support the same `{file:path}` secret placeholder syntax as
463/// API routes.
464#[derive(Clone, Debug, Eq, PartialEq)]
465pub struct WebhookRoute {
466 pub path: String, // e.g. `/webhook/payments`
467 pub handler: Option<String>, // `None` when the route forwards upstream
468 pub upstream_host: Option<String>, // mutually exclusive with `handler`
469 pub upstream_port: Option<u16>,
470 pub upstream_path: Option<String>, // the payload is POSTed here verbatim
471 pub upstream_tls: bool, // `https://`; false for loopback HTTP
472 pub config: Vec<(String, String)>, // in-process mode only
473}
474
475impl WebhookRoute {
476 /// Parse a webhook route from a `DaticleMap`.
477 ///
478 /// Accepts either an in-process `handler` field or an
479 /// `upstream` URL; exactly one of the two is required, and
480 /// setting both is a configuration error. The `upstream` URL
481 /// follows the same `https://` / `http://` grammar as
482 /// [`ApiRoute::parse_upstream`].
483 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
484 let path = match m.get(&dat!("path")) {
485 Some(Dat::Str(s)) => s.clone(),
486 _ => return Err(err!(
487 "WebhookRoute: 'path' is required and must be a string.";
488 Invalid, Input, Missing)),
489 };
490 let handler = match m.get(&dat!("handler")) {
491 Some(Dat::Str(s)) => Some(s.clone()),
492 None => None,
493 _ => return Err(err!(
494 "WebhookRoute '{}': 'handler' must be a string when present.", path;
495 Invalid, Input, Mismatch)),
496 };
497 let upstream_str = match m.get(&dat!("upstream")) {
498 Some(Dat::Str(s)) => Some(s.clone()),
499 None => None,
500 _ => return Err(err!(
501 "WebhookRoute '{}': 'upstream' must be a string when present.", path;
502 Invalid, Input, Mismatch)),
503 };
504 match (&handler, &upstream_str) {
505 (None, None) => return Err(err!(
506 "WebhookRoute '{}': must specify either 'handler' (for \
507 in-process webhooks) or 'upstream' (for forwarded \
508 webhooks).", path;
509 Invalid, Input, Missing)),
510 (Some(_), Some(_)) => return Err(err!(
511 "WebhookRoute '{}': 'handler' and 'upstream' are mutually \
512 exclusive. A webhook route is either in-process or \
513 forwarded, not both.", path;
514 Invalid, Input, Conflict)),
515 _ => (),
516 }
517 let (upstream_host, upstream_port, upstream_path, upstream_tls) = match upstream_str {
518 Some(url) => {
519 let (h, p, up, tls) = res!(ApiRoute::parse_upstream(&url));
520 (Some(h), Some(p), Some(up), tls)
521 }
522 None => (None, None, None, true),
523 };
524 let config = match m.get(&dat!("config")) {
525 Some(Dat::Map(sub)) => {
526 let mut out = Vec::new();
527 for (k, v) in sub.iter() {
528 let name = match k {
529 Dat::Str(s) => s.clone(),
530 _ => continue,
531 };
532 let val = match v {
533 Dat::Str(s) => s.clone(),
534 _ => continue,
535 };
536 out.push((name, val));
537 }
538 out
539 }
540 None => Vec::new(),
541 _ => Vec::new(),
542 };
543 Ok(Self {
544 path,
545 handler,
546 upstream_host,
547 upstream_port,
548 upstream_path,
549 upstream_tls,
550 config,
551 })
552 }
553
554 pub fn resolve_config(&mut self, root: &Path) -> Outcome<()> {
555 for (_name, value) in &mut self.config {
556 *value = res!(ApiRoute::resolve_file_refs(value, root));
557 }
558 Ok(())
559 }
560
561 pub fn get_config(&self, key: &str) -> Option<&str> {
562 self.config.iter()
563 .find(|(k, _)| k == key)
564 .map(|(_, v)| v.as_str())
565 }
566
567 /// True when the route forwards to an upstream instead of
568 /// dispatching to an in-process handler.
569 pub fn is_upstream(&self) -> bool {
570 self.upstream_host.is_some()
571 }
572}
573
574
575// ┌───────────────────────────────────────────────────────────────────────────┐
576// │ PROXY ROUTES │
577// │ │
578// │ A reverse-proxy route forwards all requests under a path prefix to an │
579// │ upstream server. Unlike ApiRoute (exact path match, buffered response), │
580// │ ProxyRoute uses prefix matching, supports WebSocket upgrade tunneling, │
581// │ and streams response bodies without buffering — making it suitable for │
582// │ proxying full web applications including those that use SSE or WebSocket │
583// │ for real-time communication. │
584// └───────────────────────────────────────────────────────────────────────────┘
585
586/// A reverse-proxy route that forwards all requests under a path prefix
587/// to an upstream server.
588///
589/// When a request's path starts with `path_prefix`, Steel connects to
590/// the upstream over TCP (optionally TLS), forwards the request, and
591/// streams the response back to the client. WebSocket upgrade requests
592/// are transparently tunnelled: Steel connects to the upstream, forwards
593/// the upgrade handshake, then bidirectionally pipes raw bytes between
594/// client and upstream for the lifetime of the WebSocket connection.
595///
596/// Proxy routes are checked after redirect rules but before static file
597/// serving and API routes. When multiple proxy routes match, the longest
598/// prefix wins.
599#[derive(Clone, Debug, Eq, PartialEq)]
600pub struct ProxyRoute {
601 pub path_prefix: String, // `/` matches everything, `/api/` a subtree
602 pub upstream_host: String, // e.g. `127.0.0.1`, `localhost`
603 pub upstream_port: u16,
604 pub upstream_tls: bool, // default false; loopback rarely needs it
605 // Whether to strip the path prefix before forwarding. When true, a request for
606 // `/chat/api/v1/users` with prefix `/chat` is forwarded as `/api/v1/users`; when false the
607 // full original path goes verbatim.
608 pub strip_prefix: bool,
609}
610
611impl ProxyRoute {
612 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
613 let path_prefix = match m.get(&dat!("path_prefix")) {
614 Some(Dat::Str(s)) => s.clone(),
615 _ => return Err(err!(
616 "ProxyRoute: 'path_prefix' is required and must be a string.";
617 Invalid, Input, Missing)),
618 };
619 let upstream_host = match m.get(&dat!("upstream_host")) {
620 Some(Dat::Str(s)) => s.clone(),
621 _ => return Err(err!(
622 "ProxyRoute '{}': 'upstream_host' is required and must be a string.",
623 path_prefix;
624 Invalid, Input, Missing)),
625 };
626 let upstream_port = match m.get(&dat!("upstream_port")) {
627 Some(Dat::U16(n)) => *n,
628 Some(Dat::U32(n)) => *n as u16,
629 Some(Dat::U64(n)) => *n as u16,
630 Some(Dat::I64(n)) => *n as u16,
631 _ => return Err(err!(
632 "ProxyRoute '{}': 'upstream_port' is required and must be a number.",
633 path_prefix;
634 Invalid, Input, Missing)),
635 };
636 let upstream_tls = match m.get(&dat!("upstream_tls")) {
637 Some(Dat::Bool(b)) => *b,
638 None => false,
639 _ => return Err(err!(
640 "ProxyRoute '{}': 'upstream_tls' must be a boolean when present.",
641 path_prefix;
642 Invalid, Input, Mismatch)),
643 };
644 let strip_prefix = match m.get(&dat!("strip_prefix")) {
645 Some(Dat::Bool(b)) => *b,
646 None => false,
647 _ => return Err(err!(
648 "ProxyRoute '{}': 'strip_prefix' must be a boolean when present.",
649 path_prefix;
650 Invalid, Input, Mismatch)),
651 };
652 Ok(Self {
653 path_prefix,
654 upstream_host,
655 upstream_port,
656 upstream_tls,
657 strip_prefix,
658 })
659 }
660
661 pub fn matches(&self, request_path: &str) -> bool {
662 request_path.starts_with(&self.path_prefix)
663 }
664
665 pub fn upstream_path_for(&self, request_path: &str) -> String {
666 if self.strip_prefix {
667 if let Some(stripped) = request_path.strip_prefix(&self.path_prefix) {
668 if stripped.is_empty() {
669 "/".to_string()
670 } else if stripped.starts_with('/') {
671 stripped.to_string()
672 } else {
673 fmt!("/{}", stripped)
674 }
675 } else {
676 request_path.to_string()
677 }
678 } else {
679 request_path.to_string()
680 }
681 }
682}
683
684
685// ┌───────────────────────────────────────────────────────────────────────────┐
686// │ WEBSOCKET ROUTES │
687// │ │
688// │ A WebSocket route hands one path's upgrades to a WebSocket server of its │
689// │ own, on loopback. Unlike ProxyRoute (a whole application behind a path │
690// │ prefix) it matches one exact path and forwards nothing else, so a site │
691// │ whose pages, files and API stay with Steel can still give a single │
692// │ endpoint to a separate process that speaks its own protocol. │
693// └───────────────────────────────────────────────────────────────────────────┘
694
695/// A route that forwards the WebSocket upgrade on one path to an upstream
696/// WebSocket server.
697///
698/// On an HTTP/1.1 `GET` with `Upgrade: websocket` whose path equals
699/// [`WsRoute::path`], Steel opens a plain TCP connection to the upstream,
700/// forwards the handshake, relays the `101 Switching Protocols` back to the
701/// client, and then copies bytes in both directions until either end closes.
702/// The frames themselves are never parsed: what the client sends is what the
703/// upstream receives.
704///
705/// Checked before proxy routes, because a route naming one exact path is more
706/// specific than a prefix that happens to contain it. A request to the same
707/// path that is *not* an upgrade is left alone, and falls through to the rest
708/// of the dispatch chain.
709///
710/// The upstream URL is `ws://host[:port]/path`. There is deliberately no
711/// `wss://` form: this exists to reach a server on the same machine, whose
712/// traffic never leaves the loopback interface, and an operator who needs TLS
713/// to the upstream is not describing loopback and should say so with a
714/// [`ProxyRoute`].
715#[derive(Clone, Debug, Eq, PartialEq)]
716pub struct WsRoute {
717 pub path: String, // matched exactly, e.g. `/ws`
718 pub upstream_host: String, // e.g. `127.0.0.1`
719 pub upstream_port: u16, // 80 where the URL gives none
720 pub upstream_path: String, // need not be the local one
721}
722
723impl WsRoute {
724 /// Both fields are required: `path` is the local path, `upstream` the
725 /// `ws://host[:port]/path` URL to forward it to.
726 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
727 let path = match m.get(&dat!("path")) {
728 Some(Dat::Str(s)) => s.clone(),
729 _ => return Err(err!(
730 "WsRoute: 'path' is required and must be a string.";
731 Invalid, Input, Missing)),
732 };
733 let upstream = match m.get(&dat!("upstream")) {
734 Some(Dat::Str(s)) => s.clone(),
735 _ => return Err(err!(
736 "WsRoute '{}': 'upstream' is required and must be a string.", path;
737 Invalid, Input, Missing)),
738 };
739 let (upstream_host, upstream_port, upstream_path) =
740 res!(Self::parse_upstream(&upstream));
741 Ok(Self {
742 path,
743 upstream_host,
744 upstream_port,
745 upstream_path,
746 })
747 }
748
749 /// Parse a `ws://host[:port][/path]` URL into `(host, port, path)`.
750 ///
751 /// A `wss://` URL is refused with an explanation rather than quietly
752 /// treated as plaintext, which is what a scheme this route cannot honour
753 /// would otherwise become.
754 pub fn parse_upstream(url: &str) -> Outcome<(String, u16, String)> {
755 let rest = match url.strip_prefix("ws://") {
756 Some(r) => r,
757 None => {
758 if url.starts_with("wss://") {
759 return Err(err!(
760 "WsRoute: 'wss://' upstream '{}' cannot be honoured: a ws_route \
761 forwards to a loopback server over plain TCP. Use a proxy_route \
762 with upstream_tls for a TLS upstream.", url;
763 Invalid, Input, Unimplemented));
764 }
765 return Err(err!(
766 "WsRoute: upstream URL must start with 'ws://'. Got: '{}'.", url;
767 Invalid, Input));
768 },
769 };
770 let (host_port, path) = match rest.find('/') {
771 Some(i) => (&rest[..i], &rest[i..]),
772 None => (rest, "/"),
773 };
774 if host_port.is_empty() {
775 return Err(err!(
776 "WsRoute: upstream URL '{}' names no host.", url;
777 Invalid, Input, Missing));
778 }
779 let (host, port) = match host_port.rfind(':') {
780 Some(i) => {
781 let p: u16 = match host_port[i + 1..].parse() {
782 Ok(n) => n,
783 Err(_) => return Err(err!(
784 "WsRoute: invalid port in upstream URL '{}'.", url;
785 Invalid, Input)),
786 };
787 (host_port[..i].to_string(), p)
788 }
789 None => (host_port.to_string(), 80u16),
790 };
791 Ok((host, port, path.to_string()))
792 }
793
794 pub fn matches(&self, request_path: &str) -> bool {
795 self.path == request_path
796 }
797}
798
799
800// ┌───────────────────────────────────────────────────────────────────────────┐
801// │ TILE CONFIG │
802// │ │
803// │ Map tiles served from local archives under a prefix, one archive per │
804// │ build. See `srv::tiles` for what the route keeps, which is nothing. │
805// └───────────────────────────────────────────────────────────────────────────┘
806
807/// A vhost's tile route: `{prefix}/{build}/{z}/{x}/{y}.{ext}` and `{prefix}/tiles.json`.
808///
809/// Each build names an archive by absolute path, which keeps a file of tens or hundreds of
810/// gigabytes out of the app tree. Several builds may be served at once so that a client holding
811/// the previous build's URLs keeps working across a refresh; `current` is the one the index
812/// advertises.
813#[derive(Clone, Debug, Eq, PartialEq)]
814pub struct TileConfig {
815 pub prefix: String, // e.g. `/t`, no trailing slash
816 pub current: String, // the build `tiles.json` names
817 pub builds: BTreeMap<String, PathBuf>,
818 pub allow_origins: Vec<String>, // exact `scheme://host[:port]` matches
819 pub attribution: String, // shown by the map, carried in the index
820}
821
822pub const TILE_ATTRIBUTION_DEFAULT: &str = "© OpenStreetMap";
823
824impl TileConfig {
825 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
826 let prefix = match m.get(&dat!("prefix")) {
827 Some(Dat::Str(s)) => s.clone(),
828 None => fmt!("/t"),
829 _ => return Err(err!(
830 "TileConfig: 'prefix' must be a string."; Invalid, Input, Mismatch)),
831 };
832 if !prefix.starts_with('/') || prefix.len() < 2 || prefix.ends_with('/') {
833 return Err(err!(
834 "TileConfig: 'prefix' '{}' must start with '/', name at least one character \
835 and not end with '/'.", prefix; Invalid, Input));
836 }
837 let current = match m.get(&dat!("current")) {
838 Some(Dat::Str(s)) => s.clone(),
839 _ => return Err(err!(
840 "TileConfig: 'current' is required and must be a build id string.";
841 Invalid, Input, Missing)),
842 };
843 let mut builds = BTreeMap::new();
844 match m.get(&dat!("builds")) {
845 Some(Dat::Map(sub)) => for (k, v) in sub {
846 let (build, path) = match (k, v) {
847 (Dat::Str(b), Dat::Str(p)) => (b.clone(), PathBuf::from(p)),
848 _ => return Err(err!(
849 "TileConfig: each 'builds' entry must map a build id string to a \
850 path string."; Invalid, Input, Mismatch)),
851 };
852 res!(Self::check_build_id(&build));
853 if !path.is_absolute() {
854 return Err(err!(
855 "TileConfig: build '{}' names {:?}, which is not an absolute path. \
856 An archive belongs outside the app tree.", build, path;
857 Invalid, Input, Path));
858 }
859 builds.insert(build, path);
860 },
861 _ => return Err(err!(
862 "TileConfig: 'builds' is required and must map build ids to archive paths.";
863 Invalid, Input, Missing)),
864 }
865 if !builds.contains_key(&current) {
866 return Err(err!(
867 "TileConfig: 'current' build '{}' is not among 'builds' {:?}.",
868 current, builds.keys().collect::<Vec<_>>(); Invalid, Input, Missing));
869 }
870 // Either list shape, since a config writes `(vek|[...])` as readily as `[...]`.
871 let items: &[Dat] = match m.get(&dat!("allow_origins")) {
872 Some(Dat::List(list)) => list,
873 Some(Dat::Vek(vek)) => vek.as_slice(),
874 None => &[],
875 _ => return Err(err!(
876 "TileConfig: 'allow_origins' must be a list of strings.";
877 Invalid, Input, Mismatch)),
878 };
879 let mut allow_origins = Vec::new();
880 for item in items {
881 match item {
882 Dat::Str(s) => {
883 res!(Self::check_origin(s));
884 allow_origins.push(s.clone());
885 }
886 _ => return Err(err!(
887 "TileConfig: 'allow_origins' entries must be strings, not {:?}.", item.kind();
888 Invalid, Input, Mismatch)),
889 }
890 }
891 let attribution = match m.get(&dat!("attribution")) {
892 Some(Dat::Str(s)) => s.clone(),
893 None => TILE_ATTRIBUTION_DEFAULT.to_string(),
894 _ => return Err(err!(
895 "TileConfig: 'attribution' must be a string."; Invalid, Input, Mismatch)),
896 };
897 Ok(Self { prefix, current, builds, allow_origins, attribution })
898 }
899
900 // A build id is one URL path segment, so it is kept to a plain alphabet.
901 fn check_build_id(build: &str) -> Outcome<()> {
902 if build.is_empty() || build.len() > 64 || !build.bytes().all(|b|
903 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
904 || build.starts_with('.')
905 {
906 return Err(err!(
907 "TileConfig: build id '{}' must be 1 to 64 characters of letters, digits, \
908 '-', '_' or '.', not starting with '.'.", build; Invalid, Input));
909 }
910 Ok(())
911 }
912
913 // An origin as a browser sends it: a scheme, a host, perhaps a port, and nothing after.
914 // A wildcard is refused, since the point of the list is who may draw the tiles.
915 fn check_origin(origin: &str) -> Outcome<()> {
916 let rest = match origin.strip_prefix("https://")
917 .or_else(|| origin.strip_prefix("http://"))
918 {
919 Some(r) => r,
920 None => return Err(err!(
921 "TileConfig: origin '{}' must start with 'https://' or 'http://'.", origin;
922 Invalid, Input)),
923 };
924 if rest.is_empty() || rest.contains('/') || rest.contains('*') {
925 return Err(err!(
926 "TileConfig: origin '{}' must be 'scheme://host[:port]' with no path and no \
927 wildcard.", origin; Invalid, Input));
928 }
929 Ok(())
930 }
931}
932
933
934// ┌───────────────────────────────────────────────────────────────────────────┐
935// │ TERMINAL CONFIG │
936// │ │
937// │ Enables terminal session management for a vhost. When configured, │
938// │ Steel adds term_* commands to the WS syntax protocol and a binary │
939// │ WS endpoint at /term/<session> for bidirectional terminal I/O. │
940// └───────────────────────────────────────────────────────────────────────────┘
941
942/// Configuration for the terminal session manager.
943///
944/// When present in a [`VhostConfig`], enables terminal features:
945/// creating, listing, closing and renaming tmux-backed sessions,
946/// plus a binary WS endpoint for terminal I/O bridging.
947#[derive(Clone, Debug, Eq, PartialEq)]
948pub struct TermConfig {
949 pub session_prefix: String, // e.g. "goose-"
950 pub launch_command: String, // e.g. "goose session"
951}
952
953impl TermConfig {
954
955 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
956 let session_prefix = match m.get(&dat!("session_prefix")) {
957 Some(Dat::Str(s)) => s.clone(),
958 _ => "term-".to_string(),
959 };
960 let launch_command = match m.get(&dat!("launch_command")) {
961 Some(Dat::Str(s)) => s.clone(),
962 _ => "/bin/bash".to_string(),
963 };
964 Ok(Self {
965 session_prefix,
966 launch_command,
967 })
968 }
969}
970
971
972// ┌───────────────────────────────────────────────────────────────────────────┐
973// │ VHOST CONFIG │
974// └───────────────────────────────────────────────────────────────────────────┘
975
976/// Configuration for a single virtual host served by Steel.
977///
978/// A vhost is selected at TLS handshake time by its SNI hostname, and may carry
979/// its own webroot, static routes, default index files, redirect rules and
980/// Ozone database. Multiple hostnames (e.g. `example.com` and a trailing-dot
981/// alias) are supported by listing them all in `hostnames`; the first entry
982/// is the primary.
983#[derive(Clone, Debug, Eq, PartialEq)]
984pub struct VhostConfig {
985 pub hostnames: Vec<String>, // the first is canonical
986 pub public_dir_rel: Option<String>, // `None` for a pure-redirect vhost
987 pub static_route_paths_rel: DaticleMap, // URL path -> file or directory
988 pub default_index_files: Vec<String>, // tried in order for a directory
989 pub redirects: Vec<RedirectRule>, // before static file resolution
990 // Relative to the app root. `None` means the vhost has no backing database, which is typical
991 // for a pure-redirect vhost. When set, Steel opens and starts a dedicated Ozone instance
992 // rooted here at server start-up.
993 pub db_dir_rel: Option<String>,
994 pub api_routes: Vec<ApiRoute>, // local POST path -> upstream URL
995 pub webhook_routes: Vec<WebhookRoute>, // local POST path -> named handler
996 // Optional allow-list of outbound egress targets. When non-empty, every upstream this vhost
997 // can reach -- an api route, a forwarded webhook route, a ws route or a proxy route -- must
998 // match at least one entry or the server refuses to start. Entries are `host` or `host:port`;
999 // `host` alone matches any port. An empty list -- the default -- means no allow-list is
1000 // configured and every upstream is permitted. Populating this on a vhost is a defence against
1001 // a compromised app config exfiltrating via an arbitrary upstream URL.
1002 pub egress_allowed: Vec<String>,
1003 // Authorised signing keys for the signed-admin-login flow. Each entry binds a named operator
1004 // to a public key and a scope list; a SignedCommand with cmd = `"admin_login"` and a
1005 // signer_id matching one of these entries' public keys issues a dashboard session cookie
1006 // without a wallet passphrase. An empty list disables the feature for this vhost, leaving the
1007 // passphrase form as the only admin entry.
1008 pub admin_keys: Vec<AdminKey>,
1009 // Optional URL of a script or stylesheet to inject into the `<head>` of every admin-served
1010 // page, so an operator can plug cross-app chrome onto a deployment without touching the Steel
1011 // source. `None` leaves the default `<head>` untouched. Taken as a raw URL and rendered as
1012 // `<script src="{url}" defer></script>`.
1013 pub head_injection_url: Option<String>,
1014 // A per-vhost `Permissions-Policy` header value that replaces the locked-down default Steel
1015 // emits on every response. `None` -- the default, and what every config written before this
1016 // existed says -- keeps that default, which denies every sensor feature. A site that must call
1017 // a browser capability from its own pages (the camera for a capture ceremony, say) sets the
1018 // FULL policy string it wants here, so a browser-capability grant is explicit and per-site
1019 // rather than a code default that would loosen every deployment at once.
1020 pub permissions_policy: Option<String>,
1021 // Each route forwards every request under a path prefix to an upstream server, with WebSocket
1022 // tunnelling and streaming responses. Checked after redirects but before static files and API
1023 // routes; the longest prefix wins.
1024 pub proxy_routes: Vec<ProxyRoute>,
1025 // Each hands the upgrades arriving on one exact path to an upstream WebSocket server on
1026 // loopback, relaying the handshake and then the bytes. Checked before proxy routes, and only
1027 // for a request that is an upgrade. Empty by default, which is what every config written
1028 // before the field existed says.
1029 pub ws_routes: Vec<WsRoute>,
1030 // Terminal session configuration. When present, enables the `term_new`, `term_list`,
1031 // `term_close` and `term_set_name` WS commands and the `/term/<session>` binary WS endpoint
1032 // for this vhost. `None` disables terminal features.
1033 pub term_config: Option<TermConfig>,
1034 // The prose this vhost publishes: a directory of Markdown served as pages, a feed and a JSON
1035 // list, under a prefix of the site's choosing. `None` publishes nothing and serves none of
1036 // those paths, which is what a config with no `publish` block means and what every config
1037 // written before the block existed says.
1038 pub publish: Option<PublishConfig>,
1039 // Who may administer this site from within it, at `/manage`. Each entry is a member's
1040 // username -- the same identifier the site's own login issues, which is the SHA-256 of the
1041 // member's passphrase. A member whose username is in this list, and who is signed in, reaches
1042 // the site console; everyone else is turned away from it.
1043 //
1044 // This is the operator's grant, and it lives here rather than in the site's database on
1045 // purpose. The operator owns the host and decides who runs each site; a site's own database,
1046 // which a content bug could reach, cannot mint its own administrators, so the blast radius of
1047 // such a bug stays content and never becomes authority. Empty -- the default, and what every
1048 // config written before this existed says -- means the site has no console.
1049 pub site_admins: Vec<String>,
1050 // Map tiles from local archives under a prefix. `None` serves none.
1051 pub tiles: Option<TileConfig>,
1052 // Whether requests on this vhost reach the log and the traffic recorder at all. `false`
1053 // writes no connection line, request line or traffic record, which a vhost serving tiles
1054 // must set, since its requests say where each viewer looked. Defaults to `true`.
1055 pub access_log: bool,
1056}
1057
1058/// A single entry in a vhost's [`VhostConfig::admin_keys`] list.
1059///
1060/// Names a public key, a human-readable identity and a scope list.
1061/// The signed-admin-login flow looks up an inbound
1062/// #raw("SignedCommand")'s #raw("signer_id") against these entries'
1063/// public keys; a match yields the matching name and scopes for the
1064/// session cookie. Scopes use the same vocabulary as
1065/// [`AdminUser::scopes`](oxedyne_fe2o3_crypto::keystore::AdminUser)
1066/// so the dashboard gates requests identically regardless of whether
1067/// the admin authenticated via passphrase or signature.
1068#[derive(Clone, Debug, Eq, PartialEq)]
1069pub struct AdminKey {
1070 pub name: String, // used in audit output and the dashboard's admin view
1071 pub public_key: Vec<u8>, // lowercase hex in the config file, bytes here
1072 pub scheme: String, // "Ed25519", "Dilithium2" or "Dilithium2_fe2o3"
1073 pub scopes: Vec<String>, // the wallet's own vocabulary; `"*"` is the wildcard
1074}
1075
1076impl Default for VhostConfig {
1077 fn default() -> Self {
1078 Self {
1079 hostnames: vec![fmt!("localhost")],
1080 public_dir_rel: Some(fmt!("./www/public")),
1081 static_route_paths_rel: DaticleMap::new(),
1082 default_index_files: vec![
1083 fmt!("index.html"),
1084 fmt!("index.htm"),
1085 fmt!("default.html"),
1086 fmt!("home.html"),
1087 ],
1088 redirects: Vec::new(),
1089 db_dir_rel: Some(fmt!("./o3db")),
1090 api_routes: Vec::new(),
1091 webhook_routes: Vec::new(),
1092 publish: None,
1093 egress_allowed: Vec::new(),
1094 admin_keys: Vec::new(),
1095 head_injection_url: None,
1096 permissions_policy: None,
1097 proxy_routes: Vec::new(),
1098 ws_routes: Vec::new(),
1099 term_config: None,
1100 site_admins: Vec::new(),
1101 tiles: None,
1102 access_log: true,
1103 }
1104 }
1105}
1106
1107impl VhostConfig {
1108 pub fn primary_hostname(&self) -> &str {
1109 self.hostnames.first().map(|s| s.as_str()).unwrap_or("")
1110 }
1111
1112 /// Has this vhost anywhere to keep a session?
1113 ///
1114 /// A session identifier is a key prefix into the vhost's own database:
1115 /// `sess:<sid>:...` for what a session holds, `sess_meta:<sid>` for the
1116 /// session itself. A vhost configured without a database has nowhere to put
1117 /// either, and its session commands already answer "no database available",
1118 /// so an identifier issued to one of its visitors can never be used for
1119 /// anything.
1120 ///
1121 /// Issuing one anyway is not free. A `Set-Cookie` on a static asset makes
1122 /// every response uncacheable by a shared cache, and a cookie set without a
1123 /// purpose is a cookie an operator has to account for to anyone who asks
1124 /// what it is for. So a vhost with no database mints none.
1125 pub fn uses_sessions(&self) -> bool {
1126 self.db_dir_rel.is_some()
1127 }
1128
1129 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
1130 // Hostnames.
1131 let hostnames = match m.get(&dat!("hostnames")) {
1132 Some(Dat::List(list)) => {
1133 let mut out = Vec::new();
1134 for item in list {
1135 match item {
1136 Dat::Str(s) => out.push(s.clone()),
1137 _ => return Err(err!(
1138 "VhostConfig: 'hostnames' entries must be strings.";
1139 Invalid, Input, Mismatch)),
1140 }
1141 }
1142 out
1143 }
1144 Some(Dat::Vek(vek)) => {
1145 let mut out = Vec::new();
1146 for item in vek.iter() {
1147 match item {
1148 Dat::Str(s) => out.push(s.clone()),
1149 _ => return Err(err!(
1150 "VhostConfig: 'hostnames' entries must be strings.";
1151 Invalid, Input, Mismatch)),
1152 }
1153 }
1154 out
1155 }
1156 None => return Err(err!(
1157 "VhostConfig: 'hostnames' field is required.";
1158 Invalid, Input, Missing)),
1159 _ => return Err(err!(
1160 "VhostConfig: 'hostnames' must be a list of strings.";
1161 Invalid, Input, Mismatch)),
1162 };
1163 if hostnames.is_empty() {
1164 return Err(err!(
1165 "VhostConfig: 'hostnames' must contain at least one entry.";
1166 Invalid, Input, Missing));
1167 }
1168 // Public dir (optional).
1169 let public_dir_rel = match m.get(&dat!("public_dir_rel")) {
1170 Some(Dat::Str(s)) if s.is_empty() => None,
1171 Some(Dat::Str(s)) => Some(s.clone()),
1172 Some(Dat::Opt(opt)) => match opt.as_ref() {
1173 Some(Dat::Str(s)) => Some(s.clone()),
1174 _ => None,
1175 },
1176 _ => None,
1177 };
1178 // Static routes.
1179 let static_route_paths_rel = match m.get(&dat!("static_route_paths_rel")) {
1180 Some(Dat::Map(sub)) => sub.clone(),
1181 None => DaticleMap::new(),
1182 _ => return Err(err!(
1183 "VhostConfig: 'static_route_paths_rel' must be a map.";
1184 Invalid, Input, Mismatch)),
1185 };
1186 // Default index files.
1187 let default_index_files = match m.get(&dat!("default_index_files")) {
1188 Some(Dat::List(list)) => {
1189 let mut out = Vec::new();
1190 for item in list {
1191 match item {
1192 Dat::Str(s) => out.push(s.clone()),
1193 _ => return Err(err!(
1194 "VhostConfig: 'default_index_files' entries must be strings.";
1195 Invalid, Input, Mismatch)),
1196 }
1197 }
1198 out
1199 }
1200 Some(Dat::Vek(vek)) => {
1201 let mut out = Vec::new();
1202 for item in vek.iter() {
1203 match item {
1204 Dat::Str(s) => out.push(s.clone()),
1205 _ => return Err(err!(
1206 "VhostConfig: 'default_index_files' entries must be strings.";
1207 Invalid, Input, Mismatch)),
1208 }
1209 }
1210 out
1211 }
1212 None => vec![
1213 fmt!("index.html"),
1214 fmt!("index.htm"),
1215 ],
1216 _ => return Err(err!(
1217 "VhostConfig: 'default_index_files' must be a list of strings.";
1218 Invalid, Input, Mismatch)),
1219 };
1220 // Redirect rules.
1221 let redirects = match m.get(&dat!("redirects")) {
1222 Some(Dat::List(list)) => {
1223 let mut out = Vec::new();
1224 for item in list {
1225 match item {
1226 Dat::Map(sub) => out.push(res!(RedirectRule::from_datmap(sub))),
1227 _ => return Err(err!(
1228 "VhostConfig: 'redirects' entries must be maps.";
1229 Invalid, Input, Mismatch)),
1230 }
1231 }
1232 out
1233 }
1234 None => Vec::new(),
1235 _ => return Err(err!(
1236 "VhostConfig: 'redirects' must be a list of maps.";
1237 Invalid, Input, Mismatch)),
1238 };
1239 // Database directory (optional).
1240 let db_dir_rel = match m.get(&dat!("db_dir_rel")) {
1241 Some(Dat::Str(s)) if s.is_empty() => None,
1242 Some(Dat::Str(s)) => Some(s.clone()),
1243 Some(Dat::Opt(opt)) => match opt.as_ref() {
1244 Some(Dat::Str(s)) => Some(s.clone()),
1245 _ => None,
1246 },
1247 None => None,
1248 _ => return Err(err!(
1249 "VhostConfig: 'db_dir_rel' must be a string.";
1250 Invalid, Input, Mismatch)),
1251 };
1252 // API proxy routes (optional).
1253 let api_routes = match m.get(&dat!("api_routes")) {
1254 Some(Dat::List(list)) => {
1255 let mut out = Vec::new();
1256 for item in list {
1257 match item {
1258 Dat::Map(sub) => out.push(res!(ApiRoute::from_datmap(sub))),
1259 _ => return Err(err!(
1260 "VhostConfig: 'api_routes' entries must be maps.";
1261 Invalid, Input, Mismatch)),
1262 }
1263 }
1264 out
1265 }
1266 None => Vec::new(),
1267 _ => return Err(err!(
1268 "VhostConfig: 'api_routes' must be a list of maps.";
1269 Invalid, Input, Mismatch)),
1270 };
1271 // Webhook routes (optional).
1272 let webhook_routes = match m.get(&dat!("webhook_routes")) {
1273 Some(Dat::List(list)) => {
1274 let mut out = Vec::new();
1275 for item in list {
1276 match item {
1277 Dat::Map(sub) => out.push(res!(WebhookRoute::from_datmap(sub))),
1278 _ => return Err(err!(
1279 "VhostConfig: 'webhook_routes' entries must be maps.";
1280 Invalid, Input, Mismatch)),
1281 }
1282 }
1283 out
1284 }
1285 None => Vec::new(),
1286 _ => return Err(err!(
1287 "VhostConfig: 'webhook_routes' must be a list of maps.";
1288 Invalid, Input, Mismatch)),
1289 };
1290 // Egress allow-list (optional).
1291 let egress_allowed = match m.get(&dat!("egress_allowed")) {
1292 Some(Dat::List(list)) => {
1293 let mut out = Vec::new();
1294 for item in list {
1295 match item {
1296 Dat::Str(s) => out.push(s.clone()),
1297 _ => return Err(err!(
1298 "VhostConfig: 'egress_allowed' entries must be strings.";
1299 Invalid, Input, Mismatch)),
1300 }
1301 }
1302 out
1303 }
1304 Some(Dat::Vek(vek)) => {
1305 let mut out = Vec::new();
1306 for item in vek.iter() {
1307 match item {
1308 Dat::Str(s) => out.push(s.clone()),
1309 _ => return Err(err!(
1310 "VhostConfig: 'egress_allowed' entries must be strings.";
1311 Invalid, Input, Mismatch)),
1312 }
1313 }
1314 out
1315 }
1316 None => Vec::new(),
1317 _ => return Err(err!(
1318 "VhostConfig: 'egress_allowed' must be a list of strings.";
1319 Invalid, Input, Mismatch)),
1320 };
1321 // Authorised signed-admin-login keys (optional).
1322 let admin_keys = match m.get(&dat!("admin_keys")) {
1323 Some(Dat::List(list)) => {
1324 let mut out = Vec::with_capacity(list.len());
1325 for item in list {
1326 out.push(res!(AdminKey::from_dat(item.clone())));
1327 }
1328 out
1329 }
1330 Some(Dat::Vek(vek)) => {
1331 let mut out = Vec::with_capacity(vek.len());
1332 for item in vek.iter() {
1333 out.push(res!(AdminKey::from_dat(item.clone())));
1334 }
1335 out
1336 }
1337 None => Vec::new(),
1338 _ => return Err(err!(
1339 "VhostConfig: 'admin_keys' must be a list of maps.";
1340 Invalid, Input, Mismatch)),
1341 };
1342 // Head-injection URL (optional).
1343 let head_injection_url = match m.get(&dat!("head_injection_url")) {
1344 Some(Dat::Str(s)) => Some(s.clone()),
1345 None => None,
1346 _ => return Err(err!(
1347 "VhostConfig: 'head_injection_url' must be a string.";
1348 Invalid, Input, Mismatch)),
1349 };
1350 // Per-vhost Permissions-Policy override (optional).
1351 let permissions_policy = match m.get(&dat!("permissions_policy")) {
1352 Some(Dat::Str(s)) => Some(s.clone()),
1353 None => None,
1354 _ => return Err(err!(
1355 "VhostConfig: 'permissions_policy' must be a string.";
1356 Invalid, Input, Mismatch)),
1357 };
1358 // Reverse proxy routes (optional).
1359 let proxy_routes = match m.get(&dat!("proxy_routes")) {
1360 Some(Dat::List(list)) => {
1361 let mut out = Vec::new();
1362 for item in list {
1363 match item {
1364 Dat::Map(sub) => out.push(res!(ProxyRoute::from_datmap(sub))),
1365 _ => return Err(err!(
1366 "VhostConfig: 'proxy_routes' entries must be maps.";
1367 Invalid, Input, Mismatch)),
1368 }
1369 }
1370 out
1371 }
1372 None => Vec::new(),
1373 _ => return Err(err!(
1374 "VhostConfig: 'proxy_routes' must be a list of maps.";
1375 Invalid, Input, Mismatch)),
1376 };
1377 // WebSocket routes (optional).
1378 let ws_routes = match m.get(&dat!("ws_routes")) {
1379 Some(Dat::List(list)) => {
1380 let mut out = Vec::new();
1381 for item in list {
1382 match item {
1383 Dat::Map(sub) => out.push(res!(WsRoute::from_datmap(sub))),
1384 _ => return Err(err!(
1385 "VhostConfig: 'ws_routes' entries must be maps.";
1386 Invalid, Input, Mismatch)),
1387 }
1388 }
1389 out
1390 }
1391 None => Vec::new(),
1392 _ => return Err(err!(
1393 "VhostConfig: 'ws_routes' must be a list of maps.";
1394 Invalid, Input, Mismatch)),
1395 };
1396 let term_config = match m.get(&dat!("term_config")) {
1397 Some(Dat::Map(sub)) => Some(res!(TermConfig::from_datmap(sub))),
1398 None => None,
1399 _ => return Err(err!(
1400 "VhostConfig: 'term_config' must be a map.";
1401 Invalid, Input, Mismatch)),
1402 };
1403 // Absent means the vhost publishes nothing, which is what every config
1404 // written before this block existed says, and what most vhosts mean.
1405 let publish = match m.get(&dat!("publish")) {
1406 Some(Dat::Map(sub)) => Some(res!(PublishConfig::from_datmap(sub))),
1407 None => None,
1408 _ => return Err(err!(
1409 "VhostConfig: 'publish' must be a map.";
1410 Invalid, Input, Mismatch)),
1411 };
1412 // A list and a vek are both written as a list of strings, so both are
1413 // read, as everywhere else a list of strings is accepted here.
1414 let site_admins = match m.get(&dat!("site_admins")) {
1415 Some(Dat::List(list)) => {
1416 let mut out = Vec::new();
1417 for item in list {
1418 match item {
1419 Dat::Str(s) => out.push(s.clone()),
1420 _ => return Err(err!(
1421 "VhostConfig: 'site_admins' entries must be strings.";
1422 Invalid, Input, Mismatch)),
1423 }
1424 }
1425 out
1426 }
1427 Some(Dat::Vek(vek)) => {
1428 let mut out = Vec::new();
1429 for item in vek.iter() {
1430 match item {
1431 Dat::Str(s) => out.push(s.clone()),
1432 _ => return Err(err!(
1433 "VhostConfig: 'site_admins' entries must be strings.";
1434 Invalid, Input, Mismatch)),
1435 }
1436 }
1437 out
1438 }
1439 None => Vec::new(),
1440 _ => return Err(err!(
1441 "VhostConfig: 'site_admins' must be a list of strings.";
1442 Invalid, Input, Mismatch)),
1443 };
1444 let tiles = match m.get(&dat!("tiles")) {
1445 Some(Dat::Map(sub)) => Some(res!(TileConfig::from_datmap(sub))),
1446 None => None,
1447 _ => return Err(err!(
1448 "VhostConfig: 'tiles' must be a map.";
1449 Invalid, Input, Mismatch)),
1450 };
1451 let access_log = match m.get(&dat!("access_log")) {
1452 Some(Dat::Bool(b)) => *b,
1453 None => true,
1454 _ => return Err(err!(
1455 "VhostConfig: 'access_log' must be a boolean.";
1456 Invalid, Input, Mismatch)),
1457 };
1458 // A tile request names where its viewer looked, so a vhost serving tiles may not keep
1459 // even the connection lines that would pair a viewer's address with the time.
1460 if tiles.is_some() && access_log {
1461 return Err(err!(
1462 "VhostConfig '{}': a vhost serving 'tiles' must set 'access_log': false, so \
1463 that no request from a map viewer is written to the log.",
1464 hostnames.first().map(|s| s.as_str()).unwrap_or("");
1465 Invalid, Input, Security, Configuration));
1466 }
1467 Ok(Self {
1468 hostnames,
1469 public_dir_rel,
1470 static_route_paths_rel,
1471 default_index_files,
1472 redirects,
1473 db_dir_rel,
1474 api_routes,
1475 webhook_routes,
1476 egress_allowed,
1477 admin_keys,
1478 head_injection_url,
1479 permissions_policy,
1480 proxy_routes,
1481 ws_routes,
1482 term_config,
1483 publish,
1484 site_admins,
1485 tiles,
1486 access_log,
1487 })
1488 }
1489
1490 /// Every upstream this vhost's configuration can reach outward to, as
1491 /// `(kind, local path, host, port)`, where the kind and path name the
1492 /// route in an error message. A route served by an in-process handler
1493 /// reaches nothing and does not appear.
1494 ///
1495 /// This is the list `egress_allowed` is enforced against, and it is the
1496 /// only such list: a route kind added to [`VhostConfig`] without a line
1497 /// here is outside the allow-list, so add the line with the field.
1498 pub fn egress_targets(&self) -> Vec<(&'static str, &str, &str, u16)> {
1499 let mut out = Vec::new();
1500 for r in &self.api_routes {
1501 if let (Some(h), Some(p)) = (&r.upstream_host, &r.upstream_port) {
1502 out.push(("api route", r.path.as_str(), h.as_str(), *p));
1503 }
1504 }
1505 // A webhook route in forwarding mode POSTs the payload onward, so it carries a body out.
1506 for r in &self.webhook_routes {
1507 if let (Some(h), Some(p)) = (&r.upstream_host, &r.upstream_port) {
1508 out.push(("webhook route", r.path.as_str(), h.as_str(), *p));
1509 }
1510 }
1511 // Ws and proxy routes always name an upstream; there is no handler form to skip.
1512 for r in &self.ws_routes {
1513 out.push(("ws route", r.path.as_str(), r.upstream_host.as_str(), r.upstream_port));
1514 }
1515 for r in &self.proxy_routes {
1516 out.push((
1517 "proxy route",
1518 r.path_prefix.as_str(),
1519 r.upstream_host.as_str(),
1520 r.upstream_port,
1521 ));
1522 }
1523 out
1524 }
1525
1526 /// Does `egress_allowed` permit a connection to this host and port?
1527 ///
1528 /// Entries are compared as `host` or `host:port`: a bare-host entry
1529 /// matches any port for that host, and a `host:port` entry requires an
1530 /// exact match. An empty list permits everything, since it means no
1531 /// allow-list was configured. The port is taken from the last colon and
1532 /// only when what follows it parses as one, so a bracketed IPv6 literal
1533 /// is read as the bare host it is rather than split down the middle.
1534 pub fn egress_permits(&self, host: &str, port: u16) -> bool {
1535 if self.egress_allowed.is_empty() {
1536 return true;
1537 }
1538 for entry in &self.egress_allowed {
1539 match entry.rsplit_once(':') {
1540 Some((eh, ep)) => match ep.parse::<u16>() {
1541 Ok(n) => if eh == host && n == port { return true; },
1542 Err(_) => if entry == host { return true; },
1543 },
1544 None => if entry == host { return true; },
1545 }
1546 }
1547 false
1548 }
1549
1550 /// Check every upstream this vhost can reach against the `egress_allowed`
1551 /// list, refusing the first that no entry permits. A no-op when the
1552 /// allow-list is empty, which is what a config that configures no
1553 /// allow-list means. See [`egress_targets`](Self::egress_targets) for
1554 /// what is covered and [`egress_permits`](Self::egress_permits) for how
1555 /// an entry is matched.
1556 pub fn validate_egress(&self) -> Outcome<()> {
1557 if self.egress_allowed.is_empty() {
1558 return Ok(());
1559 }
1560 for (kind, path, host, port) in self.egress_targets() {
1561 if !self.egress_permits(host, port) {
1562 return Err(err!(
1563 "VhostConfig '{}': {} '{}' upstream {}:{} is not \
1564 in the configured egress_allowed list ({:?}).",
1565 self.primary_hostname(), kind, path, host, port,
1566 self.egress_allowed;
1567 Invalid, Input, Security, Configuration));
1568 }
1569 }
1570 Ok(())
1571 }
1572
1573 /// Resolve the vhost's database directory to an absolute path, creating
1574 /// it if it does not yet exist. Returns `None` when the vhost has no
1575 /// configured database. Unlike `get_public_dir`, this tolerates a missing
1576 /// directory and creates it: Ozone expects a writable root and will
1577 /// populate it on first start-up.
1578 /// Supports both relative (anchored at `root`) and absolute paths.
1579 pub fn get_db_dir(
1580 &self,
1581 root: &NormPathBuf,
1582 )
1583 -> Outcome<Option<PathBuf>>
1584 {
1585 let rel = match &self.db_dir_rel {
1586 Some(s) if !s.is_empty() => s,
1587 _ => return Ok(None),
1588 };
1589 let path = if Path::new(rel).is_absolute() {
1590 PathBuf::from(rel)
1591 } else {
1592 let norm = Path::new(rel).normalise();
1593 if norm.escapes() {
1594 return Err(err!(
1595 "VhostConfig: database directory {} escapes the directory {:?}.",
1596 rel, root;
1597 Invalid, Input, Path));
1598 }
1599 root.clone().join(norm).normalise().absolute().as_pathbuf()
1600 };
1601 res!(std::fs::create_dir_all(&path));
1602 Ok(Some(path))
1603 }
1604
1605 /// Resolve the vhost's webroot to an absolute validated path, returning
1606 /// `None` for pure-redirect vhosts that have no webroot. Supports both
1607 /// relative paths (anchored at `root`) and absolute paths (used as-is).
1608 pub fn get_public_dir(
1609 &self,
1610 root: &NormPathBuf,
1611 )
1612 -> Outcome<Option<PathBuf>>
1613 {
1614 let rel = match &self.public_dir_rel {
1615 Some(s) if !s.is_empty() => s,
1616 _ => return Ok(None),
1617 };
1618 let path = if Path::new(rel).is_absolute() {
1619 PathBuf::from(rel)
1620 } else {
1621 let norm = Path::new(rel).normalise();
1622 if norm.escapes() {
1623 return Err(err!(
1624 "VhostConfig: public directory {} escapes the directory {:?}.",
1625 rel, root;
1626 Invalid, Input, Path));
1627 }
1628 root.clone().join(norm).normalise().absolute().as_pathbuf()
1629 };
1630 res!(PathState::DirMustExist.validate(
1631 &path,
1632 "",
1633 ));
1634 Ok(Some(path))
1635 }
1636
1637 pub fn get_static_route_paths<M: MapMut<String, OsPath>>(
1638 &self,
1639 root: &NormPathBuf,
1640 mut map: M,
1641 )
1642 -> Outcome<M>
1643 {
1644 for (route_dat, path_dat) in &self.static_route_paths_rel {
1645 let route = try_extract_dat!(route_dat, Str).clone();
1646 if route.is_empty() {
1647 warn!("VhostConfig: Static route key is empty, skipping.");
1648 continue;
1649 }
1650 let path_str = try_extract_dat!(path_dat, Str);
1651 if path_str.is_empty() {
1652 warn!("VhostConfig: Static route '{}' path is empty, skipping.", route);
1653 continue;
1654 }
1655 let is_dir = path_str.ends_with("/");
1656 let path = Path::new(&path_str).normalise();
1657 if path.escapes() {
1658 warn!("VhostConfig: route '{}' target path '{}' escapes the directory \
1659 {:?}, skipping.",
1660 route, path_str, root);
1661 continue;
1662 }
1663 let path = root.clone().join(path).normalise().absolute();
1664 if is_dir {
1665 match PathState::DirMustExist.validate(&path, "") {
1666 Ok(()) => {
1667 map.insert(route, OsPath::Dir(path.as_pathbuf()));
1668 }
1669 Err(_) => {
1670 warn!("VhostConfig: Directory '{}' for route '{}' not found, \
1671 skipping.",
1672 path_str, route);
1673 continue;
1674 }
1675 }
1676 } else {
1677 match PathState::FileMustExist.validate(&path, "") {
1678 Ok(()) => {
1679 map.insert(route, OsPath::File(path.as_pathbuf()));
1680 }
1681 Err(_) => {
1682 warn!("VhostConfig: File '{}' for route '{}' not found, skipping.",
1683 path_str, route);
1684 continue;
1685 }
1686 }
1687 }
1688 }
1689 Ok(map)
1690 }
1691
1692 pub fn get_default_index_files(&self) -> Outcome<Vec<String>> {
1693 if self.default_index_files.is_empty() {
1694 warn!("VhostConfig: No default index files specified, using '{}'.",
1695 constant::DEFAULT_INDEX_FILE);
1696 return Ok(vec![fmt!("{}", constant::DEFAULT_INDEX_FILE)]);
1697 }
1698 let mut out = Vec::new();
1699 for filename in &self.default_index_files {
1700 if filename.is_empty() {
1701 return Err(err!(
1702 "VhostConfig: Default index file entry is empty.";
1703 Invalid, Input, Path));
1704 }
1705 if oxedyne_fe2o3_core::path::is_filename(filename) {
1706 out.push(filename.clone());
1707 } else {
1708 return Err(err!(
1709 "VhostConfig: Default index file '{}' must be a filename, not a path.",
1710 filename;
1711 Invalid, Input, String));
1712 }
1713 }
1714 Ok(out)
1715 }
1716
1717 pub fn get_hostnames_fqdn(&self) -> Outcome<Vec<Fqdn>> {
1718 let mut out = Vec::new();
1719 for name in &self.hostnames {
1720 if name.is_empty() {
1721 return Err(err!(
1722 "VhostConfig: hostname entry is empty.";
1723 Invalid, Input, Missing));
1724 }
1725 let fqdn = match Fqdn::new(name) {
1726 Ok(fqdn) => fqdn,
1727 Err(e) => return Err(err!(e,
1728 "While validating vhost hostname '{}'.", name;
1729 Network)),
1730 };
1731 out.push(fqdn);
1732 }
1733 Ok(out)
1734 }
1735}
1736
1737
1738// ┌───────────────────────────────────────────────────────────────────────────┐
1739// │ ACME CONFIG │
1740// └───────────────────────────────────────────────────────────────────────────┘
1741
1742/// Configuration for Steel's built-in ACME (Let's Encrypt) client.
1743///
1744/// When `enabled` is `true`, Steel will request and automatically renew TLS
1745/// certificates for every configured vhost hostname via the TLS-ALPN-01
1746/// challenge on the same port Steel is already listening on. The hostname of
1747/// an enabled mail listener is included automatically, as are any
1748/// `extra_domains`.
1749#[derive(Clone, Debug, Eq, PartialEq)]
1750pub struct AcmeConfig {
1751 pub enabled: bool, // false loads certificates from disk instead
1752 pub contact_email: String, // registered with the ACME account, for notices
1753 pub directory_url: String, // defaults to the Let's Encrypt staging endpoint
1754 pub cache_dir_rel: String, // account key and certificates, from the app root
1755 // Additional hostnames to name in the certificate, beyond the vhost hostnames and the mail
1756 // listener. Steel can only issue for a name that resolves to it, but it need not be the
1757 // service that ultimately serves that name: where another daemon on the same host terminates
1758 // TLS for a hostname Steel does not route -- an MTA, say -- listing it here puts it in the
1759 // certificate Steel already renews, and that daemon can be pointed at the result. Without it
1760 // such a name has no renewal path at all, and the failure is silent until the certificate
1761 // expires.
1762 pub extra_domains: Vec<String>,
1763}
1764
1765impl Default for AcmeConfig {
1766 fn default() -> Self {
1767 Self {
1768 enabled: false,
1769 contact_email: fmt!(""),
1770 // Staging by default, deliberately. Switch to production once
1771 // everything works end to end on staging.
1772 directory_url: fmt!("https://acme-staging-v02.api.letsencrypt.org/directory"),
1773 cache_dir_rel: fmt!("./tls/acme"),
1774 extra_domains: Vec::new(),
1775 }
1776 }
1777}
1778
1779impl AcmeConfig {
1780 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
1781 let mut out = Self::default();
1782 if let Some(Dat::Bool(b)) = m.get(&dat!("enabled")) {
1783 out.enabled = *b;
1784 }
1785 if let Some(Dat::Str(s)) = m.get(&dat!("contact_email")) {
1786 out.contact_email = s.clone();
1787 }
1788 if let Some(Dat::Str(s)) = m.get(&dat!("directory_url")) {
1789 out.directory_url = s.clone();
1790 }
1791 if let Some(Dat::Str(s)) = m.get(&dat!("cache_dir_rel")) {
1792 out.cache_dir_rel = s.clone();
1793 }
1794 match m.get(&dat!("extra_domains")) {
1795 Some(Dat::List(l)) => {
1796 for d in l {
1797 if let Dat::Str(s) = d {
1798 out.extra_domains.push(s.clone());
1799 }
1800 }
1801 }
1802 Some(Dat::Vek(v)) => {
1803 for d in v.iter() {
1804 if let Dat::Str(s) = d {
1805 out.extra_domains.push(s.clone());
1806 }
1807 }
1808 }
1809 _ => (),
1810 }
1811 Ok(out)
1812 }
1813
1814 pub fn to_datmap(&self) -> DaticleMap {
1815 let mut m = DaticleMap::new();
1816 m.insert(dat!("enabled"), dat!(self.enabled));
1817 m.insert(dat!("contact_email"), dat!(self.contact_email.clone()));
1818 m.insert(dat!("directory_url"), dat!(self.directory_url.clone()));
1819 m.insert(dat!("cache_dir_rel"), dat!(self.cache_dir_rel.clone()));
1820 m.insert(dat!("extra_domains"), Dat::List(
1821 self.extra_domains.iter().map(|d| dat!(d.clone())).collect()
1822 ));
1823 m
1824 }
1825
1826 pub fn get_cache_dir(
1827 &self,
1828 root: &NormPathBuf,
1829 )
1830 -> Outcome<PathBuf>
1831 {
1832 let path = Path::new(&self.cache_dir_rel).normalise();
1833 if path.escapes() {
1834 return Err(err!(
1835 "AcmeConfig: cache directory {} escapes the directory {:?}.",
1836 self.cache_dir_rel, root;
1837 Invalid, Input, Path));
1838 }
1839 let path = root.clone().join(path).normalise().absolute().as_pathbuf();
1840 res!(std::fs::create_dir_all(&path));
1841 Ok(path)
1842 }
1843}
1844
1845
1846// ┌───────────────────────────────────────────────────────────────────────────┐
1847// │ MAIL CONFIG │
1848// └───────────────────────────────────────────────────────────────────────────┘
1849
1850/// Hematite mail listener configuration.
1851///
1852/// When present (and `enabled = true`), Steel binds three TCP ports
1853/// alongside the HTTPS listener: SMTP receive, SMTP submission, and
1854/// IMAP. All three share the rustls cert resolver Steel uses for
1855/// HTTPS so a single ACME-issued cert covers every protocol.
1856#[derive(Clone, Debug, Eq, PartialEq)]
1857pub struct MailConfig {
1858 pub enabled: bool, // false leaves the mail server unstarted
1859 pub hostname: String, // advertised in the greetings; the public MX name
1860 pub smtp_port: u16, // MX receive, standard 25
1861 pub submission_port: u16, // standard 587
1862 pub imap_port: u16, // implicit TLS, standard 993
1863 pub maildir_root: String, // per-user trees live at `<root>/<delivery_dir>/`
1864 pub users_file_rel: String, // JDAT user file: passwords and delivery dirs
1865 pub spool_dir_rel: String, // the outbound spool
1866 pub dkim_key_file: String, // PKCS#8 DER; empty disables DKIM signing
1867 pub dkim_selector: String, // published at `<selector>._domainkey.<domain>`
1868 /// Path to an RSA DKIM private key (PKCS#8 or PKCS#1 DER). Empty
1869 /// disables RSA signing.
1870 ///
1871 /// Signing with both an ed25519 and an RSA key, under two selectors, is
1872 /// what RFC 8463 asks for: ed25519 verification is still patchy in the
1873 /// wild, and a receiver that cannot verify a signature treats the message
1874 /// as *unsigned*, leaving DMARC to rest on SPF alone.
1875 ///
1876 /// Steel will not create this key. `ring` refuses to generate RSA keys,
1877 /// and hand-rolling the arithmetic to do so is not a road worth taking to
1878 /// save one command:
1879 ///
1880 /// ```text
1881 /// openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
1882 /// -outform DER -out mail/dkim_rsa.key
1883 /// ```
1884 pub dkim_rsa_key_file: String,
1885 pub dkim_rsa_selector: String, // must differ from `dkim_selector`; default "rsa"
1886 pub dkim_domain: String, // may differ from `hostname`
1887 pub local_domains: Vec<String>, // recipients outside this set are refused at RCPT TO
1888}
1889
1890impl Default for MailConfig {
1891 fn default() -> Self {
1892 Self {
1893 enabled: false,
1894 hostname: String::new(),
1895 smtp_port: 25,
1896 submission_port: 587,
1897 imap_port: 993,
1898 maildir_root: String::new(),
1899 users_file_rel: String::new(),
1900 spool_dir_rel: String::new(),
1901 dkim_key_file: String::new(),
1902 dkim_selector: String::new(),
1903 dkim_rsa_key_file: String::new(),
1904 dkim_rsa_selector: String::new(),
1905 dkim_domain: String::new(),
1906 local_domains: Vec::new(),
1907 }
1908 }
1909}
1910
1911impl MailConfig {
1912 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
1913 let mut out = Self::default();
1914 if let Some(Dat::Bool(b)) = m.get(&dat!("enabled")) {
1915 out.enabled = *b;
1916 }
1917 if let Some(Dat::Str(s)) = m.get(&dat!("hostname")) {
1918 out.hostname = s.clone();
1919 }
1920 if let Some(Dat::U16(n)) = m.get(&dat!("smtp_port")) {
1921 out.smtp_port = *n;
1922 }
1923 if let Some(Dat::U16(n)) = m.get(&dat!("submission_port")) {
1924 out.submission_port = *n;
1925 }
1926 if let Some(Dat::U16(n)) = m.get(&dat!("imap_port")) {
1927 out.imap_port = *n;
1928 }
1929 if let Some(Dat::Str(s)) = m.get(&dat!("maildir_root")) {
1930 out.maildir_root = s.clone();
1931 }
1932 if let Some(Dat::Str(s)) = m.get(&dat!("users_file_rel")) {
1933 out.users_file_rel = s.clone();
1934 }
1935 if let Some(Dat::Str(s)) = m.get(&dat!("spool_dir_rel")) {
1936 out.spool_dir_rel = s.clone();
1937 }
1938 if let Some(Dat::Str(s)) = m.get(&dat!("dkim_key_file")) {
1939 out.dkim_key_file = s.clone();
1940 }
1941 if let Some(Dat::Str(s)) = m.get(&dat!("dkim_selector")) {
1942 out.dkim_selector = s.clone();
1943 }
1944 if let Some(Dat::Str(s)) = m.get(&dat!("dkim_rsa_key_file")) {
1945 out.dkim_rsa_key_file = s.clone();
1946 }
1947 if let Some(Dat::Str(s)) = m.get(&dat!("dkim_rsa_selector")) {
1948 out.dkim_rsa_selector = s.clone();
1949 }
1950 if let Some(Dat::Str(s)) = m.get(&dat!("dkim_domain")) {
1951 out.dkim_domain = s.clone();
1952 }
1953 match m.get(&dat!("local_domains")) {
1954 Some(Dat::List(l)) => {
1955 for d in l {
1956 if let Dat::Str(s) = d {
1957 out.local_domains.push(s.clone());
1958 }
1959 }
1960 }
1961 Some(Dat::Vek(v)) => {
1962 for d in v.iter() {
1963 if let Dat::Str(s) = d {
1964 out.local_domains.push(s.clone());
1965 }
1966 }
1967 }
1968 _ => (),
1969 }
1970 Ok(out)
1971 }
1972}
1973
1974
1975// ┌───────────────────────────────────────────────────────────────────────────┐
1976// │ ALERT CONFIG │
1977// └───────────────────────────────────────────────────────────────────────────┘
1978
1979/// Where an alert is posted, when it goes through a provider rather than
1980/// straight to the recipient's MX.
1981///
1982/// # Why this is usually the right choice
1983///
1984/// Delivering directly to the recipient's MX means a receiver decides whether
1985/// to trust a message that arrived, unannounced and unauthenticated, from a
1986/// server it has never heard of. Without a PTR record for the sending IP and
1987/// an SPF record naming it, a strict receiver -- Gmail, for one -- is entitled
1988/// to bin it. The message that says something is wrong is exactly the one that
1989/// must not land in a spam folder.
1990///
1991/// Submitting through the sender's own provider authenticates the sender, and
1992/// the provider's reputation carries the message the rest of the way. It also
1993/// serves the rule that the machine raising the alarm should not be the only
1994/// machine on the path.
1995///
1996/// # The credential must be readable while sealed
1997///
1998/// It cannot live in the wallet's encrypted secrets, because the most
1999/// important alert of all is the one saying Steel came up *sealed* -- and at
2000/// that moment there is no master key with which to decrypt anything. So the
2001/// password is a plain config value, and should be given as a `{file:...}`
2002/// reference to a file the server user alone can read, rather than written
2003/// into `config.jdat` in the clear.
2004#[derive(Clone, Debug)]
2005pub struct AlertSubmission {
2006 pub host: String, // also the name its certificate is validated against
2007 pub port: u16, // conventionally 587 (STARTTLS) or 465 (implicit TLS)
2008 pub security: String, // "starttls", "implicit", or "plain" (loopback only)
2009 pub user: String, // the account to authenticate as
2010 // That account's password. Supply as `{file:path}`; a provider with two-factor
2011 // authentication wants an application password here, not the one a human types into a
2012 // browser.
2013 pub password: String,
2014}
2015
2016/// Operator alerting by email. See `srv::alert`.
2017///
2018/// With no `submission` block, mail is delivered straight to the recipient's
2019/// MX by the in-tree SMTP client, so no relay and no local mail daemon are
2020/// required -- but deliverability then rests on this host having a PTR record
2021/// and the `from` domain having an SPF record that names it. With a
2022/// `submission` block, the message is posted through the sender's own
2023/// provider, which authenticates it. See [`AlertSubmission`].
2024#[derive(Clone, Debug)]
2025pub struct AlertConfig {
2026 pub enabled: bool, // false sends no alert, ever
2027 pub from: String, // use a domain whose SPF names this host
2028 pub submission: Option<AlertSubmission>, // a provider, not the MX direct
2029 // Recipients. Address these off this machine: an alert delivered to a mailbox on the host it
2030 // is warning about is one the operator cannot read precisely when they need to.
2031 pub to: Vec<String>,
2032 pub ehlo_hostname: String, // the name whose PTR matches the IP
2033 pub failed_threshold: u32, // failures in the window before alerting
2034 // Window over which failures are counted. Failures further apart than this start a fresh
2035 // count, so a slow trickle does not eventually add up to something that reads as an attack.
2036 pub failed_window_secs: u64,
2037 // Minimum gap between two failed-attempt alerts, so a sustained campaign produces a
2038 // sustained defence rather than a sustained mailbox.
2039 pub failed_cooldown_secs: u64,
2040 // Where to send the text-message half, when there is one. Absent on most hosts: it belongs
2041 // on whichever machines do the watching, because a machine that has died cannot text
2042 // anybody about it.
2043 pub sms: Option<SmsAlertConfig>,
2044}
2045
2046/// The text-message leg of alerting.
2047///
2048/// **Why a second channel at all.** Mail and a text fail for different reasons:
2049/// mail needs a working MX, a mailbox somebody reads and a spam filter that
2050/// lets it through; a text needs a funded account and a carrier. Two channels
2051/// that fail independently is the entire value of having two. A text is also
2052/// the only one that arrives with no data connection, which is the state a
2053/// phone is in exactly often enough to matter.
2054///
2055/// **The credential is not here.** Only the names of the environment variables
2056/// holding it. A configuration file is copied between machines, pasted into a
2057/// chat window to ask why a server will not start, and committed by accident;
2058/// an environment variable is none of those things by default.
2059#[derive(Clone, Debug, Eq, PartialEq)]
2060pub struct SmsAlertConfig {
2061 pub enabled: bool, // this leg alone; mail is unaffected
2062 pub provider: SmsProvider, // which gateway
2063 pub to: Vec<String>, // E.164, with the leading `+`
2064 // Sender, as the gateway wants it. Empty asks the gateway for its default, which is what an
2065 // account with one number should do rather than repeat itself in configuration.
2066 pub from: String,
2067 pub user_env: String, // env var holding the gateway account identifier
2068 pub secret_env: String, // env var holding the gateway secret
2069}
2070
2071impl Default for SmsAlertConfig {
2072 fn default() -> Self {
2073 Self {
2074 enabled: false,
2075 provider: SmsProvider::ClickSend,
2076 to: Vec::new(),
2077 from: String::new(),
2078 user_env: fmt!("STEEL_SMS_USER"),
2079 secret_env: fmt!("STEEL_SMS_SECRET"),
2080 }
2081 }
2082}
2083
2084/// One machine this node watches, and where to ask.
2085#[derive(Clone, Debug, Eq, PartialEq)]
2086pub struct WatchPeer {
2087 // What to call it in an alert: a person's name for the machine, not a hostname, since the
2088 // alert is read on a phone in the dark.
2089 pub name: String,
2090 // The machine this entry probes something on: the row the Fleet page draws it in, and the
2091 // unit an outage is told by, so a gateway's `/api/health` and the Steel health body on the
2092 // same box are two entries, one row and one text. Absent in configuration, it is the
2093 // entry's own name.
2094 pub host: String,
2095 pub url: String, // the health URL, `https` unless `plain_ok` is set
2096 // Whether a plain `http` URL is acceptable for this one peer. Off unless the operator
2097 // writes it, and never a global switch: see `crate::srv::watch` for the single case it is
2098 // meant for.
2099 pub plain_ok: bool,
2100 // Health-body field -> the value at or above which that class is in distress. Empty -- the
2101 // default -- leaves the peer as plain up/down, so an existing peer list keeps working
2102 // unchanged. Every field is "higher is worse" (memory per cent, load, dropped connections),
2103 // so distress is an at-or-above test.
2104 pub distress: BTreeMap<String, i64>,
2105 // Health-body field -> the value at or below which that class has cleared, giving hysteresis:
2106 // a peer enters distress at `distress` and leaves it only under `clear`. A field named in
2107 // `distress` but not here uses its distress value as the clear boundary, i.e. no dead-band.
2108 pub clear: BTreeMap<String, i64>,
2109 // The shared secret to present in the `x-steel-health-token` header, so the peer serves its
2110 // body rather than a 404. `None` sends no header and reads only liveness. Supports
2111 // `{file:...}`, resolved at start-up.
2112 pub token: Option<String>,
2113 // Seconds between reminders about this peer alone, down or distressed, in place of
2114 // `watch.repeat_secs`. `None` -- the default -- uses the watcher's. A peer whose fault is
2115 // known and slow to mend, such as a stale backup, reminds every six hours rather than
2116 // texting every fifteen minutes for a fortnight.
2117 pub repeat_secs: Option<u64>,
2118}
2119
2120/// Watching the other machines in the estate.
2121///
2122/// See [`crate::srv::watch`] for why this is a mesh of peers rather than a
2123/// monitoring server, and why adding a machine is one line here and nothing
2124/// else anywhere.
2125#[derive(Clone, Debug, Eq, PartialEq)]
2126pub struct WatchConfig {
2127 pub enabled: bool,
2128 // The machines this node watches, not including itself: a node cannot report its own death,
2129 // which is the whole premise.
2130 pub peers: Vec<WatchPeer>,
2131 pub interval_secs: u64, // seconds between rounds
2132 pub fail_threshold: u32, // consecutive failures before a peer is called down
2133 pub timeout_secs: u64, // seconds to wait for a health answer
2134 // Seconds between reminders while a peer stays down. An alarm that fires every round is an
2135 // alarm that gets silenced, and the text leg costs money per message.
2136 pub repeat_secs: u64,
2137 // Seconds between proof-of-life messages; zero switches them off. The alerting path is used
2138 // rarely by design, and a path used rarely is broken when it is needed -- an expired
2139 // credential, a rotated key, a changed number, a lapsed verification. This exercises every
2140 // leg on a schedule, so the failure is found on an ordinary afternoon.
2141 pub heartbeat_secs: u64,
2142}
2143
2144/// Every string in a named list, whatever list shape the daticle used.
2145///
2146/// A jdat list can arrive as `Dat::List` or, when it was written with the `vek`
2147/// type tag, as `Dat::Vek`. They mean the same thing to a reader and a
2148/// configuration file uses whichever its author typed -- so a parser that knows
2149/// only one of them silently reads an empty list from a file that plainly has
2150/// entries in it. That cost a live deploy: `alerts.sms.to` was written in the
2151/// same `(vek|[...])` form as `alerts.to` beside it, and the SMS half read
2152/// nothing and refused to start.
2153fn strings_in(m: &DaticleMap, key: &str) -> Vec<String> {
2154 let mut out = Vec::new();
2155 let push = |out: &mut Vec<String>, d: &Dat| {
2156 if let Dat::Str(s) = d {
2157 out.push(s.clone());
2158 }
2159 };
2160 match m.get(&dat!(key)) {
2161 Some(Dat::List(l)) => for d in l { push(&mut out, d); },
2162 Some(Dat::Vek(v)) => for d in v.iter() { push(&mut out, d); },
2163 _ => {},
2164 }
2165 out
2166}
2167
2168impl SmsAlertConfig {
2169 /// Parse an `SmsAlertConfig` from a `DaticleMap`.
2170 ///
2171 /// Every field falls back to the default, so an existing configuration that
2172 /// has never heard of this block keeps loading unchanged. What is *not*
2173 /// tolerated is an enabled block that cannot work: a gateway nobody
2174 /// recognises, or nobody to text. Both are refused at start-up, because the
2175 /// alternative is discovering them on the night the alert was needed.
2176 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
2177 let mut out = Self::default();
2178 if let Some(Dat::Bool(b)) = m.get(&dat!("enabled")) {
2179 out.enabled = *b;
2180 }
2181 if let Some(Dat::Str(s)) = m.get(&dat!("provider")) {
2182 out.provider = res!(SmsProvider::from_id(s).ok_or_else(|| err!(
2183 "alerts.sms.provider is '{}'. Known gateways: {}.",
2184 s, SmsProvider::ALL.iter().map(|p| p.id())
2185 .collect::<Vec<_>>().join(", ");
2186 Configuration, Invalid)));
2187 }
2188 if let Some(Dat::Str(s)) = m.get(&dat!("from")) {
2189 out.from = s.clone();
2190 }
2191 if let Some(Dat::Str(s)) = m.get(&dat!("user_env")) {
2192 out.user_env = s.clone();
2193 }
2194 if let Some(Dat::Str(s)) = m.get(&dat!("secret_env")) {
2195 out.secret_env = s.clone();
2196 }
2197 out.to = strings_in(m, "to");
2198 if out.enabled {
2199 if out.to.is_empty() {
2200 return Err(err!(
2201 "alerts.sms is enabled with no numbers in 'to'. An alerter \
2202 with nobody to tell is worse than none, because it looks \
2203 like cover.";
2204 Configuration, Invalid, Missing));
2205 }
2206 // Checked here rather than at the first alert. A number that a
2207 // gateway will refuse is a text that never arrives, and the moment
2208 // it is discovered would otherwise be an outage at three in the
2209 // morning.
2210 for n in &out.to {
2211 if !oxedyne_fe2o3_net::sms::is_e164(n) {
2212 return Err(err!(
2213 "alerts.sms.to contains {:?}, which is not E.164. It \
2214 needs a leading '+' and the country code, e.g. \
2215 '+61400000000' -- every gateway refuses anything else, \
2216 so this would be a text that never arrived.", n;
2217 Configuration, Invalid, Input));
2218 }
2219 }
2220 }
2221 Ok(out)
2222 }
2223}
2224
2225impl WatchConfig {
2226 /// Parse a `WatchConfig` from a `DaticleMap`.
2227 ///
2228 /// A peer with no name or no URL is refused rather than skipped: a watch
2229 /// list that silently watches four machines out of five is the failure this
2230 /// whole module exists to prevent.
2231 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
2232 let mut out = Self::default();
2233 if let Some(Dat::Bool(b)) = m.get(&dat!("enabled")) {
2234 out.enabled = *b;
2235 }
2236 if let Some(Dat::U64(n)) = m.get(&dat!("interval_secs")) {
2237 out.interval_secs = *n;
2238 }
2239 if let Some(Dat::U64(n)) = m.get(&dat!("timeout_secs")) {
2240 out.timeout_secs = *n;
2241 }
2242 if let Some(Dat::U64(n)) = m.get(&dat!("repeat_secs")) {
2243 out.repeat_secs = *n;
2244 }
2245 if let Some(Dat::U64(n)) = m.get(&dat!("heartbeat_secs")) {
2246 out.heartbeat_secs = *n;
2247 }
2248 if let Some(Dat::U32(n)) = m.get(&dat!("fail_threshold")) {
2249 out.fail_threshold = *n;
2250 }
2251 if let Some(Dat::List(l)) = m.get(&dat!("peers")) {
2252 for (i, d) in l.iter().enumerate() {
2253 let pm = match d {
2254 Dat::Map(pm) => pm,
2255 _ => return Err(err!(
2256 "watch.peers entry {} is not a map.", i;
2257 Configuration, Invalid, Input)),
2258 };
2259 let get = |k: &str| -> String {
2260 match pm.get(&dat!(k)) {
2261 Some(Dat::Str(v)) => v.clone(),
2262 _ => String::new(),
2263 }
2264 };
2265 let name = get("name");
2266 let url = get("url");
2267 if name.is_empty() || url.is_empty() {
2268 return Err(err!(
2269 "watch.peers entry {} needs both a 'name' and a 'url'.", i;
2270 Configuration, Invalid, Missing));
2271 }
2272 let host = match get("host") {
2273 h if h.is_empty() => name.clone(),
2274 h => h,
2275 };
2276 // Absent means false, so every peer written before this key existed keeps
2277 // demanding TLS, which is the answer a silent config should give.
2278 let plain_ok = matches!(pm.get(&dat!("plain_ok")), Some(Dat::Bool(true)));
2279 // Distress / clear threshold maps: field -> integer. Absent leaves the peer plain
2280 // up/down. A non-integer value is refused rather than skipped, so a typo in a
2281 // threshold is a start-up failure and not a silently unwatched class.
2282 let thresholds = |key: &str| -> Outcome<BTreeMap<String, i64>> {
2283 let mut out = BTreeMap::new();
2284 if let Some(Dat::Map(tm)) = pm.get(&dat!(key)) {
2285 for (k, v) in tm.iter() {
2286 let field = match k {
2287 Dat::Str(s) => s.clone(),
2288 _ => return Err(err!(
2289 "watch.peers entry {} '{}' has a non-string field name.", i, key;
2290 Configuration, Invalid, Input)),
2291 };
2292 let value = match v {
2293 Dat::I64(n) => *n,
2294 Dat::U64(n) => *n as i64,
2295 Dat::U32(n) => *n as i64,
2296 Dat::U16(n) => *n as i64,
2297 Dat::U8(n) => *n as i64,
2298 Dat::I32(n) => *n as i64,
2299 Dat::I16(n) => *n as i64,
2300 Dat::I8(n) => *n as i64,
2301 _ => return Err(err!(
2302 "watch.peers entry {} '{}.{}' is not an integer.", i, key, field;
2303 Configuration, Invalid, Input)),
2304 };
2305 out.insert(field, value);
2306 }
2307 }
2308 Ok(out)
2309 };
2310 let distress = res!(thresholds("distress"));
2311 let clear = res!(thresholds("clear"));
2312 let token = match pm.get(&dat!("token")) {
2313 Some(Dat::Str(s)) if !s.is_empty() => Some(s.clone()),
2314 _ => None,
2315 };
2316 // Any unsigned integer width, as a threshold takes; anything else is refused, so
2317 // a mistyped cadence is a start-up failure rather than the watcher's default.
2318 let repeat_secs = match pm.get(&dat!("repeat_secs")) {
2319 None => None,
2320 Some(Dat::U64(n)) => Some(*n),
2321 Some(Dat::U32(n)) => Some(*n as u64),
2322 Some(Dat::U16(n)) => Some(*n as u64),
2323 Some(Dat::U8(n)) => Some(*n as u64),
2324 Some(Dat::I64(n)) if *n >= 0 => Some(*n as u64),
2325 Some(Dat::I32(n)) if *n >= 0 => Some(*n as u64),
2326 Some(other) => return Err(err!(
2327 "watch.peers entry {} ('{}') has repeat_secs {:?}, which is not a \
2328 count of seconds.", i, name, other;
2329 Configuration, Invalid, Input)),
2330 };
2331 out.peers.push(WatchPeer {
2332 name, host, url, plain_ok, distress, clear, token, repeat_secs,
2333 });
2334 }
2335 }
2336 if out.enabled {
2337 if out.peers.is_empty() {
2338 return Err(err!(
2339 "watch is enabled with no peers. A watcher watching nothing \
2340 looks like cover and is not.";
2341 Configuration, Invalid, Missing));
2342 }
2343 if out.fail_threshold == 0 {
2344 return Err(err!(
2345 "watch.fail_threshold is 0, which would call a machine down \
2346 on a single dropped packet.";
2347 Configuration, Invalid, Range));
2348 }
2349 }
2350 Ok(out)
2351 }
2352}
2353
2354impl Default for WatchConfig {
2355 fn default() -> Self {
2356 Self {
2357 enabled: false,
2358 peers: Vec::new(),
2359 interval_secs: 60,
2360 // Three, against a sixty second round: a machine is called down
2361 // after roughly three minutes of not answering. Long enough that a
2362 // restart or a certificate renewal does not raise the alarm, short
2363 // enough that fifty minutes of silence cannot happen again.
2364 fail_threshold: 3,
2365 timeout_secs: 10,
2366 repeat_secs: 900,
2367 // Monthly. Often enough that a dead path is caught before it
2368 // matters, rare enough that the message stays worth reading.
2369 heartbeat_secs: 2_592_000,
2370 }
2371 }
2372}
2373
2374impl Default for AlertConfig {
2375 fn default() -> Self {
2376 Self {
2377 enabled: false,
2378 from: String::new(),
2379 submission: None,
2380 to: Vec::new(),
2381 ehlo_hostname: String::new(),
2382 failed_threshold: 5,
2383 failed_window_secs: 900, // 15 minutes
2384 failed_cooldown_secs: 3_600, // 1 hour
2385 sms: None,
2386 }
2387 }
2388}
2389
2390impl AlertConfig {
2391 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
2392 let mut out = Self::default();
2393 if let Some(Dat::Bool(b)) = m.get(&dat!("enabled")) {
2394 out.enabled = *b;
2395 }
2396 if let Some(Dat::Str(s)) = m.get(&dat!("from")) {
2397 out.from = s.clone();
2398 }
2399 if let Some(Dat::Str(s)) = m.get(&dat!("ehlo_hostname")) {
2400 out.ehlo_hostname = s.clone();
2401 }
2402 if let Some(Dat::Map(sm)) = m.get(&dat!("submission")) {
2403 let get_str = |k: &str| -> String {
2404 match sm.get(&dat!(k)) {
2405 Some(Dat::Str(v)) => v.clone(),
2406 _ => String::new(),
2407 }
2408 };
2409 let host = get_str("host");
2410 if host.is_empty() {
2411 return Err(err!(
2412 "alerts.submission is present but has no 'host'.";
2413 Configuration, Invalid, Missing));
2414 }
2415 let port = match sm.get(&dat!("port")) {
2416 Some(Dat::U16(n)) => *n,
2417 _ => 587,
2418 };
2419 let security = match get_str("security").as_str() {
2420 "" => fmt!("starttls"),
2421 other => other.to_string(),
2422 };
2423 match security.as_str() {
2424 "starttls" | "implicit" | "plain" => (),
2425 other => return Err(err!(
2426 "alerts.submission.security is '{}'; expected 'starttls', \
2427 'implicit' or 'plain'.", other;
2428 Configuration, Invalid)),
2429 }
2430 out.submission = Some(AlertSubmission {
2431 host,
2432 port,
2433 security,
2434 user: get_str("user"),
2435 password: get_str("password"),
2436 });
2437 }
2438 out.to = strings_in(m, "to");
2439 if let Some(Dat::U32(n)) = m.get(&dat!("failed_threshold")) {
2440 out.failed_threshold = *n;
2441 }
2442 if let Some(Dat::U64(n)) = m.get(&dat!("failed_window_secs")) {
2443 out.failed_window_secs = *n;
2444 }
2445 if let Some(Dat::U64(n)) = m.get(&dat!("failed_cooldown_secs")) {
2446 out.failed_cooldown_secs = *n;
2447 }
2448 if out.failed_threshold == 0 {
2449 return Err(err!(
2450 "alerts.failed_threshold is 0, which would raise an alert on \
2451 every failed attempt and turn the alerter into an amplifier \
2452 pointed at the operator's mailbox.";
2453 Configuration, Invalid, Range));
2454 }
2455 if let Some(Dat::Map(sm)) = m.get(&dat!("sms")) {
2456 out.sms = Some(res!(SmsAlertConfig::from_datmap(sm)));
2457 }
2458 Ok(out)
2459 }
2460
2461 /// Expand `{file:path}` and `{env:VAR}` placeholders in the submission
2462 /// credential, so the password need not be written into `config.jdat`
2463 /// in the clear.
2464 ///
2465 /// Not the wallet's encrypted secrets: the alert that matters most is the
2466 /// one saying Steel came up *sealed*, and at that moment there is no
2467 /// master key to decrypt anything with. The credential has to be readable
2468 /// before the wallet is open, which means a file the server user alone
2469 /// can read.
2470 pub fn resolve_secrets(&mut self, root: &Path) -> Outcome<()> {
2471 if let Some(sub) = &mut self.submission {
2472 sub.password = res!(ApiRoute::resolve_file_refs(&sub.password, root));
2473 sub.user = res!(ApiRoute::resolve_file_refs(&sub.user, root));
2474 }
2475 Ok(())
2476 }
2477}
2478
2479
2480// ┌───────────────────────────────────────────────────────────────────────────┐
2481// │ SERVER CONFIG │
2482// └───────────────────────────────────────────────────────────────────────────┘
2483
2484/// Top-level server configuration. Fields here are shared across all vhosts;
2485/// per-site settings live on `VhostConfig` entries inside `vhosts`.
2486#[derive(Clone, Debug, Eq, PartialEq, FromDatMap, ToDatMap)]
2487pub struct ServerConfig {
2488 // --- TLS fallback (only used when acme.enabled = false) -----------------
2489 // Directory holding per-vhost certificates when ACME is disabled, relative to the app root.
2490 // Each vhost's certs live in `{tls_dir_rel}/{dev|prod}/{primary_hostname}/fullchain.pem` and
2491 // `privkey.pem`.
2492 pub tls_dir_rel: String,
2493
2494 // --- Server bind and policy (shared) ------------------------------------
2495 pub log_level: String, // used by the server once running
2496 pub server_address: String, // typically "0.0.0.0"
2497 pub server_port_tcp: u16, // the primary HTTPS port
2498 // Optional plaintext HTTP listener port. When non-zero, Steel binds this port too and
2499 // answers every incoming HTTP request with a `301 Moved Permanently` to the equivalent HTTPS
2500 // URL on the primary port. Typically 80 in production and 0 in local development. Defaults
2501 // to 0.
2502 pub server_port_tcp_plaintext: u16,
2503 // `Strict-Transport-Security` `max-age` in seconds, injected into every HTTPS response when
2504 // non-zero. 31536000, one year, is conventional for production. Defaults to 0, no HSTS.
2505 pub hsts_max_age_secs: u32,
2506 // `Cache-Control` `max-age` in seconds for static assets, which is how long a browser may
2507 // reuse one without asking. Entry documents are excluded and always revalidate, since a
2508 // deploy that changes one is invisible to anyone still holding the old copy. Raise this above
2509 // zero only when asset filenames carry a content hash: an asset cached under a stable name
2510 // outlives the deploy that replaced it. Defaults to 0, which revalidates everything -- cheap,
2511 // because the entity tag turns an unchanged asset into a bodiless 304.
2512 #[optional]
2513 pub static_max_age_secs: u32,
2514 // `Cache-Control` `max-age` in seconds for an asset whose filename carries a content hash,
2515 // which is a promise that the file cannot change under that name. Such a response also says
2516 // `immutable`, so a browser does not revalidate it even on a manual reload. Entry documents
2517 // are excluded whatever their name. Defaults to one year, the conventional value and the
2518 // longest RFC 9111 5.2.2.1 suggests anyone use. Set to 0 if a build here emits hash-shaped
2519 // names that it then overwrites in place, which would otherwise leave a browser holding a
2520 // stale copy for a year.
2521 #[optional]
2522 pub fingerprint_max_age_secs: u32,
2523 // Whether to encode eligible responses with gzip when the client says it will accept one.
2524 // Markup, script, stylesheets, JSON, SVG and WebAssembly typically go out at a third to a half
2525 // of their raw weight; formats that carry their own compression are never encoded twice.
2526 // Defaults to true.
2527 #[optional]
2528 pub compression_enabled: bool,
2529 // Smallest response body, in bytes, worth encoding. A gzip member costs eighteen bytes of
2530 // framing before it encodes anything, so under about a kilobyte the saving is noise. Defaults
2531 // to 1024.
2532 #[optional]
2533 pub compression_min_bytes: u64,
2534 // Optional plaintext HTTP listener bound to `127.0.0.1` for the admin dashboard only. When
2535 // non-zero, Steel binds this port on the loopback interface and serves the `/admin/*` routes
2536 // without TLS: SSH-tunnel to the host and reach the dashboard without going through the
2537 // public TLS chain, which is what an expired cert, a broken ACME or an emergency needs.
2538 // Anything other than `/admin*` returns 404. Defaults to 0, disabled.
2539 #[optional]
2540 pub admin_local_port: u16,
2541 pub session_expiry_default_secs: u32, // seconds
2542 pub ws_ping_interval_secs: u8, // seconds
2543 pub server_max_errors_allowed: u8, // consecutive, on one connection
2544 // Whether to issue a session cookie to unauthenticated clients on first contact. When true,
2545 // Steel generates a fresh session id for any incoming request that does not already carry
2546 // one and attaches it as an `HttpOnly`, `Secure`, `SameSite=Lax` cookie, which is what makes
2547 // session-scoped WebSocket commands work for anonymous browsers. When false, requests
2548 // without a session cookie are still served, but session-scoped commands reject until the
2549 // client obtains a session id some other way.
2550 pub allow_anonymous_sessions: bool,
2551 // ── Hardening knobs ───────────────────────────────────────────────────
2552 //
2553 // Every field below is `#[optional]` so on-disk configs from
2554 // earlier Steel builds continue to load. Missing fields fall
2555 // through to the Default impl (which reproduces the pre-feature
2556 // "permissive" behaviour for each: no size/time limits, headers
2557 // enabled, empty CSP, empty guard block).
2558
2559 // Maximum bytes accepted in the HTTP request header block before the reader returns `413
2560 // Content Too Large`. Zero disables the limit.
2561 #[optional]
2562 pub http_max_header_bytes: u64,
2563 // Maximum bytes accepted in the HTTP request body before the reader returns `413 Content Too
2564 // Large`. Zero disables the limit.
2565 #[optional]
2566 pub http_max_body_bytes: u64,
2567 // Wall-clock budget for the HTTP header read phase, in milliseconds. A slow client that fails
2568 // to finish sending its header block within this window is disconnected with a `Timeout`
2569 // error. Zero disables the deadline.
2570 #[optional]
2571 pub http_header_read_timeout_ms: u64,
2572 // When true, Steel injects a baseline set of security response headers into every HTTPS
2573 // response: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
2574 // `Permissions-Policy`.
2575 #[optional]
2576 pub security_headers_enabled: bool,
2577 #[optional]
2578 pub content_security_policy: String, // empty sends no CSP header
2579 #[optional]
2580 pub addr_guard: DaticleMap, // empty map restores the defaults
2581 // URL path prefixes routed through the tighter auth-path rate limiter.
2582 #[optional]
2583 pub auth_path_prefixes: Vec<String>,
2584 // Maximum average requests per second permitted against the auth path prefixes.
2585 #[optional]
2586 pub auth_rps_max: u64,
2587 // The immediate peers entitled to speak the forwarding headers -- `X-Forwarded-For`,
2588 // `X-Forwarded-Proto`, `X-Forwarded-Host` and RFC 7239 `Forwarded` -- written either as a
2589 // bare address, `198.51.100.7`, or as a prefix, `198.51.100.0/24`.
2590 //
2591 // Empty means trust nobody, which means a caller's copies of those headers are stripped
2592 // before Steel appends its own. That is the default, and it is the correct setting for a host
2593 // facing the public directly: nothing sits in front of Steel there, so nothing in front of
2594 // Steel is entitled to name the client.
2595 //
2596 // Deleting this field, or emptying a populated one, is not tidying. It reads like hardening a
2597 // later reader can drop, and it is the opposite. A forged `X-Forwarded-For` copied through
2598 // arrives ahead of Steel's own, and the obvious way to read a repeated header --
2599 // `HeaderFields::get_one` -- returns the first. An upstream address guard keyed on that
2600 // counts a fresh allowance for every fresh invented address, so it is not a weaker limit but
2601 // no limit at all, while looking configured. A forged `X-Forwarded-Proto: http` read the same
2602 // way tells an upstream that a TLS request arrived in plaintext, and an upstream that
2603 // redirects plaintext to HTTPS on that basis loops.
2604 //
2605 // Populate it only when something really does sit in front -- a CDN, a load balancer --
2606 // naming that thing's egress addresses. Stripping unconditionally would then discard the real
2607 // client address rather than preserve it, replacing every client with the CDN's egress, which
2608 // is the same bug wearing a safer face. When the peer is named here the caller's chain is
2609 // preserved and Steel's value appended to it; Steel's own value is last in either case, which
2610 // is why an upstream should read these headers with `HeaderFields::get_last`.
2611 //
2612 // Entries are parsed at start-up, so a typo is a start-up failure rather than a silently
2613 // empty allow-list. The policy itself lives in `oxedyne_fe2o3_net::http::fwd`; what stays
2614 // here is the configuration.
2615 #[optional]
2616 pub trusted_proxies: Vec<String>,
2617
2618 // ── Admission control ─────────────────────────────────────────────────
2619 //
2620 // Three concurrency bounds on the accept path, all `#[optional]` and all
2621 // inert at their defaults: a fresh binary changes nothing until a deployment
2622 // sets them to its own vCPU and RAM. They are concurrency limits, orthogonal
2623 // to the per-IP *rate* guard (`addr_guard`), and together they close the
2624 // distributed-flood, slow-hold and handshake-flood shapes a rate limiter
2625 // alone cannot see.
2626
2627 // Maximum concurrent connections in flight across every address. A
2628 // connection beyond it is dropped before the handshake. 0 disables the cap.
2629 #[optional]
2630 pub max_conn: u64,
2631 // Maximum concurrent connections from any one address, enforced by the
2632 // generic `fe2o3_net` guard's `acquire`. 0 disables the cap.
2633 #[optional]
2634 pub max_conn_per_ip: u64,
2635 // Maximum TLS handshakes running at once, the CPU-exhaustion bound on a
2636 // single-vCPU box. A flood then queues handshakes rather than melting the
2637 // core. 0 disables the semaphore.
2638 #[optional]
2639 pub max_tls_handshakes: u64,
2640 // Deadline on each TLS handshake, and on the wait for a handshake permit,
2641 // in milliseconds. Without it a drip-fed handshake pins a permit for ever and
2642 // turns `max_tls_handshakes` into a slowloris amplifier, so a non-zero
2643 // `max_tls_handshakes` requires a non-zero deadline here (checked at load).
2644 // 0 leaves handshakes untimed, which is safe only when the semaphore is also
2645 // off. A timed-out handshake counts as a dropped connection.
2646 #[optional]
2647 pub tls_handshake_timeout_ms: u64,
2648
2649 // ── Health body ───────────────────────────────────────────────────────
2650 //
2651 // The path a token-gated integer health body is served at (see
2652 // `crate::srv::health`). Empty -- the default -- serves no health body and
2653 // the path is a plain 404 like any other. A deployment opts in by naming a
2654 // path, e.g. `/_steel/health`.
2655 #[optional]
2656 pub health_path: String,
2657 // The per-peer shared secret a caller must present in the
2658 // `x-steel-health-token` header to be served the body; anyone else gets a
2659 // 404, so the path stays invisible. Supports `{file:...}`, resolved at
2660 // start-up so it is readable while the box is sealed. Empty disables the body
2661 // even when a path is set, since a body with no token is a body with no gate.
2662 #[optional]
2663 pub health_token: String,
2664 // Job stamps whose ages the body reports: field name -> absolute path, e.g.
2665 // `{ "forge_state_age_s": "/var/lib/forge-pull/stamp/state.ok" }`. Each is
2666 // `lstat`ed on every authorised request and reported in whole seconds since its mtime;
2667 // a stamp that is missing, unreadable or a symlink reads as never written (see
2668 // `crate::srv::health::stamp_age_secs`). Checked at start-up: names of lower-case
2669 // letters, digits and `_`, none a built-in field, absolute paths, at most 16. Empty --
2670 // the default -- reports none.
2671 #[optional]
2672 pub health_stamps: DaticleMap,
2673
2674 // ── Address whitelist ─────────────────────────────────────────────────
2675 //
2676 // IP addresses that are never rate-limited, throttled or blacklisted -- the
2677 // guard's `Whitelist` state, but written down so it survives a restart. The
2678 // runtime `whitelist()` call alone lives only in the in-memory map and is
2679 // lost when the process ends, which is how a shared home NAT address ends up
2680 // stuck blocked after a busy spell. Each entry is parsed to an `IpAddr` at
2681 // start-up (an unparseable one is a start-up failure, like `trusted_proxies`),
2682 // and applied to the guard as the process comes up. Empty -- the default --
2683 // whitelists nobody.
2684 #[optional]
2685 pub whitelist_ips: Vec<String>,
2686
2687 // ── Health residents ──────────────────────────────────────────────────
2688 //
2689 // Process names whose resident memory the health body reports, one
2690 // `res.<name>.*` group each (see `crate::srv::health`), e.g. `["steel",
2691 // "daimond_gateway"]`. Matched on the kernel's command name, which keeps
2692 // fifteen bytes. A name is letters, digits, `_`, `-` and `.`, checked at
2693 // load. Empty -- the default -- reports no residents.
2694 #[optional]
2695 pub health_residents: Vec<String>,
2696
2697 // --- Virtual hosts ------------------------------------------------------
2698 // Stored as a `Dat::List` of `Dat::Map` entries and parsed via `get_vhosts()`.
2699 pub vhosts: Dat,
2700
2701 // --- ACME ---------------------------------------------------------------
2702 pub acme: DaticleMap, // parsed via `get_acme()`
2703
2704 // --- Mail ---------------------------------------------------------------
2705 // Parsed via `get_mail()`. Absent, or an empty map, disables the mail server entirely.
2706 // `#[optional]` since 2026-09-23, so a host that runs no mail -- a watcher, a forge proxy
2707 // -- need not carry a disabled block to satisfy the loader.
2708 #[optional]
2709 pub mail: DaticleMap,
2710
2711 // --- Alerts -------------------------------------------------------------
2712 // Operator alerting, parsed via `get_alerts()`. Absent, or an empty map, disables alerting
2713 // entirely.
2714 //
2715 // `#[optional]` because a config block for a feature nobody has switched on must not be
2716 // mandatory. Without it, `from_datmap` treats the field as required and every existing
2717 // `config.jdat` in the world becomes invalid the moment a new block is added to this struct
2718 // -- which is a fine way to take a production server down while adding a feature it does not
2719 // even use. Any block added here in future should be `#[optional]` too.
2720 #[optional]
2721 pub alerts: DaticleMap,
2722
2723 // --- Watch --------------------------------------------------------------
2724 // The other machines this node watches, parsed via `get_watch()`. Absent, or an empty map,
2725 // means this node watches nobody -- which is the right default, since most hosts in an estate
2726 // are watched rather than watching.
2727 //
2728 // `#[optional]`, per the note above, and this block is the reason that note was worth writing
2729 // down: it was added to a struct backing two live production configurations that had never
2730 // heard of it.
2731 #[optional]
2732 pub watch: DaticleMap,
2733}
2734
2735impl Config for ServerConfig {}
2736
2737impl Default for ServerConfig {
2738 fn default() -> Self {
2739 // Build a default single-vhost setup.
2740 let default_vhost = VhostConfig::default();
2741 let mut vhost_map = DaticleMap::new();
2742 let hostnames_list: Vec<Dat> = default_vhost
2743 .hostnames
2744 .iter()
2745 .map(|s| dat!(s.clone()))
2746 .collect();
2747 vhost_map.insert(dat!("hostnames"), Dat::List(hostnames_list));
2748 if let Some(ref p) = default_vhost.public_dir_rel {
2749 vhost_map.insert(dat!("public_dir_rel"), dat!(p.clone()));
2750 }
2751 let mut routes = DaticleMap::new();
2752 routes.insert(dat!("/"), dat!("./www/public/"));
2753 vhost_map.insert(dat!("static_route_paths_rel"), Dat::Map(routes));
2754 let idx_list: Vec<Dat> = default_vhost
2755 .default_index_files
2756 .iter()
2757 .map(|s| dat!(s.clone()))
2758 .collect();
2759 vhost_map.insert(dat!("default_index_files"), Dat::List(idx_list));
2760 vhost_map.insert(dat!("redirects"), Dat::List(Vec::new()));
2761 if let Some(ref p) = default_vhost.db_dir_rel {
2762 vhost_map.insert(dat!("db_dir_rel"), dat!(p.clone()));
2763 }
2764
2765 Self {
2766 tls_dir_rel: fmt!("./tls"),
2767 log_level: fmt!("debug"),
2768 server_address: fmt!("0.0.0.0"),
2769 server_port_tcp: 8443,
2770 server_port_tcp_plaintext: 0, // disabled by default
2771 hsts_max_age_secs: 0, // disabled by default
2772 static_max_age_secs: 0, // revalidate every asset
2773 fingerprint_max_age_secs: 31_536_000, // one year, for a hashed name
2774 compression_enabled: true,
2775 compression_min_bytes: encoding::MIN_BYTES_DEFAULT as u64,
2776 admin_local_port: 0, // disabled by default
2777 session_expiry_default_secs: 604_800, // 1 week.
2778 ws_ping_interval_secs: 30,
2779 server_max_errors_allowed: 30,
2780 allow_anonymous_sessions: true,
2781 http_max_header_bytes: 16 * 1024, // 16 KiB
2782 http_max_body_bytes: 8 * 1024 * 1024, // 8 MiB
2783 http_header_read_timeout_ms: 15_000, // 15 s
2784 security_headers_enabled: true,
2785 content_security_policy: String::new(),
2786 addr_guard: DaticleMap::new(),
2787 auth_path_prefixes: vec![
2788 fmt!("/login"),
2789 fmt!("/admin/login"),
2790 ],
2791 auth_rps_max: 5,
2792 trusted_proxies: Vec::new(), // Trust nobody: always strip.
2793 max_conn: 0, // no total ceiling by default
2794 max_conn_per_ip: 0, // no per-IP concurrency cap by default
2795 max_tls_handshakes: 0, // handshakes unbounded by default
2796 tls_handshake_timeout_ms: 0, // untimed by default (safe while the sem is off)
2797 health_path: String::new(), // no health body by default
2798 health_token: String::new(), // no token, so no body served
2799 health_stamps: DaticleMap::new(), // no stamp ages reported
2800 whitelist_ips: Vec::new(), // nothing whitelisted by default
2801 health_residents: Vec::new(), // no residents reported
2802 vhosts: Dat::List(vec![Dat::Map(vhost_map)]),
2803 acme: AcmeConfig::default().to_datmap(),
2804 mail: DaticleMap::new(),
2805 alerts: DaticleMap::new(),
2806 watch: DaticleMap::new(),
2807 }
2808 }
2809}
2810
2811impl ServerConfig {
2812
2813 /// Validate the whole server configuration: each vhost's webroot, static
2814 /// routes, default index files and hostnames, plus the ACME cache path.
2815 pub fn validate(
2816 &self,
2817 root: &NormPathBuf,
2818 )
2819 -> Outcome<()>
2820 {
2821 let vhosts = res!(self.get_vhosts());
2822 if vhosts.is_empty() {
2823 return Err(err!(
2824 "ServerConfig: at least one vhost must be defined.";
2825 Invalid, Input, Missing));
2826 }
2827 for vh in &vhosts {
2828 let _ = res!(vh.get_public_dir(root));
2829 let _ = res!(vh.get_static_route_paths(root, ()));
2830 let _ = res!(vh.get_default_index_files());
2831 let _ = res!(vh.get_hostnames_fqdn());
2832 // Egress allow-list check: a vhost naming an upstream
2833 // outside its configured allow-list is refused at
2834 // start-up, whichever kind of route names it. The check
2835 // is a no-op when the allow-list is empty.
2836 res!(vh.validate_egress());
2837 }
2838 let _ = res!(self.get_acme());
2839 // A resident name that cannot ride in a flattened body key is refused here, rather than
2840 // silently missing from every body the host serves.
2841 let _ = res!(self.get_health_residents());
2842 // A mistyped trusted proxy must be a start-up failure. An entry that failed to parse and
2843 // was skipped would leave an allow-list that looks populated and trusts nobody -- or, read
2844 // the other way round, an operator who believes their CDN is named here when it is not.
2845 let _ = res!(self.get_forwarded_policy());
2846 // A mistyped whitelist address is likewise a start-up failure, since the whole point of
2847 // the entry is to keep a real address reachable.
2848 let _ = res!(self.get_whitelist_ips());
2849 // The handshake semaphore is a slowloris amplifier without a deadline: a permit held
2850 // across a drip-fed handshake is never returned. Refuse the unsafe combination at start-up
2851 // rather than discover it as an outage.
2852 if self.max_tls_handshakes > 0 && self.tls_handshake_timeout_ms == 0 {
2853 return Err(err!(
2854 "ServerConfig: max_tls_handshakes is {} but tls_handshake_timeout_ms is 0. A \
2855 bounded handshake count without a deadline lets one slow client pin a permit for \
2856 ever, turning the defence into a denial-of-service. Set a timeout (e.g. 10000).",
2857 self.max_tls_handshakes;
2858 Configuration, Invalid, Input));
2859 }
2860 Ok(())
2861 }
2862
2863 /// The configured whitelist addresses, parsed. An unparseable entry is an
2864 /// error rather than a skip, so a typo cannot silently leave a real address
2865 /// exposed to the rate limiter it was meant to bypass.
2866 pub fn get_whitelist_ips(&self) -> Outcome<Vec<std::net::IpAddr>> {
2867 let mut out = Vec::with_capacity(self.whitelist_ips.len());
2868 for entry in &self.whitelist_ips {
2869 let ip: std::net::IpAddr = res!(entry.trim().parse().map_err(|_| err!(
2870 "ServerConfig: whitelist_ips entry '{}' is not a valid IP address.", entry;
2871 Configuration, Invalid, Input)));
2872 out.push(ip);
2873 }
2874 Ok(out)
2875 }
2876
2877 /// The configured health stamps, each a field the body can carry and an absolute path.
2878 ///
2879 /// A bad entry is an error rather than a skip, and the server refuses to start on one:
2880 /// a stamp dropped here would be a field silently absent from every body, and an absent
2881 /// field trips no watcher's threshold.
2882 pub fn get_health_stamps(&self) -> Outcome<Vec<crate::srv::health::HealthStamp>> {
2883 use crate::srv::health::{
2884 BUILTIN_FIELDS,
2885 HealthStamp,
2886 STAMP_NAME_MAX,
2887 STAMPS_MAX,
2888 is_stamp_name,
2889 };
2890 if self.health_stamps.len() > STAMPS_MAX {
2891 return Err(err!(
2892 "ServerConfig: health_stamps names {} stamps, and at most {} are read, since each \
2893 is an lstat on every health request.", self.health_stamps.len(), STAMPS_MAX;
2894 Configuration, Invalid, Range));
2895 }
2896 let mut out = Vec::with_capacity(self.health_stamps.len());
2897 for (k, v) in self.health_stamps.iter() {
2898 let field = match k {
2899 Dat::Str(s) => s.clone(),
2900 other => return Err(err!(
2901 "ServerConfig: health_stamps has a field name {:?} that is not a string.",
2902 other.kind();
2903 Configuration, Invalid, Input)),
2904 };
2905 if BUILTIN_FIELDS.contains(&field.as_str()) {
2906 return Err(err!(
2907 "ServerConfig: health_stamps field '{}' is one of the health body's own \
2908 fields, and a stamp under that name would replace the reading a watcher's \
2909 threshold was written for. Name it for its job, e.g. 'forge_state_age_s'.",
2910 field;
2911 Configuration, Invalid, Input));
2912 }
2913 if !is_stamp_name(&field) {
2914 return Err(err!(
2915 "ServerConfig: health_stamps field '{}' is not a usable name. It becomes a \
2916 key in the health body, so it must be 1 to {} of lower-case letters, digits \
2917 and '_'.", field, STAMP_NAME_MAX;
2918 Configuration, Invalid, Input));
2919 }
2920 let path = match v {
2921 Dat::Str(s) => PathBuf::from(s),
2922 other => return Err(err!(
2923 "ServerConfig: health_stamps field '{}' must name its stamp's path as a \
2924 string, got {:?}.", field, other.kind();
2925 Configuration, Invalid, Input)),
2926 };
2927 if !path.is_absolute() {
2928 return Err(err!(
2929 "ServerConfig: health_stamps field '{}' names {:?}, which is not an absolute \
2930 path. A stamp belongs to another job's tree rather than this app's root, so \
2931 it is written out in full.", field, path;
2932 Configuration, Invalid, Input, Path));
2933 }
2934 out.push(HealthStamp { field, path });
2935 }
2936 Ok(out)
2937 }
2938
2939 /// Which immediate peers are entitled to speak the forwarding headers.
2940 ///
2941 /// See [`trusted_proxies`](Self::trusted_proxies). An empty list yields a policy that trusts
2942 /// nobody, which strips every caller-supplied forwarding header.
2943 pub fn get_forwarded_policy(&self) -> Outcome<ForwardedPolicy> {
2944 ForwardedPolicy::new(&self.trusted_proxies)
2945 }
2946
2947 pub fn get_vhosts(&self) -> Outcome<Vec<VhostConfig>> {
2948 let list = match &self.vhosts {
2949 Dat::List(items) => items,
2950 _ => return Err(err!(
2951 "ServerConfig: 'vhosts' must be a list of vhost maps.";
2952 Invalid, Input, Mismatch)),
2953 };
2954 let mut out = Vec::new();
2955 for (i, vh_dat) in list.iter().enumerate() {
2956 let vh_map = match vh_dat {
2957 Dat::Map(m) => m,
2958 _ => return Err(err!(
2959 "ServerConfig: vhost entry {} is not a map.", i;
2960 Invalid, Input, Mismatch)),
2961 };
2962 out.push(res!(VhostConfig::from_datmap(vh_map)));
2963 }
2964 Ok(out)
2965 }
2966
2967 pub fn get_acme(&self) -> Outcome<AcmeConfig> {
2968 AcmeConfig::from_datmap(&self.acme)
2969 }
2970
2971 /// Parse and return the mail configuration. Returns `None` if no
2972 /// mail block is configured (`mail = {}` in JDAT).
2973 pub fn get_mail(&self) -> Outcome<Option<MailConfig>> {
2974 if self.mail.is_empty() {
2975 return Ok(None);
2976 }
2977 let cfg = res!(MailConfig::from_datmap(&self.mail));
2978 if !cfg.enabled {
2979 return Ok(None);
2980 }
2981 Ok(Some(cfg))
2982 }
2983
2984 /// The mail config regardless of `enabled`.
2985 ///
2986 /// [`get_mail`](Self::get_mail) gates on `enabled` because it answers "should the mail *server*
2987 /// (the listeners) start?". Sending a newsletter is a different question: a site can hold a DKIM
2988 /// identity and an outbound client to send with, without binding SMTP-receive/submission/IMAP and
2989 /// becoming an MX. So the newsletter sender reads the block here, `enabled` or not, and is built
2990 /// whenever there is a hostname and a signing key.
2991 pub fn get_mail_any(&self) -> Outcome<Option<MailConfig>> {
2992 if self.mail.is_empty() {
2993 return Ok(None);
2994 }
2995 Ok(Some(res!(MailConfig::from_datmap(&self.mail))))
2996 }
2997
2998 /// The watch block, parsed, or `None` when this node watches nobody.
2999 ///
3000 /// See [`crate::srv::watch`]: most hosts in an estate are watched rather
3001 /// than watching, so absent is the ordinary answer.
3002 pub fn get_watch(&self) -> Outcome<Option<WatchConfig>> {
3003 if self.watch.is_empty() {
3004 return Ok(None);
3005 }
3006 let cfg = res!(WatchConfig::from_datmap(&self.watch));
3007 if !cfg.enabled {
3008 return Ok(None);
3009 }
3010 Ok(Some(cfg))
3011 }
3012
3013 /// The configured health residents, each checked to be a name that can ride in a
3014 /// `res.<name>.<figure>` key.
3015 pub fn get_health_residents(&self) -> Outcome<Vec<String>> {
3016 let mut out = Vec::with_capacity(self.health_residents.len());
3017 for name in &self.health_residents {
3018 let name = name.trim();
3019 if !crate::srv::health::is_resident_name(name) {
3020 return Err(err!(
3021 "ServerConfig: health_residents entry '{}' is not a usable process name. It \
3022 becomes part of a health-body key, so it must be 1 to {} of letters, digits, \
3023 '_', '-' and '.'.", name, crate::srv::health::RES_NAME_MAX;
3024 Configuration, Invalid, Input));
3025 }
3026 out.push(name.to_string());
3027 }
3028 Ok(out)
3029 }
3030
3031 /// Parse the `alerts` block. An empty map, or an `enabled: false` map, disables alerting.
3032 pub fn get_alerts(&self) -> Outcome<Option<AlertConfig>> {
3033 if self.alerts.is_empty() {
3034 return Ok(None);
3035 }
3036 let cfg = res!(AlertConfig::from_datmap(&self.alerts));
3037 if !cfg.enabled {
3038 return Ok(None);
3039 }
3040 Ok(Some(cfg))
3041 }
3042
3043 /// Parse the `addr_guard` map block into runtime settings for the
3044 /// per-IP address guard. Every field is optional; a missing or
3045 /// unrecognised field falls back to the module default, and an
3046 /// entirely empty map restores every default.
3047 pub fn get_addr_guard_settings(
3048 &self,
3049 )
3050 -> crate::srv::admin::guard::AddrGuardSettings
3051 {
3052 use crate::srv::admin::guard::AddrGuardSettings;
3053 let mut s = AddrGuardSettings::default();
3054 let take_u64 = |key: &str| -> Option<u64> {
3055 match self.addr_guard.get(&dat!(key)) {
3056 Some(Dat::U64(v)) => Some(*v),
3057 Some(Dat::U32(v)) => Some(*v as u64),
3058 Some(Dat::U16(v)) => Some(*v as u64),
3059 Some(Dat::U8(v)) => Some(*v as u64),
3060 _ => None,
3061 }
3062 };
3063 if let Some(v) = take_u64("rps_max") {
3064 s.rps_max = v;
3065 }
3066 if let Some(v) = take_u64("tint_min_ms") {
3067 s.tint_min = Duration::from_millis(v);
3068 }
3069 if let Some(v) = take_u64("tsunset_base_secs") {
3070 s.tsunset_base = Duration::from_secs(v);
3071 }
3072 if let Some(v) = take_u64("tsunset_spread_secs") {
3073 s.tsunset_spread = Duration::from_secs(v);
3074 }
3075 if let Some(v) = take_u64("blist_cnt") {
3076 s.blist_cnt = v.min(u16::MAX as u64) as u16;
3077 }
3078 if let Some(v) = take_u64("decay_secs") {
3079 s.decay_after = Duration::from_secs(v);
3080 }
3081 // The per-IP concurrency cap is a top-level admission knob, not part of
3082 // the rate-guard block, but it is enforced by the same guard object.
3083 s.conn_max = self.max_conn_per_ip as usize;
3084 s
3085 }
3086
3087 pub fn session_cookie_default(&self, sid: String) -> Cookie {
3088 let session_cookie_attrs = [
3089 SetCookieAttributes::HttpOnly,
3090 SetCookieAttributes::MaxAge(self.session_expiry_default_secs),
3091 SetCookieAttributes::Path("/".to_string()),
3092 SetCookieAttributes::SameSite(SameSite::Lax),
3093 SetCookieAttributes::Secure,
3094 ];
3095 let session_cookie_attrs =
3096 BTreeSet::from_iter(session_cookie_attrs.iter().cloned());
3097 Cookie {
3098 key: SESSION_ID_KEY_LABEL.to_string(),
3099 val: sid,
3100 attrs: Some(session_cookie_attrs),
3101 }
3102 }
3103
3104 pub fn session_expiry(&self) -> Duration {
3105 Duration::from_secs(self.session_expiry_default_secs as u64)
3106 }
3107
3108 pub fn log_level(&self) -> Outcome<LogLevel> {
3109 LogLevel::from_str(&self.log_level)
3110 }
3111
3112 /// Resolve the TLS directory for a given mode (dev or prod) to an absolute
3113 /// validated path. Used only when ACME is disabled.
3114 pub fn get_tls_dir(
3115 &self,
3116 root: &NormPathBuf,
3117 dev_mode: bool,
3118 )
3119 -> Outcome<PathBuf>
3120 {
3121 let tls_dir_str = &self.tls_dir_rel;
3122 if tls_dir_str.is_empty() {
3123 return Err(err!(
3124 "ServerConfig: TLS directory is empty.";
3125 Invalid, Input, Missing));
3126 }
3127 let tls_dir = Path::new(tls_dir_str).normalise();
3128 if tls_dir.escapes() {
3129 return Err(err!(
3130 "ServerConfig: TLS directory {} escapes the directory {:?}.",
3131 tls_dir_str, root;
3132 Invalid, Input, Path));
3133 }
3134 let tls_dir = root.clone().join(tls_dir).normalise().absolute().as_pathbuf();
3135 let tls_dir = if dev_mode {
3136 res!(PathState::Create.validate(
3137 &tls_dir,
3138 constant::TLS_DIR_DEV,
3139 ));
3140 tls_dir.join(constant::TLS_DIR_DEV)
3141 } else {
3142 res!(PathState::Create.validate(
3143 &tls_dir,
3144 constant::TLS_DIR_PROD,
3145 ));
3146 tls_dir.join(constant::TLS_DIR_PROD)
3147 };
3148 Ok(tls_dir)
3149 }
3150}
3151
3152
3153impl AdminKey {
3154 /// Parses a single admin-key entry from a `Dat` map. Expected
3155 /// shape:
3156 ///
3157 /// ```text
3158 /// {
3159 /// "name": "alice",
3160 /// "scheme": "Ed25519",
3161 /// "public_key": "<base2x HEMATITE64 bytes>",
3162 /// "scopes": ["*"],
3163 /// }
3164 /// ```
3165 ///
3166 /// `public_key` is the canonical fe2o3 byte-string encoding --
3167 /// [`base2x::HEMATITE64`](oxedyne_fe2o3_text::base2x::HEMATITE64) --
3168 /// matching what `oxegen keygen` prints.
3169 pub fn from_dat(dat: Dat) -> Outcome<Self> {
3170 let mut dat = dat;
3171 if dat.kind() != oxedyne_fe2o3_jdat::kind::Kind::Map
3172 && dat.kind() != oxedyne_fe2o3_jdat::kind::Kind::OrdMap
3173 {
3174 return Err(err!(
3175 "admin_keys entry must be a map, got {:?}.", dat.kind();
3176 Invalid, Input, Mismatch));
3177 }
3178 let name = match dat.map_remove_must(&dat!("name")) {
3179 Ok(Dat::Str(s)) => s,
3180 Ok(other) => return Err(err!(
3181 "admin_keys entry 'name' must be a string, got {:?}.",
3182 other.kind();
3183 Invalid, Input, Mismatch)),
3184 Err(_) => return Err(err!(
3185 "admin_keys entry missing 'name'.";
3186 Invalid, Input, Missing)),
3187 };
3188 let scheme = match dat.map_remove_must(&dat!("scheme")) {
3189 Ok(Dat::Str(s)) => s,
3190 Ok(other) => return Err(err!(
3191 "admin_keys entry 'scheme' must be a string, got {:?}.",
3192 other.kind();
3193 Invalid, Input, Mismatch)),
3194 Err(_) => "Ed25519".to_string(), // default
3195 };
3196 let public_key_enc = match dat.map_remove_must(&dat!("public_key")) {
3197 Ok(Dat::Str(s)) => s,
3198 _ => return Err(err!(
3199 "admin_keys entry '{}' missing or non-string 'public_key'.",
3200 name;
3201 Invalid, Input, Mismatch)),
3202 };
3203 let public_key = match oxedyne_fe2o3_text::base2x::HEMATITE64
3204 .from_str(&public_key_enc)
3205 {
3206 Ok(b) => b,
3207 Err(e) => return Err(err!(e,
3208 "admin_keys entry '{}' 'public_key' is not valid \
3209 base2x HEMATITE64.", name;
3210 Invalid, Input, Decode)),
3211 };
3212 let scopes = match dat.map_remove_must(&dat!("scopes")) {
3213 Ok(Dat::List(list)) => {
3214 let mut out = Vec::with_capacity(list.len());
3215 for item in list {
3216 match item {
3217 Dat::Str(s) => out.push(s),
3218 other => return Err(err!(
3219 "admin_keys entry '{}' scope must be a string, \
3220 got {:?}.", name, other.kind();
3221 Invalid, Input, Mismatch)),
3222 }
3223 }
3224 out
3225 }
3226 Ok(other) => return Err(err!(
3227 "admin_keys entry '{}' 'scopes' must be a list, got {:?}.",
3228 name, other.kind();
3229 Invalid, Input, Mismatch)),
3230 Err(_) => Vec::new(),
3231 };
3232 Ok(Self { name, scheme, public_key, scopes })
3233 }
3234}
3235
3236
3237#[cfg(test)]
3238mod tests {
3239 use super::*;
3240
3241 /// A whitelist address parses to an `IpAddr`, and a malformed one is a hard
3242 /// error rather than a silent skip -- the whole point of the entry is to keep
3243 /// a real address reachable, so a typo must be loud.
3244 #[test]
3245 fn whitelist_ips_parse_and_reject() {
3246 let mut cfg = ServerConfig::default();
3247 cfg.whitelist_ips = vec![fmt!("203.0.113.7"), fmt!("2001:db8::1")];
3248 let ips = cfg.get_whitelist_ips().expect("valid addresses must parse");
3249 assert_eq!(ips.len(), 2);
3250 assert!(ips.iter().any(|ip| ip.to_string() == "203.0.113.7"));
3251
3252 cfg.whitelist_ips = vec![fmt!("not-an-ip")];
3253 assert!(cfg.get_whitelist_ips().is_err(),
3254 "a malformed whitelist address must be a start-up failure, not a skip");
3255 }
3256
3257 /// A field added to `ServerConfig` without `#[optional]` invalidates every
3258 /// config file already on disk, which is an outage rather than a feature.
3259 /// The compression and fingerprint settings are new, so a config written
3260 /// before they existed must still load and must come up with the defaults.
3261 ///
3262 /// The map below still names `num_server_bots`, which is no longer a field,
3263 /// and that is deliberate: `from_datmap` reads the keys it knows and does
3264 /// not object to the rest, so this is also the check that REMOVING a field
3265 /// leaves every config already written for it loading unchanged. Nine
3266 /// `config.jdat` files across the apps set that key and none needs editing.
3267 #[test]
3268 fn a_config_written_before_these_fields_existed_still_loads() -> Outcome<()> {
3269 let mut m = DaticleMap::new();
3270 m.insert(dat!("tls_dir_rel"), dat!("./tls"));
3271 m.insert(dat!("log_level"), dat!("debug"));
3272 m.insert(dat!("num_server_bots"), Dat::U16(1));
3273 m.insert(dat!("server_address"), dat!("0.0.0.0"));
3274 m.insert(dat!("server_port_tcp"), Dat::U16(8443));
3275 m.insert(dat!("server_port_tcp_plaintext"), Dat::U16(0));
3276 m.insert(dat!("hsts_max_age_secs"), Dat::U32(0));
3277 m.insert(dat!("session_expiry_default_secs"), Dat::U32(604_800));
3278 m.insert(dat!("ws_ping_interval_secs"), Dat::U8(30));
3279 m.insert(dat!("server_max_errors_allowed"), Dat::U8(30));
3280 m.insert(dat!("allow_anonymous_sessions"), Dat::Bool(true));
3281 m.insert(dat!("vhosts"), Dat::List(Vec::new()));
3282 m.insert(dat!("acme"), Dat::Map(DaticleMap::new()));
3283 m.insert(dat!("mail"), Dat::Map(DaticleMap::new()));
3284
3285 let cfg = res!(ServerConfig::from_datmap(m));
3286 assert!(cfg.compression_enabled,
3287 "compression must be on for a config that says nothing about it");
3288 assert_eq!(cfg.compression_min_bytes, 1024);
3289 assert_eq!(cfg.fingerprint_max_age_secs, 31_536_000);
3290 Ok(())
3291 }
3292
3293 /// A server config as karri, jarrah and birch carried it after the 2026-09-21 deploy, before
3294 /// `health_residents`, `health_stamps` or anything else added since.
3295 fn deployed_config_map() -> DaticleMap {
3296 let mut m = DaticleMap::new();
3297 m.insert(dat!("tls_dir_rel"), dat!("./tls"));
3298 m.insert(dat!("log_level"), dat!("info"));
3299 m.insert(dat!("server_address"), dat!("0.0.0.0"));
3300 m.insert(dat!("server_port_tcp"), Dat::U16(8443));
3301 m.insert(dat!("server_port_tcp_plaintext"), Dat::U16(80));
3302 m.insert(dat!("hsts_max_age_secs"), Dat::U32(0));
3303 m.insert(dat!("session_expiry_default_secs"), Dat::U32(604_800));
3304 m.insert(dat!("ws_ping_interval_secs"), Dat::U8(30));
3305 m.insert(dat!("server_max_errors_allowed"), Dat::U8(30));
3306 m.insert(dat!("allow_anonymous_sessions"), Dat::Bool(true));
3307 // The fields the 2026-09-21 deploy added, as karri, jarrah and birch carry them now.
3308 m.insert(dat!("health_path"), dat!("/_steel/health"));
3309 m.insert(dat!("health_token"), dat!("a-token"));
3310 m.insert(dat!("max_conn"), Dat::U64(512));
3311 m.insert(dat!("vhosts"), Dat::List(Vec::new()));
3312 m.insert(dat!("acme"), Dat::Map(DaticleMap::new()));
3313 m.insert(dat!("mail"), Dat::Map(DaticleMap::new()));
3314 m
3315 }
3316
3317 /// Every production `config.jdat` predates `health_residents`, so one without it must load
3318 /// and report no residents, and one that names residents must read them in both list forms.
3319 #[test]
3320 fn a_config_without_health_residents_still_loads() -> Outcome<()> {
3321 let mut m = deployed_config_map();
3322
3323 let cfg = res!(ServerConfig::from_datmap(m.clone()));
3324 assert!(cfg.health_residents.is_empty());
3325 assert!(res!(cfg.get_health_residents()).is_empty());
3326
3327 m.insert(dat!("health_residents"), Dat::List(vec![dat!("steel"), dat!("daimond_gateway")]));
3328 let cfg = res!(ServerConfig::from_datmap(m.clone()));
3329 assert_eq!(res!(cfg.get_health_residents()), vec![fmt!("steel"), fmt!("daimond_gateway")]);
3330
3331 m.insert(dat!("health_residents"), Dat::Vek(Vek(vec![dat!("steel")])));
3332 let cfg = res!(ServerConfig::from_datmap(m));
3333 assert_eq!(res!(cfg.get_health_residents()), vec![fmt!("steel")],
3334 "the (vek|[...]) form must read the same as a plain list");
3335 Ok(())
3336 }
3337
3338 /// A resident name that could break the body is a start-up failure, not a silently
3339 /// missing resident.
3340 #[test]
3341 fn a_health_resident_that_cannot_ride_in_a_key_is_refused() {
3342 let mut cfg = ServerConfig::default();
3343 cfg.health_residents = vec![fmt!("steel"), fmt!("bad:name")];
3344 let msg = match cfg.get_health_residents() {
3345 Err(e) => fmt!("{}", e),
3346 Ok(_) => String::new(),
3347 };
3348 assert!(msg.contains("bad:name"),
3349 "the name must be refused, and the refusal must name it, got: '{}'", msg);
3350 }
3351
3352 /// A watch entry names the machine it probes on, and an entry that does not is its own.
3353 #[test]
3354 fn a_watch_peer_host_defaults_to_its_name() -> Outcome<()> {
3355 let peer = |name: &str, host: Option<&str>| -> Dat {
3356 let mut pm = DaticleMap::new();
3357 pm.insert(dat!("name"), dat!(name));
3358 pm.insert(dat!("url"), dat!("https://example.test/health"));
3359 if let Some(h) = host {
3360 pm.insert(dat!("host"), dat!(h));
3361 }
3362 Dat::Map(pm)
3363 };
3364 let mut m = DaticleMap::new();
3365 m.insert(dat!("enabled"), Dat::Bool(true));
3366 m.insert(dat!("peers"), Dat::List(vec![
3367 peer("jarrah", None),
3368 peer("daimond gateway", Some("jarrah")),
3369 ]));
3370 let w = res!(WatchConfig::from_datmap(&m));
3371 assert_eq!(w.peers[0].host, "jarrah");
3372 assert_eq!(w.peers[1].name, "daimond gateway");
3373 assert_eq!(w.peers[1].host, "jarrah", "the gateway sits on jarrah's row");
3374 Ok(())
3375 }
3376
3377 /// Every production config predates `health_stamps`, so one without it must load and
3378 /// report no stamps, and one that names stamps must read them as written.
3379 #[test]
3380 fn a_config_without_health_stamps_still_loads() -> Outcome<()> {
3381 let mut m = deployed_config_map();
3382 let cfg = res!(ServerConfig::from_datmap(m.clone()));
3383 assert!(cfg.health_stamps.is_empty());
3384 assert!(res!(cfg.get_health_stamps()).is_empty());
3385
3386 let mut stamps = DaticleMap::new();
3387 stamps.insert(dat!("forge_state_age_s"), dat!("/var/lib/forge-pull/stamp/state.ok"));
3388 stamps.insert(dat!("forge_repos_age_s"), dat!("/var/lib/forge-pull/stamp/repos.ok"));
3389 m.insert(dat!("health_stamps"), Dat::Map(stamps));
3390 let cfg = res!(ServerConfig::from_datmap(m));
3391 let got = res!(cfg.get_health_stamps());
3392 let fields: Vec<&str> = got.iter().map(|s| s.field.as_str()).collect();
3393 assert_eq!(fields, vec!["forge_repos_age_s", "forge_state_age_s"]);
3394 assert_eq!(got[1].path, PathBuf::from("/var/lib/forge-pull/stamp/state.ok"));
3395 Ok(())
3396 }
3397
3398 /// A stamp that cannot be served as written is a start-up failure that names it, not a field
3399 /// silently missing from every body.
3400 #[test]
3401 fn a_bad_health_stamp_is_refused_and_named() {
3402 let refusal = |field: &str, path: Dat| -> String {
3403 let mut cfg = ServerConfig::default();
3404 cfg.health_stamps.insert(dat!(field), path);
3405 match cfg.get_health_stamps() {
3406 Err(e) => fmt!("{}", e),
3407 Ok(_) => String::new(),
3408 }
3409 };
3410 let msg = refusal("mem_pct", dat!("/var/lib/job/ok"));
3411 assert!(msg.contains("mem_pct") && msg.contains("own"),
3412 "a built-in name must be refused as such, got: '{}'", msg);
3413 // The Fleet view's fields are the body's own as well, and the watcher writes its measured
3414 // `probe_ms` over whatever the body carried under that name.
3415 for field in ["disk_pct", "sealed_dbs", "mail_down", "probe_ms"] {
3416 let msg = refusal(field, dat!("/var/lib/job/ok"));
3417 assert!(msg.contains(field) && msg.contains("own"),
3418 "the Fleet field '{}' must be refused as the body's own, got: '{}'", field, msg);
3419 }
3420 for bad in ["Forge_Age", "forge-age", "res.x.procs", "a:b"] {
3421 let msg = refusal(bad, dat!("/var/lib/job/ok"));
3422 assert!(msg.contains(bad), "'{}' must be refused by name, got: '{}'", bad, msg);
3423 }
3424 let msg = refusal("job_age_s", dat!("stamp/ok"));
3425 assert!(msg.contains("absolute"), "a relative path must be refused, got: '{}'", msg);
3426 let msg = refusal("job_age_s", Dat::U64(3));
3427 assert!(msg.contains("job_age_s"), "a non-string path must be refused, got: '{}'", msg);
3428
3429 let mut cfg = ServerConfig::default();
3430 for i in 0..=crate::srv::health::STAMPS_MAX {
3431 cfg.health_stamps.insert(dat!(fmt!("job{}_age_s", i)), dat!(fmt!("/var/lib/{}", i)));
3432 }
3433 assert!(cfg.get_health_stamps().is_err(), "more than STAMPS_MAX stamps must be refused");
3434 }
3435
3436 /// A host that runs no mail need not carry a disabled block, and one without it has mail off.
3437 #[test]
3438 fn a_config_without_a_mail_block_loads_with_mail_off() -> Outcome<()> {
3439 let mut m = deployed_config_map();
3440 m.remove(&dat!("mail"));
3441 let cfg = res!(ServerConfig::from_datmap(m));
3442 assert!(res!(cfg.get_mail()).is_none());
3443 assert!(res!(cfg.get_mail_any()).is_none());
3444 Ok(())
3445 }
3446
3447 /// A peer's own reminder cadence is read in any unsigned width, an absent one leaves the
3448 /// watcher's, and one that is not a count of seconds is refused rather than defaulted.
3449 #[test]
3450 fn a_peer_repeat_is_read_and_a_bad_one_refused() -> Outcome<()> {
3451 let peer = |name: &str, repeat: Option<Dat>| -> Dat {
3452 let mut pm = DaticleMap::new();
3453 pm.insert(dat!("name"), dat!(name));
3454 pm.insert(dat!("url"), dat!("https://example.test/_steel/health"));
3455 if let Some(r) = repeat {
3456 pm.insert(dat!("repeat_secs"), r);
3457 }
3458 Dat::Map(pm)
3459 };
3460 let watch = |peers: Vec<Dat>| -> DaticleMap {
3461 let mut m = DaticleMap::new();
3462 m.insert(dat!("enabled"), Dat::Bool(true));
3463 m.insert(dat!("peers"), Dat::List(peers));
3464 m
3465 };
3466 let w = res!(WatchConfig::from_datmap(&watch(vec![
3467 peer("jarrah", None),
3468 peer("jarrah forge copy", Some(Dat::U64(21_600))),
3469 peer("birch", Some(Dat::U32(3_600))),
3470 ])));
3471 assert_eq!(w.peers[0].repeat_secs, None, "absent must leave the watcher's cadence");
3472 assert_eq!(w.peers[1].repeat_secs, Some(21_600));
3473 assert_eq!(w.peers[2].repeat_secs, Some(3_600));
3474
3475 let refused = WatchConfig::from_datmap(&watch(vec![peer("karri", Some(dat!("6h")))]));
3476 let msg = match refused {
3477 Err(e) => fmt!("{}", e),
3478 Ok(_) => String::new(),
3479 };
3480 assert!(msg.contains("karri") && msg.contains("repeat_secs"),
3481 "a cadence that is not seconds must be refused by peer, got: '{}'", msg);
3482 Ok(())
3483 }
3484
3485 /// A session identifier is a key prefix into the vhost's own database, so a
3486 /// vhost without one has nowhere to keep a session and issues none.
3487 #[test]
3488 fn only_a_vhost_with_a_database_keeps_sessions() {
3489 let mut vh = VhostConfig::default();
3490 assert!(vh.uses_sessions(), "the default vhost is configured with a database");
3491 vh.db_dir_rel = None;
3492 assert!(!vh.uses_sessions(), "a static vhost has nowhere to keep a session");
3493 }
3494
3495 fn vh(allowed: &[&str]) -> VhostConfig {
3496 let mut vh = VhostConfig::default();
3497 vh.egress_allowed = allowed.iter().map(|s| s.to_string()).collect();
3498 vh
3499 }
3500
3501 fn api_up(path: &str, host: &str, port: u16) -> ApiRoute {
3502 ApiRoute {
3503 path: path.to_string(),
3504 upstream_host: Some(host.to_string()),
3505 upstream_port: Some(port),
3506 upstream_path: Some(fmt!("/")),
3507 upstream_tls: true,
3508 headers: Vec::new(),
3509 handler: None,
3510 config: Vec::new(),
3511 }
3512 }
3513
3514 fn api_handler(path: &str) -> ApiRoute {
3515 ApiRoute {
3516 path: path.to_string(),
3517 upstream_host: None,
3518 upstream_port: None,
3519 upstream_path: None,
3520 upstream_tls: true,
3521 headers: Vec::new(),
3522 handler: Some(fmt!("some_handler")),
3523 config: Vec::new(),
3524 }
3525 }
3526
3527 fn hook_up(path: &str, host: &str, port: u16) -> WebhookRoute {
3528 WebhookRoute {
3529 path: path.to_string(),
3530 handler: None,
3531 upstream_host: Some(host.to_string()),
3532 upstream_port: Some(port),
3533 upstream_path: Some(fmt!("/")),
3534 upstream_tls: true,
3535 config: Vec::new(),
3536 }
3537 }
3538
3539 fn hook_handler(path: &str) -> WebhookRoute {
3540 WebhookRoute {
3541 path: path.to_string(),
3542 handler: Some(fmt!("some_handler")),
3543 upstream_host: None,
3544 upstream_port: None,
3545 upstream_path: None,
3546 upstream_tls: true,
3547 config: Vec::new(),
3548 }
3549 }
3550
3551 fn ws_up(path: &str, host: &str, port: u16) -> WsRoute {
3552 WsRoute {
3553 path: path.to_string(),
3554 upstream_host: host.to_string(),
3555 upstream_port: port,
3556 upstream_path: fmt!("/"),
3557 }
3558 }
3559
3560 fn proxy_up(prefix: &str, host: &str, port: u16) -> ProxyRoute {
3561 ProxyRoute {
3562 path_prefix: prefix.to_string(),
3563 upstream_host: host.to_string(),
3564 upstream_port: port,
3565 upstream_tls: false,
3566 strip_prefix: false,
3567 }
3568 }
3569
3570 /// The route kind whose enforcement has always worked, kept here so a
3571 /// rewrite of the check cannot quietly drop it.
3572 #[test]
3573 fn an_api_route_outside_the_allowlist_is_refused() {
3574 let mut v = vh(&["127.0.0.1"]);
3575 v.api_routes = vec![api_up("/api/pay", "evil.example.com", 443)];
3576 assert!(v.validate_egress().is_err(),
3577 "an api_route to a host the operator did not name must be refused");
3578 }
3579
3580 /// A ws_route dials an upstream of its own, so an operator's allow-list
3581 /// that does not name that upstream must refuse it.
3582 #[test]
3583 fn a_ws_route_outside_the_allowlist_is_refused() -> Outcome<()> {
3584 let mut v = vh(&["127.0.0.1"]);
3585 v.ws_routes = vec![ws_up("/ws", "evil.example.com", 9000)];
3586 let e = res!(v.validate_egress().err().ok_or_else(|| err!(
3587 "a ws_route to a host the operator did not name must be refused"; Test)));
3588 let msg = fmt!("{}", e);
3589 assert!(msg.contains("evil.example.com"),
3590 "the refusal must name the host it refused, got: {}", msg);
3591 Ok(())
3592 }
3593
3594 /// A proxy_route forwards a whole path prefix to its own upstream, which
3595 /// is the broadest outward reach a vhost has.
3596 #[test]
3597 fn a_proxy_route_outside_the_allowlist_is_refused() {
3598 let mut v = vh(&["127.0.0.1"]);
3599 v.proxy_routes = vec![proxy_up("/chat/", "evil.example.com", 8080)];
3600 assert!(v.validate_egress().is_err(),
3601 "a proxy_route to a host the operator did not name must be refused");
3602 }
3603
3604 /// A webhook route in forwarding mode POSTs the payload onward, which is
3605 /// egress carrying a body.
3606 #[test]
3607 fn a_forwarded_webhook_outside_the_allowlist_is_refused() {
3608 let mut v = vh(&["127.0.0.1"]);
3609 v.webhook_routes = vec![hook_up("/webhook/pay", "evil.example.com", 443)];
3610 assert!(v.validate_egress().is_err(),
3611 "a forwarded webhook to a host the operator did not name must be refused");
3612 }
3613
3614 /// A refusal that catches a legitimate configuration is a refusal an
3615 /// operator switches off, so a vhost with no allow-list at all keeps
3616 /// reaching every upstream it names.
3617 #[test]
3618 fn no_allowlist_permits_every_upstream() -> Outcome<()> {
3619 let mut v = vh(&[]);
3620 v.api_routes = vec![api_up("/api/pay", "api.example.com", 443)];
3621 v.webhook_routes = vec![hook_up("/webhook/pay", "hooks.example.com", 443)];
3622 v.ws_routes = vec![ws_up("/ws", "ws.example.com", 9000)];
3623 v.proxy_routes = vec![proxy_up("/chat/", "chat.example.com", 8080)];
3624 res!(v.validate_egress());
3625 Ok(())
3626 }
3627
3628 /// An allow-list that names every upstream permits them all, in both
3629 /// entry forms: a bare host for any port, and `host:port` for one.
3630 #[test]
3631 fn a_correct_allowlist_permits_every_upstream() -> Outcome<()> {
3632 let mut v = vh(&["127.0.0.1", "api.example.com", "ws.example.com:9000"]);
3633 v.api_routes = vec![
3634 api_up("/api/pay", "api.example.com", 443),
3635 api_handler("/api/local"),
3636 ];
3637 v.webhook_routes = vec![
3638 hook_up("/webhook/pay", "127.0.0.1", 7000),
3639 hook_handler("/webhook/local"),
3640 ];
3641 v.ws_routes = vec![ws_up("/ws", "ws.example.com", 9000)];
3642 v.proxy_routes = vec![proxy_up("/chat/", "127.0.0.1", 8080)];
3643 res!(v.validate_egress());
3644 Ok(())
3645 }
3646
3647 /// A `host:port` entry is the operator saying one port and not another.
3648 #[test]
3649 fn a_port_specific_entry_refuses_another_port() {
3650 let mut v = vh(&["ws.example.com:9000"]);
3651 v.ws_routes = vec![ws_up("/ws", "ws.example.com", 9001)];
3652 assert!(v.validate_egress().is_err(),
3653 "an entry naming port 9000 must not permit port 9001");
3654 }
3655
3656 /// Every outward reach the configuration has, so a route kind added later
3657 /// without a line in `egress_targets` is a failing test rather than a
3658 /// silent hole in the allow-list.
3659 #[test]
3660 fn the_enumeration_covers_all_four_route_kinds() {
3661 let mut v = vh(&[]);
3662 v.api_routes = vec![api_up("/api/pay", "a.example.com", 443), api_handler("/api/x")];
3663 v.webhook_routes = vec![hook_up("/webhook/pay", "b.example.com", 443), hook_handler("/w")];
3664 v.ws_routes = vec![ws_up("/ws", "c.example.com", 9000)];
3665 v.proxy_routes = vec![proxy_up("/chat/", "d.example.com", 8080)];
3666 let hosts: Vec<&str> = v.egress_targets().iter().map(|t| t.2).collect();
3667 assert_eq!(
3668 hosts,
3669 vec!["a.example.com", "b.example.com", "c.example.com", "d.example.com"],
3670 "every route with an upstream must be enumerated, and only those");
3671 }
3672
3673 /// An IPv6 upstream is written bracketed, and the brackets contain colons:
3674 /// an allow-list entry read as `host:port` at the first colon it finds
3675 /// never matches one, which is an allow-list that silently allows nothing.
3676 #[test]
3677 fn a_bracketed_ipv6_entry_is_a_host_not_a_host_and_port() -> Outcome<()> {
3678 let mut v = vh(&["[::1]"]);
3679 v.ws_routes = vec![ws_up("/ws", "[::1]", 9000)];
3680 res!(v.validate_egress());
3681 v.ws_routes = vec![ws_up("/ws", "[2001:db8::1]", 9000)];
3682 assert!(v.validate_egress().is_err(),
3683 "an entry naming the loopback must not permit another address");
3684 Ok(())
3685 }
3686
3687 /// A unique, empty scratch directory under the system temp root.
3688 fn scratch_dir(tag: &str) -> Outcome<PathBuf> {
3689 let nanos = match std::time::SystemTime::now()
3690 .duration_since(std::time::UNIX_EPOCH)
3691 {
3692 Ok(d) => d.as_nanos(),
3693 Err(_) => 0,
3694 };
3695 let dir = std::env::temp_dir().join(fmt!(
3696 "fe2o3_steel_file_resolver_{}_{}_{}", tag, std::process::id(), nanos));
3697 res!(std::fs::create_dir_all(&dir));
3698 Ok(dir)
3699 }
3700
3701 /// `{file:path}` fails when the file is missing; `{file?:path}` yields the
3702 /// empty string on that one io kind — not-found — and nothing else. The
3703 /// two optional cases below straddle the branch: a missing file must give
3704 /// `""` while a read that fails for any *other* reason must still error,
3705 /// so the test fails if the not-found guard is dropped (every error
3706 /// swallowed) or inverted (not-found not swallowed).
3707 #[test]
3708 fn optional_file_marker_swallows_only_not_found() -> Outcome<()> {
3709 let root = res!(scratch_dir("optmark"));
3710 let present = "present.key";
3711 // A trailing newline proves the resolver trims, as the required form does.
3712 res!(std::fs::write(root.join(present), "s3cret\n"));
3713
3714 // Required form: present resolves to the trimmed contents, ...
3715 assert_eq!(
3716 res!(ApiRoute::resolve_file_only(&fmt!("{{file:{}}}", present), &root)),
3717 "s3cret",
3718 "{{file:PRESENT}} must resolve to the trimmed file contents");
3719 // ... and missing hard-errors exactly as before this change.
3720 assert!(
3721 ApiRoute::resolve_file_only("{file:absent.key}", &root).is_err(),
3722 "{{file:MISSING}} must still hard-error");
3723
3724 // Optional form: present behaves identically to the required form.
3725 assert_eq!(
3726 res!(ApiRoute::resolve_file_only(&fmt!("{{file?:{}}}", present), &root)),
3727 "s3cret",
3728 "{{file?:PRESENT}} must resolve to the trimmed file contents");
3729 // The load-bearing case: a not-found file resolves to the empty string.
3730 assert_eq!(
3731 res!(ApiRoute::resolve_file_only("{file?:absent.key}", &root)),
3732 "",
3733 "{{file?:MISSING}} must resolve to \"\", not error");
3734
3735 // A read that fails for a reason other than not-found must still
3736 // error, so a present-but-unreadable key is never silently dropped.
3737 // Traversing through a regular file yields ENOTDIR, an io error whose
3738 // kind is not NotFound and therefore must not be swallowed. This holds
3739 // for any user, needing no permission games.
3740 res!(std::fs::write(root.join("notdir"), "x"));
3741 assert!(
3742 ApiRoute::resolve_file_only("{file?:notdir/child}", &root).is_err(),
3743 "{{file?:...}} must error on a non-not-found io error (ENOTDIR here)");
3744
3745 // The literal present-but-unreadable case, where the platform allows
3746 // it to be constructed: a mode-000 file read by a non-root user fails
3747 // with PermissionDenied, which the optional form must not swallow.
3748 #[cfg(unix)]
3749 {
3750 use std::os::unix::fs::PermissionsExt;
3751 let locked = root.join("locked.key");
3752 res!(std::fs::write(&locked, "nope\n"));
3753 res!(std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)));
3754 // Skip the assertion if the test runs as root, where mode-000 is
3755 // still readable and the read would succeed.
3756 if std::fs::read_to_string(&locked).is_err() {
3757 assert!(
3758 ApiRoute::resolve_file_only("{file?:locked.key}", &root).is_err(),
3759 "{{file?:PRESENT_BUT_UNREADABLE}} must error, not yield \"\"");
3760 }
3761 let _ = std::fs::set_permissions(
3762 &locked, std::fs::Permissions::from_mode(0o600));
3763 }
3764
3765 // Both markers may co-occur in one value, and the required finder must
3766 // not mis-match the optional marker: here the absent optional collapses
3767 // to nothing while the present required resolves around it.
3768 assert_eq!(
3769 res!(ApiRoute::resolve_file_only(
3770 &fmt!("a{{file?:absent.key}}b{{file:{}}}c", present), &root)),
3771 "abs3cretc",
3772 "the required finder must not mis-read '{{file?:' as '{{file:'");
3773
3774 // The optional marker also works through the public wired entry point,
3775 // which runs the env pass first.
3776 assert_eq!(
3777 res!(ApiRoute::resolve_file_refs("{file?:absent.key}", &root)),
3778 "",
3779 "the optional marker must resolve through resolve_file_refs too");
3780
3781 let _ = std::fs::remove_dir_all(&root);
3782 Ok(())
3783 }
3784}