1mod ai;
2mod apps;
3mod auth;
4mod identity;
5mod cache;
6mod core;
7mod deploys;
8mod files;
9mod guest;
10mod host;
11mod index;
12mod mcp;
13mod observability;
14mod oidc;
15mod relay;
16mod shale;
17mod shale_page;
18mod storage;
19mod telemetry;
20mod users;
21mod vms;
22mod youtube;
23
24use axum::{
25 Router,
26 body::Bytes,
27 extract::{Request, State},
28 http::{HeaderMap, Method, StatusCode},
29 response::{IntoResponse, Response, Sse, sse::Event},
30 routing::any,
31};
32use serde_json::{Value, json};
33use std::{
34 collections::HashMap,
35 path::PathBuf,
36 sync::{Arc, Mutex},
37 time::Duration,
38};
39use subtle::ConstantTimeEq;
40use tokio::sync::{Semaphore, watch};
41use tokio_stream::{StreamExt, wrappers::WatchStream};
42use tower_http::services::{ServeDir, ServeFile};
43
44type Result<T> = std::result::Result<T, Error>;
45
46#[derive(Clone, Debug)]
47struct Error {
48 status: u16,
49 message: String,
50}
51impl Error {
52 fn new(status: u16, message: impl Into<String>) -> Self {
53 Self {
54 status,
55 message: message.into(),
56 }
57 }
58}
59impl<E: std::error::Error> From<E> for Error {
60 fn from(error: E) -> Self {
61 Self::new(500, error.to_string())
62 }
63}
64impl IntoResponse for Error {
65 fn into_response(self) -> Response {
66 if self.status >= 500 {
67 eprintln!("{}: {}", self.status, self.message);
68 }
69 (
70 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
71 self.message,
72 )
73 .into_response()
74 }
75}
76
77struct Document {
78 value: Value,
79 bytes: Bytes,
80}
81impl Document {
82 fn new(value: Value) -> Self {
83 let bytes = Bytes::from(serde_json::to_vec(&value).unwrap());
84 Self { value, bytes }
85 }
86 fn response(&self) -> Response {
87 ([("content-type", "application/json")], self.bytes.clone()).into_response()
88 }
89}
90
91struct App {
92 auth: auth::Store,
93 mcp: mcp::Store,
94 relay: relay::Broker,
95 shale: shale::Backend,
96 http: reqwest::Client,
97 shale_http: reqwest::Client,
98 internal: Option<(url::Url, reqwest::Client)>,
99 cache: cache::Cache,
100 nomad_slots: Semaphore,
101 specs: Mutex<HashMap<String, Value>>,
102 repo: PathBuf,
103 data: PathBuf,
104 live: watch::Sender<Bytes>,
105 usage: Mutex<HashMap<String, Value>>,
106 vm_usage: Mutex<HashMap<String, Value>>,
107 seed_samples: Mutex<Vec<Value>>,
108 youtube: std::sync::OnceLock<youtube::Worker>,
109 heavy: Semaphore,
110 file_changes: tokio::sync::Mutex<()>,
111 index: Option<Arc<index::Index>>,
112}
113impl App {
114 fn request(&self, method: Method, address: &str) -> Result<reqwest::RequestBuilder> {
115 let url = url::Url::parse(address)?;
116 let client = match &self.internal {
117 Some((base, client)) if url.origin() == base.origin() => client,
118 _ => &self.http,
119 };
120 Ok(client.request(method, url))
121 }
122}
123
124fn env(name: &str, default: &str) -> String {
125 std::env::var(name).unwrap_or_else(|_| default.into())
126}
127fn array(value: &Value) -> &[Value] {
128 value.as_array().map(Vec::as_slice).unwrap_or_default()
129}
130fn string(value: &Value) -> &str {
131 value.as_str().unwrap_or_default()
132}
133fn number(value: &Value) -> f64 {
134 value
135 .as_f64()
136 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
137 .unwrap_or(0.0)
138}
139fn now() -> f64 {
140 std::time::SystemTime::now()
141 .duration_since(std::time::UNIX_EPOCH)
142 .unwrap()
143 .as_secs_f64()
144}
145fn encoded(value: &str) -> String {
146 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
147}
148fn params(items: &[(&str, String)]) -> String {
149 url::form_urlencoded::Serializer::new(String::new())
150 .extend_pairs(items.iter().map(|(k, v)| (*k, v)))
151 .finish()
152}
153
154fn user(headers: &HeaderMap) -> Result<Value> {
155 let name = headers.get("User-Name").and_then(|v| v.to_str().ok()).filter(|v| !v.is_empty()).ok_or_else(|| Error::new(401, "No signed-in user came with this request. Open the dashboard through its sign-in page."))?;
156 let groups: Vec<&str> = headers
157 .get("User-Groups")
158 .and_then(|v| v.to_str().ok())
159 .unwrap_or_default()
160 .split(|c: char| c == ',' || c.is_whitespace())
161 .filter(|s| !s.is_empty())
162 .map(|s| s.strip_prefix("role:").unwrap_or(s))
163 .collect();
164 let preview = headers
165 .get("cookie")
166 .and_then(|v| v.to_str().ok())
167 .unwrap_or_default()
168 .split(';')
169 .find_map(|s| s.trim().strip_prefix("view-as="));
170 let viewing = preview.is_some() && groups.contains(&"infra-admin");
171 let groups = if viewing {
172 preview
173 .unwrap()
174 .split(',')
175 .filter(|s| !s.is_empty() && *s != "infra-admin")
176 .collect()
177 } else {
178 groups
179 };
180 let sections: Vec<&str> = [
181 ("launcher", None),
182 ("admin", Some("infra-admin")),
183 ("metrics", Some("metrics")),
184 ("media", Some("media-manage")),
185 ("vms", Some("vm")),
186 ("ai", Some("ai")),
187 ]
188 .into_iter()
189 .filter(|(_, group)| can_open(&groups, *group))
190 .map(|(section, _)| section)
191 .collect();
192 let id = headers
193 .get("User-Id")
194 .and_then(|v| v.to_str().ok())
195 .unwrap_or(name);
196 Ok(json!({"id":id,"name":name,"groups":groups,"sections":sections,"viewing":viewing}))
197}
198fn can_open(groups: &[&str], access: Option<&str>) -> bool {
199 access.is_none() || groups.contains(&"infra-admin") || groups.contains(&access.unwrap())
200}
201fn need(user: &Value, section: &str) -> Result<()> {
202 if array(&user["sections"]).iter().any(|s| s == section) {
203 Ok(())
204 } else {
205 Err(Error::new(
206 403,
207 "Your account can't open this section. Ask an admin to add you to its group.",
208 ))
209 }
210}
211
212async fn command(program: &str, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>> {
213 use std::process::Stdio;
214 use tokio::io::AsyncWriteExt;
215 let mut child = tokio::process::Command::new(program)
216 .args(args)
217 .stdin(if input.is_some() {
218 Stdio::piped()
219 } else {
220 Stdio::null()
221 })
222 .stdout(Stdio::piped())
223 .stderr(Stdio::piped())
224 .kill_on_drop(true)
225 .spawn()?;
226 if let Some(input) = input {
227 child.stdin.take().unwrap().write_all(input).await?;
228 }
229 let output = tokio::time::timeout(Duration::from_secs(120), child.wait_with_output())
230 .await
231 .map_err(|_| {
232 Error::new(
233 504,
234 "The operation is taking too long. Check its logs, then retry.",
235 )
236 })??;
237 if !output.status.success() {
238 return Err(Error::new(
239 502,
240 String::from_utf8_lossy(&output.stderr).trim(),
241 ));
242 }
243 Ok(output.stdout)
244}
245
246async fn api(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
247 let path = request
248 .uri()
249 .path()
250 .trim_start_matches("/api/")
251 .trim_end_matches('/')
252 .to_owned();
253 let method = request.method().clone();
254 let query: HashMap<String, String> =
255 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
256 .into_owned()
257 .collect();
258 let parts: Vec<&str> = path.split('/').collect();
259 if parts.first() == Some(&"icons") && method == Method::GET {
260 return core::icon(&app, &parts, &query).await;
261 }
262 if parts.starts_with(&["account", "pictures"]) && method == Method::GET {
263 return users::picture(&app, &parts).await;
264 }
265 let me = user(request.headers())?;
266 let headers = request.headers().clone();
267 let section = match parts.first().copied().unwrap_or_default() {
268 "host" | "metrics" | "live" => Some("metrics"),
269 "services" if parts.len() == 1 => Some("metrics"),
270 "storage" if parts.len() == 1 => Some("metrics"),
271 "ai" | "mcp" => Some("ai"),
272 "services" | "traces" | "storage" | "users" | "deploys" | "paper-clover" => {
273 Some("admin")
274 }
275 "media" | "seedbox" | "youtube" => Some("media"),
276 "vms" => Some("vms"),
277 _ => None,
278 };
279 if let Some(section) = section {
280 need(&me, section)?;
281 }
282 if let ["vms", name, ..] = parts.as_slice() {
283 if !(parts.len() == 2
284 && ((*name == "history" && method == Method::GET)
285 || (*name == "iso" && method == Method::PUT)))
286 {
287 vms::authorize(&me, name).await?;
288 }
289 }
290 if let ["deploys", "runs", id] = parts.as_slice() {
291 return deploys::run_stream(app, id).await;
292 }
293 if parts.first() == Some(&"account") {
294 return users::account(app, request, &parts[1..], &me).await;
295 }
296 if path == "live" && method == Method::GET {
297 let stream = WatchStream::new(app.live.subscribe())
298 .filter(|data| !data.is_empty())
299 .map(|data| {
300 Ok::<_, std::convert::Infallible>(
301 Event::default().data(String::from_utf8_lossy(&data)),
302 )
303 });
304 return Ok(Sse::new(stream).into_response());
305 }
306 if let ["vms", name, kind @ ("console" | "serial")] = parts.as_slice() {
307 if method == Method::GET {
308 return vms::console(request, name, *kind == "serial").await;
309 }
310 }
311 if parts == ["vms", "iso"] && method == Method::PUT {
312 return vms::upload(app, request, &query).await;
313 }
314 let body = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await?;
315 let value = if body.is_empty() {
316 Value::Null
317 } else {
318 serde_json::from_slice(&body)
319 .map_err(|_| Error::new(400, "The request didn't match what this route expects."))?
320 };
321 match parts[0] {
322 "ai" => ai::route(app, &method, &parts[1..], &me, &headers).await,
323 "mcp" => mcp::manage(app, &method, &parts[1..], &me, value, &headers).await,
324 "users" => users::route(app, &method, &parts[1..], &me, value).await,
325 "vms" | "seedbox" | "paper-clover" => {
326 apps::route(app, &method, &parts, &query, &me, value).await
327 }
328 "youtube" => youtube::route(app, &method, &parts[1..], value).await,
329 "media" => files::route(app, true, &method, &parts[1..], &query, value).await,
330 "storage" if parts.get(1) == Some(&"files") => {
331 if parts.len() == 3 && ["map", "largest"].contains(&parts[2]) {
332 index::route(app, parts[2], &query).await
333 } else {
334 files::route(app, false, &method, &parts[2..], &query, value).await
335 }
336 }
337 "storage" => storage::route(app, &method, &parts[1..], &query, value).await,
338 "deploys" => deploys::route(app, &method, &parts[1..], &query).await,
339 _ => core::route(app, &method, &parts, &query, &me, value).await,
340 }
341}
342
343#[tokio::main(worker_threads = 4)]
344async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
345 let address: std::net::IpAddr = env("STUDIO_LISTEN_ADDRESS", "127.0.0.1").parse()?;
346 let proof = match std::env::var_os("STUDIO_PROXY_TOKEN_FILE") {
347 Some(file) => {
348 let token = std::fs::read_to_string(file)?.trim().to_owned();
349 if token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) {
350 return Err(std::io::Error::other(
351 "The dashboard proxy token must contain 64 hexadecimal characters.",
352 )
353 .into());
354 }
355 Some(Arc::<str>::from(token))
356 }
357 None if address.is_loopback() => None,
358 None => {
359 return Err(std::io::Error::other(
360 "Set STUDIO_PROXY_TOKEN_FILE before listening on a non-loopback address.",
361 )
362 .into());
363 }
364 };
365 let certificates = if let Ok(path) =
366 std::env::var("STUDIO_CA_BUNDLE").or_else(|_| std::env::var("NODE_EXTRA_CA_CERTS"))
367 {
368 reqwest::Certificate::from_pem_bundle(&std::fs::read(path)?)?
369 } else {
370 Vec::new()
371 };
372 let client = || {
373 let mut http = reqwest::Client::builder().timeout(Duration::from_secs(15));
374 for certificate in &certificates {
375 http = http.add_root_certificate(certificate.clone());
376 }
377 http
378 };
379 let internal = std::env::var("STUDIO_INTERNAL_URL")
380 .ok()
381 .map(
382 |address| -> std::result::Result<_, Box<dyn std::error::Error>> {
383 let base = url::Url::parse(&address)?;
384 if base.scheme() != "https"
385 || base.host_str().is_none()
386 || !base.username().is_empty()
387 || base.password().is_some()
388 || base.path() != "/"
389 || base.query().is_some()
390 || base.fragment().is_some()
391 {
392 return Err(std::io::Error::other(
393 "STUDIO_INTERNAL_URL must be an HTTPS origin.",
394 )
395 .into());
396 }
397 let token = proof.as_ref().ok_or_else(|| {
398 std::io::Error::other("STUDIO_INTERNAL_URL requires STUDIO_PROXY_TOKEN_FILE.")
399 })?;
400 let mut value = axum::http::HeaderValue::from_str(token)?;
401 value.set_sensitive(true);
402 let mut headers = HeaderMap::new();
403 headers.insert("Studio-Proxy-Token", value);
404 Ok((
405 base,
406 client()
407 .default_headers(headers)
408 .redirect(reqwest::redirect::Policy::none())
409 .build()?,
410 ))
411 },
412 )
413 .transpose()?;
414 let mut shale_http = reqwest::Client::builder()
415 .connect_timeout(Duration::from_secs(5))
416 .read_timeout(Duration::from_secs(30))
417 .redirect(reqwest::redirect::Policy::none())
418 .retry(reqwest::retry::never());
419 for certificate in &certificates {
420 shale_http = shale_http.add_root_certificate(certificate.clone());
421 }
422 if internal.is_some() {
423 let mut token = axum::http::HeaderValue::from_str(proof.as_ref().unwrap())?;
424 token.set_sensitive(true);
425 let mut headers = HeaderMap::new();
426 headers.insert("Studio-Proxy-Token", token);
427 shale_http = shale_http.default_headers(headers);
428 }
429 let shale_http = shale_http.build()?;
430 let (live, _) = watch::channel(Bytes::new());
431 let index = std::env::var("STUDIO_INDEX_POOL").ok().map(|pool| {
432 Arc::new(index::Index::new(
433 pool,
434 env("STUDIO_INDEX_DIR", ".cache/index").into(),
435 ))
436 });
437 let origin = env(
438 "STUDIO_PUBLIC_ORIGIN",
439 &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")),
440 );
441 let auth = auth::Store::new(
442 &PathBuf::from(env("STUDIO_DATA_DIR", "data")),
443 &origin,
444 &env(
445 "STUDIO_FILE_ORIGIN",
446 &format!("https://file.{}", env("STUDIO_DOMAIN", "studio.test")),
447 ),
448 &env(
449 "STUDIO_AUTH_RP_ID",
450 &format!("auth.{}", env("STUDIO_DOMAIN", "studio.test")),
451 ),
452 )
453 .map_err(|error| std::io::Error::other(error.message))?;
454 if let Some(path) = std::env::args().skip(1).next() {
455 if path == "--import-commit-identity" {
456 let input = serde_json::from_reader(std::io::stdin())?;
457 let output = identity::import(&auth, &input).map_err(|error| std::io::Error::other(error.message))?;
458 println!("{output}");
459 return Ok(());
460 }
461 if path == "--guest-provider" {
462 let input = serde_json::from_reader(std::io::stdin())?;
463 let output = guest::provision(&auth, input)
464 .map_err(|error| std::io::Error::other(error.message))?;
465 println!("{output}");
466 return Ok(());
467 }
468 if path == "--oidc-client" {
469 let input = serde_json::from_reader(std::io::stdin())?;
470 let output = oidc::provision(&auth, input)
471 .map_err(|error| std::io::Error::other(error.message))?;
472 println!("{output}");
473 return Ok(());
474 }
475 if path != "--import-accounts" {
476 return Err(std::io::Error::other("Unknown dashboard argument.").into());
477 }
478 let path = std::env::args()
479 .nth(2)
480 .ok_or_else(|| std::io::Error::other("Provide an account export path."))?;
481 let result = auth
482 .import(serde_json::from_slice(&std::fs::read(path)?)?)
483 .map_err(|error| std::io::Error::other(error.message))?;
484 println!("{result}");
485 return Ok(());
486 }
487 let import = PathBuf::from(env("STUDIO_DATA_DIR", "data")).join("accounts-import.json");
488 if import.exists() {
489 auth.import(serde_json::from_slice(&std::fs::read(&import)?)?)
490 .map_err(|error| std::io::Error::other(error.message))?;
491 std::fs::remove_file(&import)?;
492 }
493 if env("STUDIO_AUTH_REQUIRED", "0") == "1" && !auth.ready() {
494 return Err(std::io::Error::other(
495 "Import accounts before starting native authentication.",
496 )
497 .into());
498 }
499 let app = Arc::new(App {
500 auth,
501 mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin)
502 .map_err(|error| std::io::Error::other(error.message))?,
503 relay: relay::Broker::default(),
504 shale: shale::Backend::new(
505 &env(
506 "STUDIO_SHALE_URL",
507 &format!("https://shale.{}", env("STUDIO_DOMAIN", "studio.test")),
508 ),
509 client(),
510 )
511 .map_err(|error| std::io::Error::other(error.message))?,
512 http: client().build()?,
513 shale_http,
514 internal,
515 cache: cache::Cache::default(),
516 nomad_slots: Semaphore::new(4),
517 specs: Mutex::new(HashMap::new()),
518 repo: env("STUDIO_REPO", "..").into(),
519 data: env("STUDIO_DATA_DIR", "data").into(),
520 live,
521 usage: Mutex::new(HashMap::new()),
522 vm_usage: Mutex::new(HashMap::new()),
523 seed_samples: Mutex::new(Vec::new()),
524 youtube: std::sync::OnceLock::new(),
525 heavy: Semaphore::new(4),
526 file_changes: tokio::sync::Mutex::new(()),
527 index,
528 });
529 if let Some(index) = app.index.clone() {
530 let state = app.clone();
531 tokio::spawn(async move {
532 index.start(state).await;
533 });
534 }
535 if std::env::var("STUDIO_YT_STATE").is_ok() {
536 for parts in [&[][..], &["library"][..]] {
537 let state = app.clone();
538 tokio::spawn(async move {
539 if let Err(error) = youtube::route(state, &Method::GET, parts, Value::Null).await {
540 eprintln!("youtube: {}", error.message);
541 }
542 });
543 }
544 }
545 core::start(app.clone());
546 telemetry::start(app.clone());
547 let dist = env("STUDIO_WEB_DIR", "dist");
548 let router = Router::new()
549 .route("/api/{*path}", any(api))
550 .route("/auth/{*path}", any(auth::route))
551 .route("/.well-known/openid-configuration", any(oidc::route))
552 .route("/oauth/{*path}", any(mcp::oauth))
553 .route("/.well-known/{*path}", any(mcp::oauth))
554 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))
555 .with_state(app.clone())
556 .merge(observability::router(app.clone()))
557 .merge(shale::router(app.clone()))
558 .merge(relay::router(app.clone()))
559 .fallback_service(
560 ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))),
561 )
562 .layer(axum::middleware::from_fn(
563 move |mut request: Request, next: axum::middleware::Next| {
564 let proof = proof.clone();
565 let app = app.clone();
566 async move {
567 request.headers_mut().remove("User-Id");
568 if mcp::public(request.uri().path()) {
569 request.headers_mut().remove("Studio-Proxy-Token");
570 request.headers_mut().remove("User-Name");
571 request.headers_mut().remove("User-Groups");
572 } else if let Some(proof) = proof {
573 let supplied = request
574 .headers()
575 .get("Studio-Proxy-Token")
576 .map(|value| value.as_bytes())
577 .unwrap_or_default();
578 if supplied.ct_eq(proof.as_bytes()).unwrap_u8() == 0 {
579 return Error::new(403, "Open the dashboard through its sign-in page.")
580 .into_response();
581 }
582 request.headers_mut().remove("Studio-Proxy-Token");
583 }
584 if request.uri().path() == "/auth/shale/page" {
585 return next.run(request).await;
586 }
587 let path = request.uri().path().to_owned();
588 let asset = path.starts_with("/api/account/pictures/")
589 || path.starts_with("/assets/")
590 || path.starts_with("/fonts/")
591 || path == "/snowflake.svg";
592 if app.auth.ready()
593 && !mcp::public(&path)
594 && !path.starts_with("/auth/")
595 && !asset
596 && path != "/sign-in"
597 {
598 request.headers_mut().remove("User-Name");
599 request.headers_mut().remove("User-Groups");
600 let account = match app.auth.session(request.headers(), "dashboard") {
601 Ok(account) => account,
602 Err(error) => return error.into_response(),
603 };
604 if account.is_null() {
605 return if path.starts_with("/api/") {
606 Error::new(401, "Sign in to Snowglobe.").into_response()
607 } else {
608 (
609 StatusCode::FOUND,
610 [(
611 "location",
612 format!(
613 "/sign-in?next={}",
614 encoded(&request.uri().to_string())
615 ),
616 )],
617 )
618 .into_response()
619 };
620 }
621 if guest::is_guest(&account) {
622 return if path.starts_with("/api/") {
623 Error::new(
624 403,
625 "Guest accounts can use Shale. Open Shale to continue.",
626 )
627 .into_response()
628 } else {
629 (
630 StatusCode::FOUND,
631 [(
632 "location",
633 format!(
634 "https://shale.{}/",
635 env("STUDIO_DOMAIN", "studio.test")
636 ),
637 )],
638 )
639 .into_response()
640 };
641 }
642 if !matches!(
643 *request.method(),
644 Method::GET | Method::HEAD | Method::OPTIONS
645 ) && request
646 .headers()
647 .get("origin")
648 .and_then(|v| v.to_str().ok())
649 != Some(app.auth.origin.origin().ascii_serialization().as_str())
650 {
651 return Error::new(403, "Open Snowglobe and try again.")
652 .into_response();
653 }
654 if !array(&account["requiredActions"]).is_empty()
655 && !path.starts_with("/api/account")
656 && path.starts_with("/api/")
657 && path != "/api/me"
658 {
659 return Error::new(
660 403,
661 "Change your temporary password in your account first.",
662 )
663 .into_response();
664 }
665 let groups = array(&account["groups"])
666 .iter()
667 .map(|v| string(&v["name"]))
668 .collect::<Vec<_>>()
669 .join(",");
670 request
671 .headers_mut()
672 .insert("User-Name", string(&account["username"]).parse().unwrap());
673 request
674 .headers_mut()
675 .insert("User-Id", string(&account["id"]).parse().unwrap());
676 request
677 .headers_mut()
678 .insert("User-Groups", groups.parse().unwrap());
679 }
680 let document = !asset && !request.uri().path().starts_with("/api/");
681 if document {
682 request.headers_mut().remove("if-modified-since");
683 request.headers_mut().remove("if-none-match");
684 }
685 let mut response = next.run(request).await;
686 response
687 .headers_mut()
688 .insert("referrer-policy", "no-referrer".parse().unwrap());
689 response
690 .headers_mut()
691 .insert("x-content-type-options", "nosniff".parse().unwrap());
692 response
693 .headers_mut()
694 .insert("x-frame-options", "DENY".parse().unwrap());
695 if asset && response.status().is_success() {
696 response.headers_mut().insert(
697 "cache-control",
698 "public, max-age=31536000, immutable".parse().unwrap(),
699 );
700 } else if document {
701 response
702 .headers_mut()
703 .insert("cache-control", "no-store".parse().unwrap());
704 }
705 response
706 }
707 },
708 ));
709 let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::new(
710 address,
711 env("PORT", "7070").parse()?,
712 ))
713 .await?;
714 println!("dashboard on {}", listener.local_addr()?);
715 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
716 let (shutdown, waiting) = tokio::sync::oneshot::channel::<()>();
717 let mut server = Box::pin(std::future::IntoFuture::into_future(
718 axum::serve(listener, router).with_graceful_shutdown(async {
719 let _ = waiting.await;
720 }),
721 ));
722 tokio::select! {
723 result = &mut server => { result?; return Ok(()); },
724 _ = terminate.recv() => {},
725 _ = tokio::signal::ctrl_c() => {},
726 }
727 let _ = shutdown.send(());
728 if let Ok(result) = tokio::time::timeout(Duration::from_secs(5), server).await {
729 result?;
730 }
731 Ok(())
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 #[test]
738 fn only_an_admin_can_preview_groups_and_preview_cannot_grant_admin() {
739 let mut headers = HeaderMap::new();
740 assert_eq!(user(&headers).unwrap_err().status, 401);
741 headers.insert("User-Name", "snow".parse().unwrap());
742 headers.insert("User-Groups", "role:metrics,media-manage".parse().unwrap());
743 headers.insert("cookie", "view-as=infra-admin".parse().unwrap());
744 let me = user(&headers).unwrap();
745 assert_eq!(me["viewing"], false);
746 assert!(need(&me, "admin").is_err());
747 assert!(need(&me, "metrics").is_ok());
748 headers.insert("User-Groups", "infra-admin".parse().unwrap());
749 let me = user(&headers).unwrap();
750 assert_eq!(me["viewing"], true);
751 assert!(need(&me, "admin").is_err());
752 headers.insert("cookie", "view-as=metrics".parse().unwrap());
753 let me = user(&headers).unwrap();
754 assert!(need(&me, "metrics").is_ok());
755 assert!(need(&me, "media").is_err());
756 headers.insert("cookie", "view-as=ai".parse().unwrap());
757 let me = user(&headers).unwrap();
758 assert!(need(&me, "ai").is_ok());
759 assert!(need(&me, "admin").is_err());
760 headers.insert("User-Groups", "ai".parse().unwrap());
761 let me = user(&headers).unwrap();
762 assert_eq!(me["viewing"], false);
763 assert!(need(&me, "ai").is_ok());
764 assert!(need(&me, "admin").is_err());
765 }
766}