Skip to main content

rws_proxy/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 access;
24pub mod ops;
25mod ui;
26
27use std::sync::atomic::{AtomicUsize, Ordering};
28use std::sync::{Arc, OnceLock};
29use std::time::{Duration, Instant};
30
31use crate::application::Application;
32use crate::entry_point::Config;
33use crate::header::Header;
34use crate::core::json::escape as json_escape;
35use crate::request::Request;
36use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
37use crate::server::{Address, ConnectionInfo};
38
39/// The token's environment variable when `token_env` is unset.
40pub const DEFAULT_TOKEN_ENV: &str = "RWS_ADMIN_TOKEN";
41/// Admin connections served at once; more are closed at accept.
42pub const MAX_CONNECTIONS: usize = 32;
43/// Path prefix of the API.
44pub const API_PREFIX: &str = "/admin/api/v1";
45
46/// `[admin]` from the environment (`RWS_CONFIG_ADMIN_*`).
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct AdminConfig {
49    /// `host:port` to listen on; empty = no admin listener.
50    pub listen: String,
51    /// The environment variable holding the bearer token.
52    pub token_env: String,
53    /// Browser origins allowed besides the listener's own.
54    pub allowed_origins: Vec<String>,
55    /// More users: `name:ENV_VAR:role`, comma-separated (18.22.0).
56    pub users: String,
57    /// Append the audit log here (empty: memory only).
58    pub audit_log: String,
59    /// OIDC sign-in to the admin UI (empty issuer and provider: off).
60    pub oidc: AdminOidcConfig,
61}
62
63/// `[admin] oidc_*` (18.22.0): sign in to the admin UI with an OIDC provider.
64#[derive(Debug, Clone, Default, PartialEq)]
65pub struct AdminOidcConfig {
66    pub issuer: String,
67    pub provider: String,
68    pub tenant: String,
69    pub client_id: String,
70    pub client_secret_env: String,
71    pub cookie_secret_env: String,
72    /// `group=role`, comma-separated; `*` matches anyone signed in.
73    pub roles: String,
74    /// The ID-token claim holding groups (default `groups`).
75    pub role_claim: String,
76}
77
78impl AdminOidcConfig {
79    pub fn enabled(&self) -> bool {
80        !self.issuer.is_empty() || !self.provider.is_empty()
81    }
82}
83
84/// Settings without a CLI flag (`[admin] users`, `audit_log`, `oidc_*`).
85pub const RWS_CONFIG_ADMIN_USERS: &str = "RWS_CONFIG_ADMIN_USERS";
86pub const RWS_CONFIG_ADMIN_AUDIT_LOG: &str = "RWS_CONFIG_ADMIN_AUDIT_LOG";
87pub const RWS_CONFIG_ADMIN_OIDC_ISSUER: &str = "RWS_CONFIG_ADMIN_OIDC_ISSUER";
88pub const RWS_CONFIG_ADMIN_OIDC_PROVIDER: &str = "RWS_CONFIG_ADMIN_OIDC_PROVIDER";
89pub const RWS_CONFIG_ADMIN_OIDC_TENANT: &str = "RWS_CONFIG_ADMIN_OIDC_TENANT";
90pub const RWS_CONFIG_ADMIN_OIDC_CLIENT_ID: &str = "RWS_CONFIG_ADMIN_OIDC_CLIENT_ID";
91pub const RWS_CONFIG_ADMIN_OIDC_CLIENT_SECRET_ENV: &str = "RWS_CONFIG_ADMIN_OIDC_CLIENT_SECRET_ENV";
92pub const RWS_CONFIG_ADMIN_OIDC_COOKIE_SECRET_ENV: &str = "RWS_CONFIG_ADMIN_OIDC_COOKIE_SECRET_ENV";
93pub const RWS_CONFIG_ADMIN_OIDC_ROLES: &str = "RWS_CONFIG_ADMIN_OIDC_ROLES";
94pub const RWS_CONFIG_ADMIN_OIDC_ROLE_CLAIM: &str = "RWS_CONFIG_ADMIN_OIDC_ROLE_CLAIM";
95
96impl AdminConfig {
97    pub fn from_env() -> AdminConfig {
98        let read = |name: &str| std::env::var(name).unwrap_or_default().trim().to_string();
99        let token_env = read(Config::RWS_CONFIG_ADMIN_TOKEN_ENV);
100        AdminConfig {
101            listen: read(Config::RWS_CONFIG_ADMIN_LISTEN),
102            token_env: if token_env.is_empty() { DEFAULT_TOKEN_ENV.to_string() } else { token_env },
103            allowed_origins: read(Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS)
104                .split(',')
105                .map(|o| o.trim().trim_end_matches('/').to_string())
106                .filter(|o| !o.is_empty())
107                .collect(),
108            users: read(RWS_CONFIG_ADMIN_USERS),
109            audit_log: read(RWS_CONFIG_ADMIN_AUDIT_LOG),
110            oidc: AdminOidcConfig {
111                issuer: read(RWS_CONFIG_ADMIN_OIDC_ISSUER).trim_end_matches('/').to_string(),
112                provider: read(RWS_CONFIG_ADMIN_OIDC_PROVIDER).to_ascii_lowercase(),
113                tenant: read(RWS_CONFIG_ADMIN_OIDC_TENANT),
114                client_id: read(RWS_CONFIG_ADMIN_OIDC_CLIENT_ID),
115                client_secret_env: read(RWS_CONFIG_ADMIN_OIDC_CLIENT_SECRET_ENV),
116                cookie_secret_env: read(RWS_CONFIG_ADMIN_OIDC_COOKIE_SECRET_ENV),
117                roles: read(RWS_CONFIG_ADMIN_OIDC_ROLES),
118                role_claim: read(RWS_CONFIG_ADMIN_OIDC_ROLE_CLAIM),
119            },
120        }
121    }
122
123    pub fn enabled(&self) -> bool {
124        !self.listen.is_empty()
125    }
126
127    /// The token, or why there is none.
128    pub fn token(&self) -> Result<String, String> {
129        match std::env::var(&self.token_env) {
130            Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
131            _ => Err(format!("the admin token variable {} is unset or empty", self.token_env)),
132        }
133    }
134}
135
136/// When the process started serving the admin API (for `uptime`).
137fn started() -> Instant {
138    static STARTED: OnceLock<Instant> = OnceLock::new();
139    *STARTED.get_or_init(Instant::now)
140}
141
142/// The admin API. See the [module docs](self).
143#[derive(Clone)]
144pub struct AdminApi {
145    inner: Arc<Inner>,
146}
147
148type Reloader = Arc<dyn Fn() + Send + Sync>;
149
150struct Inner {
151    users: Vec<access::TokenUser>,
152    allowed_origins: Vec<String>,
153    reloader: Reloader,
154    reload_wait: Duration,
155    #[cfg(feature = "sso")]
156    oidc: Option<Arc<AdminOidc>>,
157}
158
159/// OIDC sign-in for the admin UI: the lazily discovered layer and the
160/// group → role map.
161#[cfg(feature = "sso")]
162struct AdminOidc {
163    layer: crate::proxy_config::oidc_route::LazyOidc,
164    roles: Vec<(String, access::Role)>,
165}
166
167impl AdminApi {
168    /// The API accepting `token`.
169    pub fn new(token: impl Into<String>) -> AdminApi {
170        started();
171        crate::log::tail::enable();
172        AdminApi {
173            inner: Arc::new(Inner {
174                users: vec![access::TokenUser { name: "admin".to_string(), token: token.into(), role: access::Role::Admin }],
175                allowed_origins: Vec::new(),
176                reloader: Arc::new(crate::config_reload::request_reload),
177                reload_wait: Duration::from_secs(10),
178                #[cfg(feature = "sso")]
179                oidc: None,
180            }),
181        }
182    }
183
184    fn edit(self, f: impl FnOnce(&mut Inner)) -> AdminApi {
185        let mut inner = Inner {
186            users: self.inner.users.clone(),
187            allowed_origins: self.inner.allowed_origins.clone(),
188            reloader: Arc::clone(&self.inner.reloader),
189            reload_wait: self.inner.reload_wait,
190            #[cfg(feature = "sso")]
191            oidc: self.inner.oidc.clone(),
192        };
193        f(&mut inner);
194        AdminApi { inner: Arc::new(inner) }
195    }
196
197    /// More users with their own tokens and roles (18.22.0).
198    pub fn users(self, users: Vec<access::TokenUser>) -> AdminApi {
199        self.edit(|i| i.users.extend(users))
200    }
201
202    /// Sign-in to the admin UI with an OIDC provider; `roles` maps groups
203    /// (or emails, or `*`) to roles (18.22.0).
204    #[cfg(feature = "sso")]
205    pub fn oidc(self, config: &AdminOidcConfig) -> Result<AdminApi, String> {
206        let roles = access::parse_role_map(&config.roles)?;
207        if roles.is_empty() {
208            return Err("[admin] oidc_roles is empty: nobody signed in would get a role".to_string());
209        }
210        let layer = crate::proxy_config::oidc_route::LazyOidc::new(crate::proxy_config::OidcRouteAuth {
211            issuer: config.issuer.clone(),
212            provider: config.provider.clone(),
213            tenant: config.tenant.clone(),
214            client_id: config.client_id.clone(),
215            client_secret_env: config.client_secret_env.clone(),
216            cookie_secret_env: config.cookie_secret_env.clone(),
217            groups_claim: config.role_claim.clone(),
218            session_ttl_secs: 12 * 3600,
219            path_prefix: "/admin".to_string(),
220            ..Default::default()
221        })?;
222        Ok(self.edit(|i| i.oidc = Some(Arc::new(AdminOidc { layer, roles }))))
223    }
224
225    /// Browser origins allowed besides the listener's own.
226    pub fn allowed_origins(self, origins: Vec<String>) -> AdminApi {
227        self.edit(|i| i.allowed_origins = origins)
228    }
229
230    /// What `POST …/reload` runs (default: [`crate::config_reload::request_reload`],
231    /// the same path as `SIGHUP`, so TLS certificates are reloaded too), and
232    /// how long it waits for the reload to finish.
233    pub fn reloader(self, reload: impl Fn() + Send + Sync + 'static, wait: Duration) -> AdminApi {
234        self.edit(|i| {
235            i.reloader = Arc::new(reload);
236            i.reload_wait = wait;
237        })
238    }
239
240    /// Request a reload and wait for it: the reload count moving, then the
241    /// errors it reported.
242    fn reload(&self) -> Response {
243        if !crate::metrics::SERVER_READY.load(Ordering::SeqCst) {
244            // Before the servers run, SIGHUP has no handler yet.
245            return error(STATUS_CODE_REASON_PHRASE.n503_service_unavailable, "the server is starting or stopping");
246        }
247        let (before, _) = crate::config_reload::last_reload();
248        (self.inner.reloader)();
249        let deadline = Instant::now() + self.inner.reload_wait;
250        loop {
251            let (count, errors) = crate::config_reload::last_reload();
252            if count > before {
253                let errors: Vec<String> = errors.iter().map(|e| format!("\"{}\"", json_escape(e))).collect();
254                let applied = errors.is_empty();
255                return json(STATUS_CODE_REASON_PHRASE.n200_ok, format!(r#"{{"reloaded":true,"applied":{applied},"reloads":{count},"errors":[{}]}}"#, errors.join(",")));
256            }
257            if Instant::now() >= deadline {
258                return error(STATUS_CODE_REASON_PHRASE.n504_gateway_timeout, "the reload didn't finish in time; check the server log");
259            }
260            std::thread::sleep(Duration::from_millis(20));
261        }
262    }
263
264    /// Who sent `request`: a bearer token's user, else (with OIDC sign-in)
265    /// the signed-in user with the role the group map gives them.
266    fn authenticate(&self, request: &Request) -> Authenticated {
267        if let Some(sent) = request.header("Authorization").and_then(|h| h.value.strip_prefix("Bearer ").map(str::trim)) {
268            // Every user compared, so timing doesn't tell which name matched.
269            let mut found = None;
270            for user in &self.inner.users {
271                if crate::proxy_config::constant_time_eq(sent.as_bytes(), user.token.as_bytes()) {
272                    found = Some(access::Principal { user: user.name.clone(), role: user.role, via: "token" });
273                }
274            }
275            return found.map_or(Authenticated::No, Authenticated::Yes);
276        }
277        #[cfg(feature = "sso")]
278        if let Some(oidc) = &self.inner.oidc {
279            if let Some(user) = oidc.layer.auth().and_then(|auth| auth.signed_in(request)) {
280                let name = user.email.clone().unwrap_or_else(|| user.sub.clone());
281                return match access::role_for(&oidc.roles, user.email.as_deref(), &user.groups) {
282                    Some(role) => Authenticated::Yes(access::Principal { user: name, role, via: "oidc" }),
283                    None => Authenticated::NoRole(name),
284                };
285            }
286        }
287        Authenticated::No
288    }
289
290    fn oidc_enabled(&self) -> bool {
291        #[cfg(feature = "sso")]
292        return self.inner.oidc.is_some();
293        #[cfg(not(feature = "sso"))]
294        false
295    }
296
297    /// A browser on another origin is refused; non-browser clients send none.
298    fn origin_allowed(&self, request: &Request) -> bool {
299        let Some(origin) = request.header("Origin").map(|h| h.value.trim().trim_end_matches('/').to_string()) else {
300            return true;
301        };
302        let host = request.header("Host").map(|h| h.value.trim().to_string()).unwrap_or_default();
303        let own = [format!("http://{host}"), format!("https://{host}")];
304        own.contains(&origin) || self.inner.allowed_origins.contains(&origin)
305    }
306}
307
308#[doc(hidden)]
309pub fn json(status: &crate::response::StatusCodeReasonPhrase, body: String) -> Response {
310    let mut response = Response::json(status, body.into_bytes());
311    response.headers.push(Header::new("Cache-Control", "no-store"));
312    response
313}
314
315#[doc(hidden)]
316pub fn error(status: &crate::response::StatusCodeReasonPhrase, message: &str) -> Response {
317    json(status, format!(r#"{{"error":"{}"}}"#, json_escape(message)))
318}
319
320impl Application for AdminApi {
321    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
322        if !self.origin_allowed(request) {
323            return Ok(error(STATUS_CODE_REASON_PHRASE.n403_forbidden, "origin not allowed"));
324        }
325        let path = crate::url::URL::request_target_path(&request.request_uri).unwrap_or_default();
326        if request.method == "GET" || request.method == "HEAD" {
327            if path == "/admin" || path == "/" {
328                return Ok(ui::redirect());
329            }
330            if let Some(file) = ui::file(path) {
331                return Ok(file);
332            }
333        }
334        // OIDC sign-in: /admin/auth/login, /callback, /logout.
335        if path.starts_with("/admin/auth/") {
336            #[cfg(feature = "sso")]
337            if let Some(oidc) = &self.inner.oidc {
338                return match oidc.layer.auth() {
339                    Some(auth) => crate::middleware::Middleware::handle(&*auth, request, connection, &NotFound),
340                    None => Ok(error(STATUS_CODE_REASON_PHRASE.n503_service_unavailable, "the identity provider can't be reached; try again shortly")),
341                };
342            }
343            return Ok(error(STATUS_CODE_REASON_PHRASE.n404_not_found, "OIDC sign-in isn't configured"));
344        }
345        let route = path.strip_prefix(API_PREFIX).map(|r| r.trim_end_matches('/'));
346        // How to sign in, for the UI's login page: no credentials needed.
347        if route == Some("/auth") && request.method == "GET" {
348            return Ok(ok(format!(r#"{{"token":true,"oidc":{}}}"#, self.oidc_enabled())));
349        }
350        let address = crate::ip_filter::client_ip(request, &connection.client.ip).into_owned();
351        let principal = match self.authenticate(request) {
352            Authenticated::Yes(p) => p,
353            Authenticated::NoRole(name) => {
354                let response = error(STATUS_CODE_REASON_PHRASE.n403_forbidden, &format!("{name} is signed in but has no admin role (see [admin] oidc_roles)"));
355                if !is_read(&request.method) {
356                    access::record_change(&access::Principal { user: name, role: access::Role::Viewer, via: "oidc" }, &address, &request.method, path, &request.body, 403);
357                }
358                return Ok(response);
359            }
360            Authenticated::No => {
361                if !is_read(&request.method) && request.header("Authorization").is_some() {
362                    access::record_change(&access::Principal { user: "(bad token)".to_string(), role: access::Role::Viewer, via: "token" }, &address, &request.method, path, &request.body, 401);
363                }
364                let mut response = error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "missing or wrong bearer token");
365                response.headers.push(Header::new("WWW-Authenticate", r#"Bearer realm="rws admin""#));
366                return Ok(response);
367            }
368        };
369        let Some(route) = route else {
370            return Ok(error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not found"));
371        };
372        // A browser signed in with a cookie: changes need the header only
373        // this origin's script can set (CSRF), besides the Origin check.
374        if principal.via == "oidc" && !is_read(&request.method) && request.header("X-Rws-Admin").is_none() {
375            access::record_change(&principal, &address, &request.method, path, &request.body, 403);
376            return Ok(error(STATUS_CODE_REASON_PHRASE.n403_forbidden, "missing X-Rws-Admin header"));
377        }
378        let needed = access::required_role(&request.method, route);
379        if principal.role < needed {
380            access::record_change(&principal, &address, &request.method, path, &request.body, 403);
381            return Ok(error(STATUS_CODE_REASON_PHRASE.n403_forbidden, &format!("{} has role {}; this needs {}", principal.user, principal.role.as_str(), needed.as_str())));
382        }
383        let response = match (request.method.as_str(), route) {
384            ("GET", "/whoami") => ok(format!(r#"{{"user":"{}","role":"{}","via":"{}"}}"#, json_escape(&principal.user), principal.role.as_str(), principal.via)),
385            ("GET", "/audit") => ok(access::recent_json(query(request, "n").and_then(|n| n.parse().ok()).unwrap_or(100).min(access::AUDIT_CAPACITY))),
386            ("GET", "/audit/verify") => match access::verify_audit_file() {
387                Ok((file, report)) => {
388                    let report = report.to_json();
389                    ok(format!(r#"{{"file":"{}",{}"#, json_escape(&file), &report[1..]))
390                }
391                Err(e) => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
392            },
393            (_, "/whoami" | "/audit" | "/audit/verify") => wrong_method("GET"),
394            _ => self.route(request, connection, route),
395        };
396        if !is_read(&request.method) && !(request.method == "POST" && route == "/authz/check") {
397            access::record_change(&principal, &address, &request.method, path, &request.body, response.status_code as u16);
398        }
399        Ok(response)
400    }
401}
402
403/// The outcome of [`AdminApi::authenticate`].
404enum Authenticated {
405    Yes(access::Principal),
406    /// Signed in (OIDC), but no role maps to them.
407    #[cfg_attr(not(feature = "sso"), allow(dead_code))]
408    NoRole(String),
409    No,
410}
411
412fn is_read(method: &str) -> bool {
413    matches!(method, "GET" | "HEAD" | "OPTIONS")
414}
415
416/// The rest of the stack behind the OIDC layer on `/admin/auth/*`: the
417/// layer answers those paths itself.
418#[cfg(feature = "sso")]
419struct NotFound;
420
421#[cfg(feature = "sso")]
422impl Application for NotFound {
423    fn execute(&self, _: &Request, _: &ConnectionInfo) -> Result<Response, String> {
424        Ok(error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not found"))
425    }
426}
427
428fn ok(body: String) -> Response {
429    json(STATUS_CODE_REASON_PHRASE.n200_ok, body)
430}
431
432fn bad_request(message: &str) -> Response {
433    error(STATUS_CODE_REASON_PHRASE.n400_bad_request, message)
434}
435
436/// The JSON body's top-level boolean `key`.
437fn body_bool(request: &Request, key: &str) -> Option<bool> {
438    let body = std::str::from_utf8(&request.body).ok()?;
439    let value = crate::mcp::json_rpc::object_members(body)?.into_iter().find(|(k, _)| k == key)?.1;
440    match value.as_str() {
441        "true" => Some(true),
442        "false" => Some(false),
443        _ => None,
444    }
445}
446
447/// The JSON body's top-level string `key`.
448fn body_str(request: &Request, key: &str) -> Option<String> {
449    let body = std::str::from_utf8(&request.body).ok()?;
450    let value = crate::mcp::json_rpc::object_members(body)?.into_iter().find(|(k, _)| k == key)?.1;
451    value.starts_with('"').then(|| crate::mcp::json_rpc::extract_str(&format!(r#"{{"v":{value}}}"#), "v")).flatten()
452}
453
454/// `405`, naming the methods that work.
455fn wrong_method(allowed: &str) -> Response {
456    let mut response = error(STATUS_CODE_REASON_PHRASE.n405_method_not_allowed, &format!("use {allowed}"));
457    response.headers.push(Header::new("Allow", allowed.to_string()));
458    response
459}
460
461/// `/authz`, `/authz/tuples`, `/authz/check` (#32).
462fn authz_route(method: &str, route: &str, request: &Request) -> Response {
463    let decoded = |key: &str| query(request, key).map(|v| crate::url::URL::percent_decode(&v)).filter(|v| !v.is_empty());
464    // A configuration problem is a 404 ("nothing to manage"), a bad request
465    // a 400.
466    let answer = |result: Result<String, String>, missing_is_404: bool| match result {
467        Ok(body) => ok(body),
468        Err(e) if missing_is_404 && e.starts_with("no authorization configured") => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
469        Err(e) => error(STATUS_CODE_REASON_PHRASE.n400_bad_request, &e),
470    };
471    match (method, route) {
472        ("GET", "/authz") => answer(ops::authz_schema_json(), true),
473        (_, "/authz") => wrong_method("GET"),
474        ("GET", "/authz/tuples") => {
475            let limit = decoded("limit").and_then(|n| n.parse().ok()).unwrap_or(100);
476            answer(ops::authz_tuples_json(decoded("object").as_deref(), decoded("relation").as_deref(), decoded("subject").as_deref(), limit), true)
477        }
478        ("POST", "/authz/tuples") => match body_str(request, "tuple") {
479            Some(tuple) => answer(ops::authz_write(&tuple), true),
480            None => bad_request(r#"expected {"tuple":"doc:42#viewer@user:alice"}"#),
481        },
482        ("DELETE", "/authz/tuples") => match decoded("tuple").or_else(|| body_str(request, "tuple")) {
483            Some(tuple) => answer(ops::authz_delete(&tuple), true),
484            None => bad_request(r#"expected ?tuple=… or {"tuple":"doc:42#viewer@user:alice"}"#),
485        },
486        (_, "/authz/tuples") => wrong_method("GET, POST, DELETE"),
487        ("POST", "/authz/check") => match (body_str(request, "subject"), body_str(request, "relation"), body_str(request, "object")) {
488            (Some(subject), Some(relation), Some(object)) => answer(ops::authz_check(&subject, &relation, &object), true),
489            _ => bad_request(r#"expected {"subject":"user:alice","relation":"viewer","object":"doc:42"}"#),
490        },
491        (_, "/authz/check") => wrong_method("POST"),
492        _ => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "unknown /authz endpoint"),
493    }
494}
495
496/// Query parameter `key` of the request target.
497fn query(request: &Request, key: &str) -> Option<String> {
498    let (_, query) = request.request_uri.split_once('?')?;
499    query.split('&').find_map(|pair| {
500        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
501        (k == key).then(|| v.to_string())
502    })
503}
504
505fn parse_ip(ip: &str) -> Option<String> {
506    let ip = crate::url::URL::percent_decode(ip.trim());
507    ip.parse::<std::net::IpAddr>().ok().map(|ip| ip.to_string())
508}
509
510impl AdminApi {
511    fn route(&self, request: &Request, connection: &ConnectionInfo, route: &str) -> Response {
512        let method = request.method.as_str();
513        if let Some(ip) = route.strip_prefix("/blocklist/") {
514            return match (method, parse_ip(ip)) {
515                ("DELETE", Some(ip)) => ok(ops::unblock(&ip)),
516                ("DELETE", None) => bad_request("not an IP address"),
517                _ => wrong_method("DELETE"),
518            };
519        }
520        if let Some(name) = route.strip_prefix("/flags/") {
521            let name = crate::url::URL::percent_decode(name);
522            return match method {
523                "PUT" => match body_bool(request, "enabled") {
524                    Some(enabled) if !name.is_empty() => ok(ops::set_flag(&name, enabled)),
525                    _ => bad_request(r#"expected {"enabled":true|false}"#),
526                },
527                _ => wrong_method("PUT"),
528            };
529        }
530        if let Some(name) = route.strip_prefix("/upstreams/").and_then(|r| r.strip_suffix("/drain")) {
531            let name = crate::url::URL::percent_decode(name);
532            return match (method, body_str(request, "backend"), body_bool(request, "drained")) {
533                ("PUT", Some(backend), Some(drained)) => match ops::set_drained(&name, &backend, drained) {
534                    Ok(body) => ok(body),
535                    Err(e) => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
536                },
537                ("PUT", _, _) => bad_request(r#"expected {"backend":"<url>","drained":true|false}"#),
538                _ => wrong_method("PUT"),
539            };
540        }
541        if let Some(name) = route.strip_prefix("/caches/") {
542            let name = crate::url::URL::percent_decode(name);
543            let decoded = |key: &str| query(request, key).map(|v| crate::url::URL::percent_decode(&v));
544            return match method {
545                "DELETE" => match ops::purge_cache(&name, decoded("path").as_deref(), decoded("prefix").as_deref()) {
546                    Some(body) => ok(body),
547                    None => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &format!("no cache named {name:?}")),
548                },
549                _ => wrong_method("DELETE"),
550            };
551        }
552        if route == "/traffic" {
553            if method != "GET" {
554                return wrong_method("GET");
555            }
556            let hours = query(request, "hours").and_then(|h| h.parse().ok()).unwrap_or(24);
557            let host = query(request, "host").map(|h| crate::url::URL::percent_decode(&h));
558            return match ops::traffic_json(hours, host.as_deref()) {
559                Ok(body) => ok(body),
560                Err(e) => error(STATUS_CODE_REASON_PHRASE.n404_not_found, &e),
561            };
562        }
563        if route == "/authz" || route.starts_with("/authz/") {
564            return authz_route(method, route, request);
565        }
566        match (method, route) {
567            ("GET", "/caches") => ok(ops::caches_json()),
568            (_, "/caches") => wrong_method("GET"),
569            ("GET", "/logs") => {
570                let n = query(request, "n").and_then(|n| n.parse().ok()).unwrap_or(100);
571                ok(ops::logs_json(n))
572            }
573            (_, "/logs") => wrong_method("GET"),
574            ("GET", "/logs/stream") => {
575                let (sender, mut response) = crate::sse::SseStream::new().buffer(256).start();
576                if !crate::log::tail::follow(sender) {
577                    return error(STATUS_CODE_REASON_PHRASE.n503_service_unavailable, "too many log followers");
578                }
579                response.headers.push(Header::new("Cache-Control", "no-store"));
580                response
581            }
582            (_, "/logs/stream") => wrong_method("GET"),
583            ("GET", "/upstreams") => ok(ops::upstreams_json()),
584            (_, "/upstreams") => wrong_method("GET"),
585            ("GET", "/certificates") => ok(ops::certificates_json()),
586            (_, "/certificates") => wrong_method("GET"),
587            ("POST", "/certificates/renew") => match ops::request_renewal() {
588                true => json(STATUS_CODE_REASON_PHRASE.n202_accepted, r#"{"requested":true}"#.to_string()),
589                false => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "ACME isn't configured: nothing to renew"),
590            },
591            (_, "/certificates/renew") => wrong_method("POST"),
592            ("GET", "/status") => ok(status_json(connection)),
593            (_, "/status") => wrong_method("GET"),
594            ("GET", "/config") => ok(ops::config_json()),
595            (_, "/config") => wrong_method("GET"),
596            ("POST", "/reload") => self.reload(),
597            (_, "/reload") => wrong_method("POST"),
598            ("GET", "/metrics") => ok(ops::metrics_json()),
599            (_, "/metrics") => wrong_method("GET"),
600            ("GET", "/build") => ok(ops::build_features_json()),
601            (_, "/build") => wrong_method("GET"),
602            ("GET", "/rate-limit") => ok(ops::rate_limit_json()),
603            (_, "/rate-limit") => wrong_method("GET"),
604            ("GET", "/rate-limit/check") => match query(request, "ip").as_deref().and_then(parse_ip) {
605                Some(ip) => ok(ops::check_rate_limit_json(&ip)),
606                None => bad_request("expected ?ip=<address>"),
607            },
608            (_, "/rate-limit/check") => wrong_method("GET"),
609            ("GET", "/cors") => ok(ops::cors_json()),
610            (_, "/cors") => wrong_method("GET"),
611            ("GET", "/maintenance") => ok(ops::maintenance_json()),
612            ("PUT", "/maintenance") => match body_bool(request, "active") {
613                Some(active) => ok(ops::set_maintenance(active)),
614                None => bad_request(r#"expected {"active":true|false}"#),
615            },
616            (_, "/maintenance") => wrong_method("GET, PUT"),
617            ("GET", "/blocklist") => ok(ops::blocklist_json()),
618            ("POST", "/blocklist") => match body_str(request, "ip").as_deref().and_then(parse_ip) {
619                Some(ip) => ok(ops::block(&ip)),
620                None => bad_request(r#"expected {"ip":"<address>"}"#),
621            },
622            (_, "/blocklist") => wrong_method("GET, POST"),
623            ("GET", "/flags") => ok(ops::flags_json()),
624            (_, "/flags") => wrong_method("GET"),
625            ("GET", "/cache") => match ops::registered_cache_json() {
626                Some(stats) => ok(stats),
627                None => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "no response cache in this server"),
628            },
629            ("DELETE", "/cache") => match ops::clear_registered_cache() {
630                true => ok(r#"{"cleared":true}"#.to_string()),
631                false => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "no response cache in this server"),
632            },
633            (_, "/cache") => wrong_method("GET, DELETE"),
634            ("GET", "/routes") => ok(ops::registered_routes_json()),
635            (_, "/routes") => wrong_method("GET"),
636            ("GET", "/requests" | "/errors") => {
637                let n = query(request, "n").and_then(|n| n.parse().ok()).unwrap_or(20);
638                ok(ops::recent_json(n, route == "/errors"))
639            }
640            (_, "/requests" | "/errors") => wrong_method("GET"),
641            _ => error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not found"),
642        }
643    }
644}
645
646/// Compile-time features of this build that matter to an operator.
647pub fn features() -> Vec<&'static str> {
648    let mut on = Vec::new();
649    macro_rules! feature {
650        ($($name:literal),*) => { $( if cfg!(feature = $name) { on.push($name); } )* };
651    }
652    feature!("http1", "http2", "http3", "acme", "auth", "sso", "sso-saml", "sso-server", "gateway-api", "tower", "openapi", "mimalloc", "brotli", "zstd", "wasm-plugins", "fips");
653    on
654}
655
656fn fleet_status() -> String {
657    #[cfg(feature = "http2")]
658    return crate::fleet::status_json().unwrap_or_else(|| "null".to_string());
659    #[cfg(not(feature = "http2"))]
660    "null".to_string()
661}
662
663fn status_json(connection: &ConnectionInfo) -> String {
664    let env = |name: &str| std::env::var(name).unwrap_or_default();
665    let tls = !env(Config::RWS_CONFIG_TLS_CERT_FILE).is_empty() || !env(Config::RWS_CONFIG_ACME_DOMAINS).is_empty();
666    let redirect = env(Config::RWS_CONFIG_HTTP_REDIRECT_PORT);
667    let features: Vec<String> = features().iter().map(|f| format!("\"{f}\"")).collect();
668    // Where rws writes files (access log, disk cache): free space and
669    // whether it's under RWS_CONFIG_MIN_FREE_DISK (18.15.0).
670    let disk: Vec<String> = crate::disk_guard::status()
671        .into_iter()
672        .map(|(dir, free, total, low)| format!(r#"{{"dir":"{}","free_bytes":{free},"total_bytes":{total},"low":{low}}}"#, json_escape(&dir)))
673        .collect();
674    format!(
675        r#"{{"version":"{}","pid":{},"uptime_secs":{},"ready":{},"features":[{}],"listeners":{{"main":{{"address":"{}:{}","tls":{tls}}},"redirect":{},"admin":"{}:{}"}},"disk":[{}],"cluster":{},"fleet":{}}}"#,
676        env!("CARGO_PKG_VERSION"),
677        std::process::id(),
678        started().elapsed().as_secs(),
679        crate::metrics::SERVER_READY.load(Ordering::SeqCst),
680        features.join(","),
681        json_escape(&env(Config::RWS_CONFIG_IP)),
682        json_escape(&env(Config::RWS_CONFIG_PORT)),
683        if redirect.trim().is_empty() { "null".to_string() } else { format!("\"{}\"", json_escape(redirect.trim())) },
684        json_escape(&connection.server.ip),
685        connection.server.port,
686        disk.join(","),
687        crate::cluster::status_json().unwrap_or_else(|| "null".to_string()),
688        fleet_status(),
689    )
690}
691
692/// Semantic checks of `[admin]` for `rws --check`; `get` reads a setting
693/// of the file.
694#[doc(hidden)]
695pub fn validate(get: &dyn Fn(&str) -> Option<String>, report: &mut crate::config_check::ConfigReport) {
696    let listen = get(Config::RWS_CONFIG_ADMIN_LISTEN).unwrap_or_default();
697    let listen = listen.trim();
698    if listen.is_empty() {
699        for key in [Config::RWS_CONFIG_ADMIN_TOKEN_ENV, Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS] {
700            if get(key).is_some_and(|v| !v.trim().is_empty()) {
701                report.warn("[admin] has settings but no listen: the admin API is off");
702                break;
703            }
704        }
705        return;
706    }
707    match listen.parse::<std::net::SocketAddr>() {
708        Err(_) => report.error(format!("[admin] listen = {listen:?}: expected host:port with an IP address, e.g. \"127.0.0.1:9901\"")),
709        Ok(addr) if !addr.ip().is_loopback() => report.warn(format!(
710            "[admin] listen = {listen:?} is reachable from other hosts: firewall it, or listen on 127.0.0.1 and use an SSH tunnel"
711        )),
712        Ok(_) => {}
713    }
714    let token_env = get(Config::RWS_CONFIG_ADMIN_TOKEN_ENV).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
715    let token_env = token_env.unwrap_or_else(|| DEFAULT_TOKEN_ENV.to_string());
716    if !crate::config_check::offline() && std::env::var(&token_env).unwrap_or_default().trim().is_empty() {
717        report.error(format!("[admin] reads its token from {token_env}, which is unset or empty: the admin API won't start"));
718    }
719    for origin in get(Config::RWS_CONFIG_ADMIN_ALLOWED_ORIGINS).unwrap_or_default().split(',').map(str::trim).filter(|o| !o.is_empty()) {
720        if !(origin.starts_with("http://") || origin.starts_with("https://")) || origin.trim_end_matches('/').matches('/').count() > 2 {
721            report.error(format!("[admin] allowed_origins: {origin:?} is not an origin (scheme://host[:port])"));
722        }
723    }
724    // 18.22.0: users, the audit log, OIDC sign-in.
725    let users = get(RWS_CONFIG_ADMIN_USERS).unwrap_or_default();
726    let env = |name: &str| if crate::config_check::offline() { Some("offline-check-placeholder".to_string()) } else { std::env::var(name).ok() };
727    if let Err(e) = access::parse_users(&users, &env) {
728        report.error(e);
729    }
730    let audit_log = get(RWS_CONFIG_ADMIN_AUDIT_LOG).unwrap_or_default();
731    if let Some(dir) = std::path::Path::new(audit_log.trim()).parent().filter(|d| !audit_log.trim().is_empty() && !d.as_os_str().is_empty()) {
732        if !crate::config_check::offline() && !dir.is_dir() {
733            report.error(format!("[admin] audit_log: directory {} doesn't exist", dir.display()));
734        }
735    }
736    let issuer = get(RWS_CONFIG_ADMIN_OIDC_ISSUER).unwrap_or_default();
737    let provider = get(RWS_CONFIG_ADMIN_OIDC_PROVIDER).unwrap_or_default();
738    if !issuer.trim().is_empty() || !provider.trim().is_empty() {
739        if get(RWS_CONFIG_ADMIN_OIDC_CLIENT_ID).unwrap_or_default().trim().is_empty() {
740            report.error("[admin] oidc sign-in has no oidc_client_id");
741        }
742        match access::parse_role_map(&get(RWS_CONFIG_ADMIN_OIDC_ROLES).unwrap_or_default()) {
743            Ok(map) if map.is_empty() => report.error("[admin] oidc sign-in needs oidc_roles (e.g. [\"rws-admins=admin\", \"*=viewer\"]): nobody would get a role"),
744            Ok(_) => {}
745            Err(e) => report.error(e),
746        }
747        if !provider.trim().is_empty() && !matches!(provider.trim().to_ascii_lowercase().as_str(), "google" | "microsoft" | "github") {
748            report.error(format!("[admin] oidc_provider = {provider:?}: expected google, microsoft or github (others: set oidc_issuer)"));
749        }
750        let cookie = get(RWS_CONFIG_ADMIN_OIDC_COOKIE_SECRET_ENV).unwrap_or_default();
751        if cookie.trim().is_empty() {
752            report.warn("[admin] oidc sign-in without oidc_cookie_secret_env: sign-ins end when rws restarts");
753        }
754        #[cfg(not(feature = "sso"))]
755        report.error("[admin] oidc sign-in needs the sso feature, which this build doesn't include; the admin API won't start");
756    }
757}
758
759/// Start the admin listener `config` describes, on a thread of its own.
760/// `Ok(None)` when it is off.
761pub fn start(config: &AdminConfig) -> Result<Option<std::net::SocketAddr>, String> {
762    if !config.enabled() {
763        return Ok(None);
764    }
765    let token = config.token()?;
766    #[cfg(all(unix, any(feature = "http1", feature = "http2")))]
767    let inherited = config.listen.parse().ok().and_then(|addr| crate::systemd::tcp(addr, Some("admin")));
768    #[cfg(not(all(unix, any(feature = "http1", feature = "http2"))))]
769    let inherited: Option<std::net::TcpListener> = None;
770    let listener = match inherited {
771        Some(listener) => listener,
772        None => std::net::TcpListener::bind(&config.listen).map_err(|e| format!("can't listen on {}: {e}", config.listen))?,
773    };
774    let local = listener.local_addr().map_err(|e| e.to_string())?;
775    let users = access::parse_users(&config.users, &|name| std::env::var(name).ok())?;
776    access::set_audit_file(&config.audit_log);
777    #[allow(unused_mut)]
778    let mut api = AdminApi::new(token).allowed_origins(config.allowed_origins.clone()).users(users);
779    if config.oidc.enabled() {
780        #[cfg(feature = "sso")]
781        {
782            api = api.oidc(&config.oidc)?;
783        }
784        #[cfg(not(feature = "sso"))]
785        return Err("[admin] oidc_* needs the sso feature, which this build doesn't include".to_string());
786    }
787    std::thread::Builder::new()
788        .name("rws-admin".to_string())
789        .spawn(move || serve(listener, api))
790        .map_err(|e| e.to_string())?;
791    Ok(Some(local))
792}
793
794/// [`start`] from `RWS_CONFIG_ADMIN_*`, reporting the outcome: the binary's
795/// entry point. A misconfigured admin API is logged, not fatal.
796pub fn start_from_env() {
797    match start(&AdminConfig::from_env()) {
798        Ok(Some(addr)) => {
799            println!("admin API at http://{addr}{API_PREFIX}/");
800            // Traffic insights are shown by the admin UI: counted when it's
801            // on, unless `[traffic] enabled = false` (18.15.0).
802            if crate::traffic::config::enabled_setting() != Some(false) {
803                crate::traffic::start();
804            }
805        }
806        Ok(None) => {}
807        Err(e) => eprintln!("admin API not started: {e}"),
808    }
809}
810
811fn serve(listener: std::net::TcpListener, api: AdminApi) {
812    let open = Arc::new(AtomicUsize::new(0));
813    for stream in listener.incoming() {
814        let Ok(stream) = stream else { continue };
815        if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
816            open.fetch_sub(1, Ordering::SeqCst);
817            continue;
818        }
819        let (api, held) = (api.clone(), Arc::clone(&open));
820        let spawned = std::thread::Builder::new().name("rws-admin-conn".to_string()).spawn(move || {
821            let address = |a: Option<std::net::SocketAddr>| match a {
822                Some(a) => Address { ip: a.ip().to_string(), port: a.port() as i32 },
823                None => Address { ip: String::new(), port: 0 },
824            };
825            let mut connection = ConnectionInfo::new(address(stream.peer_addr().ok()), address(stream.local_addr().ok()));
826            connection.request_size = 16 * 1024;
827            let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
828            let _ = stream.set_nodelay(true);
829            let _ = crate::server::Server::process(stream, connection, api);
830            held.fetch_sub(1, Ordering::SeqCst);
831        });
832        if spawned.is_err() {
833            open.fetch_sub(1, Ordering::SeqCst);
834        }
835    }
836}
837
838#[cfg(test)]
839mod tests;