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