Skip to main content

rws_proxy/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::core::json::escape as 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", "geo"
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    crate::cluster::publish(crate::cluster::Op::Maintenance(active));
93    maintenance_json()
94}
95
96pub fn blocklist_json() -> String {
97    let list: Vec<String> = crate::blocklist::global().list().iter().map(|ip| quoted(ip)).collect();
98    format!("[{}]", list.join(","))
99}
100
101pub fn block(ip: &str) -> String {
102    crate::blocklist::global().block(ip);
103    crate::cluster::publish(crate::cluster::Op::Block(ip.to_string()));
104    format!(r#"{{"blocked":{}}}"#, quoted(ip))
105}
106
107pub fn unblock(ip: &str) -> String {
108    crate::blocklist::global().unblock(ip);
109    crate::cluster::publish(crate::cluster::Op::Unblock(ip.to_string()));
110    format!(r#"{{"unblocked":{}}}"#, quoted(ip))
111}
112
113/// Runtime feature flags ([`crate::feature`]).
114pub fn flags_json() -> String {
115    let flags: Vec<String> =
116        crate::feature::global().list().iter().map(|(name, on)| format!(r#"{{"name":{},"enabled":{on}}}"#, quoted(name))).collect();
117    format!("[{}]", flags.join(","))
118}
119
120pub fn set_flag(name: &str, enabled: bool) -> String {
121    crate::feature::global().set(name, enabled);
122    crate::cluster::publish(crate::cluster::Op::Flag { name: name.to_string(), enabled });
123    format!(r#"{{"name":{},"enabled":{enabled}}}"#, quoted(name))
124}
125
126/// The last `n` requests (at most 100), or the last `n` with a 4xx/5xx
127/// status.
128pub fn recent_json(n: usize, errors_only: bool) -> String {
129    let n = n.min(100);
130    let log = crate::request_log::global();
131    let entries = if errors_only { log.recent_errors(n) } else { log.recent(n) };
132    let entries: Vec<String> = entries
133        .iter()
134        .map(|e| {
135            format!(
136                r#"{{"ts":{},"method":{},"path":{},"status":{},"ip":{},"latency_ms":{}}}"#,
137                e.timestamp,
138                quoted(&e.method),
139                quoted(&e.path),
140                e.status,
141                quoted(&e.client_ip),
142                e.latency_ms
143            )
144        })
145        .collect();
146    format!("[{}]", entries.join(","))
147}
148
149fn cache_slot() -> &'static RwLock<Option<CacheLayer>> {
150    static SLOT: OnceLock<RwLock<Option<CacheLayer>>> = OnceLock::new();
151    SLOT.get_or_init(RwLock::default)
152}
153
154fn routes_slot() -> &'static RwLock<Vec<(String, String)>> {
155    static SLOT: OnceLock<RwLock<Vec<(String, String)>>> = OnceLock::new();
156    SLOT.get_or_init(RwLock::default)
157}
158
159/// The response cache the admin API reports on and clears (a clone shares
160/// the store).
161pub fn register_cache(cache: CacheLayer) {
162    crate::cache::registry::register("app", cache.clone());
163    *cache_slot().write().unwrap_or_else(|e| e.into_inner()) = Some(cache);
164}
165
166/// The application's routes as `(method, pattern)`, for listing.
167pub fn register_routes(routes: Vec<(String, String)>) {
168    *routes_slot().write().unwrap_or_else(|e| e.into_inner()) = routes;
169}
170
171pub fn cache_stats_json(cache: &CacheLayer) -> String {
172    format!(r#"{{"hits":{},"misses":{},"size":{}}}"#, cache.hits(), cache.misses(), cache.size())
173}
174
175/// Stats of the registered cache; `None` without one.
176pub fn registered_cache_json() -> Option<String> {
177    cache_slot().read().unwrap_or_else(|e| e.into_inner()).as_ref().map(cache_stats_json)
178}
179
180/// Clear the registered cache; `false` without one.
181pub fn clear_registered_cache() -> bool {
182    match cache_slot().read().unwrap_or_else(|e| e.into_inner()).as_ref() {
183        Some(cache) => {
184            cache.clear();
185            true
186        }
187        None => false,
188    }
189}
190
191pub fn routes_json(routes: &[(String, String)]) -> String {
192    let entries: Vec<String> =
193        routes.iter().map(|(m, p)| format!(r#"{{"method":{},"pattern":{}}}"#, quoted(m), quoted(p))).collect();
194    format!("[{}]", entries.join(","))
195}
196
197pub fn registered_routes_json() -> String {
198    routes_json(&routes_slot().read().unwrap_or_else(|e| e.into_inner()))
199}
200
201/// The config file's upstreams: each backend's weight, health, breaker
202/// state and whether it is drained.
203pub fn upstreams_json() -> String {
204    let upstreams: Vec<String> = crate::proxy_config::upstream::snapshot()
205        .iter()
206        .map(|(name, backends)| {
207            let backends: Vec<String> = backends
208                .iter()
209                .map(|b| {
210                    format!(
211                        r#"{{"url":{},"weight":{},"healthy":{},"breaker":{},"drained":{}}}"#,
212                        quoted(&b.url),
213                        b.weight,
214                        b.healthy,
215                        b.breaker.map_or("null".to_string(), quoted),
216                        b.drained
217                    )
218                })
219                .collect();
220            format!(r#"{{"name":{},"backends":[{}]}}"#, quoted(name), backends.join(","))
221        })
222        .collect();
223    format!("[{}]", upstreams.join(","))
224}
225
226/// Drain or undrain `backend` of `upstream`; `Err` names what doesn't exist.
227pub fn set_drained(upstream: &str, backend: &str, drained: bool) -> Result<String, String> {
228    let snapshot = crate::proxy_config::upstream::snapshot();
229    let Some((_, backends)) = snapshot.iter().find(|(name, _)| name == upstream) else {
230        return Err(format!("no upstream {upstream:?}"));
231    };
232    if !backends.iter().any(|b| b.url == backend) {
233        return Err(format!("upstream {upstream:?} has no backend {backend:?}"));
234    }
235    crate::proxy_config::upstream::set_drained(upstream, backend, drained);
236    crate::cluster::publish(crate::cluster::Op::Drain { upstream: upstream.to_string(), backend: backend.to_string(), drained });
237    Ok(format!(r#"{{"upstream":{},"backend":{},"drained":{drained}}}"#, quoted(upstream), quoted(backend)))
238}
239
240/// The certificates served, soonest expiry first, and the ACME renewal
241/// loop's state.
242pub fn certificates_json() -> String {
243    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
244    let mut certificates = crate::metrics::tls::certificates();
245    certificates.sort_by_key(|(_, not_after, _)| *not_after);
246    let list: Vec<String> = certificates
247        .iter()
248        .map(|(name, not_after, source)| {
249            format!(
250                r#"{{"name":{},"not_after":{},"expires":{},"days_left":{},"source":"{source}"}}"#,
251                quoted(name),
252                not_after,
253                quoted(&crate::http::date::format((*not_after).max(0) as u64)),
254                (not_after - now).div_euclid(86_400)
255            )
256        })
257        .collect();
258    format!(r#"{{"certificates":[{}],"acme":{}}}"#, list.join(","), acme_json())
259}
260
261#[cfg(feature = "acme")]
262fn acme_json() -> String {
263    let (running, last) = crate::acme::renewal_status();
264    let last = last.map_or("null".to_string(), |(at, outcome)| format!(r#"{{"at":{at},"outcome":{}}}"#, quoted(&outcome)));
265    format!(r#"{{"running":{running},"last_check":{last}}}"#)
266}
267
268#[cfg(not(feature = "acme"))]
269fn acme_json() -> String {
270    r#"{"running":false,"last_check":null}"#.to_string()
271}
272
273/// Ask ACME to renew now; `false` when it isn't running.
274pub fn request_renewal() -> bool {
275    #[cfg(feature = "acme")]
276    return crate::acme::request_renewal();
277    #[cfg(not(feature = "acme"))]
278    false
279}
280
281/// The last `n` access-log lines (at most [`crate::log::tail::CAPACITY`]),
282/// oldest first, each as a string.
283pub fn logs_json(n: usize) -> String {
284    let lines: Vec<String> = crate::log::tail::recent(n).iter().map(|l| quoted(l)).collect();
285    format!("[{}]", lines.join(","))
286}
287
288/// Every named cache (the binary's `app`, `route:<name>` for config-file
289/// routes): kind, entries, bytes on disk, hits, misses.
290pub fn caches_json() -> String {
291    let caches: Vec<String> = crate::cache::registry::list()
292        .iter()
293        .map(|(name, c)| {
294            format!(
295                r#"{{"name":{},"kind":"{}","entries":{},"bytes":{},"hits":{},"misses":{}}}"#,
296                quoted(name),
297                c.kind(),
298                c.size(),
299                c.stored_bytes().map_or("null".to_string(), |b| b.to_string()),
300                c.hits(),
301                c.misses()
302            )
303        })
304        .collect();
305    format!("[{}]", caches.join(","))
306}
307
308/// Purge `name`: everything, one target (`path`) or a `prefix`; `None` for
309/// an unknown cache.
310pub fn purge_cache(name: &str, path: Option<&str>, prefix: Option<&str>) -> Option<String> {
311    let cache = crate::cache::registry::get(name)?;
312    let target = match (path, prefix) {
313        (Some(p), _) if !p.is_empty() => crate::cache::Purge::Exact(p.to_string()),
314        (_, Some(p)) if !p.is_empty() => crate::cache::Purge::Prefix(p.to_string()),
315        _ => crate::cache::Purge::All,
316    };
317    let n = cache.purge(&target);
318    crate::cluster::publish(crate::cluster::Op::Purge { cache: name.to_string(), path: path.filter(|p| !p.is_empty()).map(str::to_string), prefix: prefix.filter(|p| !p.is_empty()).map(str::to_string) });
319    Some(format!(r#"{{"name":{},"purged":{n}}}"#, quoted(name)))
320}
321
322// ── Traffic insights (18.15.0) ────────────────────────────────────────────────
323
324/// Who visited in the last `hours` (1–2160), optionally for one host: the
325/// Traffic page's JSON. `Err` when counting is off.
326/// `GET /traffic/query`: page views from the traffic database for a date
327/// range (`from`, `to`: `YYYY-MM-DD`, inclusive; default the last 30 days)
328/// and any filters; `Err` (404) without a database.
329pub fn traffic_query_json(arg: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
330    #[cfg(feature = "model-postgres")]
331    {
332        use crate::traffic::store::{parse_day, query_json, Query};
333        if !crate::traffic::store::enabled() {
334            return Err("no traffic database: set [traffic] database_url_env".to_string());
335        }
336        let today = crate::traffic::record::now() / 86_400;
337        let day = |key: &str, default: u64| match arg(key) {
338            Some(v) if !v.is_empty() => parse_day(&v).ok_or(format!("{key}: expected YYYY-MM-DD, got {v:?}")),
339            _ => Ok(default),
340        };
341        let to = day("to", today)?;
342        let from = day("from", to.saturating_sub(29))?;
343        if from > to || to - from > 3660 {
344            return Err("from must be on or before to, at most ten years apart".to_string());
345        }
346        let q = Query {
347            from: from * 86_400,
348            to: (to + 1) * 86_400,
349            host: arg("host"),
350            path: arg("path"),
351            country: arg("country"),
352            channel: arg("channel"),
353            utm_source: arg("utm_source"),
354            utm_campaign: arg("utm_campaign"),
355            browser: arg("browser"),
356            os: arg("os"),
357            device: arg("device"),
358        };
359        query_json(&q)
360    }
361    #[cfg(not(feature = "model-postgres"))]
362    {
363        let _ = arg;
364        Err("no traffic database: this rws is built without the model-postgres feature".to_string())
365    }
366}
367
368/// `GET /traffic/live`: who is on the site now.
369pub fn traffic_live_json(host: Option<&str>) -> Result<String, String> {
370    if !crate::traffic::enabled() {
371        return Err("traffic counting is off (on with the admin API unless [traffic] enabled = false)".to_string());
372    }
373    Ok(crate::traffic::summary::live_json(host.filter(|h| !h.is_empty()), crate::traffic::record::now()))
374}
375
376pub fn traffic_json(hours: u64, host: Option<&str>) -> Result<String, String> {
377    if !crate::traffic::enabled() {
378        return Err("traffic counting is off (on with the admin API unless [traffic] enabled = false)".to_string());
379    }
380    Ok(crate::traffic::summary::json(hours, host.filter(|h| !h.is_empty()), crate::traffic::record::now()))
381}
382
383/// `GET /traffic?from=YYYY-MM-DD&to=YYYY-MM-DD`: the summary for those days
384/// (within what's kept). `Err((404|400, message))`.
385pub fn traffic_range_json(from: &str, to: &str, host: Option<&str>) -> Result<String, (u16, String)> {
386    if !crate::traffic::enabled() {
387        return Err((404, "traffic counting is off (on with the admin API unless [traffic] enabled = false)".to_string()));
388    }
389    let (hours, end) = crate::traffic::summary::day_range(from, to, crate::traffic::record::now()).map_err(|e| (400, e))?;
390    Ok(crate::traffic::summary::json(hours, host.filter(|h| !h.is_empty()), end))
391}
392
393// ── Authorization (#32) ───────────────────────────────────────────────────────
394
395fn authorizer_slot() -> &'static RwLock<Option<crate::authz::Authorizer>> {
396    static SLOT: OnceLock<RwLock<Option<crate::authz::Authorizer>>> = OnceLock::new();
397    SLOT.get_or_init(RwLock::default)
398}
399
400/// The authorizer the admin API and MCP tools manage, for a library app
401/// (the config file's `[authz]` is used otherwise). Since 18.14.0.
402pub fn register_authorizer(authorizer: crate::authz::Authorizer) {
403    *authorizer_slot().write().unwrap_or_else(|e| e.into_inner()) = Some(authorizer);
404}
405
406/// The registered authorizer, else the config file's; `Err` says why there
407/// is none.
408pub fn authorizer() -> Result<crate::authz::Authorizer, String> {
409    if let Some(authorizer) = authorizer_slot().read().unwrap_or_else(|e| e.into_inner()).clone() {
410        return Ok(authorizer);
411    }
412    match crate::authz::config::current() {
413        Some(result) => result,
414        None => Err("no authorization configured ([authz] in the config file, or admin::ops::register_authorizer)".to_string()),
415    }
416}
417
418/// The schema: `{"types":{"doc":["owner","viewer"],…}}`.
419pub fn authz_schema_json() -> Result<String, String> {
420    let authorizer = authorizer()?;
421    let schema = authorizer.schema();
422    let types: Vec<String> = schema
423        .types()
424        .iter()
425        .map(|kind| format!("{}:[{}]", quoted(kind), schema.relations(kind).iter().map(|r| quoted(r)).collect::<Vec<_>>().join(",")))
426        .collect();
427    Ok(format!(r#"{{"types":{{{}}}}}"#, types.join(",")))
428}
429
430/// Tuples matching the given parts (each optional), at most `limit`.
431pub fn authz_tuples_json(object: Option<&str>, relation: Option<&str>, subject: Option<&str>, limit: usize) -> Result<String, String> {
432    let authorizer = authorizer()?;
433    let filter = crate::authz::TupleFilter {
434        object: object.filter(|o| o.contains(':')).map(crate::authz::ObjectRef::parse).transpose().map_err(|e| e.to_string())?,
435        object_kind: object.filter(|o| !o.contains(':')).map(str::to_string),
436        relation: relation.map(str::to_string),
437        subject: subject.map(crate::authz::Subject::parse).transpose().map_err(|e| e.to_string())?,
438    };
439    let tuples = authorizer.store().list(&filter, limit.clamp(1, 1000)).map_err(|e| e.to_string())?;
440    Ok(format!(r#"{{"tuples":[{}]}}"#, tuples.iter().map(|t| quoted(&t.to_string())).collect::<Vec<_>>().join(",")))
441}
442
443/// Write `tuple`; `{"tuple":…,"written":true|false}` (false: already there).
444pub fn authz_write(tuple: &str) -> Result<String, String> {
445    let tuple = crate::authz::Tuple::parse(tuple).map_err(|e| e.to_string())?;
446    let written = authorizer()?.write(&tuple).map_err(|e| e.to_string())?;
447    Ok(format!(r#"{{"tuple":{},"written":{written}}}"#, quoted(&tuple.to_string())))
448}
449
450/// Delete `tuple`; `{"tuple":…,"deleted":true|false}`.
451pub fn authz_delete(tuple: &str) -> Result<String, String> {
452    let tuple = crate::authz::Tuple::parse(tuple).map_err(|e| e.to_string())?;
453    let deleted = authorizer()?.delete(&tuple).map_err(|e| e.to_string())?;
454    Ok(format!(r#"{{"tuple":{},"deleted":{deleted}}}"#, quoted(&tuple.to_string())))
455}
456
457/// Whether `subject` has `relation` on `object`, with the path that allowed
458/// it: `{"allowed":true,"path":[…]}`.
459pub fn authz_check(subject: &str, relation: &str, object: &str) -> Result<String, String> {
460    let path = authorizer()?.explain(subject, relation, object).map_err(|e| e.to_string())?;
461    Ok(match path {
462        Some(path) => format!(r#"{{"allowed":true,"path":[{}]}}"#, path.iter().map(|p| quoted(p)).collect::<Vec<_>>().join(",")),
463        None => r#"{"allowed":false,"path":[]}"#.to_string(),
464    })
465}