1use alloc::string::String;
2use core::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3use picoserve::{
4 extract::FromRequestParts,
5 request::RequestParts,
6 response::Response,
7 routing::{get, parse_path_segment, post},
8};
9
10use crate::{net, sysinfo};
11const ADMIN_TOKEN_PATH: &str = "/initfs/web-admin.token";
12const KILL_RATE_LIMIT_WINDOW_NS: u64 = 10_000_000_000;
13const KILL_RATE_LIMIT_MAX_PER_WINDOW: u32 = 8;
14static KILL_WINDOW_START_NS: AtomicU64 = AtomicU64::new(0);
15static KILL_WINDOW_COUNT: AtomicU32 = AtomicU32::new(0);
16
17pub struct HtmlContent(pub &'static str);
22
23impl picoserve::response::Content for HtmlContent {
24 fn content_type(&self) -> &'static str {
26 "text/html; charset=utf-8"
27 }
28 fn content_length(&self) -> usize {
30 self.0.len()
31 }
32 async fn write_content<W: picoserve::io::Write>(self, mut writer: W) -> Result<(), W::Error> {
33 writer.write_all(self.0.as_bytes()).await
34 }
35}
36
37pub struct JsonContent(pub String);
38
39impl picoserve::response::Content for JsonContent {
40 fn content_type(&self) -> &'static str {
42 "application/json"
43 }
44 fn content_length(&self) -> usize {
46 self.0.len()
47 }
48 async fn write_content<W: picoserve::io::Write>(self, mut writer: W) -> Result<(), W::Error> {
49 writer.write_all(self.0.as_bytes()).await
50 }
51}
52
53fn json_ok(
59 body: String,
60) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
61 Response::ok(JsonContent(body)).with_header("Access-Control-Allow-Origin", "*")
62}
63
64fn json_admin(
66 body: String,
67) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
68 Response::ok(JsonContent(body))
69}
70
71fn admin_token() -> String {
73 let token = net::read_file_text(ADMIN_TOKEN_PATH);
74 let trimmed = token.trim();
75 String::from(trimmed)
76}
77
78fn allow_kill_now() -> bool {
80 let now = net::clock_gettime_ns();
81 let start = KILL_WINDOW_START_NS.load(Ordering::Relaxed);
82 if now.saturating_sub(start) > KILL_RATE_LIMIT_WINDOW_NS {
83 KILL_WINDOW_START_NS.store(now, Ordering::Relaxed);
84 KILL_WINDOW_COUNT.store(0, Ordering::Relaxed);
85 }
86 let prev = KILL_WINDOW_COUNT.fetch_add(1, Ordering::Relaxed);
87 prev < KILL_RATE_LIMIT_MAX_PER_WINDOW
88}
89
90struct AdminAuth;
91
92impl<'r, State> FromRequestParts<'r, State> for AdminAuth {
93 type Rejection = String;
94
95 async fn from_request_parts(
97 _state: &'r State,
98 request_parts: &RequestParts<'r>,
99 ) -> Result<Self, Self::Rejection> {
100 let expected = admin_token();
101 if expected.is_empty() {
102 return Err(String::from(
103 r#"{"killed":false,"error":"admin token not configured"}"#,
104 ));
105 }
106 let provided = request_parts
107 .headers()
108 .get("authorization")
109 .and_then(|v| v.as_str().ok())
110 .unwrap_or("");
111 let ok = if let Some(bearer) = provided.strip_prefix("Bearer ") {
112 bearer == expected
113 } else {
114 provided == expected
115 };
116 if ok {
117 Ok(Self)
118 } else {
119 Err(String::from(r#"{"killed":false,"error":"unauthorized"}"#))
120 }
121 }
122}
123
124async fn index() -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body>
130{
131 Response::ok(HtmlContent(DASHBOARD_HTML))
132}
133
134async fn api_health(
136) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
137 json_ok(sysinfo::json_health())
138}
139
140async fn api_uptime(
142) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
143 json_ok(sysinfo::json_uptime())
144}
145
146async fn api_version(
148) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
149 json_ok(sysinfo::json_version())
150}
151
152async fn api_cpuinfo(
154) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
155 json_ok(sysinfo::json_cpuinfo())
156}
157
158async fn api_meminfo(
160) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
161 json_ok(sysinfo::json_meminfo())
162}
163
164async fn api_silos(
166) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
167 json_ok(sysinfo::json_silos())
168}
169
170async fn api_processes(
172) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
173 json_ok(sysinfo::json_processes())
174}
175
176async fn api_network(
178) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
179 json_ok(sysinfo::json_network())
180}
181
182async fn api_routes(
184) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
185 json_ok(sysinfo::json_routes())
186}
187
188async fn api_graphics_open(
189 sid: u32,
190 _auth: AdminAuth,
191) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
192 json_admin(sysinfo::json_graphics_open(sid))
193}
194
195async fn api_graphics_close(
196 session_id: u64,
197 _auth: AdminAuth,
198) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
199 json_admin(sysinfo::json_graphics_close(session_id))
200}
201
202async fn api_graphics_info(
203 session_id: u64,
204 _auth: AdminAuth,
205) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
206 json_admin(sysinfo::json_graphics_info(session_id))
207}
208
209async fn api_all() -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body>
211{
212 json_ok(sysinfo::json_all())
213}
214
215async fn api_kill(
217 pid: u32,
218 _auth: AdminAuth,
219) -> Response<impl picoserve::response::HeadersIter, impl picoserve::response::Body> {
220 if !allow_kill_now() {
221 return json_admin(String::from(r#"{"killed":false,"error":"rate limit"}"#));
222 }
223 json_admin(sysinfo::json_kill_result(pid))
224}
225
226pub fn build_router() -> picoserve::Router<impl picoserve::routing::PathRouter> {
232 picoserve::Router::new()
233 .route("/", get(index))
234 .route("/api/health", get(api_health))
235 .route("/api/uptime", get(api_uptime))
236 .route("/api/version", get(api_version))
237 .route("/api/cpuinfo", get(api_cpuinfo))
238 .route("/api/meminfo", get(api_meminfo))
239 .route("/api/silos", get(api_silos))
240 .route("/api/processes", get(api_processes))
241 .route("/api/network", get(api_network))
242 .route("/api/routes", get(api_routes))
243 .route("/api/all", get(api_all))
244 .route(
245 ("/api/graphics/open", parse_path_segment::<u32>()),
246 post(|sid, auth| async move { api_graphics_open(sid, auth).await }),
247 )
248 .route(
249 ("/api/graphics/close", parse_path_segment::<u64>()),
250 post(|session_id, auth| async move { api_graphics_close(session_id, auth).await }),
251 )
252 .route(
253 ("/api/graphics/info", parse_path_segment::<u64>()),
254 get(|session_id, auth| async move { api_graphics_info(session_id, auth).await }),
255 )
256 .route(
257 ("/api/kill", parse_path_segment::<u32>()),
258 post(|pid, auth| async move { api_kill(pid, auth).await }),
259 )
260}
261
262const DASHBOARD_HTML: &str = include_str!("../assets/dashboard.html");