use super::server::LimiteronState;
use super::service::{AdminService, AdminServiceError};
use axum::{Json, Router, response::Html, routing::get};
use std::sync::Arc;
#[async_trait::async_trait]
pub trait ReadOnlySnapshotSource: Send + Sync {
async fn status(&self) -> super::handlers::SystemStatus;
async fn introspect(&self) -> serde_json::Value;
#[cfg(feature = "circuit-breaker")]
async fn circuit_breaker_status(
&self,
) -> Result<super::handlers::CircuitBreakerStatus, AdminServiceError>;
}
#[async_trait::async_trait]
impl<T: AdminService + ?Sized> ReadOnlySnapshotSource for T {
async fn status(&self) -> super::handlers::SystemStatus {
AdminService::status(self).await
}
async fn introspect(&self) -> serde_json::Value {
AdminService::introspect(self).await
}
#[cfg(feature = "circuit-breaker")]
async fn circuit_breaker_status(
&self,
) -> Result<super::handlers::CircuitBreakerStatus, AdminServiceError> {
AdminService::circuit_breaker_status(self).await
}
}
pub(crate) const ROUTE_TABLE: [&str; 3] = ["/", "/snapshot", "/circuit-breaker"];
pub fn create_router(snapshot_source: Arc<dyn ReadOnlySnapshotSource>) -> Router {
let mut router = Router::new();
for path in ROUTE_TABLE {
let source = snapshot_source.clone();
router = router.route(
path,
get(move || async move { serve_snapshot(&source, path).await }),
);
}
router
}
async fn serve_snapshot(
source: &Arc<dyn ReadOnlySnapshotSource>,
path: &str,
) -> axum::response::Response {
match path {
"/" => axum::response::IntoResponse::into_response(Html(INDEX_HTML)),
"/snapshot" => axum::response::IntoResponse::into_response(Json(source.introspect().await)),
#[cfg(feature = "circuit-breaker")]
"/circuit-breaker" => {
let body = match source.circuit_breaker_status().await {
Ok(status) => serde_json::json!({
"success": true,
"message": crate::i18n::t_simple("admin-ui-message-ok"),
"data": status,
}),
Err(e) => serde_json::json!({
"success": false,
"message": e.to_string(),
"data": serde_json::Value::Null,
}),
};
axum::response::IntoResponse::into_response(Json(body))
}
#[cfg(not(feature = "circuit-breaker"))]
"/circuit-breaker" => {
axum::response::IntoResponse::into_response(Json(serde_json::json!({
"success": false,
"message": crate::i18n::t_simple("admin-ui-circuit-breaker-not-configured"),
"data": serde_json::Value::Null,
})))
}
other => axum::response::IntoResponse::into_response(Json(serde_json::json!({
"success": false,
"message": crate::i18n::t(
"admin-ui-unhandled-route",
&[("path", other.to_string())],
),
}))),
}
}
pub(crate) const INDEX_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Limiteron admin snapshot (read-only)</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }
h1 { font-size: 1.3rem; } h2 { font-size: 1.05rem; margin-top: 1.8rem; }
table { border-collapse: collapse; width: 100%; margin-top: .6rem; }
th, td { border: 1px solid #8884; padding: .35rem .55rem; text-align: left; font-size: .88rem; }
th { background: #8881; }
.meta { color: #888; font-size: .8rem; }
.ok { color: #1a7f37; } .bad { color: #c0392b; }
code { background: #8882; padding: .05rem .3rem; border-radius: 3px; }
</style>
</head>
<body>
<h1>Limiteron admin snapshot (read-only)</h1>
<p class="meta">This page issues GET requests only; auto-refresh <span id="refresh">5</span>s. Data sources: /snapshot, /circuit-breaker.</p>
<h2>Aggregate statistics</h2>
<table id="stats"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody></tbody></table>
<h2>Health</h2>
<table id="health"><thead><tr><th>Component</th><th>Status</th></tr></thead><tbody></tbody></table>
<h2>Rules and decision chains</h2>
<table id="rules"><thead><tr><th>Rule</th><th>Priority</th><th>Matcher</th><th>Limiter</th><th>Nodes</th></tr></thead><tbody></tbody></table>
<h2>Circuit breaker</h2>
<table id="cb"><thead><tr><th>State</th><th>Failure rate</th><th>Slow call rate</th></tr></thead><tbody></tbody></table>
<script>
function put(id, rows) {
const tb = document.querySelector(id + ' tbody');
tb.innerHTML = '';
for (const [k, v, cls] of rows) {
const tr = document.createElement('tr');
const td1 = document.createElement('td'); td1.textContent = k;
const td2 = document.createElement('td'); td2.textContent = String(v);
if (cls) td2.className = cls;
tr.append(td1, td2); tb.append(tr);
}
}
async function refresh() {
try {
const snap = await (await fetch('/snapshot')).json();
const s = snap.stats || {};
put('#stats', [
['Total requests', s.total_requests ?? '-'],
['Rejected requests', s.rejected_requests ?? '-'],
['Banned requests', s.banned_requests ?? '-'],
['Active limit keys', s.active_keys ?? '-'],
['Config version', snap.config_version ?? '-'],
['Shutdown', snap.is_shutdown ?? '-', snap.is_shutdown ? 'bad' : 'ok'],
]);
const h = snap.health || {};
const healthy = (v) => v === true || v === 'healthy';
put('#health', [
['storage', h.storage_healthy ?? '-', healthy(h.storage_healthy) ? 'ok' : 'bad'],
['ban_storage', h.ban_storage_healthy ?? '-', healthy(h.ban_storage_healthy) ? 'ok' : 'bad'],
['cache', h.cache_healthy ?? '-', healthy(h.cache_healthy) ? 'ok' : 'bad'],
['background_tasks', h.background_tasks_alive ?? '-', healthy(h.background_tasks_alive) ? 'ok' : 'bad'],
]);
const rules = snap.rules || [];
const chains = snap.chains || [];
const chainNodes = (id) => {
const c = chains.find((x) => x.rule_id === id || x.id === id);
return c ? (c.node_count ?? (c.nodes || []).length) : '-';
};
put('#rules', rules.map((r) => [
(r.id ?? '') + ' / ' + (r.name ?? ''),
r.priority ?? '-',
r.matcher_count ?? (r.matchers || []).length,
r.limiter_kinds ? r.limiter_kinds.join(', ') : ((r.limiters || []).length),
chainNodes(r.id),
]));
try {
const cb = await (await fetch('/circuit-breaker')).json();
const d = cb.data || {};
put('#cb', [[
d.state ?? 'unavailable', d.failure_rate ?? '-',
d.slow_call_rate ?? '-',
].slice(0, 1).map((v) => [ 'state', v, v === 'Open' ? 'bad' : 'ok' ])[0],
['failure_rate', d.failure_rate ?? '-'],
['slow_call_rate', d.slow_call_rate ?? '-']]);
} catch { put('#cb', [['state', 'unavailable', 'bad']]); }
} catch (e) {
put('#health', [['fetch', String(e), 'bad']]);
}
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
"#;
#[derive(Debug, Clone)]
pub struct WebUiConfig {
pub host: String,
pub port: u16,
}
impl Default for WebUiConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 9091,
}
}
}
impl WebUiConfig {
#[must_use]
pub fn is_loopback_binding(&self) -> bool {
self.host
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(matches!(self.host.as_str(), "localhost"))
}
#[must_use]
pub fn address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
pub struct WebUiServer {
state: LimiteronState,
config: WebUiConfig,
}
impl WebUiServer {
#[must_use]
pub fn new(state: LimiteronState, config: WebUiConfig) -> Self {
Self { state, config }
}
#[must_use]
pub fn address(&self) -> String {
self.config.address()
}
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if !self.config.is_loopback_binding() {
tracing::warn!(
address = %self.config.address(),
"web UI bound to a non-loopback address: the snapshot endpoints are UNAUTHENTICATED; \
expose only behind an authenticating reverse proxy (TLS terminated there)"
);
}
let router = create_router(Arc::new(self.state.service()));
let listener = tokio::net::TcpListener::bind(self.config.address()).await?;
log::info!(
target: "admin-ui",
"read-only web UI listening on {} (auth: none; data: GET-only snapshots)",
self.config.address()
);
axum::serve(listener, router).await?;
Ok(())
}
pub fn into_router(&self) -> Router {
create_router(Arc::new(self.state.service()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::LimiteronState;
use crate::admin::handlers::tests::make_state;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tower::ServiceExt;
#[test]
fn route_table_is_get_only() {
assert_eq!(ROUTE_TABLE.len(), 3);
assert!(ROUTE_TABLE.contains(&"/"));
assert!(ROUTE_TABLE.contains(&"/snapshot"));
assert!(ROUTE_TABLE.contains(&"/circuit-breaker"));
}
#[tokio::test]
async fn route_table_paths_are_all_registered_and_serve() {
let state = make_state().await;
let server = WebUiServer::new(state, WebUiConfig::default());
let router = server.into_router();
for path in ROUTE_TABLE {
let response = router
.clone()
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"route table path {path} is not registered (table drifted from Router)"
);
}
}
#[tokio::test]
async fn non_get_methods_are_rejected_on_every_path() {
let state = make_state().await;
let server = WebUiServer::new(state, WebUiConfig::default());
let router = server.into_router();
for path in ROUTE_TABLE {
for method in [Method::POST, Method::PUT, Method::DELETE] {
let response = router
.clone()
.oneshot(
Request::builder()
.method(method.clone())
.uri(path)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"{method} {path} must be rejected (read-only guard)"
);
}
}
}
#[tokio::test]
async fn get_endpoints_serve_readonly_snapshots() {
let state = make_state().await;
let server = WebUiServer::new(state, WebUiConfig::default());
let router = server.into_router();
let index = router
.clone()
.oneshot(Request::get("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(index.status(), StatusCode::OK);
let body = axum::body::to_bytes(index.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("read-only"),
"the embedded single page should state its read-only nature"
);
assert!(
!html.contains("POST") && !html.contains("DELETE"),
"the page script must not contain write method calls"
);
let snapshot = router
.oneshot(Request::get("/snapshot").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(snapshot.status(), StatusCode::OK);
let body = axum::body::to_bytes(snapshot.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("stats").is_some(),
"the snapshot should include the stats surface"
);
}
#[test]
fn binding_defaults_to_loopback_and_flags_non_loopback() {
let config = WebUiConfig::default();
assert!(config.is_loopback_binding());
assert_eq!(config.address(), "127.0.0.1:9091");
let exposed = WebUiConfig {
host: "0.0.0.0".to_string(),
port: 8080,
};
assert!(!exposed.is_loopback_binding());
}
#[tokio::test]
async fn readonly_projection_serves_from_state() {
let state: LimiteronState = make_state().await;
let source: Arc<dyn ReadOnlySnapshotSource> = Arc::new(state.service());
let status = source.status().await;
assert_eq!(status.total_requests, 0);
let snapshot = source.introspect().await;
assert!(snapshot.get("stats").is_some());
}
#[tokio::test]
async fn server_serves_on_loopback() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let state = make_state().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let config = WebUiConfig {
host: "127.0.0.1".to_string(),
port: addr.port(),
};
let server = WebUiServer::new(state, config);
let handle = tokio::spawn(async move { server.start().await });
let mut attempt = 0;
let mut stream = loop {
attempt += 1;
match tokio::net::TcpStream::connect(addr).await {
Ok(s) => break s,
Err(_) if attempt < 50 => {
tokio::time::sleep(std::time::Duration::from_millis(20)).await
}
Err(e) => panic!("server never became reachable: {e}"),
}
};
stream
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let text = String::from_utf8_lossy(&buf);
assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}");
handle.abort();
}
}