Skip to main content

rust_web_server/admin/
ops.rs

1//! What the admin API and the `rws` binary's MCP tools both do, as JSON.
2//! One implementation, so the two can't drift apart.
3//!
4//! State that belongs to an application rather than the process (its
5//! response cache, its route table) is registered here by whoever builds
6//! the application ([`register_cache`], [`register_routes`]).
7
8use std::sync::atomic::Ordering;
9use std::sync::{OnceLock, RwLock};
10
11use crate::cache::CacheLayer;
12use crate::mcp::json_escape;
13
14fn quoted(s: &str) -> String {
15    format!("\"{}\"", json_escape(s))
16}
17
18/// Every `RWS_CONFIG_*` setting in effect, sorted; secrets (names with
19/// `SECRET`, `PASSWORD` or `TOKEN`) shown as `(hidden)`.
20pub fn config_json() -> String {
21    let mut pairs: Vec<(String, String)> = std::env::vars().filter(|(k, _)| k.starts_with("RWS_CONFIG_")).collect();
22    pairs.sort();
23    let members: Vec<String> =
24        pairs.iter().map(|(k, v)| format!("{}:{}", quoted(k), quoted(&crate::entry_point::shown(k, v)))).collect();
25    format!("{{{}}}", members.join(","))
26}
27
28/// Compile-time features as `{"name":bool}`.
29pub fn build_features_json() -> String {
30    macro_rules! features {
31        ($($name:literal),*) => { vec![$( ($name, cfg!(feature = $name)) ),*] };
32    }
33    let all: Vec<(&str, bool)> = features!(
34        "http1", "http2", "http3", "acme", "auth", "serde", "macros", "sso", "sso-saml", "sso-server", "gateway-api",
35        "tower", "openapi", "mimalloc", "brotli", "zstd", "wasm-plugins"
36    );
37    let members: Vec<String> = all.iter().map(|(name, on)| format!("\"{name}\":{on}")).collect();
38    format!("{{{}}}", members.join(","))
39}
40
41/// The server's counters.
42pub fn metrics_json() -> String {
43    use crate::metrics;
44    format!(
45        r#"{{"requests_total":{},"errors_total":{},"active_connections":{},"thread_pool_queued":{},"ready":{}}}"#,
46        metrics::REQUESTS_TOTAL.load(Ordering::Relaxed),
47        metrics::ERRORS_TOTAL.load(Ordering::Relaxed),
48        metrics::ACTIVE_CONNECTIONS.load(Ordering::Relaxed),
49        metrics::THREAD_POOL_QUEUED.load(Ordering::Relaxed),
50        metrics::SERVER_READY.load(Ordering::Relaxed),
51    )
52}
53
54pub fn rate_limit_json() -> String {
55    let cfg = crate::config_reload::current();
56    format!(
57        r#"{{"configured":{},"max_requests":{},"window_secs":{}}}"#,
58        crate::rate_limit::is_configured(),
59        cfg.rate_limit_max_requests,
60        cfg.rate_limit_window_secs
61    )
62}
63
64/// `ip`'s remaining quota under the global limit; counts as one request.
65pub fn check_rate_limit_json(ip: &str) -> String {
66    let limiter = crate::rate_limit::global();
67    let remaining = limiter.remaining(ip);
68    let allowed = limiter.check(ip);
69    format!(r#"{{"ip":{},"remaining":{remaining},"allowed":{allowed}}}"#, quoted(ip))
70}
71
72pub fn cors_json() -> String {
73    let cfg = crate::config_reload::current();
74    format!(
75        r#"{{"allow_all":{},"origins":{},"methods":{},"headers":{},"credentials":{},"expose_headers":{},"max_age":{}}}"#,
76        cfg.cors_allow_all,
77        quoted(&cfg.cors_allow_origins),
78        quoted(&cfg.cors_allow_methods),
79        quoted(&cfg.cors_allow_headers),
80        quoted(&cfg.cors_allow_credentials.to_string()),
81        quoted(&cfg.cors_expose_headers),
82        quoted(&cfg.cors_max_age.to_string()),
83    )
84}
85
86pub fn maintenance_json() -> String {
87    format!(r#"{{"active":{}}}"#, crate::maintenance::MAINTENANCE_MODE.load(Ordering::Relaxed))
88}
89
90pub fn set_maintenance(active: bool) -> String {
91    crate::maintenance::MAINTENANCE_MODE.store(active, Ordering::SeqCst);
92    maintenance_json()
93}
94
95pub fn blocklist_json() -> String {
96    let list: Vec<String> = crate::blocklist::global().list().iter().map(|ip| quoted(ip)).collect();
97    format!("[{}]", list.join(","))
98}
99
100pub fn block(ip: &str) -> String {
101    crate::blocklist::global().block(ip);
102    format!(r#"{{"blocked":{}}}"#, quoted(ip))
103}
104
105pub fn unblock(ip: &str) -> String {
106    crate::blocklist::global().unblock(ip);
107    format!(r#"{{"unblocked":{}}}"#, quoted(ip))
108}
109
110/// Runtime feature flags ([`crate::feature`]).
111pub fn flags_json() -> String {
112    let flags: Vec<String> =
113        crate::feature::global().list().iter().map(|(name, on)| format!(r#"{{"name":{},"enabled":{on}}}"#, quoted(name))).collect();
114    format!("[{}]", flags.join(","))
115}
116
117pub fn set_flag(name: &str, enabled: bool) -> String {
118    crate::feature::global().set(name, enabled);
119    format!(r#"{{"name":{},"enabled":{enabled}}}"#, quoted(name))
120}
121
122/// The last `n` requests (at most 100), or the last `n` with a 4xx/5xx
123/// status.
124pub fn recent_json(n: usize, errors_only: bool) -> String {
125    let n = n.min(100);
126    let log = crate::request_log::global();
127    let entries = if errors_only { log.recent_errors(n) } else { log.recent(n) };
128    let entries: Vec<String> = entries
129        .iter()
130        .map(|e| {
131            format!(
132                r#"{{"ts":{},"method":{},"path":{},"status":{},"ip":{},"latency_ms":{}}}"#,
133                e.timestamp,
134                quoted(&e.method),
135                quoted(&e.path),
136                e.status,
137                quoted(&e.client_ip),
138                e.latency_ms
139            )
140        })
141        .collect();
142    format!("[{}]", entries.join(","))
143}
144
145fn cache_slot() -> &'static RwLock<Option<CacheLayer>> {
146    static SLOT: OnceLock<RwLock<Option<CacheLayer>>> = OnceLock::new();
147    SLOT.get_or_init(RwLock::default)
148}
149
150fn routes_slot() -> &'static RwLock<Vec<(String, String)>> {
151    static SLOT: OnceLock<RwLock<Vec<(String, String)>>> = OnceLock::new();
152    SLOT.get_or_init(RwLock::default)
153}
154
155/// The response cache the admin API reports on and clears (a clone shares
156/// the store).
157pub fn register_cache(cache: CacheLayer) {
158    *cache_slot().write().unwrap_or_else(|e| e.into_inner()) = Some(cache);
159}
160
161/// The application's routes as `(method, pattern)`, for listing.
162pub fn register_routes(routes: Vec<(String, String)>) {
163    *routes_slot().write().unwrap_or_else(|e| e.into_inner()) = routes;
164}
165
166pub fn cache_stats_json(cache: &CacheLayer) -> String {
167    format!(r#"{{"hits":{},"misses":{},"size":{}}}"#, cache.hits(), cache.misses(), cache.size())
168}
169
170/// Stats of the registered cache; `None` without one.
171pub fn registered_cache_json() -> Option<String> {
172    cache_slot().read().unwrap_or_else(|e| e.into_inner()).as_ref().map(cache_stats_json)
173}
174
175/// Clear the registered cache; `false` without one.
176pub fn clear_registered_cache() -> bool {
177    match cache_slot().read().unwrap_or_else(|e| e.into_inner()).as_ref() {
178        Some(cache) => {
179            cache.clear();
180            true
181        }
182        None => false,
183    }
184}
185
186pub fn routes_json(routes: &[(String, String)]) -> String {
187    let entries: Vec<String> =
188        routes.iter().map(|(m, p)| format!(r#"{{"method":{},"pattern":{}}}"#, quoted(m), quoted(p))).collect();
189    format!("[{}]", entries.join(","))
190}
191
192pub fn registered_routes_json() -> String {
193    routes_json(&routes_slot().read().unwrap_or_else(|e| e.into_inner()))
194}
195
196/// The config file's upstreams: each backend's weight, health, breaker
197/// state and whether it is drained.
198pub fn upstreams_json() -> String {
199    let upstreams: Vec<String> = crate::proxy_config::upstream::snapshot()
200        .iter()
201        .map(|(name, backends)| {
202            let backends: Vec<String> = backends
203                .iter()
204                .map(|b| {
205                    format!(
206                        r#"{{"url":{},"weight":{},"healthy":{},"breaker":{},"drained":{}}}"#,
207                        quoted(&b.url),
208                        b.weight,
209                        b.healthy,
210                        b.breaker.map_or("null".to_string(), quoted),
211                        b.drained
212                    )
213                })
214                .collect();
215            format!(r#"{{"name":{},"backends":[{}]}}"#, quoted(name), backends.join(","))
216        })
217        .collect();
218    format!("[{}]", upstreams.join(","))
219}
220
221/// Drain or undrain `backend` of `upstream`; `Err` names what doesn't exist.
222pub fn set_drained(upstream: &str, backend: &str, drained: bool) -> Result<String, String> {
223    let snapshot = crate::proxy_config::upstream::snapshot();
224    let Some((_, backends)) = snapshot.iter().find(|(name, _)| name == upstream) else {
225        return Err(format!("no upstream {upstream:?}"));
226    };
227    if !backends.iter().any(|b| b.url == backend) {
228        return Err(format!("upstream {upstream:?} has no backend {backend:?}"));
229    }
230    crate::proxy_config::upstream::set_drained(upstream, backend, drained);
231    Ok(format!(r#"{{"upstream":{},"backend":{},"drained":{drained}}}"#, quoted(upstream), quoted(backend)))
232}
233
234/// The certificates served, soonest expiry first, and the ACME renewal
235/// loop's state.
236pub fn certificates_json() -> String {
237    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
238    let mut certificates = crate::metrics::tls::certificates();
239    certificates.sort_by_key(|(_, not_after, _)| *not_after);
240    let list: Vec<String> = certificates
241        .iter()
242        .map(|(name, not_after, source)| {
243            format!(
244                r#"{{"name":{},"not_after":{},"expires":{},"days_left":{},"source":"{source}"}}"#,
245                quoted(name),
246                not_after,
247                quoted(&crate::http::date::format((*not_after).max(0) as u64)),
248                (not_after - now).div_euclid(86_400)
249            )
250        })
251        .collect();
252    format!(r#"{{"certificates":[{}],"acme":{}}}"#, list.join(","), acme_json())
253}
254
255#[cfg(feature = "acme")]
256fn acme_json() -> String {
257    let (running, last) = crate::acme::renewal_status();
258    let last = last.map_or("null".to_string(), |(at, outcome)| format!(r#"{{"at":{at},"outcome":{}}}"#, quoted(&outcome)));
259    format!(r#"{{"running":{running},"last_check":{last}}}"#)
260}
261
262#[cfg(not(feature = "acme"))]
263fn acme_json() -> String {
264    r#"{"running":false,"last_check":null}"#.to_string()
265}
266
267/// Ask ACME to renew now; `false` when it isn't running.
268pub fn request_renewal() -> bool {
269    #[cfg(feature = "acme")]
270    return crate::acme::request_renewal();
271    #[cfg(not(feature = "acme"))]
272    false
273}
274
275/// The last `n` access-log lines (at most [`crate::log::tail::CAPACITY`]),
276/// oldest first, each as a string.
277pub fn logs_json(n: usize) -> String {
278    let lines: Vec<String> = crate::log::tail::recent(n).iter().map(|l| quoted(l)).collect();
279    format!("[{}]", lines.join(","))
280}