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 | |
| 4 | use crate::srv::{ |
| 5 | constant, |
| 6 | publish::PublishConfig, |
| 7 | }; |
| 8 | |
| 9 | use oxedyne_fe2o3_core::{ |
| 10 | prelude::*, |
| 11 | file::{ |
| 12 | OsPath, |
| 13 | PathState, |
| 14 | }, |
| 15 | map::MapMut, |
| 16 | path::{ |
| 17 | NormalPath, |
| 18 | NormPathBuf, |
| 19 | }, |
| 20 | }; |
| 21 | use oxedyne_fe2o3_jdat::{ |
| 22 | prelude::*, |
| 23 | cfg::Config, |
| 24 | }; |
| 25 | use 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 | |
| 40 | use 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)] |
| 59 | pub 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 | |
| 65 | impl 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)] |
| 80 | pub 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 | |
| 89 | impl 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)] |
| 157 | pub 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 | |
| 172 | impl 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)] |
| 465 | pub 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 | |
| 475 | impl 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)] |
| 600 | pub 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 | |
| 611 | impl 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)] |
| 716 | pub 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 | |
| 723 | impl 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)] |
| 814 | pub 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 | |
| 822 | pub const TILE_ATTRIBUTION_DEFAULT: &str = "© OpenStreetMap"; |
| 823 | |
| 824 | impl 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(¤t) { |
| 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)] |
| 948 | pub struct TermConfig { |
| 949 | pub session_prefix: String, // e.g. "goose-" |
| 950 | pub launch_command: String, // e.g. "goose session" |
| 951 | } |
| 952 | |
| 953 | impl 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)] |
| 984 | pub 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)] |
| 1069 | pub 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 | |
| 1076 | impl 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 | |
| 1107 | impl 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)] |
| 1750 | pub 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 | |
| 1765 | impl 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 | |
| 1779 | impl 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)] |
| 1857 | pub 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 | |
| 1890 | impl 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 | |
| 1911 | impl 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)] |
| 2005 | pub 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)] |
| 2025 | pub 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)] |
| 2060 | pub 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 | |
| 2071 | impl 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)] |
| 2086 | pub 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)] |
| 2126 | pub 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. |
| 2153 | fn 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 | |
| 2168 | impl 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 | |
| 2225 | impl 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 | |
| 2354 | impl 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 | |
| 2374 | impl 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 | |
| 2390 | impl 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)] |
| 2487 | pub 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 | |
| 2735 | impl Config for ServerConfig {} |
| 2736 | |
| 2737 | impl 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 | |
| 2811 | impl 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 | |
| 3153 | impl 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)] |
| 3238 | mod 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 | } |