Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/admin/handler.rs

70.7 KiB, 573 runs

created by r1870400018:10324, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! HTTP dispatcher for the admin dashboard.
2//!
3//! Routes `/admin/*` paths to login, logout, and authenticated views.
4//! Extracts the session cookie via [`session`](super::session) on
5//! every authenticated request and rejects any path whose principal
6//! lacks the needed scope.
7//!
8//! The same dispatcher is reused by the loopback plaintext listener
9//! added in task #8 -- localhost gets the same routes, the same
10//! login gate, and the same session cookie format.
11//!
12//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
13//! Anthropic Claude
14
15use crate::srv::admin::{
16 AdminPrincipal,
17 assets::{
18 html_escape,
19 render_layout,
20 render_login_layout,
21 upload_head_html,
22 },
23 audit::{
24 self,
25 ADMIN_ANON,
26 VERB_DASHBOARD_ADMIN_ADD,
27 VERB_DASHBOARD_ADMIN_REMOVE,
28 VERB_DASHBOARD_GUARD_BLACKLIST,
29 VERB_DASHBOARD_GUARD_UNBLOCK,
30 VERB_DASHBOARD_GUARD_WHITELIST,
31 VERB_DASHBOARD_LOGIN,
32 VERB_DASHBOARD_LOGOUT,
33 },
34 auth::{
35 self,
36 LoginOutcome,
37 },
38 guard::DEFAULT_SNAPSHOT_CAP,
39 session::{
40 self,
41 SESSION_COOKIE_NAME,
42 },
43 state::AdminState,
44};
45
46use oxedyne_fe2o3_crypto::keystore::DEFAULT_WALLET_KDF_NAME;
47use oxedyne_fe2o3_jdat::{
48 file::JdatFile,
49 string::enc::EncoderConfig,
50};
51
52use std::{
53 net::SocketAddr,
54 time::{
55 SystemTime,
56 UNIX_EPOCH,
57 },
58};
59
60use oxedyne_fe2o3_core::prelude::*;
61use oxedyne_fe2o3_net::http::{
62 fields::{
63 Cookie,
64 HeaderFields,
65 HeaderFieldValue,
66 HeaderName,
67 SameSite,
68 SetCookieAttributes,
69 },
70 msg::HttpMessage,
71 status::HttpStatus,
72};
73
74use std::{
75 collections::BTreeSet,
76 sync::Arc,
77};
78
79// ┌───────────────────────────────────────────────────────────────────────────┐
80// │ ROUTE PATHS │
81// └───────────────────────────────────────────────────────────────────────────┘
82
83pub const PATH_ROOT: &str = "/admin";
84pub const PATH_LOGIN: &str = "/admin/login";
85pub const PATH_LOGOUT: &str = "/admin/logout";
86pub const PATH_TRAFFIC: &str = "/admin/traffic";
87pub const PATH_HOST_JSON: &str = "/admin/host.json";
88pub const PATH_TRAFFIC_JSON: &str = "/admin/traffic.json";
89pub const PATH_SECURITY: &str = "/admin/security";
90pub const PATH_FLEET: &str = "/admin/fleet";
91pub const PATH_FLEET_JSON: &str = "/admin/fleet.json";
92pub const PATH_ADMINS: &str = "/admin/admins";
93// Served unauthenticated, so the login page can use the font for headings
94// before the visitor has a session.
95pub const PATH_ASSET_OXANIUM: &str = "/admin/assets/oxanium.ttf";
96pub const PATH_CHALLENGE: &str = "/admin/challenge";
97pub const PATH_SIGNED_LOGIN: &str = "/admin/signed-login";
98
99// ┌───────────────────────────────────────────────────────────────────────────┐
100// │ GET │
101// └───────────────────────────────────────────────────────────────────────────┘
102
103pub async fn handle_get(
104 state: &AdminState,
105 path: &str,
106 headers: &Arc<HeaderFields>,
107 peer: SocketAddr,
108 id: &str,
109)
110 -> Outcome<HttpMessage>
111{
112 let _ = peer; // GET routes do not yet audit by address.
113 debug!("{}: dashboard GET {}", id, path);
114 // `/admin/` is `/admin`. A person types the slash, or a browser adds it to a
115 // bare directory name, and either way it is the dashboard root -- not an
116 // unknown sub-route to 404. Only the root is folded: `/admin/traffic/` is a
117 // real miss, since nothing serves it.
118 let path = if path == "/admin/" { PATH_ROOT } else { path };
119 match path {
120 PATH_ASSET_OXANIUM => Ok(serve_font_oxanium()),
121 PATH_LOGIN => Ok(render_login_form(state.seal_withholds_data(), None)),
122 PATH_LOGOUT => Ok(handle_logout(state, headers)),
123 PATH_ROOT => Ok(render_home(state, headers)),
124 PATH_TRAFFIC => Ok(render_traffic(state, headers)),
125 PATH_HOST_JSON => Ok(render_host_json(state, headers)),
126 PATH_TRAFFIC_JSON => Ok(render_traffic_json(state, headers)),
127 PATH_SECURITY => Ok(render_security(state, headers, None)),
128 PATH_FLEET => Ok(crate::srv::admin::fleet_view::render_fleet_page(state, headers)),
129 PATH_FLEET_JSON => Ok(crate::srv::admin::fleet_view::render_fleet_json(state, headers)),
130 PATH_ADMINS => Ok(render_admins(state, headers, None)),
131 PATH_CHALLENGE => Ok(
132 crate::srv::admin::signed_login::handle_challenge(state),
133 ),
134 _ => Ok(HttpMessage::respond_with_text(
135 HttpStatus::NotFound,
136 "Dashboard route not found.",
137 )),
138 }
139}
140
141/// Carries a long-cache header so the browser keeps one copy across dashboard
142/// navigations.
143fn serve_font_oxanium() -> HttpMessage {
144 HttpMessage::new_response(HttpStatus::OK)
145 .with_field(
146 HeaderName::ContentType,
147 HeaderFieldValue::Generic("font/ttf".to_string()),
148 )
149 .with_field(
150 HeaderName::CacheControl,
151 HeaderFieldValue::Generic("public, max-age=86400, immutable".to_string()),
152 )
153 .with_body(crate::srv::admin::assets::FONT_OXANIUM_TTF.to_vec())
154}
155
156// ┌───────────────────────────────────────────────────────────────────────────┐
157// │ POST │
158// └───────────────────────────────────────────────────────────────────────────┘
159
160pub async fn handle_post(
161 state: &AdminState,
162 path: &str,
163 body: &[u8],
164 _headers: &Arc<HeaderFields>,
165 peer: SocketAddr,
166 id: &str,
167)
168 -> Outcome<HttpMessage>
169{
170 debug!("{}: dashboard POST {}", id, path);
171 let path = if path == "/admin/" { PATH_ROOT } else { path };
172 match path {
173 PATH_LOGIN => Ok(handle_login(state, body, peer)),
174 PATH_SIGNED_LOGIN => Ok(handle_signed_login(state, body)),
175 PATH_SECURITY => Ok(handle_security_post(state, _headers, body)),
176 PATH_ADMINS => Ok(handle_admins_post(state, _headers, body)),
177 _ => Ok(HttpMessage::respond_with_text(
178 HttpStatus::NotFound,
179 "Dashboard route not found.",
180 )),
181 }
182}
183
184/// Verifies a signed-admin-login envelope and, on success, issues the same admin
185/// session cookie the passphrase flow issues. A failure returns a short JDAT
186/// error body rather than re-rendering the passphrase login form: the client is
187/// a programmatic caller, not a browser form submission.
188fn handle_signed_login(
189 state: &AdminState,
190 body: &[u8],
191)
192 -> HttpMessage
193{
194 use crate::srv::admin::signed_login::{
195 audit_signed_login,
196 verify_signed_login,
197 SignedLoginOutcome,
198 };
199 let outcome = verify_signed_login(state, body);
200 audit_signed_login(&outcome);
201 match outcome {
202 SignedLoginOutcome::Ok(principal) => issue_session_cookie(
203 state, &principal,
204 ),
205 _ => HttpMessage::respond_with_text(
206 HttpStatus::Unauthorized,
207 "Signed admin login rejected.",
208 ),
209 }
210}
211
212// ┌───────────────────────────────────────────────────────────────────────────┐
213// │ LOGIN │
214// └───────────────────────────────────────────────────────────────────────────┘
215
216/// Processes a login form submission. On success, sets the session cookie and
217/// 303-redirects to `/admin`; on failure, re-renders the login form with a
218/// generic error message.
219///
220/// Every outcome -- success, bad credentials, missing dashboard scope,
221/// structural error -- writes one line to the admin audit log, under the
222/// unlocked admin's name where known and `(anon)` otherwise. The response to
223/// the client is deliberately generic regardless of outcome, so the audit log
224/// is the only place where "wrong passphrase" and "no dashboard scope" can be
225/// told apart.
226fn handle_login(
227 state: &AdminState,
228 body: &[u8],
229 peer: SocketAddr,
230)
231 -> HttpMessage
232{
233 let passphrase = match extract_form_field(body, "passphrase") {
234 Some(p) => p,
235 None => {
236 audit::append(
237 ADMIN_ANON,
238 VERB_DASHBOARD_LOGIN,
239 "err",
240 "reason=missing_passphrase_field",
241 );
242 return render_login_form(state.seal_withholds_data(), Some(
243 "Login form did not include a passphrase."));
244 },
245 };
246
247 let outcome = match auth::verify_passphrase(state, passphrase.as_bytes(), peer) {
248 Ok(o) => o,
249 Err(e) => {
250 warn!("dashboard login: structural error during verify: {}", e);
251 audit::append(
252 ADMIN_ANON,
253 VERB_DASHBOARD_LOGIN,
254 "err",
255 "reason=verify_structural_error",
256 );
257 return render_login_form(state.seal_withholds_data(), Some("Internal error during login."));
258 },
259 };
260
261 match outcome {
262 LoginOutcome::Ok(principal) => {
263 audit::append(
264 &principal.name,
265 VERB_DASHBOARD_LOGIN,
266 "ok",
267 &fmt!("scopes={} src={}",
268 principal.scopes.join(","), peer.ip()),
269 );
270 issue_session_cookie(state, &principal)
271 },
272 LoginOutcome::BadCredentials => {
273 audit::append(
274 ADMIN_ANON,
275 VERB_DASHBOARD_LOGIN,
276 "err",
277 &fmt!("reason=bad_credentials src={}", peer.ip()),
278 );
279 // Generic message: do not leak whether any admin exists.
280 render_login_form(state.seal_withholds_data(), Some("Invalid credentials."))
281 },
282 LoginOutcome::NoDashboardScope { name } => {
283 audit::append(
284 &name,
285 VERB_DASHBOARD_LOGIN,
286 "err",
287 &fmt!("reason=no_dashboard_scope src={}", peer.ip()),
288 );
289 warn!("dashboard login: admin '{}' authenticated but \
290 holds no dashboard scope.", name);
291 render_login_form(state.seal_withholds_data(), Some(
292 "Authenticated, but this admin is not authorised \
293 to use the dashboard. Ask an operator to grant \
294 'dashboard.view' or 'dashboard.admin'."))
295 },
296 }
297}
298
299fn issue_session_cookie(
300 state: &AdminState,
301 principal: &AdminPrincipal,
302)
303 -> HttpMessage
304{
305 let cookie_value = match session::encode_session(state, principal) {
306 Ok(s) => s,
307 Err(e) => {
308 error!(e, "dashboard login: failed to encode session cookie");
309 return render_login_form(state.seal_withholds_data(), Some(
310 "Login succeeded but session encoding failed."));
311 },
312 };
313 let cookie = build_session_cookie(cookie_value, false);
314 HttpMessage::new_response(HttpStatus::SeeOther)
315 .with_field(
316 HeaderName::Location,
317 HeaderFieldValue::Generic(PATH_ROOT.to_string()),
318 )
319 .set_cookie(cookie)
320}
321
322/// The cookie is constrained to the `/admin` path and marked `HttpOnly`,
323/// `Secure` and `SameSite=Strict`, so page JavaScript cannot read it and
324/// cross-site requests cannot present it. `clear` produces a `Max-Age=0`
325/// cookie, which is how the logout flow evicts the browser's stored copy.
326fn build_session_cookie(cookie_value: String, clear: bool) -> Cookie {
327 let mut attrs: BTreeSet<SetCookieAttributes> = BTreeSet::new();
328 attrs.insert(SetCookieAttributes::Path("/admin".to_string()));
329 attrs.insert(SetCookieAttributes::HttpOnly);
330 attrs.insert(SetCookieAttributes::Secure);
331 attrs.insert(SetCookieAttributes::SameSite(SameSite::Strict));
332 if clear {
333 attrs.insert(SetCookieAttributes::MaxAge(0));
334 }
335 Cookie {
336 key: SESSION_COOKIE_NAME.to_string(),
337 val: cookie_value,
338 attrs: Some(attrs),
339 }
340}
341
342// ┌───────────────────────────────────────────────────────────────────────────┐
343// │ LOGOUT │
344// └───────────────────────────────────────────────────────────────────────────┘
345
346/// Stateless logout: sessions are not stored server-side, so setting the cookie
347/// empty with `Max-Age=0` and redirecting to the login form is sufficient.
348///
349/// Audits the logout under the actor's name when a valid session cookie is
350/// present and as `(anon)` otherwise, so the log distinguishes "Alice signed
351/// out" from "an unauthorised visitor hit /admin/logout".
352fn handle_logout(
353 state: &AdminState,
354 headers: &Arc<HeaderFields>,
355)
356 -> HttpMessage
357{
358 let actor = match extract_principal(state, headers) {
359 Some(p) => p.name,
360 None => ADMIN_ANON.to_string(),
361 };
362 audit::append(&actor, VERB_DASHBOARD_LOGOUT, "ok", "");
363 let cookie = build_session_cookie(String::new(), true);
364 HttpMessage::new_response(HttpStatus::SeeOther)
365 .with_field(
366 HeaderName::Location,
367 HeaderFieldValue::Generic(PATH_LOGIN.to_string()),
368 )
369 .set_cookie(cookie)
370}
371
372// ┌───────────────────────────────────────────────────────────────────────────┐
373// │ HOME │
374// └───────────────────────────────────────────────────────────────────────────┘
375
376/// Validates the session cookie and, on any failure -- no cookie, tampered,
377/// expired, unknown version -- sends the visitor to the login form.
378fn render_home(
379 state: &AdminState,
380 headers: &Arc<HeaderFields>,
381)
382 -> HttpMessage
383{
384 let principal = match extract_principal(state, headers) {
385 Some(p) => p,
386 None => return redirect_to_login(),
387 };
388 let host_block = render_host_sparkline_strip();
389 let body = fmt!(
390 "<h1>Overview</h1>\n\
391 <p>Welcome, <strong>{}</strong>.</p>\n\
392 <p class=\"meta\">Scopes: <code>{}</code></p>\n\
393 {host}\
394 <h2>Live views</h2>\n\
395 <ul>\n\
396 <li><a href=\"/admin/fleet\">Fleet</a> &mdash; \
397 this host and every machine its watcher reads, judged against \
398 the alarm's own thresholds (requires <code>dashboard.admin</code>).</li>\n\
399 <li><a href=\"/admin/traffic\">Traffic</a> &mdash; \
400 recent requests across every vhost on this host.</li>\n\
401 <li><a href=\"/admin/database\">Database</a> &mdash; \
402 browse keys and fetch values from this vhost's ozone database.</li>\n\
403 <li><a href=\"/admin/security\">Security</a> &mdash; \
404 address guard state and per-IP controls.</li>\n\
405 <li><a href=\"/admin/admins\">Admins</a> &mdash; \
406 manage wallet admin entries (requires <code>admin</code> scope).</li>\n\
407 </ul>\n",
408 html_escape(&principal.name),
409 html_escape(&principal.scopes.join(" ")),
410 host = host_block,
411 );
412 let head_extra = fmt!(
413 "{uplot}\n<script>{spark}</script>\n<script>{refresh}</script>\n",
414 uplot = upload_head_html(),
415 spark = crate::srv::admin::assets::OVERVIEW_SPARKLINE_JS,
416 refresh = crate::srv::admin::assets::AUTO_REFRESH_JS,
417 );
418 let html = render_layout(
419 "Overview",
420 "/admin",
421 &principal,
422 &body,
423 &head_extra,
424 );
425 html_response(html)
426}
427
428/// The inline JS shipped alongside in `head_extra` fetches `/admin/host.json`
429/// and populates each card's headline value and uPlot chart.
430fn render_host_sparkline_strip() -> String {
431 "<h2>Host resources</h2>\n\
432 <p class=\"meta\">Last hour, sampled every 5 s. \
433 Waiting on the first full pair of samples before charts draw.</p>\n\
434 <div class=\"spark-row\">\n\
435 <div class=\"spark-card\">\
436 <div class=\"spark-header\">\
437 <span class=\"spark-label\">CPU busy</span>\
438 <span class=\"spark-value\" id=\"spark-cpu-val\">&mdash;</span>\
439 </div>\
440 <div class=\"spark-plot\" id=\"spark-cpu\"></div>\
441 </div>\n\
442 <div class=\"spark-card\">\
443 <div class=\"spark-header\">\
444 <span class=\"spark-label\">Memory used</span>\
445 <span class=\"spark-value\" id=\"spark-mem-val\">&mdash;</span>\
446 </div>\
447 <div class=\"spark-plot\" id=\"spark-mem\"></div>\
448 </div>\n\
449 <div class=\"spark-card\">\
450 <div class=\"spark-header\">\
451 <span class=\"spark-label\">Disk I/O</span>\
452 <span class=\"spark-value\" id=\"spark-disk-val\">&mdash;</span>\
453 </div>\
454 <div class=\"spark-plot\" id=\"spark-disk\"></div>\
455 </div>\n\
456 <div class=\"spark-card\">\
457 <div class=\"spark-header\">\
458 <span class=\"spark-label\">Network</span>\
459 <span class=\"spark-value\" id=\"spark-net-val\">&mdash;</span>\
460 </div>\
461 <div class=\"spark-plot\" id=\"spark-net\"></div>\
462 </div>\n\
463 </div>\n".to_string()
464}
465
466/// Serialises the host sampler history as four time-aligned series.
467///
468/// The series are computed at the later-of-pair timestamp because the
469/// rate-based figures (CPU busy, disk throughput, network throughput) need two
470/// snapshots. Memory is a level metric, emitted at the same later-of-pair
471/// timestamp for alignment.
472fn render_host_json(
473 state: &AdminState,
474 headers: &Arc<HeaderFields>,
475)
476 -> HttpMessage
477{
478 if extract_principal(state, headers).is_none() {
479 return HttpMessage::respond_with_text(
480 HttpStatus::Unauthorized,
481 "Sign in required.",
482 );
483 }
484
485 let merged = match state.host_sampler.merged_derived_history() {
486 Ok(v) => v,
487 Err(e) => {
488 error!(e, "dashboard: host merged_derived_history failed");
489 return HttpMessage::respond_with_text(
490 HttpStatus::InternalServerError,
491 "Host sampler error.",
492 );
493 },
494 };
495
496 // No points yet (fewer than two live samples and nothing persisted).
497 // Emit an empty payload so the browser-side JS can render the
498 // "warming up" state cleanly rather than throw on undefined.
499 if merged.is_empty() {
500 let empty = "{\"t\":[],\"cpu\":[],\"mem\":[],\"disk\":[],\"net\":[]}";
501 return HttpMessage::new_response(HttpStatus::OK)
502 .with_field(
503 HeaderName::ContentType,
504 HeaderFieldValue::Generic(
505 "application/json; charset=utf-8".to_string()),
506 )
507 .with_field(
508 HeaderName::CacheControl,
509 HeaderFieldValue::Generic("no-store".to_string()),
510 )
511 .with_body(empty.as_bytes().to_vec());
512 }
513
514 let mut ts = String::from("[");
515 let mut cpu = String::from("[");
516 let mut mem = String::from("[");
517 let mut disk = String::from("[");
518 let mut net = String::from("[");
519 for p in &merged {
520 if !ts.ends_with('[') {
521 ts.push(',');
522 cpu.push(',');
523 mem.push(',');
524 disk.push(',');
525 net.push(',');
526 }
527 ts.push_str(&fmt!("{}", p.t_secs));
528 cpu.push_str(&format_float(p.cpu_pct));
529 mem.push_str(&format_float(p.mem_pct));
530 disk.push_str(&format_float(p.disk_bps));
531 net.push_str(&format_float(p.net_bps));
532 }
533 ts.push(']');
534 cpu.push(']');
535 mem.push(']');
536 disk.push(']');
537 net.push(']');
538
539 let body = fmt!(
540 "{{\"t\":{t},\"cpu\":{cpu},\"mem\":{mem},\"disk\":{disk},\"net\":{net}}}",
541 t = ts,
542 cpu = cpu,
543 mem = mem,
544 disk = disk,
545 net = net,
546 );
547
548 HttpMessage::new_response(HttpStatus::OK)
549 .with_field(
550 HeaderName::ContentType,
551 HeaderFieldValue::Generic(
552 "application/json; charset=utf-8".to_string()),
553 )
554 .with_field(
555 HeaderName::CacheControl,
556 HeaderFieldValue::Generic("no-store".to_string()),
557 )
558 .with_body(body.into_bytes())
559}
560
561/// One decimal place, which keeps the response bounded and matches what uPlot
562/// will render anyway. `NaN` and the infinities fall back to `0`, so the
563/// emitted document is always valid JSON.
564fn format_float(v: f64) -> String {
565 if !v.is_finite() {
566 return "0".to_string();
567 }
568 let scaled = (v * 10.0).round() as i64;
569 let whole = scaled / 10;
570 let frac = (scaled % 10).abs();
571 fmt!("{}.{}", whole, frac)
572}
573
574/// Counters, chart series and the recent-requests table in a single payload, so
575/// the auto-refresh client can update each section without reloading the page.
576fn render_traffic_json(
577 state: &AdminState,
578 headers: &Arc<HeaderFields>,
579)
580 -> HttpMessage
581{
582 if extract_principal(state, headers).is_none() {
583 return HttpMessage::respond_with_text(
584 HttpStatus::Unauthorized,
585 "Sign in required.",
586 );
587 }
588
589 let counters = match state.traffic.counters_snapshot() {
590 Ok(c) => c,
591 Err(e) => {
592 error!(e, "dashboard: traffic counters_snapshot() failed");
593 crate::srv::admin::traffic::CountersSnapshot::default()
594 },
595 };
596 let history = match state.traffic.history_snapshot() {
597 Ok(v) => v,
598 Err(e) => {
599 error!(e, "dashboard: traffic history_snapshot() failed");
600 Vec::new()
601 },
602 };
603 let recent = match state.traffic.recent(50) {
604 Ok(v) => v,
605 Err(e) => {
606 error!(e, "dashboard: traffic recent() failed");
607 Vec::new()
608 },
609 };
610
611 let mut status_keys: Vec<u16> = counters.by_status.keys().copied().collect();
612 status_keys.sort();
613 let rate_last = compute_rate_last(&history);
614 let chart_json = build_chart_json(&history, &status_keys);
615
616 // Build the counters JSON by hand to stay dependency-free.
617 let mut by_status_json = String::from("[");
618 for (i, s) in status_keys.iter().enumerate() {
619 if i > 0 { by_status_json.push(','); }
620 let count = counters.by_status.get(s).copied().unwrap_or(0);
621 by_status_json.push_str(&fmt!(
622 "{{\"code\":{c},\"count\":{n}}}",
623 c = s,
624 n = count,
625 ));
626 }
627 by_status_json.push(']');
628
629 let mut recent_json = String::from("[");
630 for (i, r) in recent.iter().enumerate() {
631 if i > 0 { recent_json.push(','); }
632 recent_json.push_str(&fmt!(
633 "{{\"method\":\"{m}\",\"vhost\":\"{v}\",\"path\":\"{p}\",\
634 \"status\":{s},\"duration\":\"{d}\"}}",
635 m = json_escape(&r.method),
636 v = json_escape(&r.vhost),
637 p = json_escape(&r.path),
638 s = r.status,
639 d = json_escape(&format_duration_us(r.duration_us)),
640 ));
641 }
642 recent_json.push(']');
643
644 let body = fmt!(
645 "{{\"counters\":{{\"total\":{total},\"rate\":{rate},\
646 \"by_status\":{by_status}}},\
647 \"chart\":{chart},\"recent\":{recent}}}",
648 total = counters.total,
649 rate = format_float(rate_last),
650 by_status = by_status_json,
651 chart = chart_json,
652 recent = recent_json,
653 );
654
655 HttpMessage::new_response(HttpStatus::OK)
656 .with_field(
657 HeaderName::ContentType,
658 HeaderFieldValue::Generic(
659 "application/json; charset=utf-8".to_string()),
660 )
661 .with_field(
662 HeaderName::CacheControl,
663 HeaderFieldValue::Generic("no-store".to_string()),
664 )
665 .with_body(body.into_bytes())
666}
667
668/// Escapes what a JSON string cannot hold raw: backslash, double quote and the
669/// control characters. Everything else, non-ASCII included, is legal as it is.
670pub(crate) fn json_escape(s: &str) -> String {
671 let mut out = String::with_capacity(s.len());
672 for ch in s.chars() {
673 match ch {
674 '"' => out.push_str("\\\""),
675 '\\' => out.push_str("\\\\"),
676 '\n' => out.push_str("\\n"),
677 '\r' => out.push_str("\\r"),
678 '\t' => out.push_str("\\t"),
679 c if (c as u32) < 0x20 => {
680 out.push_str(&fmt!("\\u{:04x}", c as u32));
681 },
682 c => out.push(c),
683 }
684 }
685 out
686}
687
688// ┌───────────────────────────────────────────────────────────────────────────┐
689// │ TRAFFIC │
690// └───────────────────────────────────────────────────────────────────────────┘
691
692/// Renders the live traffic view from the shared `TrafficRecorder`:
693///
694/// - A headline card with the monotonic total, the per-status summary, and the
695/// rate over the last sample window.
696/// - A uPlot chart drawn from the periodic sample history, showing request rate
697/// over time.
698/// - The most recent 50 request records as a table.
699///
700/// The chart is drawn client-side by a small inline JavaScript fragment that
701/// reads a JSON blob embedded in the page.
702fn render_traffic(
703 state: &AdminState,
704 headers: &Arc<HeaderFields>,
705)
706 -> HttpMessage
707{
708 let principal = match extract_principal(state, headers) {
709 Some(p) => p,
710 None => return redirect_to_login(),
711 };
712
713 let recent = match state.traffic.recent(50) {
714 Ok(v) => v,
715 Err(e) => {
716 error!(e, "dashboard: traffic recent() failed");
717 Vec::new()
718 },
719 };
720 let counters = match state.traffic.counters_snapshot() {
721 Ok(c) => c,
722 Err(e) => {
723 error!(e, "dashboard: traffic counters_snapshot() failed");
724 crate::srv::admin::traffic::CountersSnapshot::default()
725 },
726 };
727 let history = match state.traffic.history_snapshot() {
728 Ok(v) => v,
729 Err(e) => {
730 error!(e, "dashboard: traffic history_snapshot() failed");
731 Vec::new()
732 },
733 };
734
735 // Collect per-status codes in sorted order so the legend is
736 // stable across refreshes.
737 let mut status_keys: Vec<u16> = counters.by_status
738 .keys().copied().collect();
739 status_keys.sort();
740
741 let rate_last = compute_rate_last(&history);
742
743 // Build the headline strip.
744 let mut headline_chips = String::new();
745 headline_chips.push_str(&fmt!(
746 "<div class=\"chip chip-total\">\
747 <div class=\"chip-label\">Total</div>\
748 <div class=\"chip-value\">{}</div>\
749 <div class=\"chip-sub\">requests since startup</div>\
750 </div>\n",
751 counters.total,
752 ));
753 headline_chips.push_str(&fmt!(
754 "<div class=\"chip\">\
755 <div class=\"chip-label\">Rate</div>\
756 <div class=\"chip-value\">{rate:.2}</div>\
757 <div class=\"chip-sub\">requests / sec (last interval)</div>\
758 </div>\n",
759 rate = rate_last,
760 ));
761 for s in &status_keys {
762 let count = counters.by_status.get(s).copied().unwrap_or(0);
763 let cls = chip_class_for_status(*s);
764 headline_chips.push_str(&fmt!(
765 "<div class=\"chip {cls}\">\
766 <div class=\"chip-label\">HTTP {status}</div>\
767 <div class=\"chip-value\">{count}</div>\
768 <div class=\"chip-sub\">{desc}</div>\
769 </div>\n",
770 cls = cls,
771 status = s,
772 count = count,
773 desc = status_description(*s),
774 ));
775 }
776
777 // Build the chart data as JSON. Each series is the delta
778 // between adjacent samples (so the chart shows
779 // "requests-in-this-sample-interval", not cumulative
780 // totals). Timestamps are unix seconds; uPlot expects
781 // numeric x values.
782 let chart_json = build_chart_json(&history, &status_keys);
783
784 // Build the recent-requests table.
785 let recent_html = if recent.is_empty() {
786 "<p class=\"notice empty\">\
787 No requests recorded yet. Interact with the dashboard \
788 or visit any vhost and this table will populate.\
789 </p>".to_string()
790 } else {
791 let mut rows = String::new();
792 for r in &recent {
793 let status_cls = chip_class_for_status(r.status);
794 rows.push_str(&fmt!(
795 "<tr>\
796 <td><code>{method}</code></td>\
797 <td>{vhost}</td>\
798 <td class=\"path\"><code>{path}</code></td>\
799 <td class=\"status {cls}\">{status}</td>\
800 <td class=\"num\">{dur}</td>\
801 </tr>\n",
802 method = html_escape(&r.method),
803 vhost = html_escape(&r.vhost),
804 path = html_escape(&r.path),
805 cls = status_cls,
806 status = r.status,
807 dur = format_duration_us(r.duration_us),
808 ));
809 }
810 fmt!(
811 "<h2>Recent requests</h2>\n\
812 <table class=\"steel-table\">\n\
813 <thead><tr>\
814 <th>Method</th><th>Vhost</th><th>Path</th>\
815 <th class=\"status\">Status</th><th class=\"num\">Duration</th>\
816 </tr></thead>\n\
817 <tbody id=\"traffic-recent-body\">{}</tbody>\n\
818 </table>\n",
819 rows,
820 )
821 };
822
823 // Page body. The chart container has a fixed height so
824 // uPlot can measure it before the JSON arrives.
825 let body = fmt!(
826 "<h1>Traffic</h1>\n\
827 <div class=\"chip-row\" id=\"traffic-chip-row\">\n{chips}</div>\n\
828 <h2>Requests per sample interval</h2>\n\
829 <div id=\"traffic-chart\" class=\"chart-panel\"></div>\n\
830 <script id=\"traffic-chart-data\" type=\"application/json\">\n\
831 {chart_json}\n\
832 </script>\n\
833 <script>\n{chart_js}\n</script>\n\
834 <script>\n{refresh_js}\n</script>\n\
835 {recent}",
836 chips = headline_chips,
837 chart_json = chart_json,
838 chart_js = TRAFFIC_CHART_JS,
839 refresh_js = crate::srv::admin::assets::AUTO_REFRESH_JS,
840 recent = recent_html,
841 );
842 let html = render_layout(
843 "Traffic",
844 PATH_TRAFFIC,
845 &principal,
846 &body,
847 &upload_head_html(),
848 );
849 html_response(html)
850}
851
852// Reads the chart JSON blob embedded in the page and draws a stacked
853// requests-per-interval chart with uPlot. Exposes `window.steelTrafficRefresh()`
854// so the auto-refresh polling loop can repaint the chip row, chart and
855// recent-requests table without reloading the page.
856const TRAFFIC_CHART_JS: &str = r#"
857(function() {
858 var el = document.getElementById('traffic-chart');
859 var dataEl = document.getElementById('traffic-chart-data');
860 if (!el || typeof uPlot === 'undefined') return;
861 var chart = null;
862 var chartSeriesLabels = [];
863 var palette = ['#f33c57', '#1976d2', '#2e7d32', '#ed6c02',
864 '#6a1b9a', '#00838f', '#455a64'];
865 function classForStatus(s) {
866 if (s >= 200 && s < 300) return 'chip-ok';
867 if (s >= 300 && s < 400) return 'chip-redirect';
868 if (s >= 400 && s < 500) return 'chip-client';
869 if (s >= 500 && s < 600) return 'chip-server';
870 return '';
871 }
872 function descForStatus(s) {
873 if (s >= 200 && s < 300) return 'success';
874 if (s >= 300 && s < 400) return 'redirect';
875 if (s >= 400 && s < 500) return 'client error';
876 if (s >= 500 && s < 600) return 'server error';
877 return 'informational';
878 }
879 function buildChart(payload) {
880 var series = [{}];
881 chartSeriesLabels = [];
882 for (var i = 0; i < payload.series.length; i++) {
883 series.push({
884 label: payload.series[i].label,
885 stroke: palette[i % palette.length],
886 width: 2,
887 fill: palette[i % palette.length] + '22',
888 paths: uPlot.paths.stepped({align: 1}),
889 });
890 chartSeriesLabels.push(payload.series[i].label);
891 }
892 var data = [payload.t];
893 for (var j = 0; j < payload.series.length; j++) {
894 data.push(payload.series[j].values);
895 }
896 var opts = {
897 width: el.clientWidth || 800,
898 height: 260,
899 scales: {x: {time: true}},
900 axes: [
901 {stroke: '#666', grid: {stroke: '#eee'}},
902 {stroke: '#666', grid: {stroke: '#eee'}},
903 ],
904 series: series,
905 legend: {live: false},
906 };
907 chart = new uPlot(opts, data, el);
908 }
909 function chartSchemaMatches(payload) {
910 if (payload.series.length !== chartSeriesLabels.length) return false;
911 for (var i = 0; i < payload.series.length; i++) {
912 if (payload.series[i].label !== chartSeriesLabels[i]) return false;
913 }
914 return true;
915 }
916 function updateChart(payload) {
917 if (!payload.t || payload.t.length < 2) {
918 if (!chart) {
919 el.innerHTML = '<p class="notice empty">Not enough samples yet. '
920 + 'The chart appears after two sample intervals '
921 + '(~10 seconds at the default cadence).</p>';
922 }
923 return;
924 }
925 if (!chart || !chartSchemaMatches(payload)) {
926 if (chart) { chart.destroy(); chart = null; }
927 el.innerHTML = '';
928 buildChart(payload);
929 return;
930 }
931 var data = [payload.t];
932 for (var j = 0; j < payload.series.length; j++) {
933 data.push(payload.series[j].values);
934 }
935 chart.setData(data);
936 }
937 function updateChips(counters) {
938 var row = document.getElementById('traffic-chip-row');
939 if (!row) return;
940 var parts = [];
941 parts.push(
942 '<div class="chip chip-total">'
943 + '<div class="chip-label">Total</div>'
944 + '<div class="chip-value">' + counters.total + '</div>'
945 + '<div class="chip-sub">requests since startup</div>'
946 + '</div>'
947 );
948 parts.push(
949 '<div class="chip">'
950 + '<div class="chip-label">Rate</div>'
951 + '<div class="chip-value">' + counters.rate.toFixed(2) + '</div>'
952 + '<div class="chip-sub">requests / sec (last interval)</div>'
953 + '</div>'
954 );
955 counters.by_status.forEach(function(entry) {
956 parts.push(
957 '<div class="chip ' + classForStatus(entry.code) + '">'
958 + '<div class="chip-label">HTTP ' + entry.code + '</div>'
959 + '<div class="chip-value">' + entry.count + '</div>'
960 + '<div class="chip-sub">' + descForStatus(entry.code) + '</div>'
961 + '</div>'
962 );
963 });
964 row.innerHTML = parts.join('');
965 }
966 function updateRecent(recent) {
967 var body = document.getElementById('traffic-recent-body');
968 if (!body) return;
969 if (!recent.length) return;
970 var html = '';
971 for (var i = 0; i < recent.length; i++) {
972 var r = recent[i];
973 html += '<tr>'
974 + '<td><code>' + r.method + '</code></td>'
975 + '<td>' + r.vhost + '</td>'
976 + '<td class="path"><code>' + r.path + '</code></td>'
977 + '<td class="status ' + classForStatus(r.status) + '">' + r.status + '</td>'
978 + '<td class="num">' + r.duration + '</td>'
979 + '</tr>';
980 }
981 body.innerHTML = html;
982 }
983 function refreshFrom(payload) {
984 if (!payload) return;
985 updateChips(payload.counters);
986 updateChart(payload.chart);
987 updateRecent(payload.recent);
988 }
989 function refresh() {
990 fetch('/admin/traffic.json', { credentials: 'same-origin' })
991 .then(function(r) { return r.ok ? r.json() : null; })
992 .then(refreshFrom)
993 .catch(function() {});
994 }
995 // First paint: prefer the inline blob for a zero-RTT render,
996 // fall back to the JSON feed if the blob is missing or empty.
997 if (dataEl) {
998 try {
999 var payload = JSON.parse(dataEl.textContent);
1000 updateChart(payload);
1001 } catch (e) {
1002 refresh();
1003 }
1004 } else {
1005 refresh();
1006 }
1007 window.steelTrafficRefresh = refresh;
1008 window.addEventListener('resize', function() {
1009 if (chart) {
1010 chart.setSize({ width: el.clientWidth, height: 260 });
1011 }
1012 });
1013})();
1014"#;
1015
1016/// Requests per second over the last sample interval in `history`, or zero when
1017/// there are fewer than two samples.
1018fn compute_rate_last(history: &[crate::srv::admin::traffic::TrafficSample]) -> f64 {
1019 if history.len() < 2 {
1020 return 0.0;
1021 }
1022 let last = &history[history.len() - 1];
1023 let prev = &history[history.len() - 2];
1024 if last.when_secs <= prev.when_secs {
1025 return 0.0;
1026 }
1027 let dt = (last.when_secs - prev.when_secs) as f64;
1028 let dn = last.total.saturating_sub(prev.total) as f64;
1029 if dt == 0.0 { 0.0 } else { dn / dt }
1030}
1031
1032/// The shape the chart script expects: `{t: [unix_ts...], series: [{label,
1033/// values}]}`, where each `values` array is the delta in that status's counter
1034/// since the previous sample, i.e. requests in this sample interval.
1035fn build_chart_json(
1036 history: &[crate::srv::admin::traffic::TrafficSample],
1037 status_keys: &[u16],
1038)
1039 -> String
1040{
1041 if history.len() < 2 {
1042 return "{\"t\": [], \"series\": []}".to_string();
1043 }
1044 let mut ts = String::from("[");
1045 let mut deltas: Vec<Vec<u64>> = status_keys.iter()
1046 .map(|_| Vec::with_capacity(history.len() - 1))
1047 .collect();
1048 for w in history.windows(2) {
1049 let prev = &w[0];
1050 let curr = &w[1];
1051 if !ts.ends_with('[') {
1052 ts.push(',');
1053 }
1054 ts.push_str(&fmt!("{}", curr.when_secs));
1055 for (i, s) in status_keys.iter().enumerate() {
1056 let a = prev.by_status.get(s).copied().unwrap_or(0);
1057 let b = curr.by_status.get(s).copied().unwrap_or(0);
1058 deltas[i].push(b.saturating_sub(a));
1059 }
1060 }
1061 ts.push(']');
1062
1063 let mut series = String::from("[");
1064 for (i, s) in status_keys.iter().enumerate() {
1065 if i > 0 { series.push(','); }
1066 let values = deltas[i].iter()
1067 .map(|v| v.to_string())
1068 .collect::<Vec<_>>()
1069 .join(",");
1070 series.push_str(&fmt!(
1071 "{{\"label\":\"{label}\",\"values\":[{values}]}}",
1072 label = fmt!("HTTP {}", s),
1073 values = values,
1074 ));
1075 }
1076 series.push(']');
1077
1078 fmt!("{{\"t\":{},\"series\":{}}}", ts, series)
1079}
1080
1081fn chip_class_for_status(status: u16) -> &'static str {
1082 match status {
1083 200..=299 => "chip-ok",
1084 300..=399 => "chip-redirect",
1085 400..=499 => "chip-client",
1086 500..=599 => "chip-server",
1087 _ => "",
1088 }
1089}
1090
1091fn status_description(status: u16) -> &'static str {
1092 match status {
1093 200..=299 => "success",
1094 300..=399 => "redirect",
1095 400..=499 => "client error",
1096 500..=599 => "server error",
1097 _ => "informational",
1098 }
1099}
1100
1101fn format_duration_us(us: u64) -> String {
1102 if us < 1_000 {
1103 fmt!("{} \u{00b5}s", us)
1104 } else if us < 1_000_000 {
1105 fmt!("{:.1} ms", (us as f64) / 1_000.0)
1106 } else {
1107 fmt!("{:.2} s", (us as f64) / 1_000_000.0)
1108 }
1109}
1110
1111// ┌───────────────────────────────────────────────────────────────────────────┐
1112// │ ADMIN MANAGEMENT │
1113// └───────────────────────────────────────────────────────────────────────────┘
1114
1115/// Threaded back into the rendered list view as a notice banner above the form.
1116struct AdminFlash {
1117 ok: bool, // true renders a green notice, false a red error
1118 message: String,
1119}
1120
1121/// Authorisation requires both a dashboard scope, so the visitor can see the
1122/// dashboard at all, and the legacy `admin` scope, which gates admin enrolment
1123/// in the CLI and the dashboard alike. A visitor with only `dashboard.view` or
1124/// only `dashboard.admin` gets the "forbidden" flavour of the page rather than
1125/// a redirect to login: they are signed in, just not authorised for this verb.
1126fn render_admins(
1127 state: &AdminState,
1128 headers: &Arc<HeaderFields>,
1129 flash: Option<AdminFlash>,
1130)
1131 -> HttpMessage
1132{
1133 let principal = match extract_principal(state, headers) {
1134 Some(p) => p,
1135 None => return redirect_to_login(),
1136 };
1137 if !principal.can_manage_admins() {
1138 return render_admin_forbidden(&principal);
1139 }
1140
1141 // Snapshot the admin list out of the wallet under a short
1142 // read lock so the rendering does not hold the lock across
1143 // any HTML formatting.
1144 let admins_snapshot: Vec<(String, u64, Vec<String>)> = {
1145 let w = match state.wallet.read() {
1146 Ok(g) => g,
1147 Err(_) => return render_admins_error(
1148 &principal,
1149 "Wallet lock is poisoned.",
1150 ),
1151 };
1152 w.admins().iter()
1153 .map(|a| (a.name.clone(), a.expires_at, a.scopes.clone()))
1154 .collect()
1155 };
1156
1157 let flash_html = render_flash(flash.as_ref());
1158 let mut rows = String::new();
1159 for (name, expires_at, scopes) in &admins_snapshot {
1160 let expiry = if *expires_at == 0 {
1161 "never".to_string()
1162 } else {
1163 fmt!("unix {}", expires_at)
1164 };
1165 let scopes_html = if scopes.is_empty() {
1166 "<em>(none)</em>".to_string()
1167 } else {
1168 html_escape(&scopes.join(", "))
1169 };
1170 let safe_name = html_escape(name);
1171 rows.push_str(&fmt!(
1172 "<tr>\
1173 <td><strong>{name}</strong></td>\
1174 <td>{expiry}</td>\
1175 <td><code>{scopes}</code></td>\
1176 <td><form method=\"POST\" action=\"/admin/admins\" \
1177 onsubmit=\"return confirm('Remove admin {name}?');\">\
1178 <input type=\"hidden\" name=\"action\" value=\"remove\">\
1179 <input type=\"hidden\" name=\"name\" value=\"{name_attr}\">\
1180 <button type=\"submit\" class=\"primary\">Remove</button>\
1181 </form></td>\
1182 </tr>\n",
1183 name = safe_name,
1184 expiry = expiry,
1185 scopes = scopes_html,
1186 name_attr = safe_name,
1187 ));
1188 }
1189 let body = fmt!(
1190 "<h1>Admin management</h1>\n\
1191 {flash}\
1192 <p>Wallet currently holds <strong>{count}</strong> admin entries. \
1193 Adding a new admin enrols their password against the same \
1194 wallet master key the CLI <code>admin --add</code> verb \
1195 uses; removing an admin revokes their password immediately. \
1196 Every action here is recorded in <code>admin-audit.log</code> \
1197 alongside the CLI events.</p>\n\
1198 <h2>Existing admins</h2>\n\
1199 <table class=\"steel-table\">\n\
1200 <thead><tr>\
1201 <th>Name</th><th>Expires</th><th>Scopes</th><th>Actions</th>\
1202 </tr></thead>\n\
1203 <tbody>{rows}</tbody>\n\
1204 </table>\n\
1205 <h2>Add an admin</h2>\n\
1206 <form class=\"steel-form\" method=\"POST\" action=\"/admin/admins\">\n\
1207 <input type=\"hidden\" name=\"action\" value=\"add\">\n\
1208 <label for=\"new_name\">Name</label>\n\
1209 <input type=\"text\" id=\"new_name\" name=\"name\" required \
1210 autocomplete=\"off\">\n\
1211 <label for=\"new_password\">Password</label>\n\
1212 <input type=\"password\" id=\"new_password\" name=\"password\" \
1213 required autocomplete=\"new-password\">\n\
1214 <label for=\"new_scopes\">Scopes (comma-separated)</label>\n\
1215 <input type=\"text\" id=\"new_scopes\" name=\"scopes\" \
1216 value=\"dashboard.view\" \
1217 placeholder=\"dashboard.view, admin\">\n\
1218 <p class=\"meta\">Well-known scopes: \
1219 <code>admin</code>, <code>dashboard.view</code>, \
1220 <code>dashboard.admin</code>. Use <code>*</code> for \
1221 operator-level access.</p>\n\
1222 <label for=\"new_expires_in\">Expires in (seconds, 0 = never)</label>\n\
1223 <input type=\"number\" id=\"new_expires_in\" name=\"expires_in\" \
1224 value=\"0\" min=\"0\">\n\
1225 <button type=\"submit\">Add admin</button>\n\
1226 </form>\n",
1227 flash = flash_html,
1228 count = admins_snapshot.len(),
1229 rows = rows,
1230 );
1231 let html = render_layout(
1232 "Admins",
1233 PATH_ADMINS,
1234 &principal,
1235 &body,
1236 "",
1237 );
1238 html_response(html)
1239}
1240
1241fn render_admin_forbidden(principal: &AdminPrincipal) -> HttpMessage {
1242 let body = "<h1>Admin management</h1>\n\
1243 <p class=\"notice error\">\
1244 You are signed in to the dashboard but your admin entry \
1245 does not hold the <code>admin</code> scope. Admin \
1246 management requires both a dashboard scope \
1247 (<code>dashboard.view</code> or <code>dashboard.admin</code>) \
1248 and the <code>admin</code> scope. Ask another operator to \
1249 grant <code>admin</code> via <code>./steel admin --add</code> \
1250 if you need to enrol new admin entries from this dashboard.\
1251 </p>\n".to_string();
1252 let html = render_layout(
1253 "Admins",
1254 PATH_ADMINS,
1255 principal,
1256 &body,
1257 "",
1258 );
1259 html_response(html)
1260}
1261
1262fn render_admins_error(principal: &AdminPrincipal, message: &str) -> HttpMessage {
1263 let body = fmt!(
1264 "<h1>Admin management</h1>\n\
1265 <p class=\"notice error\">{}</p>\n",
1266 html_escape(message),
1267 );
1268 let html = render_layout(
1269 "Admins",
1270 PATH_ADMINS,
1271 principal,
1272 &body,
1273 "",
1274 );
1275 html_response(html)
1276}
1277
1278fn render_flash(flash: Option<&AdminFlash>) -> String {
1279 match flash {
1280 None => String::new(),
1281 Some(f) => fmt!(
1282 "<p class=\"notice {}\">{}</p>\n",
1283 if f.ok { "" } else { "error" },
1284 html_escape(&f.message),
1285 ),
1286 }
1287}
1288
1289/// The `action` field selects between `add` and `remove`. Authorisation is the
1290/// same as for the GET view: a dashboard scope plus `admin`.
1291fn handle_admins_post(
1292 state: &AdminState,
1293 headers: &Arc<HeaderFields>,
1294 body: &[u8],
1295)
1296 -> HttpMessage
1297{
1298 let principal = match extract_principal(state, headers) {
1299 Some(p) => p,
1300 None => return redirect_to_login(),
1301 };
1302 if !principal.can_manage_admins() {
1303 return render_admin_forbidden(&principal);
1304 }
1305
1306 let action = extract_form_field(body, "action").unwrap_or_default();
1307 let flash = match action.as_str() {
1308 "add" => handle_admin_add(state, &principal, body),
1309 "remove" => handle_admin_remove(state, &principal, body),
1310 _ => AdminFlash {
1311 ok: false,
1312 message: "Unknown action.".to_string(),
1313 },
1314 };
1315 render_admins(state, headers, Some(flash))
1316}
1317
1318/// `expires_at` is computed from the optional `expires_in` duration. The wallet
1319/// is saved to disk and the change audit-logged.
1320fn handle_admin_add(
1321 state: &AdminState,
1322 principal: &AdminPrincipal,
1323 body: &[u8],
1324)
1325 -> AdminFlash
1326{
1327 let new_name = extract_form_field(body, "name").unwrap_or_default();
1328 let new_pass = extract_form_field(body, "password").unwrap_or_default();
1329 let scopes_raw = extract_form_field(body, "scopes").unwrap_or_default();
1330 let expires_in_raw = extract_form_field(body, "expires_in")
1331 .unwrap_or_default();
1332
1333 if new_name.is_empty() || new_pass.is_empty() {
1334 return AdminFlash {
1335 ok: false,
1336 message: "Name and password are required.".to_string(),
1337 };
1338 }
1339 let new_scopes: Vec<String> = scopes_raw
1340 .split(',')
1341 .map(|s| s.trim().to_string())
1342 .filter(|s| !s.is_empty())
1343 .collect();
1344 let expires_in: u64 = expires_in_raw.parse::<u64>().unwrap_or(0);
1345 let expires_at = if expires_in == 0 {
1346 0
1347 } else {
1348 let now = SystemTime::now()
1349 .duration_since(UNIX_EPOCH)
1350 .map(|d| d.as_secs())
1351 .unwrap_or(0);
1352 now.saturating_add(expires_in)
1353 };
1354
1355 // Enrolling wraps the master key under the new admin's password, so
1356 // it cannot be done while sealed. In practice this is unreachable
1357 // from the dashboard -- signing in *is* the unseal -- but the state
1358 // is shared, and a caller reaching here without a key deserves a
1359 // straight answer rather than a panic.
1360 let master_key = match state.master_key() {
1361 Ok(k) => k,
1362 Err(_) => return AdminFlash {
1363 ok: false,
1364 message: "Steel is sealed: no master key is loaded, so a new \
1365 admin cannot be enrolled.".to_string(),
1366 },
1367 };
1368 let result = {
1369 let mut w = match state.wallet.write() {
1370 Ok(g) => g,
1371 Err(_) => return AdminFlash {
1372 ok: false,
1373 message: "Wallet lock is poisoned.".to_string(),
1374 },
1375 };
1376 let enrol_res = w.enrol(
1377 &master_key,
1378 new_name.clone(),
1379 new_pass.as_bytes(),
1380 new_scopes.clone(),
1381 expires_at,
1382 DEFAULT_WALLET_KDF_NAME,
1383 );
1384 if let Err(e) = enrol_res {
1385 audit::append(
1386 &principal.name,
1387 VERB_DASHBOARD_ADMIN_ADD,
1388 "err",
1389 &fmt!("target={} reason={}", new_name, e),
1390 );
1391 return AdminFlash {
1392 ok: false,
1393 message: fmt!("Failed to enrol '{}': {}", new_name, e),
1394 };
1395 }
1396 w.save_secret(
1397 &state.wallet_path,
1398 " ",
1399 Some(EncoderConfig::<(), ()>::default()),
1400 )
1401 };
1402 if let Err(e) = result {
1403 audit::append(
1404 &principal.name,
1405 VERB_DASHBOARD_ADMIN_ADD,
1406 "err",
1407 &fmt!("target={} reason=save_failed:{}", new_name, e),
1408 );
1409 return AdminFlash {
1410 ok: false,
1411 message: fmt!(
1412 "Admin enrolled in memory but the wallet could not be \
1413 saved to disk: {}", e),
1414 };
1415 }
1416 audit::append(
1417 &principal.name,
1418 VERB_DASHBOARD_ADMIN_ADD,
1419 "ok",
1420 &fmt!(
1421 "target={} scopes={} expires_at={}",
1422 new_name, new_scopes.join(","), expires_at,
1423 ),
1424 );
1425 AdminFlash {
1426 ok: true,
1427 message: fmt!("Added admin '{}'.", new_name),
1428 }
1429}
1430
1431fn handle_admin_remove(
1432 state: &AdminState,
1433 principal: &AdminPrincipal,
1434 body: &[u8],
1435)
1436 -> AdminFlash
1437{
1438 let target = extract_form_field(body, "name").unwrap_or_default();
1439 if target.is_empty() {
1440 return AdminFlash {
1441 ok: false,
1442 message: "Missing target name.".to_string(),
1443 };
1444 }
1445 let result = {
1446 let mut w = match state.wallet.write() {
1447 Ok(g) => g,
1448 Err(_) => return AdminFlash {
1449 ok: false,
1450 message: "Wallet lock is poisoned.".to_string(),
1451 },
1452 };
1453 let remove_res = w.remove_by_name(&target);
1454 if let Err(e) = remove_res {
1455 audit::append(
1456 &principal.name,
1457 VERB_DASHBOARD_ADMIN_REMOVE,
1458 "err",
1459 &fmt!("target={} reason={}", target, e),
1460 );
1461 return AdminFlash {
1462 ok: false,
1463 message: fmt!("Failed to remove '{}': {}", target, e),
1464 };
1465 }
1466 w.save_secret(
1467 &state.wallet_path,
1468 " ",
1469 Some(EncoderConfig::<(), ()>::default()),
1470 )
1471 };
1472 if let Err(e) = result {
1473 audit::append(
1474 &principal.name,
1475 VERB_DASHBOARD_ADMIN_REMOVE,
1476 "err",
1477 &fmt!("target={} reason=save_failed:{}", target, e),
1478 );
1479 return AdminFlash {
1480 ok: false,
1481 message: fmt!(
1482 "Admin removed in memory but the wallet could not be \
1483 saved to disk: {}", e),
1484 };
1485 }
1486 audit::append(
1487 &principal.name,
1488 VERB_DASHBOARD_ADMIN_REMOVE,
1489 "ok",
1490 &fmt!("target={}", target),
1491 );
1492 AdminFlash {
1493 ok: true,
1494 message: fmt!("Removed admin '{}'.", target),
1495 }
1496}
1497
1498// ┌───────────────────────────────────────────────────────────────────────────┐
1499// │ SECURITY │
1500// └───────────────────────────────────────────────────────────────────────────┘
1501
1502struct SecurityFlash {
1503 ok: bool,
1504 message: String,
1505}
1506
1507/// Renders the Security view: a chip row of per-state counts, a table of
1508/// observed addresses with whitelist / blacklist / unblock buttons, and a manual
1509/// blacklist form for operators who need to pre-block a known-bad IP.
1510///
1511/// The read path needs only `dashboard.view`; mutations from the accompanying
1512/// POST handler require `dashboard.admin`.
1513fn render_security(
1514 state: &AdminState,
1515 headers: &Arc<HeaderFields>,
1516 flash: Option<SecurityFlash>,
1517)
1518 -> HttpMessage
1519{
1520 let principal = match extract_principal(state, headers) {
1521 Some(p) => p,
1522 None => return redirect_to_login(),
1523 };
1524 let snap = match state.addr_guard.snapshot(DEFAULT_SNAPSHOT_CAP) {
1525 Ok(s) => s,
1526 Err(e) => {
1527 error!(e, "dashboard: addr guard snapshot failed");
1528 return render_security_error(&principal, "Address guard is unavailable.");
1529 },
1530 };
1531
1532 let flash_html = match flash.as_ref() {
1533 Some(f) => fmt!(
1534 "<p class=\"notice {}\">{}</p>\n",
1535 if f.ok { "" } else { "error" },
1536 html_escape(&f.message),
1537 ),
1538 None => String::new(),
1539 };
1540
1541 let can_mutate = principal.can_admin_dashboard();
1542 let chip_row = fmt!(
1543 "<div class=\"chip-row\">\n\
1544 <div class=\"chip\">\
1545 <div class=\"chip-label\">Monitored</div>\
1546 <div class=\"chip-value\">{mon}</div>\
1547 <div class=\"chip-sub\">rate-limited only</div>\
1548 </div>\n\
1549 <div class=\"chip\">\
1550 <div class=\"chip-label\">Throttled</div>\
1551 <div class=\"chip-value\">{thr}</div>\
1552 <div class=\"chip-sub\">under active cooldown</div>\
1553 </div>\n\
1554 <div class=\"chip\">\
1555 <div class=\"chip-label\">Blacklisted</div>\
1556 <div class=\"chip-value\">{bl}</div>\
1557 <div class=\"chip-sub\">dropping every packet</div>\
1558 </div>\n\
1559 <div class=\"chip\">\
1560 <div class=\"chip-label\">Whitelisted</div>\
1561 <div class=\"chip-value\">{wl}</div>\
1562 <div class=\"chip-sub\">always allowed</div>\
1563 </div>\n\
1564 </div>\n",
1565 mon = snap.counts.monitor,
1566 thr = snap.counts.throttle,
1567 bl = snap.counts.blacklist,
1568 wl = snap.counts.whitelist,
1569 );
1570
1571 // Stable ordering: blacklist first so attacks rise to the top,
1572 // then throttle, then monitor, then whitelist; within each state
1573 // by descending total_reqs. Using a local Vec::sort because the
1574 // snapshot itself emits entries in shard-traversal order.
1575 let mut entries = snap.entries;
1576 entries.sort_by(|a, b| {
1577 let rank = |label: &str| -> u8 {
1578 match label {
1579 "blacklist" => 0,
1580 "throttle" => 1,
1581 "monitor" => 2,
1582 "whitelist" => 3,
1583 _ => 4,
1584 }
1585 };
1586 let ra = rank(a.state);
1587 let rb = rank(b.state);
1588 if ra != rb { return ra.cmp(&rb); }
1589 b.total_reqs.cmp(&a.total_reqs)
1590 });
1591
1592 let table_html = if entries.is_empty() {
1593 "<p class=\"notice empty\">No addresses observed yet.</p>\n".to_string()
1594 } else {
1595 let mut rows = String::new();
1596 for e in &entries {
1597 let ip = fmt!("{}", e.ip);
1598 let ip_attr = html_escape(&ip);
1599 let actions = if can_mutate {
1600 fmt!(
1601 "<form method=\"POST\" action=\"/admin/security\" \
1602 class=\"inline-form\">\
1603 <input type=\"hidden\" name=\"ip\" value=\"{ip}\">\
1604 <button type=\"submit\" name=\"action\" value=\"whitelist\">\
1605 Whitelist</button>\
1606 <button type=\"submit\" name=\"action\" value=\"blacklist\">\
1607 Blacklist</button>\
1608 <button type=\"submit\" name=\"action\" value=\"unblock\">\
1609 Reset</button>\
1610 </form>",
1611 ip = ip_attr,
1612 )
1613 } else {
1614 "<em>view only</em>".to_string()
1615 };
1616 rows.push_str(&fmt!(
1617 "<tr>\
1618 <td><code>{ip}</code></td>\
1619 <td class=\"pill pill-{state_class}\">{state_label}</td>\
1620 <td>{total}</td>\
1621 <td>{thrcnt}</td>\
1622 <td>{actions}</td>\
1623 </tr>\n",
1624 ip = html_escape(&ip),
1625 state_class = e.state,
1626 state_label = e.state,
1627 total = e.total_reqs,
1628 thrcnt = e.throttle_cnt,
1629 actions = actions,
1630 ));
1631 }
1632 fmt!(
1633 "<table class=\"steel-table\">\n\
1634 <thead><tr>\
1635 <th>IP</th><th>State</th><th>Requests</th>\
1636 <th>Throttles</th><th>Actions</th>\
1637 </tr></thead>\n\
1638 <tbody>{rows}</tbody>\n\
1639 </table>\n",
1640 rows = rows,
1641 )
1642 };
1643
1644 let manual_form_html = if can_mutate {
1645 "<h2>Block a specific address</h2>\n\
1646 <form class=\"steel-form\" method=\"POST\" action=\"/admin/security\">\n\
1647 <input type=\"hidden\" name=\"action\" value=\"blacklist\">\n\
1648 <label for=\"ip\">IP address</label>\n\
1649 <input type=\"text\" id=\"ip\" name=\"ip\" required \
1650 placeholder=\"1.2.3.4 or ::1\" autocomplete=\"off\">\n\
1651 <button type=\"submit\">Add to blacklist</button>\n\
1652 </form>\n".to_string()
1653 } else {
1654 String::new()
1655 };
1656
1657 let body = fmt!(
1658 "<h1>Security</h1>\n\
1659 {flash}\
1660 <p>The address guard runs before the TLS handshake on every \
1661 incoming TCP connection. Blacklisted and throttled addresses \
1662 are dropped at the accept loop so they cost the server nothing \
1663 more than a SYN/ACK.</p>\n\
1664 {chips}\
1665 <h2>Observed addresses</h2>\n\
1666 <p class=\"meta\">Total observed: <strong>{total}</strong>. \
1667 Snapshot cap: {cap}. Showing {shown} rows.</p>\n\
1668 {table}\
1669 {manual}",
1670 flash = flash_html,
1671 chips = chip_row,
1672 total = snap.counts.total,
1673 cap = DEFAULT_SNAPSHOT_CAP,
1674 shown = entries.len(),
1675 table = table_html,
1676 manual = manual_form_html,
1677 );
1678
1679 let html = render_layout("Security", PATH_SECURITY, &principal, &body, "");
1680 html_response(html)
1681}
1682
1683fn render_security_error(principal: &AdminPrincipal, message: &str) -> HttpMessage {
1684 let body = fmt!(
1685 "<h1>Security</h1>\n\
1686 <p class=\"notice error\">{}</p>\n",
1687 html_escape(message),
1688 );
1689 let html = render_layout("Security", PATH_SECURITY, principal, &body, "");
1690 html_response(html)
1691}
1692
1693/// Requires `dashboard.admin`: guard mutations are privileged. The actions are
1694/// `whitelist`, `blacklist` and `unblock`, each taking a single `ip` field.
1695fn handle_security_post(
1696 state: &AdminState,
1697 headers: &Arc<HeaderFields>,
1698 body: &[u8],
1699)
1700 -> HttpMessage
1701{
1702 let principal = match extract_principal(state, headers) {
1703 Some(p) => p,
1704 None => return redirect_to_login(),
1705 };
1706 if !principal.can_admin_dashboard() {
1707 return render_security_error(
1708 &principal,
1709 "You need the dashboard.admin scope to mutate the address guard.",
1710 );
1711 }
1712
1713 let action_raw = extract_form_field(body, "action").unwrap_or_default();
1714 let ip_raw = extract_form_field(body, "ip").unwrap_or_default();
1715 let flash = apply_security_action(state, &principal, &action_raw, &ip_raw);
1716 render_security(state, headers, Some(flash))
1717}
1718
1719fn apply_security_action(
1720 state: &AdminState,
1721 principal: &AdminPrincipal,
1722 action_raw: &str,
1723 ip_raw: &str,
1724)
1725 -> SecurityFlash
1726{
1727 let ip = match ip_raw.parse::<std::net::IpAddr>() {
1728 Ok(ip) => ip,
1729 Err(e) => {
1730 audit::append(
1731 &principal.name,
1732 audit_verb_for(action_raw),
1733 "err",
1734 &fmt!("ip={} reason=parse:{}", ip_raw, e),
1735 );
1736 return SecurityFlash {
1737 ok: false,
1738 message: fmt!("Not a valid IP address: '{}'.", ip_raw),
1739 };
1740 },
1741 };
1742 let (verb, result) = match action_raw {
1743 "whitelist" => (
1744 VERB_DASHBOARD_GUARD_WHITELIST,
1745 state.addr_guard.whitelist(&ip),
1746 ),
1747 "blacklist" => (
1748 VERB_DASHBOARD_GUARD_BLACKLIST,
1749 state.addr_guard.blacklist(&ip),
1750 ),
1751 "unblock" => (
1752 VERB_DASHBOARD_GUARD_UNBLOCK,
1753 state.addr_guard.unblock(&ip),
1754 ),
1755 other => {
1756 return SecurityFlash {
1757 ok: false,
1758 message: fmt!("Unknown security action '{}'.", other),
1759 };
1760 },
1761 };
1762 match result {
1763 Ok(()) => {
1764 audit::append(&principal.name, verb, "ok", &fmt!("ip={}", ip));
1765 SecurityFlash {
1766 ok: true,
1767 message: fmt!("{} {}.", action_label(action_raw), ip),
1768 }
1769 },
1770 Err(e) => {
1771 audit::append(
1772 &principal.name,
1773 verb,
1774 "err",
1775 &fmt!("ip={} reason={}", ip, e),
1776 );
1777 SecurityFlash {
1778 ok: false,
1779 message: fmt!(
1780 "Failed to {} {}: {}",
1781 action_raw, ip, e,
1782 ),
1783 }
1784 },
1785 }
1786}
1787
1788/// The verb to record when parsing fails before the branch is known.
1789fn audit_verb_for(action_raw: &str) -> &'static str {
1790 match action_raw {
1791 "whitelist" => VERB_DASHBOARD_GUARD_WHITELIST,
1792 "blacklist" => VERB_DASHBOARD_GUARD_BLACKLIST,
1793 "unblock" => VERB_DASHBOARD_GUARD_UNBLOCK,
1794 _ => VERB_DASHBOARD_GUARD_UNBLOCK,
1795 }
1796}
1797
1798fn action_label(action_raw: &str) -> &'static str {
1799 match action_raw {
1800 "whitelist" => "Whitelisted",
1801 "blacklist" => "Blacklisted",
1802 "unblock" => "Reset",
1803 _ => "Applied",
1804 }
1805}
1806
1807fn html_response(body: String) -> HttpMessage {
1808 HttpMessage::new_response(HttpStatus::OK)
1809 .with_field(
1810 HeaderName::ContentType,
1811 HeaderFieldValue::Generic("text/html; charset=utf-8".to_string()),
1812 )
1813 .with_body(body.into_bytes())
1814}
1815
1816pub fn redirect_to_login() -> HttpMessage {
1817 HttpMessage::new_response(HttpStatus::SeeOther)
1818 .with_field(
1819 HeaderName::Location,
1820 HeaderFieldValue::Generic(PATH_LOGIN.to_string()),
1821 )
1822}
1823
1824/// Decodes and verifies the admin session cookie, returning the embedded
1825/// [`AdminPrincipal`] only if it is still authorised to see the dashboard. Any
1826/// failure -- missing cookie, tampered cookie, expired cookie, missing dashboard
1827/// scope -- is flattened to `None`, so the caller can simply 303 to login.
1828pub fn extract_principal(
1829 state: &AdminState,
1830 headers: &HeaderFields,
1831)
1832 -> Option<AdminPrincipal>
1833{
1834 let cookie_value = ok!(read_cookie(headers, SESSION_COOKIE_NAME));
1835 let principal = match session::decode_session(state, &cookie_value) {
1836 Ok(p) => p,
1837 Err(e) => {
1838 debug!("dashboard: session cookie rejected: {}", e);
1839 return None;
1840 },
1841 };
1842 if !principal.can_view_dashboard() {
1843 debug!("dashboard: principal '{}' lacks dashboard scope",
1844 principal.name);
1845 return None;
1846 }
1847 Some(principal)
1848}
1849
1850fn read_cookie(headers: &HeaderFields, name: &str) -> Option<String> {
1851 if let Some(HeaderFieldValue::Cookie(cookies)) =
1852 headers.get_one(&HeaderName::Cookie)
1853 {
1854 for c in cookies {
1855 if c.key == name {
1856 return Some(c.val.clone());
1857 }
1858 }
1859 }
1860 None
1861}
1862
1863// ┌───────────────────────────────────────────────────────────────────────────┐
1864// │ LOGIN FORM │
1865// └───────────────────────────────────────────────────────────────────────────┘
1866
1867/// `error_msg`, when present, is rendered above the form. Its wording is
1868/// deliberately generic, to avoid leaking which axis -- no admin, wrong
1869/// passphrase, no dashboard scope -- caused the failure.
1870///
1871/// When Steel is sealed the form says so, and says what signing in will do.
1872/// This is the cold-start path: the process is up and serving its static sites,
1873/// but the databases are shut until an admin's passphrase unwraps the master
1874/// key. Without that, a sealed Steel looks like a healthy one that has
1875/// mysteriously lost its data.
1876fn render_login_form(sealed: bool, error_msg: Option<&str>) -> HttpMessage {
1877 let error_html = match error_msg {
1878 Some(msg) => fmt!(
1879 "<p class=\"notice error\">{}</p>",
1880 html_escape(msg),
1881 ),
1882 None => String::new(),
1883 };
1884 let sealed_html = if sealed {
1885 "<p class=\"notice warn\">\
1886 <strong>Steel is sealed.</strong> The websites are serving, but the \
1887 databases are shut: no master key is loaded. Signing in with an admin \
1888 passphrase unseals them.\
1889 </p>\n"
1890 } else {
1891 ""
1892 };
1893 let body = fmt!(
1894 "{sealed}{error}\
1895 <form class=\"steel-form\" method=\"POST\" action=\"/admin/login\">\n\
1896 <label for=\"passphrase\">Wallet passphrase</label>\n\
1897 <input type=\"password\" id=\"passphrase\" name=\"passphrase\" \
1898 autofocus required>\n\
1899 <button type=\"submit\">{action}</button>\n\
1900 </form>\n",
1901 sealed = sealed_html,
1902 error = error_html,
1903 action = if sealed { "Sign in and unseal" } else { "Sign in" },
1904 );
1905 let html = render_login_layout(
1906 if sealed { "Sign in and unseal" } else { "Sign in" },
1907 &body,
1908 );
1909 html_response(html)
1910}
1911
1912// ┌───────────────────────────────────────────────────────────────────────────┐
1913// │ HELPERS │
1914// └───────────────────────────────────────────────────────────────────────────┘
1915
1916/// The first matching key's value, URL-decoded. Sized for tiny login-style
1917/// bodies of one or two fields.
1918pub(crate) fn extract_form_field(body: &[u8], key: &str) -> Option<String> {
1919 let s = ok!(std::str::from_utf8(body).ok());
1920 for pair in s.split('&') {
1921 let mut kv = pair.splitn(2, '=');
1922 let k = ok!(kv.next());
1923 let v = kv.next().unwrap_or("");
1924 if url_decode(k) == key {
1925 return Some(url_decode(v));
1926 }
1927 }
1928 None
1929}
1930
1931/// Replaces `+` with a space and `%XX` with the corresponding byte. An invalid
1932/// escape passes through unchanged.
1933fn url_decode(s: &str) -> String {
1934 let bytes = s.as_bytes();
1935 let mut out = Vec::with_capacity(bytes.len());
1936 let mut i = 0;
1937 while i < bytes.len() {
1938 match bytes[i] {
1939 b'+' => {
1940 out.push(b' ');
1941 i += 1;
1942 },
1943 b'%' if i + 2 < bytes.len() => {
1944 let hi = hex_nibble(bytes[i + 1]);
1945 let lo = hex_nibble(bytes[i + 2]);
1946 match (hi, lo) {
1947 (Some(h), Some(l)) => {
1948 out.push((h << 4) | l);
1949 i += 3;
1950 },
1951 _ => {
1952 out.push(bytes[i]);
1953 i += 1;
1954 },
1955 }
1956 },
1957 b => {
1958 out.push(b);
1959 i += 1;
1960 },
1961 }
1962 }
1963 String::from_utf8_lossy(&out).into_owned()
1964}
1965
1966fn hex_nibble(b: u8) -> Option<u8> {
1967 match b {
1968 b'0'..=b'9' => Some(b - b'0'),
1969 b'a'..=b'f' => Some(10 + b - b'a'),
1970 b'A'..=b'F' => Some(10 + b - b'A'),
1971 _ => None,
1972 }
1973}
1974