1pub mod assets;
25pub mod html;
26pub(crate) mod intents;
27pub(crate) mod mcp;
28pub(crate) mod monitor;
29pub(crate) mod nav;
30pub(crate) mod pages;
31pub(crate) mod plan;
32pub(crate) mod reasoning;
33pub(crate) mod trains;
34pub(crate) mod view;
35
36use std::sync::Arc;
37
38use crate::capability::Context;
39use crate::http::router::{percent_decode, Request, Response};
40use crate::live::{IntoLive, Live};
41
42use assets::Assets;
43
44pub const PREFIX: &str = "/cockpit";
46
47pub const SURFACE: &str = "cockpit";
49
50pub const STYLESHEET: &str = "cockpit.css";
52
53pub const SHELL_SCRIPTS: &[&str] = &["palette.js"];
61
62pub(crate) const BAR_SCRIPTS: &[&str] = &["nav.js", "theme.js"];
66
67pub(crate) const VENDOR: &[&str] = &[
74 "alpine.csp.min.js",
75 "cytoscape.min.js",
76 "three.module.min.js",
77 "p5.min.js",
78];
79
80pub struct Cockpit {
83 live: Arc<Live>,
84 version: &'static str,
85 assets: Assets,
86}
87
88pub const STATIC_ROUTES: &[(&str, &str)] = &[
105 (PREFIX, "overview"),
106 ("/cockpit/capabilities", "capabilities"),
107 ("/cockpit/commands", "commands"),
108 ("/cockpit/objects", "objects"),
109 ("/cockpit/object", ""),
110 ("/cockpit/executions", "executions"),
111 ("/cockpit/graphs", "graphs"),
112 ("/cockpit/graphs/topology", ""),
113 ("/cockpit/continuity", "continuity"),
114 ("/cockpit/intents", "intents"),
115 ("/cockpit/plan", "plan"),
116 ("/cockpit/worktrees", "worktrees"),
117 ("/cockpit/peers", "peers"),
118 ("/cockpit/integration", "integration"),
119 ("/cockpit/trains", "trains"),
120 ("/cockpit/mesh", "mesh"),
121 ("/cockpit/monitor", ""),
122 ("/cockpit/models", "models"),
123 ("/cockpit/reasoning", "reasoning"),
124 ("/cockpit/economics", "economics"),
125 ("/cockpit/directories", "directories"),
126 ("/cockpit/health", "health"),
127 ("/cockpit/quality", "quality"),
128 ("/cockpit/artifacts", "artifacts"),
129 ("/cockpit/release", "release"),
130 ("/cockpit/design", "design"),
131 ("/cockpit/api", "api"),
132 ("/cockpit/mcp", "mcp"),
133 ("/cockpit/search", ""),
134 ("/cockpit/activity", ""),
135];
136
137impl Cockpit {
138 pub fn new(
140 live: impl IntoLive,
141 version: &'static str,
142 share_dir: Option<&std::path::Path>,
143 ) -> Self {
144 let assets = match share_dir {
145 Some(dir) => Assets::new(dir),
146 None => Assets::none(),
147 };
148 if !assets.present() {
149 tracing::warn!(
150 "the distribution has no share/cockpit/ directory: the Cockpit will serve unstyled markup; run `just cockpit-assets` to build it"
151 );
152 }
153 Cockpit {
154 live: live.into_live(),
155 version,
156 assets,
157 }
158 }
159
160 pub fn owns(path: &str) -> bool {
170 path == PREFIX || path.starts_with(&format!("{PREFIX}/"))
171 }
172
173 pub fn handle(&self, req: &Request) -> Response {
175 if req.method != "GET" {
176 return Response::error(
177 405,
178 "method_not_allowed",
179 "the Cockpit's pages are read with GET; a capability that changes something is called on its own route",
180 );
181 }
182 if let Some(name) = req.path.strip_prefix(assets::PREFIX) {
183 return self.assets.respond(name, req);
184 }
185
186 let ctx = self.live.current();
189 let page = self.route(&ctx, req);
190 let navigation = nav::build(&ctx, &req.path);
191 let scripts = SHELL_SCRIPTS
192 .iter()
193 .copied()
194 .chain(page.scripts.iter().copied())
195 .map(|name| self.assets.url(name))
196 .collect();
197 let shell = view::Shell {
198 title: &page.title,
199 subtitle: page.subtitle.clone(),
200 area: page.area,
201 breadcrumbs: page.breadcrumbs.clone(),
202 navigation: &navigation,
203 stylesheet: self.assets.url(STYLESHEET),
204 favicon: self.assets.url("favicon.svg"),
205 scripts,
206 bar_scripts: BAR_SCRIPTS.iter().map(|s| self.assets.url(s)).collect(),
207 vendor: self.vendor_urls(),
208 assets_present: self.assets.present(),
209 version: self.version,
210 };
211 let body = view::page(&shell, page.main);
212 let mut response = Response::new(page.status, "text/html; charset=utf-8", body);
213 response
217 .headers
218 .push(("Cache-Control".into(), "no-cache".into()));
219 response
220 .headers
221 .push(("Content-Security-Policy".into(), csp().into()));
222 response
223 .headers
224 .push(("X-Content-Type-Options".into(), "nosniff".into()));
225 response
226 .headers
227 .push(("Referrer-Policy".into(), "no-referrer".into()));
228 response
229 }
230
231 fn vendor_urls(&self) -> String {
234 let urls: std::collections::BTreeMap<&str, String> = VENDOR
235 .iter()
236 .map(|file| (*file, format!("vendor/{file}")))
237 .filter(|(_, path)| self.assets.has(path))
238 .map(|(file, path)| (file, self.assets.url(&path)))
239 .collect();
240 serde_json::to_string(&urls).unwrap_or_else(|_| "{}".into())
241 }
242
243 fn route(&self, ctx: &Arc<Context>, req: &Request) -> pages::Page {
244 let path = req.path.trim_end_matches('/');
245 let query = &req.query;
246 match path {
247 "" | PREFIX => pages::overview(ctx),
248 "/cockpit/capabilities" => pages::capabilities(ctx, query),
249 "/cockpit/commands" => pages::commands(ctx, query),
250 "/cockpit/objects" => pages::objects(ctx, query),
251 "/cockpit/object" => match query.iter().find(|(k, _)| k == "uri") {
254 Some((_, uri)) => pages::object(ctx, uri),
255 None => pages::objects(ctx, query),
256 },
257 "/cockpit/executions" => pages::executions(ctx, query),
258 "/cockpit/graphs" => pages::graphs(ctx),
259 "/cockpit/graphs/topology" => pages::topology(ctx),
260 "/cockpit/continuity" => pages::continuity(ctx),
261 "/cockpit/intents" => intents::list(ctx),
262 "/cockpit/plan" => plan::plan(ctx, query),
263 "/cockpit/worktrees" => pages::worktrees(ctx),
264 "/cockpit/peers" => pages::peers(ctx),
265 "/cockpit/integration" => pages::integration(ctx),
266 "/cockpit/trains" => trains::page(ctx),
267 "/cockpit/mesh" => pages::mesh(ctx),
268 "/cockpit/monitor" => monitor::page(ctx, query),
269 "/cockpit/models" => pages::models(ctx),
270 "/cockpit/reasoning" => reasoning::page(ctx),
271 "/cockpit/economics" => pages::economics(ctx),
272 "/cockpit/directories" => pages::directories(ctx, query),
273 "/cockpit/health" => pages::health(ctx),
274 "/cockpit/quality" => pages::quality(ctx),
275 "/cockpit/artifacts" => pages::artifacts(ctx),
276 "/cockpit/release" => pages::release(ctx),
277 "/cockpit/design" => pages::design(ctx),
278 "/cockpit/api" => pages::api(ctx),
279 "/cockpit/mcp" => mcp::page(ctx, query),
280 "/cockpit/search" => pages::search(ctx, query),
281 "/cockpit/activity" => pages::activity(ctx),
282 other => {
283 if let Some(id) = other.strip_prefix("/cockpit/executions/") {
284 pages::execution(ctx, &percent_decode(id))
285 } else if let Some(id) = other.strip_prefix("/cockpit/capabilities/") {
286 pages::capability(ctx, &percent_decode(id))
287 } else if let Some(id) = other.strip_prefix("/cockpit/commands/") {
288 pages::command(ctx, &percent_decode(id))
289 } else if let Some(id) = other.strip_prefix("/cockpit/plan/milestones/") {
290 plan::milestone(ctx, &percent_decode(id))
291 } else if let Some(id) = other.strip_prefix("/cockpit/plan/issues/") {
292 plan::issue(ctx, &percent_decode(id))
293 } else if let Some(id) = other.strip_prefix("/cockpit/intents/") {
294 intents::intent(ctx, &percent_decode(id))
295 } else if let Some(id) = other.strip_prefix("/cockpit/graphs/") {
296 pages::graph(ctx, &percent_decode(id), query)
297 } else if let Some(rest) = other.strip_prefix("/cockpit/objects/") {
298 let rest = percent_decode(rest);
302 match rest.split_once('/') {
303 Some((kind, slug)) => pages::entity(ctx, kind, slug),
304 None => pages::objects_of_kind(ctx, &rest, query),
305 }
306 } else {
307 pages::not_found(other)
308 }
309 }
310 }
311 }
312}
313
314pub fn csp() -> &'static str {
335 static POLICY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
336 POLICY.get_or_init(|| {
337 use sha2::{Digest, Sha256};
338 let mut hasher = Sha256::new();
339 hasher.update(view::theme_bootstrap().as_bytes());
340 let digest = base64(&hasher.finalize());
341 format!(
342 "default-src 'none'; script-src 'self' 'sha256-{digest}'; \
343 style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; \
344 font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
345 )
346 })
347}
348
349pub fn base64(bytes: &[u8]) -> String {
361 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
362 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
363 for chunk in bytes.chunks(3) {
364 let n = ((chunk[0] as u32) << 16)
365 | ((*chunk.get(1).unwrap_or(&0) as u32) << 8)
366 | *chunk.get(2).unwrap_or(&0) as u32;
367 out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
368 out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
369 out.push(if chunk.len() > 1 {
370 ALPHABET[(n >> 6 & 63) as usize] as char
371 } else {
372 '='
373 });
374 out.push(if chunk.len() > 2 {
375 ALPHABET[(n & 63) as usize] as char
376 } else {
377 '='
378 });
379 }
380 out
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
391 fn every_vendored_library_a_script_loads_is_published_with_its_digest() {
392 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share/cockpit");
393 let mut named = Vec::new();
394 for entry in std::fs::read_dir(&dir).expect("share/cockpit") {
395 let path = entry.expect("an entry").path();
396 if path.extension().is_none_or(|e| e != "js") {
397 continue;
398 }
399 let source = std::fs::read_to_string(&path).expect("a script");
400 for call in ["vendor('", "vendorModule('"] {
401 for rest in source.split(call).skip(1) {
402 let file = rest.split('\'').next().unwrap_or_default();
403 named.push((path.display().to_string(), file.to_string()));
404 }
405 }
406 }
407 assert!(
408 named.iter().any(|(_, f)| f == "alpine.csp.min.js"),
409 "the scan found the shell's own Alpine load: {named:?}"
410 );
411 for (script, file) in &named {
412 assert!(
413 VENDOR.contains(&file.as_str()),
414 "{script} loads {file}, which VENDOR does not name"
415 );
416 }
417 }
418
419 #[test]
420 fn the_policy_allows_no_remote_origin_and_no_unsafe_source() {
421 let policy = csp();
422 assert!(policy.contains("script-src 'self' 'sha256-"), "{policy}");
423 assert!(!policy.contains("unsafe-eval"), "{policy}");
424 assert!(
426 policy.contains("style-src 'self' 'unsafe-inline'"),
427 "{policy}"
428 );
429 let script_src = policy
430 .split("script-src ")
431 .nth(1)
432 .and_then(|s| s.split(';').next())
433 .expect("a script-src directive");
434 assert!(!script_src.contains("unsafe-inline"), "{script_src}");
435 assert!(!policy.contains("http"), "{policy}");
436 assert!(policy.contains("frame-ancestors 'none'"), "{policy}");
437 }
438
439 #[test]
440 fn the_policy_names_the_digest_of_the_script_it_allows() {
441 use sha2::{Digest, Sha256};
442 let mut h = Sha256::new();
443 h.update(view::theme_bootstrap().as_bytes());
444 let expected = base64(&h.finalize());
445 assert!(
446 csp().contains(&format!("'sha256-{expected}'")),
447 "the policy names the digest of the bootstrap it ships"
448 );
449 assert_eq!(expected.len(), 44, "base64 of 32 bytes");
450 }
451
452 #[test]
456 fn every_area_has_a_route_and_every_route_its_area() {
457 let areas = nav::areas();
458 let missing_route: Vec<&str> = areas
459 .iter()
460 .filter(|a| !STATIC_ROUTES.iter().any(|(path, _)| *path == a.href))
461 .map(|a| a.href)
462 .collect();
463 assert!(
464 missing_route.is_empty(),
465 "the navigation offers areas no route answers: {missing_route:?}"
466 );
467
468 let unnavigable: Vec<&str> = STATIC_ROUTES
469 .iter()
470 .filter(|(_, area)| !area.is_empty())
471 .filter(|(_, area)| !areas.iter().any(|a| a.id == *area))
472 .map(|(path, _)| *path)
473 .collect();
474 assert!(
475 unnavigable.is_empty(),
476 "the dispatcher answers routes the navigation does not name, so nothing links to \
477 them and no crawl reaches them: {unnavigable:?}"
478 );
479 }
480
481 #[test]
486 fn every_plain_arm_of_the_dispatcher_is_a_static_route() {
487 let src = include_str!("mod.rs");
488 let start = src
489 .find("fn route(&self")
490 .expect("cockpit/mod.rs carries the dispatcher as fn route");
491 let body = &src[start..];
492 let end = body
493 .find("other =>")
494 .expect("the dispatcher ends its plain arms with a catch-all");
495 let mut arms: Vec<&str> = body[..end]
496 .lines()
497 .map(str::trim_start)
498 .filter_map(|line| line.strip_prefix('"'))
499 .filter_map(|line| line.split_once("\" =>").map(|(path, _)| path))
500 .filter(|path| path.starts_with("/cockpit"))
501 .collect();
502 if body[..end].contains("\"\" | PREFIX =>") {
503 arms.push(PREFIX);
504 }
505 assert!(
506 arms.len() > 10,
507 "the dispatcher's arms were not found, so nothing was compared: {arms:?}"
508 );
509
510 let unlisted: Vec<&str> = arms
511 .iter()
512 .copied()
513 .filter(|arm| !STATIC_ROUTES.iter().any(|(path, _)| path == arm))
514 .collect();
515 assert!(
516 unlisted.is_empty(),
517 "the dispatcher answers plain routes STATIC_ROUTES does not list: {unlisted:?}"
518 );
519 let unanswered: Vec<&str> = STATIC_ROUTES
520 .iter()
521 .map(|(path, _)| *path)
522 .filter(|path| !arms.contains(path))
523 .collect();
524 assert!(
525 unanswered.is_empty(),
526 "STATIC_ROUTES lists routes the dispatcher has no arm for: {unanswered:?}"
527 );
528 }
529
530 #[test]
536 fn the_documented_route_table_is_the_dispatchers() {
537 let doc = include_str!("../../../../docs/COCKPIT.md");
538 let start = doc
539 .find("## What is on it")
540 .expect("docs/COCKPIT.md carries the route table under 'What is on it'");
541 let section = &doc[start..];
542 let end = section[3..].find("\n## ").map_or(section.len(), |i| i + 3);
543 let documented: Vec<&str> = section[..end]
544 .lines()
545 .filter_map(|line| line.strip_prefix("| `"))
546 .filter_map(|line| line.split('`').next())
547 .filter(|route| !route.contains('<'))
548 .map(|route| route.split('?').next().unwrap_or(route))
549 .collect();
550
551 let undocumented: Vec<&str> = STATIC_ROUTES
552 .iter()
553 .map(|(path, _)| *path)
554 .filter(|path| !documented.contains(path))
555 .collect();
556 assert!(
557 undocumented.is_empty(),
558 "docs/COCKPIT.md has no row for routes the dispatcher answers: {undocumented:?}"
559 );
560
561 let unrouted: Vec<&str> = documented
562 .iter()
563 .copied()
564 .filter(|route| route.starts_with("/cockpit") && !route.starts_with("/cockpit/assets"))
565 .filter(|route| !STATIC_ROUTES.iter().any(|(path, _)| path == route))
566 .collect();
567 assert!(
568 unrouted.is_empty(),
569 "docs/COCKPIT.md documents routes the dispatcher does not answer: {unrouted:?}"
570 );
571 }
572}