Skip to main content

rust_web_server/admin/
mod.rs

1//! The admin API (18.10.0, #29): change and inspect a running server on a
2//! listener of its own, never on the public routes.
3//!
4//! ```toml
5//! [admin]
6//! listen          = "127.0.0.1:9901"     # off when unset
7//! token_env       = "RWS_ADMIN_TOKEN"    # env var holding the bearer token (this is the default)
8//! allowed_origins = ["https://ops.example.com"]   # browsers on other origins (the UI's own is always allowed)
9//! ```
10//!
11//! Every request needs `Authorization: Bearer <token>`, on loopback too;
12//! without a token the listener doesn't start (and `rws --check` says so).
13//! A browser request from an origin other than the listener's own or one in
14//! `allowed_origins` is refused, so a page elsewhere can't drive it with a
15//! token it learned.
16//!
17//! The API lives under `/admin/api/v1/`; [`AdminApi`] is an
18//! [`Application`], so tests and embedders can drive it without a socket.
19//! Requests are served one thread per connection (at most
20//! [`MAX_CONNECTIONS`] at once), apart from the server's own workers, so a
21//! saturated server can still be administered.
22
23pub mod ops;
24mod ui;
25
26use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Arc, OnceLock};
28use std::time::{Duration, Instant};
29
30use crate::application::Application;
31use crate::entry_point::Config;
32use crate::header::Header;
33use crate::mcp::json_escape;
34use crate::request::Request;
35use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
36use crate::server::{Address, ConnectionInfo};
37
38/// The token's environment variable when `token_env` is unset.
39pub const DEFAULT_TOKEN_ENV: &str = "RWS_ADMIN_TOKEN";
40/// Admin connections served at once; more are closed at accept.
41pub const MAX_CONNECTIONS: usize = 32;
42/// Path prefix of the API.
43pub const API_PREFIX: &str = "/admin/api/v1";
44
45/// `[admin]` from the environment (`RWS_CONFIG_ADMIN_*`).
46#[derive(Debug, Clone, Default, PartialEq)]
47pub struct AdminConfig {
48    /// `host:port` to listen on; empty = no admin listener.
49    pub listen: String,
50    /// The environment variable holding the bearer token.
51    pub token_env: String,
52    /// Browser origins allowed besides the listener's own.
53    pub allowed_origins: Vec<String>,
54}
55
56impl AdminConfig {
57    pub fn from_env() -> AdminConfig {
58        let read = |name: &str| std::env::var(name).unwrap_or_default().trim().to_string();
59        let token_env = read(Config::RWS_CONFIG_ADMIN_TOKEN_ENV);
60        AdminConfig {
61            listen: read(Config::RWS_CONFIG_ADMIN_LISTEN),
62            token_env: if token_env.is_empty() { DEFAULT_TOKEN_ENV.to_string() } else { token_env },
63            allowed_origins: read(Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS)
64                .split(',')
65                .map(|o| o.trim().trim_end_matches('/').to_string())
66                .filter(|o| !o.is_empty())
67                .collect(),
68        }
69    }
70
71    pub fn enabled(&self) -> bool {
72        !self.listen.is_empty()
73    }
74
75    /// The token, or why there is none.
76    pub fn token(&self) -> Result<String, String> {
77        match std::env::var(&self.token_env) {
78            Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
79            _ => Err(format!("the admin token variable {} is unset or empty", self.token_env)),
80        }
81    }
82}
83
84/// When the process started serving the admin API (for `uptime`).
85fn started() -> Instant {
86    static STARTED: OnceLock<Instant> = OnceLock::new();
87    *STARTED.get_or_init(Instant::now)
88}
89
90/// The admin API. See the [module docs](self).
91#[derive(Clone)]
92pub struct AdminApi {
93    inner: Arc<Inner>,
94}
95
96type Reloader = Arc<dyn Fn() + Send + Sync>;
97
98struct Inner {
99    token: String,
100    allowed_origins: Vec<String>,
101    reloader: Reloader,
102    reload_wait: Duration,
103}
104
105impl AdminApi {
106    /// The API accepting `token`.
107    pub fn new(token: impl Into<String>) -> AdminApi {
108        started();
109        crate::log::tail::enable();
110        AdminApi {
111            inner: Arc::new(Inner {
112                token: token.into(),
113                allowed_origins: Vec::new(),
114                reloader: Arc::new(crate::config_reload::request_reload),
115                reload_wait: Duration::from_secs(10),
116            }),
117        }
118    }
119
120    fn edit(self, f: impl FnOnce(&mut Inner)) -> AdminApi {
121        let mut inner = Inner {
122            token: self.inner.token.clone(),
123            allowed_origins: self.inner.allowed_origins.clone(),
124            reloader: Arc::clone(&self.inner.reloader),
125            reload_wait: self.inner.reload_wait,
126        };
127        f(&mut inner);
128        AdminApi { inner: Arc::new(inner) }
129    }
130
131    /// Browser origins allowed besides the listener's own.
132    pub fn allowed_origins(self, origins: Vec<String>) -> AdminApi {
133        self.edit(|i| i.allowed_origins = origins)
134    }
135
136    /// What `POST …/reload` runs (default: [`crate::config_reload::request_reload`],
137    /// the same path as `SIGHUP`, so TLS certificates are reloaded too), and
138    /// how long it waits for the reload to finish.
139    pub fn reloader(self, reload: impl Fn() + Send + Sync + 'static, wait: Duration) -> AdminApi {
140        self.edit(|i| {
141            i.reloader = Arc::new(reload);
142            i.reload_wait = wait;
143        })
144    }
145
146    /// Request a reload and wait for it: the reload count moving, then the
147    /// errors it reported.
148    fn reload(&self) -> Response {
149        if !crate::metrics::SERVER_READY.load(Ordering::SeqCst) {
150            // Before the servers run, SIGHUP has no handler yet.
151            return error(STATUS_CODE_REASON_PHRASE.n503_service_unavailable, "the server is starting or stopping");
152        }
153        let (before, _) = crate::config_reload::last_reload();
154        (self.inner.reloader)();
155        let deadline = Instant::now() + self.inner.reload_wait;
156        loop {
157            let (count, errors) = crate::config_reload::last_reload();
158            if count > before {
159                let errors: Vec<String> = errors.iter().map(|e| format!("\"{}\"", json_escape(e))).collect();
160                let applied = errors.is_empty();
161                return json(STATUS_CODE_REASON_PHRASE.n200_ok, format!(r#"{{"reloaded":true,"applied":{applied},"reloads":{count},"errors":[{}]}}"#, errors.join(",")));
162            }
163            if Instant::now() >= deadline {
164                return error(STATUS_CODE_REASON_PHRASE.n504_gateway_timeout, "the reload didn't finish in time; check the server log");
165            }
166            std::thread::sleep(Duration::from_millis(20));
167        }
168    }
169
170    fn authorized(&self, request: &Request) -> bool {
171        let Some(sent) = request.header("Authorization").and_then(|h| h.value.strip_prefix("Bearer ").map(str::trim)) else {
172            return false;
173        };
174        crate::proxy_config::constant_time_eq(sent.as_bytes(), self.inner.token.as_bytes())
175    }
176
177    /// A browser on another origin is refused; non-browser clients send none.
178    fn origin_allowed(&self, request: &Request) -> bool {
179        let Some(origin) = request.header("Origin").map(|h| h.value.trim().trim_end_matches('/').to_string()) else {
180            return true;
181        };
182        let host = request.header("Host").map(|h| h.value.trim().to_string()).unwrap_or_default();
183        let own = [format!("http://{host}"), format!("https://{host}")];
184        own.contains(&origin) || self.inner.allowed_origins.contains(&origin)
185    }
186}
187
188pub(crate) fn json(status: &crate::response::StatusCodeReasonPhrase, body: String) -> Response {
189    let mut response = Response::json(status, body.into_bytes());
190    response.headers.push(Header::new("Cache-Control", "no-store"));
191    response
192}
193
194pub(crate) fn error(status: &crate::response::StatusCodeReasonPhrase, message: &str) -> Response {
195    json(status, format!(r#"{{"error":"{}"}}"#, json_escape(message)))
196}
197
198impl Application for AdminApi {
199    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
200        if !self.origin_allowed(request) {
201            return Ok(error(STATUS_CODE_REASON_PHRASE.n403_forbidden, "origin not allowed"));
202        }
203        let path = crate::url::URL::request_target_path(&request.request_uri).unwrap_or_default();
204        if request.method == "GET" || request.method == "HEAD" {
205            if path == "/admin" || path == "/" {
206                return Ok(ui::redirect());
207            }
208            if let Some(file) = ui::file(path) {
209                return Ok(file);
210            }
211        }
212        if !self.authorized(request) {
213            let mut response = error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "missing or wrong bearer token");
214            response.headers.push(Header::new("WWW-Authenticate", r#"Bearer realm="rws admin""#));
215            return Ok(response);
216        }
217        let Some(route) = path.strip_prefix(API_PREFIX) else {
218            return Ok(error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not found"));
219        };
220        Ok(self.route(request, connection, route.trim_end_matches('/')))
221    }
222}
223
224fn ok(body: String) -> Response {
225    json(STATUS_CODE_REASON_PHRASE.n200_ok, body)
226}
227
228fn bad_request(message: &str) -> Response {
229    error(STATUS_CODE_REASON_PHRASE.n400_bad_request, message)
230}
231
232/// The JSON body's top-level boolean `key`.
233fn body_bool(request: &Request, key: &str) -> Option<bool> {
234    let body = std::str::from_utf8(&request.body).ok()?;
235    let value = crate::mcp::json_rpc::object_members(body)?.into_iter().find(|(k, _)| k == key)?.1;
236    match value.as_str() {
237        "true" => Some(true),
238        "false" => Some(false),
239        _ => None,
240    }
241}
242
243/// The JSON body's top-level string `key`.
244fn body_str(request: &Request, key: &str) -> Option<String> {
245    let body = std::str::from_utf8(&request.body).ok()?;
246    let value = crate::mcp::json_rpc::object_members(body)?.into_iter().find(|(k, _)| k == key)?.1;
247    value.starts_with('"').then(|| crate::mcp::json_rpc::extract_str(&format!(r#"{{"v":{value}}}"#), "v")).flatten()
248}
249
250/// `405`, naming the methods that work.
251fn wrong_method(allowed: &str) -> Response {
252    let mut response = error(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed, &format!("use {allowed}"));
253    response.headers.push(Header::new("Allow", allowed.to_string()));
254    response
255}
256
257/// `/authz`, `/authz/tuples`, `/authz/check` (#32).
258fn authz_route(method: &str, route: &str, request: &Request) -> Response {
259    let decoded = |key: &str| query(request, key).map(|v| crate::url::URL::percent_decode(&v)).filter(|v| !v.is_empty());
260    // A configuration problem is a 404 ("nothing to manage"), a bad request
261    // a 400.
262    let answer = |result: Result<String, String>, missing_is_404: bool| match result {
263        Ok(body) => ok(body),
264        Err(e) if missing_is_404 && e.starts_with("no authorization configured") => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
265        Err(e) => error(STATUS_CODE_REASON_PHRASE.n400_bad_request, &e),
266    };
267    match (method, route) {
268        ("GET", "/authz") => answer(ops::authz_schema_json(), true),
269        (_, "/authz") => wrong_method("GET"),
270        ("GET", "/authz/tuples") => {
271            let limit = decoded("limit").and_then(|n| n.parse().ok()).unwrap_or(100);
272            answer(ops::authz_tuples_json(decoded("object").as_deref(), decoded("relation").as_deref(), decoded("subject").as_deref(), limit), true)
273        }
274        ("POST", "/authz/tuples") => match body_str(request, "tuple") {
275            Some(tuple) => answer(ops::authz_write(&tuple), true),
276            None => bad_request(r#"expected {"tuple":"doc:42#viewer@user:alice"}"#),
277        },
278        ("DELETE", "/authz/tuples") => match decoded("tuple").or_else(|| body_str(request, "tuple")) {
279            Some(tuple) => answer(ops::authz_delete(&tuple), true),
280            None => bad_request(r#"expected ?tuple=… or {"tuple":"doc:42#viewer@user:alice"}"#),
281        },
282        (_, "/authz/tuples") => wrong_method("GET, POST, DELETE"),
283        ("POST", "/authz/check") => match (body_str(request, "subject"), body_str(request, "relation"), body_str(request, "object")) {
284            (Some(subject), Some(relation), Some(object)) => answer(ops::authz_check(&subject, &relation, &object), true),
285            _ => bad_request(r#"expected {"subject":"user:alice","relation":"viewer","object":"doc:42"}"#),
286        },
287        (_, "/authz/check") => wrong_method("POST"),
288        _ => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "unknown /authz endpoint"),
289    }
290}
291
292/// Query parameter `key` of the request target.
293fn query(request: &Request, key: &str) -> Option<String> {
294    let (_, query) = request.request_uri.split_once('?')?;
295    query.split('&').find_map(|pair| {
296        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
297        (k == key).then(|| v.to_string())
298    })
299}
300
301fn parse_ip(ip: &str) -> Option<String> {
302    let ip = crate::url::URL::percent_decode(ip.trim());
303    ip.parse::<std::net::IpAddr>().ok().map(|ip| ip.to_string())
304}
305
306impl AdminApi {
307    fn route(&self, request: &Request, connection: &ConnectionInfo, route: &str) -> Response {
308        let method = request.method.as_str();
309        if let Some(ip) = route.strip_prefix("/blocklist/") {
310            return match (method, parse_ip(ip)) {
311                ("DELETE", Some(ip)) => ok(ops::unblock(&ip)),
312                ("DELETE", None) => bad_request("not an IP address"),
313                _ => wrong_method("DELETE"),
314            };
315        }
316        if let Some(name) = route.strip_prefix("/flags/") {
317            let name = crate::url::URL::percent_decode(name);
318            return match method {
319                "PUT" => match body_bool(request, "enabled") {
320                    Some(enabled) if !name.is_empty() => ok(ops::set_flag(&name, enabled)),
321                    _ => bad_request(r#"expected {"enabled":true|false}"#),
322                },
323                _ => wrong_method("PUT"),
324            };
325        }
326        if let Some(name) = route.strip_prefix("/upstreams/").and_then(|r| r.strip_suffix("/drain")) {
327            let name = crate::url::URL::percent_decode(name);
328            return match (method, body_str(request, "backend"), body_bool(request, "drained")) {
329                ("PUT", Some(backend), Some(drained)) => match ops::set_drained(&name, &backend, drained) {
330                    Ok(body) => ok(body),
331                    Err(e) => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
332                },
333                ("PUT", _, _) => bad_request(r#"expected {"backend":"<url>","drained":true|false}"#),
334                _ => wrong_method("PUT"),
335            };
336        }
337        if let Some(name) = route.strip_prefix("/caches/") {
338            let name = crate::url::URL::percent_decode(name);
339            let decoded = |key: &str| query(request, key).map(|v| crate::url::URL::percent_decode(&v));
340            return match method {
341                "DELETE" => match ops::purge_cache(&name, decoded("path").as_deref(), decoded("prefix").as_deref()) {
342                    Some(body) => ok(body),
343                    None => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &format!("no cache named {name:?}")),
344                },
345                _ => wrong_method("DELETE"),
346            };
347        }
348        if route == "/traffic" {
349            if method != "GET" {
350                return wrong_method("GET");
351            }
352            let hours = query(request, "hours").and_then(|h| h.parse().ok()).unwrap_or(24);
353            let host = query(request, "host").map(|h| crate::url::URL::percent_decode(&h));
354            return match ops::traffic_json(hours, host.as_deref()) {
355                Ok(body) => ok(body),
356                Err(e) => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
357            };
358        }
359        if route == "/authz" || route.starts_with("/authz/") {
360            return authz_route(method, route, request);
361        }
362        match (method, route) {
363            ("GET", "/caches") => ok(ops::caches_json()),
364            (_, "/caches") => wrong_method("GET"),
365            ("GET", "/logs") => {
366                let n = query(request, "n").and_then(|n| n.parse().ok()).unwrap_or(100);
367                ok(ops::logs_json(n))
368            }
369            (_, "/logs") => wrong_method("GET"),
370            ("GET", "/logs/stream") => {
371                let (sender, mut response) = crate::sse::SseStream::new().buffer(256).start();
372                if !crate::log::tail::follow(sender) {
373                    return error(STATUS_CODE_REASON_PHRASE.n503_service_unavailable, "too many log followers");
374                }
375                response.headers.push(Header::new("Cache-Control", "no-store"));
376                response
377            }
378            (_, "/logs/stream") => wrong_method("GET"),
379            ("GET", "/upstreams") => ok(ops::upstreams_json()),
380            (_, "/upstreams") => wrong_method("GET"),
381            ("GET", "/certificates") => ok(ops::certificates_json()),
382            (_, "/certificates") => wrong_method("GET"),
383            ("POST", "/certificates/renew") => match ops::request_renewal() {
384                true => json(STATUS_CODE_REASON_PHRASE.n202_accepted, r#"{"requested":true}"#.to_string()),
385                false => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "ACME isn't configured: nothing to renew"),
386            },
387            (_, "/certificates/renew") => wrong_method("POST"),
388            ("GET", "/status") => ok(status_json(connection)),
389            (_, "/status") => wrong_method("GET"),
390            ("GET", "/config") => ok(ops::config_json()),
391            (_, "/config") => wrong_method("GET"),
392            ("POST", "/reload") => self.reload(),
393            (_, "/reload") => wrong_method("POST"),
394            ("GET", "/metrics") => ok(ops::metrics_json()),
395            (_, "/metrics") => wrong_method("GET"),
396            ("GET", "/build") => ok(ops::build_features_json()),
397            (_, "/build") => wrong_method("GET"),
398            ("GET", "/rate-limit") => ok(ops::rate_limit_json()),
399            (_, "/rate-limit") => wrong_method("GET"),
400            ("GET", "/rate-limit/check") => match query(request, "ip").as_deref().and_then(parse_ip) {
401                Some(ip) => ok(ops::check_rate_limit_json(&ip)),
402                None => bad_request("expected ?ip=<address>"),
403            },
404            (_, "/rate-limit/check") => wrong_method("GET"),
405            ("GET", "/cors") => ok(ops::cors_json()),
406            (_, "/cors") => wrong_method("GET"),
407            ("GET", "/maintenance") => ok(ops::maintenance_json()),
408            ("PUT", "/maintenance") => match body_bool(request, "active") {
409                Some(active) => ok(ops::set_maintenance(active)),
410                None => bad_request(r#"expected {"active":true|false}"#),
411            },
412            (_, "/maintenance") => wrong_method("GET, PUT"),
413            ("GET", "/blocklist") => ok(ops::blocklist_json()),
414            ("POST", "/blocklist") => match body_str(request, "ip").as_deref().and_then(parse_ip) {
415                Some(ip) => ok(ops::block(&ip)),
416                None => bad_request(r#"expected {"ip":"<address>"}"#),
417            },
418            (_, "/blocklist") => wrong_method("GET, POST"),
419            ("GET", "/flags") => ok(ops::flags_json()),
420            (_, "/flags") => wrong_method("GET"),
421            ("GET", "/cache") => match ops::registered_cache_json() {
422                Some(stats) => ok(stats),
423                None => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "no response cache in this server"),
424            },
425            ("DELETE", "/cache") => match ops::clear_registered_cache() {
426                true => ok(r#"{"cleared":true}"#.to_string()),
427                false => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "no response cache in this server"),
428            },
429            (_, "/cache") => wrong_method("GET, DELETE"),
430            ("GET", "/routes") => ok(ops::registered_routes_json()),
431            (_, "/routes") => wrong_method("GET"),
432            ("GET", "/requests" | "/errors") => {
433                let n = query(request, "n").and_then(|n| n.parse().ok()).unwrap_or(20);
434                ok(ops::recent_json(n, route == "/errors"))
435            }
436            (_, "/requests" | "/errors") => wrong_method("GET"),
437            _ => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not found"),
438        }
439    }
440}
441
442/// Compile-time features of this build that matter to an operator.
443pub fn features() -> Vec<&'static str> {
444    let mut on = Vec::new();
445    macro_rules! feature {
446        ($($name:literal),*) => { $( if cfg!(feature = $name) { on.push($name); } )* };
447    }
448    feature!("http1", "http2", "http3", "acme", "auth", "sso", "sso-saml", "sso-server", "gateway-api", "tower", "openapi", "mimalloc", "brotli", "zstd", "wasm-plugins");
449    on
450}
451
452fn status_json(connection: &ConnectionInfo) -> String {
453    let env = |name: &str| std::env::var(name).unwrap_or_default();
454    let tls = !env(Config::RWS_CONFIG_TLS_CERT_FILE).is_empty() || !env(Config::RWS_CONFIG_ACME_DOMAINS).is_empty();
455    let redirect = env(Config::RWS_CONFIG_HTTP_REDIRECT_PORT);
456    let features: Vec<String> = features().iter().map(|f| format!("\"{f}\"")).collect();
457    // Where rws writes files (access log, disk cache): free space and
458    // whether it's under RWS_CONFIG_MIN_FREE_DISK (18.15.0).
459    let disk: Vec<String> = crate::disk_guard::status()
460        .into_iter()
461        .map(|(dir, free, total, low)| format!(r#"{{"dir":"{}","free_bytes":{free},"total_bytes":{total},"low":{low}}}"#, json_escape(&dir)))
462        .collect();
463    format!(
464        r#"{{"version":"{}","pid":{},"uptime_secs":{},"ready":{},"features":[{}],"listeners":{{"main":{{"address":"{}:{}","tls":{tls}}},"redirect":{},"admin":"{}:{}"}},"disk":[{}]}}"#,
465        env!("CARGO_PKG_VERSION"),
466        std::process::id(),
467        started().elapsed().as_secs(),
468        crate::metrics::SERVER_READY.load(Ordering::SeqCst),
469        features.join(","),
470        json_escape(&env(Config::RWS_CONFIG_IP)),
471        json_escape(&env(Config::RWS_CONFIG_PORT)),
472        if redirect.trim().is_empty() { "null".to_string() } else { format!("\"{}\"", json_escape(redirect.trim())) },
473        json_escape(&connection.server.ip),
474        connection.server.port,
475        disk.join(","),
476    )
477}
478
479/// Semantic checks of `[admin]` for `rws --check`; `get` reads a setting
480/// of the file.
481pub(crate) fn validate(get: &dyn Fn(&str) -> Option<String>, report: &mut crate::config_check::ConfigReport) {
482    let listen = get(Config::RWS_CONFIG_ADMIN_LISTEN).unwrap_or_default();
483    let listen = listen.trim();
484    if listen.is_empty() {
485        for key in [Config::RWS_CONFIG_ADMIN_TOKEN_ENV, Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS] {
486            if get(key).is_some_and(|v| !v.trim().is_empty()) {
487                report.warn("[admin] has settings but no listen: the admin API is off");
488                break;
489            }
490        }
491        return;
492    }
493    match listen.parse::<std::net::SocketAddr>() {
494        Err(_) => report.error(format!("[admin] listen = {listen:?}: expected host:port with an IP address, e.g. \"127.0.0.1:9901\"")),
495        Ok(addr) if !addr.ip().is_loopback() => report.warn(format!(
496            "[admin] listen = {listen:?} is reachable from other hosts: firewall it, or listen on 127.0.0.1 and use an SSH tunnel"
497        )),
498        Ok(_) => {}
499    }
500    let token_env = get(Config::RWS_CONFIG_ADMIN_TOKEN_ENV).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
501    let token_env = token_env.unwrap_or_else(|| DEFAULT_TOKEN_ENV.to_string());
502    if !crate::config_check::offline() && std::env::var(&token_env).unwrap_or_default().trim().is_empty() {
503        report.error(format!("[admin] reads its token from {token_env}, which is unset or empty: the admin API won't start"));
504    }
505    for origin in get(Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS).unwrap_or_default().split(',').map(str::trim).filter(|o| !o.is_empty()) {
506        if !(origin.starts_with("http://") || origin.starts_with("https://")) || origin.trim_end_matches('/').matches('/').count() > 2 {
507            report.error(format!("[admin] allowed_origins: {origin:?} is not an origin (scheme://host[:port])"));
508        }
509    }
510}
511
512/// Start the admin listener `config` describes, on a thread of its own.
513/// `Ok(None)` when it is off.
514pub fn start(config: &AdminConfig) -> Result<Option<std::net::SocketAddr>, String> {
515    if !config.enabled() {
516        return Ok(None);
517    }
518    let token = config.token()?;
519    #[cfg(unix)]
520    let inherited = config.listen.parse().ok().and_then(|addr| crate::systemd::tcp(addr, Some("admin")));
521    #[cfg(not(unix))]
522    let inherited: Option<std::net::TcpListener> = None;
523    let listener = match inherited {
524        Some(listener) => listener,
525        None => std::net::TcpListener::bind(&config.listen).map_err(|e| format!("can't listen on {}: {e}", config.listen))?,
526    };
527    let local = listener.local_addr().map_err(|e| e.to_string())?;
528    let api = AdminApi::new(token).allowed_origins(config.allowed_origins.clone());
529    std::thread::Builder::new()
530        .name("rws-admin".to_string())
531        .spawn(move || serve(listener, api))
532        .map_err(|e| e.to_string())?;
533    Ok(Some(local))
534}
535
536/// [`start`] from `RWS_CONFIG_ADMIN_*`, reporting the outcome: the binary's
537/// entry point. A misconfigured admin API is logged, not fatal.
538pub fn start_from_env() {
539    match start(&AdminConfig::from_env()) {
540        Ok(Some(addr)) => {
541            println!("admin API at http://{addr}{API_PREFIX}/");
542            // Traffic insights are shown by the admin UI: counted when it's
543            // on, unless `[traffic] enabled = false` (18.15.0).
544            if crate::traffic::config::enabled_setting() != Some(false) {
545                crate::traffic::start();
546            }
547        }
548        Ok(None) => {}
549        Err(e) => eprintln!("admin API not started: {e}"),
550    }
551}
552
553fn serve(listener: std::net::TcpListener, api: AdminApi) {
554    let open = Arc::new(AtomicUsize::new(0));
555    for stream in listener.incoming() {
556        let Ok(stream) = stream else { continue };
557        if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
558            open.fetch_sub(1, Ordering::SeqCst);
559            continue;
560        }
561        let (api, held) = (api.clone(), Arc::clone(&open));
562        let spawned = std::thread::Builder::new().name("rws-admin-conn".to_string()).spawn(move || {
563            let address = |a: Option<std::net::SocketAddr>| match a {
564                Some(a) => Address { ip: a.ip().to_string(), port: a.port() as i32 },
565                None => Address { ip: String::new(), port: 0 },
566            };
567            let mut connection = ConnectionInfo::new(address(stream.peer_addr().ok()), address(stream.local_addr().ok()));
568            connection.request_size = 16 * 1024;
569            let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
570            let _ = stream.set_nodelay(true);
571            let _ = crate::server::Server::process(stream, connection, api);
572            held.fetch_sub(1, Ordering::SeqCst);
573        });
574        if spawned.is_err() {
575            open.fetch_sub(1, Ordering::SeqCst);
576        }
577    }
578}
579
580#[cfg(test)]
581mod tests;