diff --git a/config/Service.pkl b/config/Service.pkl index 70b148abeb46ec9ecec2f7e480ba1174c743c2f9..40d6deab0009d258f8f62f5afef16838b2532c04 100644 --- a/config/Service.pkl +++ b/config/Service.pkl @@ -17,6 +17,7 @@ class Metadata { class Http { containerPort: UInt16 hostPort: UInt16? + loopback: Boolean = false subdomain: String? authRole: String? /// Backend header set from the authenticated OIDC preferred username. diff --git a/config/policies/dashboard.hcl b/config/policies/dashboard.hcl index 6bddf9f13f6447a9365a3b5d212342e74f7df787..82e8fa3a7a2027cdec49a42e6f79d6d51906df5e 100644 --- a/config/policies/dashboard.hcl +++ b/config/policies/dashboard.hcl @@ -1,5 +1,9 @@ namespace "default" { capabilities = ["list-jobs", "read-job", "read-logs"] + variables { + path "nomad/jobs/local-ai" { capabilities = ["read"] } + path "nomad/jobs/local-ai-preview-*" { capabilities = ["read"] } + } } node { diff --git a/dashboard/ai/launch.sh b/dashboard/ai/launch.sh new file mode 100644 index 0000000000000000000000000000000000000000..4e03588d24e11358d5854187ad69ce988767472d --- /dev/null +++ b/dashboard/ai/launch.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -euo pipefail +umask 077 +client=@CLIENT@ +endpoint=@ENDPOINT@ +model=@MODEL@ +config="${XDG_CONFIG_HOME:-$HOME/.config}/snowglobe-ai" +mkdir -p "$config" +chmod 700 "$config" +if ! command -v "$client" >/dev/null 2>&1; then + printf 'Install %s before running snow-%s.\n' "$client" "$client" >&2 + exit 1 +fi +if [[ -z ${SNOWGLOBE_AI_KEY:-} ]]; then + if [[ ! -s "$config/api-key" ]]; then + if [[ ! -t 0 ]]; then + printf 'Set SNOWGLOBE_AI_KEY or run snow-%s in a terminal to save your API key.\n' "$client" >&2 + exit 1 + fi + read -r -s -p 'Snow Globe API key: ' key + printf '\n' >&2 + [[ -n "$key" ]] || exit 1 + printf '%s\n' "$key" > "$config/api-key" + chmod 600 "$config/api-key" + fi + IFS= read -r SNOWGLOBE_AI_KEY < "$config/api-key" +fi +export SNOWGLOBE_AI_KEY +endpoint="${SNOWGLOBE_AI_URL:-$endpoint}" +endpoint="${endpoint%/}" +if [[ "$client" == codex ]]; then + catalog=$(mktemp "$config/catalog.XXXXXXXX") + trap 'rm -f "$catalog"' EXIT + cat > "$catalog" <<'SNOW_MODEL_CATALOG' +@CATALOG@ +SNOW_MODEL_CATALOG + codex --no-daemon --approve-for-me \ + -c "model=\"$model\"" \ + -c 'model_provider="snowglobe"' \ + -c 'model_context_window=@CONTEXT@' \ + -c 'model_auto_compact_token_limit=@COMPACT@' \ + -c 'model_reasoning_effort="@REASONING@"' \ + -c 'model_reasoning_summary="none"' \ + -c "model_catalog_json=\"$catalog\"" \ + -c 'web_search="disabled"' \ + -c 'features.plugins=false' -c 'features.apps=false' -c 'features.memories=false' \ + -c 'analytics.enabled=false' \ + -c 'model_providers.snowglobe.name="Snow Globe"' \ + -c "model_providers.snowglobe.base_url=\"$endpoint/v1\"" \ + -c 'model_providers.snowglobe.env_key="SNOWGLOBE_AI_KEY"' \ + -c 'model_providers.snowglobe.wire_api="responses"' \ + -c 'model_providers.snowglobe.requires_openai_auth=false' \ + -c 'model_providers.snowglobe.supports_websockets=false' \ + -c 'model_providers.snowglobe.stream_idle_timeout_ms=28800000' "$@" +else + unset ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR \ + CLAUDE_CODE_OAUTH_REFRESH_TOKEN CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY + mkdir -p "$config/claude-credentials" + chmod 700 "$config/claude-credentials" + export CLAUDE_SECURESTORAGE_CONFIG_DIR="$config/claude-credentials" + export ENABLE_CLAUDEAI_MCP_SERVERS=false + export ANTHROPIC_BASE_URL="$endpoint" ANTHROPIC_API_KEY="$SNOWGLOBE_AI_KEY" + export ANTHROPIC_MODEL="$model" ANTHROPIC_DEFAULT_MODEL="$model" + export ANTHROPIC_DEFAULT_SONNET_MODEL="$model" ANTHROPIC_DEFAULT_OPUS_MODEL="$model" + export ANTHROPIC_DEFAULT_HAIKU_MODEL="$model" ANTHROPIC_DEFAULT_FABLE_MODEL="$model" + export CLAUDE_CODE_SUBAGENT_MODEL="$model" CLAUDE_CODE_MAX_CONTEXT_TOKENS=@CONTEXT@ + export CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096 CLAUDE_CODE_AUTO_MODE_SERVER=0 + export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 + export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 API_TIMEOUT_MS=28800000 API_FORCE_IDLE_TIMEOUT=0 + export CLAUDE_STREAM_IDLE_TIMEOUT_MS=28800000 CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS=1800000 + export CLAUDE_ASYNC_AGENT_STALL_TIMEOUT_MS=28800000 + exec claude --permission-mode auto "$@" +fi diff --git a/dashboard/package.json b/dashboard/package.json index 76b4f286bdf333124daa27c832a0862066f2e225..e023c713b83c6c8f6ec552f4d0be1df5b67721c8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -12,6 +12,8 @@ "hono": "^4.13.9" }, "devDependencies": { + "@clo/terminal": "file:vendor/clo-terminal-0.1.0.tgz", + "@novnc/novnc": "^1.7.0", "@solidjs/router": "^1.0.0", "@types/node": "^26.6.2", "concurrently": "^10.0.5", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 4fb3cd5cb9f11e321709f526dbfdef9e12f23daf..0038601f2260dd83a3da50cbd6ec75dd4c04b649 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -12,6 +12,12 @@ importers: specifier: ^4.13.9 version: 4.13.9 devDependencies: + '@clo/terminal': + specifier: file:vendor/clo-terminal-0.1.0.tgz + version: file:vendor/clo-terminal-0.1.0.tgz(solid-js@1.9.15) + '@novnc/novnc': + specifier: ^1.7.0 + version: 1.7.0 '@solidjs/router': specifier: ^1.0.0 version: 1.0.0(solid-js@1.9.15) @@ -126,6 +132,18 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@clo/terminal@file:vendor/clo-terminal-0.1.0.tgz': + resolution: {integrity: sha512-koyTaJalv972A3dwQBTYjDtNAfYrel/PAWxHPb9RH5Z+YG/u+gY27NPtIFf4NlaHq8p5FsUXw52xrmhMODMQsQ==, tarball: file:vendor/clo-terminal-0.1.0.tgz} + version: 0.1.0 + peerDependencies: + react: '>=18 <20' + solid-js: '>=1.8 <2' + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -298,6 +316,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@novnc/novnc@1.7.0': + resolution: {integrity: sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==} + '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -1054,6 +1075,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@clo/terminal@file:vendor/clo-terminal-0.1.0.tgz(solid-js@1.9.15)': + optionalDependencies: + solid-js: 1.9.15 + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -1151,6 +1176,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@novnc/novnc@1.7.0': {} + '@oxc-project/types@0.151.0': {} '@rolldown/binding-android-arm-eabi@1.2.11': diff --git a/dashboard/src/ai.rs b/dashboard/src/ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d2e19457f7b486633920feac37d4640c7fdc894 --- /dev/null +++ b/dashboard/src/ai.rs @@ -0,0 +1,89 @@ +use crate::*; +use axum::Json; + +pub async fn route( + app: Arc, + method: &Method, + parts: &[&str], + me: &Value, + headers: &HeaderMap, +) -> Result { + need(me, "ai")?; + if method == Method::POST && me["viewing"] == true { + return Err(Error::new( + 403, + "Switch back to your account to access Local AI.", + )); + } + if !matches!( + (method, parts), + (&Method::GET, []) + | (&Method::POST, ["key"]) + | (&Method::GET, ["snow-codex" | "snow-claude"]) + ) { + return Err(Error::new(404, "No Local AI setting here.")); + } + if method == Method::POST + && headers.get("origin").and_then(|v| v.to_str().ok()) + != Some(app.mcp.origin.origin().ascii_serialization().as_str()) + { + return Err(Error::new( + 403, + "Open Local AI in Snowglobe to copy its key.", + )); + } + let jobs = core::nomad(&app, "/v1/jobs").await?; + let id = array(&jobs) + .iter() + .filter(|job| job["Stop"] != true) + .map(|job| string(&job["ID"])) + .find(|id| *id == "local-ai" || id.starts_with("local-ai-preview-")) + .ok_or_else(|| { + Error::new( + 503, + "Local AI is offline. Retry when the service is running.", + ) + })?; + let job = core::nomad(&app, &format!("/v1/job/{}", encoded(id))).await?; + let hostname = string(&job["Meta"]["studio_hostname"]); + if hostname.is_empty() + || !hostname + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-')) + { + return Err(Error::new(502, "Local AI has no valid endpoint.")); + } + let endpoint = format!("https://{hostname}"); + let catalog: Value = serde_json::from_slice( + &tokio::fs::read(app.repo.join("service/local-ai/catalog.json")).await?, + )?; + let model = &catalog["models"][0]; + let response = match parts { + [] => Json(json!({"endpoint":endpoint,"model":model["display_name"],"context":model["context_window"]})).into_response(), + ["key"] => { + let secret = core::nomad(&app, &format!("/v1/var/nomad/jobs/{}", encoded(id))).await?; + let key = string(&secret["Items"]["api_key"]); + if key.is_empty() { return Err(Error::new(503, "Local AI has no API key configured.")); } + Json(json!({"key":key})).into_response() + } + [name] => { + let client = name.strip_prefix("snow-").unwrap(); + let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''")); + let script = include_str!("../ai/launch.sh") + .replace("@CLIENT@", client) + .replace("@ENDPOINT@", "e(&endpoint)) + .replace("@MODEL@", "e(string(&model["slug"]))) + .replace("@CATALOG@", &serde_json::to_string_pretty(&catalog)?) + .replace("@CONTEXT@", &model["context_window"].to_string()) + .replace("@COMPACT@", &model["auto_compact_token_limit"].to_string()) + .replace("@REASONING@", string(&model["default_reasoning_level"])); + ([("content-type", "text/x-shellscript; charset=utf-8"), ("content-disposition", &format!("attachment; filename=\"{name}\""))], script).into_response() + } + _ => unreachable!(), + }; + let mut response = response; + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + Ok(response) +} diff --git a/dashboard/src/apps.rs b/dashboard/src/apps.rs index 5099862ab0b44d6189299b84b5f20d94005463d4..fda98af268eb69c874cf1ca90080f8157546dcf3 100644 --- a/dashboard/src/apps.rs +++ b/dashboard/src/apps.rs @@ -106,17 +106,9 @@ fn vm_name(name: &str) -> Result<()> { } fn validate_vm(mut spec: Value) -> Result { vm_name(string(&spec["name"]))?; - let description = spec["description"] - .as_str() - .ok_or_else(|| Error::new(400, "Enter a description."))? - .trim(); - if description.chars().count() > 200 { - return Err(Error::new( - 400, - "Keep the description under 200 characters.", - )); - } - spec["description"] = json!(description); + spec["description"] = json!(""); + spec["autostart"] = json!(false); + spec["start"] = json!(spec["mode"] == "preset"); if string(&spec["image"]).is_empty() { return Err(Error::new(400, "Pick an OS image.")); } @@ -130,12 +122,6 @@ fn validate_vm(mut spec: Value) -> Result { return Err(Error::new(400, format!("Invalid {key}."))); } } - if !spec["autostart"].is_boolean() || !spec["start"].is_boolean() { - return Err(Error::new( - 400, - "Choose whether this VM starts automatically.", - )); - } Ok(spec) } async fn paper(app: Arc, path: &str, body: Option) -> Result { @@ -197,6 +183,7 @@ pub async fn route( method: &Method, parts: &[&str], query: &HashMap, + me: &Value, body: Value, ) -> Result { let value = match parts { @@ -299,7 +286,7 @@ pub async fn route( } ["vms"] if method == Method::GET => { let mut values = Vec::new(); - for (action, ttl) in [("node", 600), ("domains", 5), ("images", 60)] { + for (action, ttl) in [("node", 600), ("domains", 5), ("library", 15)] { let value = app .cache .get( @@ -310,6 +297,17 @@ pub async fn route( .await?; values.push(value.value.clone()); } + let allocated: f64 = array(&values[1]) + .iter() + .filter(|vm| !["shutoff", "crashed"].contains(&string(&vm["state"]))) + .map(|vm| number(&vm["balloon"])) + .sum(); + values[0]["availableMemory"] = + json!((number(&values[0]["memory"]) - allocated).max(0.0)); + values[1] + .as_array_mut() + .unwrap() + .retain(|domain| vms::visible(me, &domain["owner"])); for domain in values[1].as_array_mut().unwrap() { domain["usage"] = app .vm_usage @@ -319,9 +317,13 @@ pub async fn route( .cloned() .unwrap_or(Value::Null); } - json!({"node":values[0],"domains":values[1],"images":values[2]}) + json!({"node":values[0],"domains":values[1],"library":values[2],"canPublish":array(&me["sections"]).iter().any(|section| section == "admin")}) } ["vms", "history"] if method == Method::GET => { + if let Some(name) = query.get("name") { + vms::authorize(me, name).await?; + } + let owned = vm_call("domains", None).await?; let range = telemetry::query_number(query, "range", 300.0, 60.0, 86400.0)?; let mut query = query.clone(); query.insert("range".into(), range.to_string()); @@ -335,6 +337,11 @@ pub async fn route( let mut domains = serde_json::Map::new(); for (values, key) in [(&cpu.value, "cpu"), (&memory.value, "memory")] { for series in array(values) { + if !array(&owned).iter().any(|domain| { + domain["name"] == series["name"] && vms::visible(me, &domain["owner"]) + }) { + continue; + } let domain = domains .entry(string(&series["name"]).to_owned()) .or_insert_with(|| json!({"cpu":[],"memory":[]})); @@ -344,13 +351,33 @@ pub async fn route( json!({"t":cpu.value[0]["t"].as_array().or(memory.value[0]["t"].as_array()).cloned().unwrap_or_default(),"domains":domains}) } ["vms"] if method == Method::POST => { - vm_call("create", Some(validate_vm(body)?)).await?; + let mut spec = validate_vm(body)?; + spec["owner"] = me["id"].clone(); + spec["username"] = me["name"].clone(); + vm_call("create", Some(spec)).await?; app.cache.invalidate("vms:domains"); Value::Null } ["vms", name, tail @ ..] => { vm_name(name)?; match tail { + ["access"] if method == Method::GET => { + let mut response = + Document::new(vm_call("access", Some(json!({"name":name}))).await?) + .response(); + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + return Ok(response); + } + ["media"] if method == Method::PUT => { + vm_call("media", Some(json!({"name":name,"image":body["image"]}))).await?; + } + ["preset"] if method == Method::POST => { + need(me, "admin")?; + vm_call("preset", Some(json!({"name":name,"id":body["id"],"os":body["os"],"description":body["description"]}))).await?; + app.cache.invalidate("vms:library"); + } [] if method == Method::PATCH => { let mut payload = json!({"name":name}); if let Some(v) = body.get("autostart") { diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index f4d98867023d8c3e8579812771f4a466988c15c3..8a7209c54e9f689e59f26bfabf0e4240dcddb874 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -11,7 +11,7 @@ use webauthn_rs::prelude::*; const COOKIE: &str = "__Host-snow-session"; const FLOW_COOKIE: &str = "__Host-snow-flow"; const SESSION_TTL: i64 = 30 * 86400; -const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"]; +const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm", "ai"]; pub struct Store { pub db: Mutex, @@ -190,6 +190,12 @@ impl Store { CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; oidc::initialise(&db)?; guest::initialise(&db)?; + for name in GROUPS { + db.execute( + "INSERT OR IGNORE INTO roles(id,name) VALUES (?,?)", + sql![uuid::Uuid::new_v4().to_string(), name], + )?; + } Ok(Self { db: Mutex::new(db), origin, @@ -239,7 +245,7 @@ impl Store { for role in array(&export["roles"]) { if GROUPS.contains(&string(&role["name"])) { transaction.execute( - "INSERT INTO roles VALUES (?,?)", + "INSERT INTO roles VALUES (?,?) ON CONFLICT(name) DO UPDATE SET id=excluded.id", sql![string(&role["id"]), string(&role["name"])], )?; } diff --git a/dashboard/src/core.rs b/dashboard/src/core.rs index cdb7e28511544714950fcabf4349c4866b9b6f7c..aaa08ef173da16edf8761b9b5f142de3baae01ac 100644 --- a/dashboard/src/core.rs +++ b/dashboard/src/core.rs @@ -732,6 +732,20 @@ pub async fn route( return Ok(empty()); } ["metrics", metric] if method == Method::GET => { + if metric.starts_with("vm.") { + need(me, "vms")?; + let owners = host::call(json!({"operation":"vm.domains"})).await?; + let mut series = telemetry::metrics(app, metric, query, None, None) + .await? + .value + .clone(); + series.as_array_mut().unwrap().retain(|series| { + array(&owners).iter().any(|domain| { + domain["name"] == series["name"] && vms::visible(me, &domain["owner"]) + }) + }); + return Ok(Document::new(series).response()); + } return Ok(telemetry::metrics(app, metric, query, None, None) .await? .response()); diff --git a/dashboard/src/host.rs b/dashboard/src/host.rs index a3fc3bf5b0a308d965bafa8bef2feca6f7aa390b..b7a31e59659b59c36b62e38932d42dca1fb29e11 100644 --- a/dashboard/src/host.rs +++ b/dashboard/src/host.rs @@ -12,8 +12,28 @@ pub async fn sample(app: Arc) -> Result> { } pub async fn call(request: Value) -> Result { - tokio::time::timeout(Duration::from_secs(70), async { - let _slot = SLOTS.acquire().await?; + let _slot = tokio::time::timeout(Duration::from_secs(70), SLOTS.acquire()) + .await + .map_err(|_| { + Error::new( + 504, + "The host operation is taking too long. Check its logs, then retry.", + ) + })??; + open(request).await.map(|(value, _)| value) +} + +// Streams authenticate the same Unix peer and use the same framed JSON header. +pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> { + let timeout = if matches!( + request["operation"].as_str(), + Some("vm.create" | "vm.media" | "vm.preset") + ) { + 910 + } else { + 70 + }; + tokio::time::timeout(Duration::from_secs(timeout), async { let mut socket = tokio::net::UnixStream::connect(env( "STUDIO_HOST_SOCKET", "/run/studio-host/host.sock", @@ -35,40 +55,40 @@ pub async fn call(request: Value) -> Result { )); } socket.write_all(&message).await?; - let length = socket.read_u32().await? as usize; - if length > 16 * 1024 * 1024 { - return Err(Error::new( - 502, - "The host response is too large. Narrow the selection.", - )); - } - let mut bytes = vec![0; length]; - socket.read_exact(&mut bytes).await?; - let mut response: Value = serde_json::from_slice(&bytes)?; - if let Some(message) = response["error"].as_str() { - return Err(Error::new( - response["status"] - .as_u64() - .filter(|s| (400..=599).contains(s)) - .unwrap_or(502) as u16, - message, - )); - } - response - .as_object_mut() - .and_then(|v| v.remove("value")) - .ok_or_else(|| { - Error::new( - 502, - "The host response is incomplete. Check its logs, then retry.", - ) - }) + let value = response(&mut socket).await?; + Ok((value, socket)) }) .await - .map_err(|_| { - Error::new( - 504, - "The host operation is taking too long. Check its logs, then retry.", - ) - })? + .map_err(|_| Error::new(504, "The host connection timed out. Try again."))? +} + +pub async fn response(socket: &mut tokio::net::UnixStream) -> Result { + let length = socket.read_u32().await? as usize; + if length > 16 * 1024 * 1024 { + return Err(Error::new( + 502, + "The host response is too large. Narrow the selection.", + )); + } + let mut bytes = vec![0; length]; + socket.read_exact(&mut bytes).await?; + let mut response: Value = serde_json::from_slice(&bytes)?; + if let Some(message) = response["error"].as_str() { + return Err(Error::new( + response["status"] + .as_u64() + .filter(|s| (400..=599).contains(s)) + .unwrap_or(502) as u16, + message, + )); + } + response + .as_object_mut() + .and_then(|v| v.remove("value")) + .ok_or_else(|| { + Error::new( + 502, + "The host response is incomplete. Check its logs, then retry.", + ) + }) } diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index ec3e3bcc338c6f04fc897f12c56bfa4577169f89..83b5de2fd3396d4ead4003d11e9190dc276ad751 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,3 +1,4 @@ +mod ai; mod apps; mod auth; mod cache; @@ -16,6 +17,7 @@ mod shale_page; mod storage; mod telemetry; mod users; +mod vms; mod youtube; use axum::{ @@ -180,12 +182,17 @@ fn user(headers: &HeaderMap) -> Result { ("metrics", Some("metrics")), ("media", Some("media-manage")), ("vms", Some("vm")), + ("ai", Some("ai")), ] .into_iter() .filter(|(_, group)| can_open(&groups, *group)) .map(|(section, _)| section) .collect(); - Ok(json!({"name":name,"groups":groups,"sections":sections,"viewing":viewing})) + let id = headers + .get("User-Id") + .and_then(|v| v.to_str().ok()) + .unwrap_or(name); + Ok(json!({"id":id,"name":name,"groups":groups,"sections":sections,"viewing":viewing})) } fn can_open(groups: &[&str], access: Option<&str>) -> bool { access.is_none() || groups.contains(&"infra-admin") || groups.contains(&access.unwrap()) @@ -260,7 +267,10 @@ async fn api(State(app): State>, request: Request) -> Result "host" | "metrics" | "live" => Some("metrics"), "services" if parts.len() == 1 => Some("metrics"), "storage" if parts.len() == 1 => Some("metrics"), - "services" | "traces" | "storage" | "users" | "deploys" | "paper-clover" => Some("admin"), + "ai" | "mcp" => Some("ai"), + "services" | "traces" | "storage" | "users" | "deploys" | "paper-clover" => { + Some("admin") + } "media" | "seedbox" | "youtube" => Some("media"), "vms" => Some("vms"), _ => None, @@ -268,6 +278,14 @@ async fn api(State(app): State>, request: Request) -> Result if let Some(section) = section { need(&me, section)?; } + if let ["vms", name, ..] = parts.as_slice() { + if !(parts.len() == 2 + && ((*name == "history" && method == Method::GET) + || (*name == "iso" && method == Method::PUT))) + { + vms::authorize(&me, name).await?; + } + } if let ["deploys", "runs", id] = parts.as_slice() { return deploys::run_stream(app, id).await; } @@ -284,6 +302,14 @@ async fn api(State(app): State>, request: Request) -> Result }); return Ok(Sse::new(stream).into_response()); } + if let ["vms", name, kind @ ("console" | "serial")] = parts.as_slice() { + if method == Method::GET { + return vms::console(request, name, *kind == "serial").await; + } + } + if parts == ["vms", "iso"] && method == Method::PUT { + return vms::upload(app, request, &query).await; + } let body = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await?; let value = if body.is_empty() { Value::Null @@ -292,10 +318,11 @@ async fn api(State(app): State>, request: Request) -> Result .map_err(|_| Error::new(400, "The request didn't match what this route expects."))? }; match parts[0] { + "ai" => ai::route(app, &method, &parts[1..], &me, &headers).await, "mcp" => mcp::manage(app, &method, &parts[1..], &me, value, &headers).await, "users" => users::route(app, &method, &parts[1..], &me, value).await, "vms" | "seedbox" | "paper-clover" => { - apps::route(app, &method, &parts, &query, value).await + apps::route(app, &method, &parts, &query, &me, value).await } "youtube" => youtube::route(app, &method, &parts[1..], value).await, "media" => files::route(app, true, &method, &parts[1..], &query, value).await, @@ -530,6 +557,7 @@ async fn main() -> std::result::Result<(), Box> { let proof = proof.clone(); let app = app.clone(); async move { + request.headers_mut().remove("User-Id"); if mcp::public(request.uri().path()) { request.headers_mut().remove("Studio-Proxy-Token"); request.headers_mut().remove("User-Name"); @@ -634,6 +662,9 @@ async fn main() -> std::result::Result<(), Box> { request .headers_mut() .insert("User-Name", string(&account["username"]).parse().unwrap()); + request + .headers_mut() + .insert("User-Id", string(&account["id"]).parse().unwrap()); request .headers_mut() .insert("User-Groups", groups.parse().unwrap()); @@ -714,5 +745,14 @@ mod tests { let me = user(&headers).unwrap(); assert!(need(&me, "metrics").is_ok()); assert!(need(&me, "media").is_err()); + headers.insert("cookie", "view-as=ai".parse().unwrap()); + let me = user(&headers).unwrap(); + assert!(need(&me, "ai").is_ok()); + assert!(need(&me, "admin").is_err()); + headers.insert("User-Groups", "ai".parse().unwrap()); + let me = user(&headers).unwrap(); + assert_eq!(me["viewing"], false); + assert!(need(&me, "ai").is_ok()); + assert!(need(&me, "admin").is_err()); } } diff --git a/dashboard/src/relay.rs b/dashboard/src/relay.rs index 1f5572bdbcef7ab2ac44efa4587353c045306736..3e2e36d489938fe857bc5bf732241f278eb1fabe 100644 --- a/dashboard/src/relay.rs +++ b/dashboard/src/relay.rs @@ -670,7 +670,7 @@ impl ServerHandler for Agents { ("set_target_machines", "Select default machines for this connection."), ("list_threads", "List recent Codex and Claude Code threads on selected machines."), ("read_thread", "Read a thread and its current status."), - ("send_message", "Submit a message. Submission acknowledges delivery, not task completion. Desktop control requires local opt-in."), + ("send_message", "Submit a message. Desktop control requires local opt-in and reopens unloaded chats, switching the selected desktop chat. Submission acknowledges delivery, not task completion."), ("interrupt_thread", "Interrupt an agent-owned session or an opted-in Codex desktop turn."), ("start_thread", "Start an agent-owned session under a locally allowed directory."), ].into_iter().map(|(name, description)| { @@ -914,10 +914,23 @@ mod tests { command("read_thread", json!({"provider":"claude","thread_id":id})).unwrap()["limit"], 20 ); - assert_eq!(command("read_thread", json!({"provider":"codex","thread_id":id,"cursor":"page"})).unwrap()["cursor"], "page"); + assert_eq!( + command( + "read_thread", + json!({"provider":"codex","thread_id":id,"cursor":"page"}) + ) + .unwrap()["cursor"], + "page" + ); for (method, params) in [ - ("read_thread", json!({"provider":"codex","thread_id":id,"cursor":""})), - ("read_thread", json!({"provider":"codex","thread_id":id,"cursor":"x".repeat(513)})), + ( + "read_thread", + json!({"provider":"codex","thread_id":id,"cursor":""}), + ), + ( + "read_thread", + json!({"provider":"codex","thread_id":id,"cursor":"x".repeat(513)}), + ), ( "read_thread", json!({"provider":"codex","thread_id":"not-a-uuid"}), diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index 39edd511b4aa7caa9f8411eecb41ae8c92109fc5..304e3424be9de055fe2a21a932fa2642e437a633 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -14,24 +14,49 @@ fn issue_csrf(document: &Html) -> Result> { let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap(); let kind = Selector::parse("input[name=t]").unwrap(); let id = Selector::parse("input[name=id]").unwrap(); - let token = Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]").unwrap(); - let last = document.select(&forms).filter(|form| { - form.select(&kind).any(|input| input.attr("value") == Some("delete")) - && form.select(&id).any(|input| input.attr("value").is_some_and(|value| value.parse::().is_ok_and(|id| id > 0))) - }).last(); + let token = + Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]") + .unwrap(); + let last = document + .select(&forms) + .filter(|form| { + form.select(&kind) + .any(|input| input.attr("value") == Some("delete")) + && form.select(&id).any(|input| { + input + .attr("value") + .is_some_and(|value| value.parse::().is_ok_and(|id| id > 0)) + }) + }) + .last(); let Some(form) = last else { return Ok(None) }; let tokens: Vec<_> = form.select(&token).collect(); match tokens.as_slice() { - [input] => input.attr("value").filter(|value| !value.is_empty()).map(|value| Some(value.to_owned())) - .ok_or_else(|| Error::new(502, "Shale's issue form changed. Open the issue to edit it.")), - _ => Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")), + [input] => input + .attr("value") + .filter(|value| !value.is_empty()) + .map(|value| Some(value.to_owned())) + .ok_or_else(|| { + Error::new( + 502, + "Shale's issue form changed. Open the issue to edit it.", + ) + }), + _ => Err(Error::new( + 502, + "Shale's issue form changed. Open the issue to edit it.", + )), } } /// The verified r1616 build predates tokenized forms. Its cookie mutations are /// protected by the service's exact-Origin gate; never infer this from a missing token alone. fn tokenless_r1616(document: &Html) -> bool { - if document.select(&Selector::parse("input[name=csrf_token]").unwrap()).next().is_some() { + if document + .select(&Selector::parse("input[name=csrf_token]").unwrap()) + .next() + .is_some() + { return false; } let links: Vec<_> = document.select(&Selector::parse("body#page-issue > footer.usa-footer .usa-footer__secondary-section a[href='https://astheno.software/shale/']").unwrap()).collect(); @@ -42,8 +67,14 @@ fn prepare_issue_csrf(document: &Html, fields: &mut HashMap) -> if tokenless_r1616(document) && !fields.contains_key("csrf_token") { return Ok(()); } - if fields.get("csrf_token").is_none_or(|token| token.is_empty()) { - return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); + if fields + .get("csrf_token") + .is_none_or(|token| token.is_empty()) + { + return Err(Error::new( + 502, + "Shale's issue form changed. Open the issue to edit it.", + )); } if let Some(token) = issue_csrf(document)? { fields.insert("csrf_token".to_owned(), token); @@ -288,18 +319,34 @@ fn issue(html: &str, repository: &str, id: Option) -> Result { .select(&Selector::parse("h1 > span").unwrap()) .collect(); let (issue_id, title) = if spans.len() == 2 { - (text(spans[0]).strip_prefix('#').and_then(|id| id.parse::().ok()), Some(text(spans[1]))) + ( + text(spans[0]) + .strip_prefix('#') + .and_then(|id| id.parse::().ok()), + Some(text(spans[1])), + ) } else if spans.is_empty() && tokenless_r1616(&document) { let headings: Vec<_> = document.select(&Selector::parse("h1").unwrap()).collect(); if let [heading] = headings.as_slice() { - text(*heading).strip_prefix("Issue #").and_then(|text| text.split_once(": ")) + text(*heading) + .strip_prefix("Issue #") + .and_then(|text| text.split_once(": ")) .map(|(id, title)| (id.parse::().ok(), Some(title.to_owned()))) .unwrap_or((None, None)) - } else { (None, None) } - } else { (None, None) }; + } else { + (None, None) + } + } else { + (None, None) + }; let statuses: Vec<_> = document .select(&Selector::parse("dl.sidebar dd span[class*='issuestatus-']").unwrap()) - .filter_map(|status| status.value().classes().find_map(|class| class.strip_prefix("issuestatus-"))) + .filter_map(|status| { + status + .value() + .classes() + .find_map(|class| class.strip_prefix("issuestatus-")) + }) .collect(); let status = match statuses.as_slice() { [status] => Some(*status), @@ -837,11 +884,29 @@ mod tests { fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() { let initial = "
"; assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None); - let deletion = |value: &str| format!("
"); - let page = format!("{initial}{}{}
", deletion("first"), deletion("latest")); - assert_eq!(issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), Some("latest")); - for bad in ["", "", ""] { - let page = format!("{initial}{}
  • {bad}
", deletion("older")); + let deletion = |value: &str| { + format!( + "
" + ) + }; + let page = format!( + "{initial}{}{}
", + deletion("first"), + deletion("latest") + ); + assert_eq!( + issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), + Some("latest") + ); + for bad in [ + "", + "", + "", + ] { + let page = format!( + "{initial}{}
  • {bad}
", + deletion("older") + ); assert!(issue_csrf(&Html::parse_document(&page)).is_err()); } } @@ -850,27 +915,49 @@ mod tests { const FOOTER: &str = ""; const FORMS: &str = "
"; let page = format!("{FORMS}{FOOTER}"); - let mut fields = HashMap::from([("t".to_owned(), "status".to_owned()), ("status".to_owned(), "done".to_owned())]); + let mut fields = HashMap::from([ + ("t".to_owned(), "status".to_owned()), + ("status".to_owned(), "done".to_owned()), + ]); let original = fields.clone(); prepare_issue_csrf(&Html::parse_document(&page), &mut fields).unwrap(); assert_eq!(fields, original); for bad in [ page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), page.replace("r1616-ga87d2f5.zig.0.16.0", "r1616-other-build"), - page.replace("https://astheno.software/shale/", "https://other.test/shale/"), + page.replace( + "https://astheno.software/shale/", + "https://other.test/shale/", + ), format!("{FORMS}
{FOOTER}
"), format!("{FORMS}{FOOTER}{FOOTER}"), - format!("{FORMS}{FOOTER}"), + format!( + "{FORMS}{FOOTER}" + ), format!("{FORMS}{FOOTER}"), ] { assert!(!tokenless_r1616(&Html::parse_document(&bad))); - assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err()); - assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())])).is_err()); + assert!( + prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err() + ); + assert!( + prepare_issue_csrf( + &Html::parse_document(&bad), + &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())]) + ) + .is_err() + ); } // A newer or mixed page must also reject a missing/empty selected form token. let newer = Html::parse_document(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new")); assert!(prepare_issue_csrf(&newer, &mut HashMap::new()).is_err()); - assert!(prepare_issue_csrf(&newer, &mut HashMap::from([("csrf_token".to_owned(), String::new())])).is_err()); + assert!( + prepare_issue_csrf( + &newer, + &mut HashMap::from([("csrf_token".to_owned(), String::new())]) + ) + .is_err() + ); } #[test] fn repository_names_cannot_change_origin_or_path_segments() { @@ -918,13 +1005,31 @@ mod tests { let page = "

#3Snow & ☃

"; assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃"); assert_eq!(issue(page, "owned", Some(3)).unwrap()["status"], "done"); - let owner = format!("{page}
"); + let owner = format!( + "{page}
" + ); assert_eq!(issue(&owner, "owned", Some(3)).unwrap()["status"], "done"); assert!(issue(page, "other", Some(3)).is_err()); assert!(issue(page, "owned", Some(4)).is_err()); assert!(issue(&page.replace("page-issue", "page-login"), "owned", Some(3)).is_err()); - assert!(issue(&page.replace("issuestatus-done", "unknown"), "owned", Some(3)).is_err()); - assert!(issue(&format!("{page}"), "owned", Some(3)).is_err()); + assert!( + issue( + &page.replace("issuestatus-done", "unknown"), + "owned", + Some(3) + ) + .is_err() + ); + assert!( + issue( + &format!( + "{page}" + ), + "owned", + Some(3) + ) + .is_err() + ); } #[test] fn verified_r1616_issue_heading_keeps_identity_checks() { @@ -932,7 +1037,14 @@ mod tests { assert_eq!(issue(page, "owned", Some(3)).unwrap()["title"], "Snow & ☃"); assert!(issue(page, "other", Some(3)).is_err()); assert!(issue(page, "owned", Some(4)).is_err()); - assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err()); + assert!( + issue( + &page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), + "owned", + Some(3) + ) + .is_err() + ); assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err()); } #[test] diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs index 7eed4ac7e256d704d0c8b4327db91fe287090a78..e0a76c3ddd65cca667ad4534af3a0f225bfce856 100644 --- a/dashboard/src/users.rs +++ b/dashboard/src/users.rs @@ -118,6 +118,82 @@ pub async fn route( me: &Value, body: Value, ) -> Result { + if parts == ["groups"] && method == Method::PUT { + let users: Vec = serde_json::from_value(body["users"].clone()) + .map_err(|_| Error::new(400, "Select users to edit."))?; + let add: Vec = serde_json::from_value(body["add"].clone()) + .map_err(|_| Error::new(400, "Choose groups to add."))?; + let remove: Vec = serde_json::from_value(body["remove"].clone()) + .map_err(|_| Error::new(400, "Choose groups to remove."))?; + if users.is_empty() || (add.is_empty() && remove.is_empty()) { + return Err(Error::new( + 400, + "Select users and change at least one group.", + )); + } + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + for group in add.iter().chain(&remove) { + if add.contains(group) && remove.contains(group) { + return Err(Error::new( + 400, + "Choose whether to add or remove each group.", + )); + } + let exists: bool = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM roles WHERE id=?)", + [group], + |r| r.get(0), + )?; + if !exists { + return Err(Error::new( + 400, + "This group no longer exists. Reload the page.", + )); + } + } + for id in &users { + uuid(id)?; + let user = auth::user(&transaction, id)?; + if guest::is_guest(&user) { + return Err(Error::new( + 400, + "Guests can use Shale only. Select regular accounts to edit groups.", + )); + } + for group in &add { + transaction.execute( + "INSERT OR IGNORE INTO memberships VALUES (?,?)", + sql![id, group], + )?; + } + for group in &remove { + if user["username"] == me["name"] + && array(&user["groups"]) + .iter() + .any(|g| g["id"] == *group && g["name"] == "infra-admin") + { + return Err(Error::new( + 400, + "Sign in as another admin to remove your admin access.", + )); + } + transaction.execute( + "DELETE FROM memberships WHERE user_id=? AND role_id=?", + sql![id, group], + )?; + } + transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?", [id])?; + } + transaction.commit()?; + drop(db); + if !remove.is_empty() { + for id in users { + revoke_connections(&app, &id)?; + } + } + return Ok(StatusCode::NO_CONTENT.into_response()); + } if parts.is_empty() && method == Method::GET { let db = app.auth.db.lock().unwrap(); let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?; @@ -179,7 +255,6 @@ pub async fn route( .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?; for group in array(&body["groups"]) { let group = string(group); - uuid(group)?; if transaction.execute( "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?", sql![id, group], diff --git a/dashboard/src/vms.rs b/dashboard/src/vms.rs new file mode 100644 index 0000000000000000000000000000000000000000..1c1513b8cf20b252851939307f4f202000ec484a --- /dev/null +++ b/dashboard/src/vms.rs @@ -0,0 +1,222 @@ +use crate::*; +use axum::extract::{ + FromRequestParts, + ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, +}; +use futures::{SinkExt, StreamExt}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +pub fn visible(me: &Value, owner: &Value) -> bool { + array(&me["groups"]) + .iter() + .any(|group| group == "infra-admin") + || (owner.as_str().is_some_and(|id| !id.is_empty()) && owner == &me["id"]) +} + +pub async fn authorize(me: &Value, name: &str) -> Result<()> { + let owner = host::call(json!({"operation":"vm.owner","payload":{"name":name}})).await?; + if visible(me, &owner) { + Ok(()) + } else { + Err(Error::new( + 404, + "This VM isn't in your account. Open your VM home.", + )) + } +} + +pub async fn console(request: Request, name: &str, serial: bool) -> Result { + // Browsers send Origin on a WebSocket handshake. Reject cross-site control. + same_origin(&request)?; + let (mut parts, _) = request.into_parts(); + let upgrade = WebSocketUpgrade::from_request_parts(&mut parts, &()) + .await + .map_err(|_| Error::new(400, "Open the screen from the VM page."))? + .max_message_size(1024 * 1024) + .max_frame_size(1024 * 1024); + let target = json!({"operation":if serial { "vm.serial" } else { "vm.console" },"payload":{"name":name}}); + Ok(upgrade + .protocols(["binary"]) + .on_upgrade(move |mut socket| async move { + match host::open(target).await { + Ok((_, stream)) => bridge(socket, stream).await, + Err(error) => { + let reason = if error.message.len() <= 120 { + error.message + } else { + "The host operation couldn't finish. Check its logs, then retry.".into() + }; + let _ = socket + .send(Message::Close(Some(CloseFrame { + code: 1011, + reason: reason.into(), + }))) + .await; + } + } + }) + .into_response()) +} + +fn same_origin(request: &Request) -> Result<()> { + let supplied = request + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + .and_then(|v| url::Url::parse(v).ok()); + // The ingress strips forwarded headers and supplies the original Host. + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if supplied.as_ref().is_none_or(|origin| { + let authority = match origin.port() { + Some(port) => format!("{}:{port}", origin.host_str().unwrap_or("")), + None => origin.host_str().unwrap_or("").to_owned(), + }; + authority != host || !matches!(origin.scheme(), "http" | "https") + }) { + return Err(Error::new(403, "Open the VM page on this site to connect.")); + } + Ok(()) +} + +async fn bridge(socket: WebSocket, stream: tokio::net::UnixStream) { + let (mut send, mut receive) = socket.split(); + let (mut read, mut write) = stream.into_split(); + let upstream = async { + while let Some(Ok(message)) = receive.next().await { + match message { + Message::Binary(data) => write.write_all(&data).await?, + Message::Close(_) => break, + Message::Ping(_) | Message::Pong(_) => {} + _ => break, + } + } + Ok::<_, std::io::Error>(()) + }; + let downstream = async { + let mut buffer = vec![0; 65536]; + loop { + let count = read.read(&mut buffer).await?; + if count == 0 { + break; + } + send.send(Message::Binary(Bytes::copy_from_slice(&buffer[..count]))) + .await + .map_err(std::io::Error::other)?; + } + Ok::<_, std::io::Error>(()) + }; + tokio::select! { _ = upstream => {}, _ = downstream => {} } + // Dropping either half disconnects the host tunnel and releases its slot. +} + +pub async fn upload( + app: Arc, + request: Request, + query: &HashMap, +) -> Result { + same_origin(&request)?; + let volume = query + .get("name") + .ok_or_else(|| Error::new(400, "Choose an ISO file to upload."))?; + let size = request + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .filter(|v| (32768..=32 * 2u64.pow(30)).contains(v)) + .ok_or_else(|| Error::new(400, "Choose an ISO between 32 KiB and 32 GiB."))?; + let (_, mut stream) = + host::open(json!({"operation":"vm.upload","payload":{"volume":volume,"size":size}})) + .await?; + let mut incoming = request.into_body().into_data_stream(); + let mut remaining = size; + while let Some(data) = tokio::time::timeout(Duration::from_secs(120), incoming.next()) + .await + .map_err(|_| Error::new(408, "The upload paused too long. Upload the ISO again."))? + { + let data = data?; + if data.len() as u64 > remaining { + return Err(Error::new( + 400, + "The ISO size changed. Upload the file again.", + )); + } + tokio::time::timeout(Duration::from_secs(120), stream.write_all(&data)) + .await + .map_err(|_| { + Error::new( + 504, + "The host stopped receiving the ISO. Try uploading again.", + ) + })??; + remaining -= data.len() as u64; + } + if remaining != 0 { + return Err(Error::new( + 400, + "The upload was interrupted. Upload the ISO again.", + )); + } + let value = tokio::time::timeout(Duration::from_secs(120), host::response(&mut stream)) + .await + .map_err(|_| { + Error::new( + 504, + "The host is still saving the ISO. Refresh the library before retrying.", + ) + })??; + app.cache.invalidate("vms:library"); + Ok(Document::new(value).response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + + #[test] + fn ownership_follows_account_id_and_effective_admin_role() { + let user = json!({"id":"account-1","name":"renamed","groups":["vm"]}); + assert!(visible(&user, &json!("account-1"))); + assert!(!visible(&user, &json!("account-2"))); + assert!(!visible(&user, &Value::Null)); + assert!(!visible( + &json!({"id":"snow","groups":["vm"]}), + &json!("other") + )); + assert!(visible( + &json!({"id":"snow","groups":["infra-admin"]}), + &Value::Null + )); + } + + #[test] + fn screen_rejects_missing_and_cross_site_origins() { + for (origin, allowed) in [ + (None, false), + (Some("https://other.test"), false), + (Some("null"), false), + (Some("https://vm.test"), true), + (Some("http://vm.test:5178"), false), + ] { + let mut request = Request::builder().header("host", "vm.test"); + if let Some(origin) = origin { + request = request.header("origin", origin); + } + assert_eq!( + same_origin(&request.body(Body::empty()).unwrap()).is_ok(), + allowed + ); + } + let request = Request::builder() + .header("host", "127.0.0.1:5178") + .header("origin", "http://127.0.0.1:5178") + .body(Body::empty()) + .unwrap(); + assert!(same_origin(&request).is_ok()); + } +} diff --git a/dashboard/vendor/clo-terminal-0.1.0.tgz b/dashboard/vendor/clo-terminal-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b3cac3102d66ffa6bd495d731961714039788f1a Binary files /dev/null and b/dashboard/vendor/clo-terminal-0.1.0.tgz differ diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index c975b431b54e6019ec4615d5356897591dfc777e..54d16d109b20c762382ddba2ac19e9ed2adab4ad 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -11,8 +11,9 @@ export default defineConfig({ // Stands in for forward auth. proxy: { "/api/": { - target: "http://127.0.0.1:7070", + target: process.env.STUDIO_DEV_API_URL ?? "http://127.0.0.1:7070", xfwd: true, + ws: true, headers: { "User-Name": process.env.STUDIO_DEV_USER ?? "snow", "User-Groups": process.env.STUDIO_DEV_GROUPS ?? "infra-admin" }, }, }, diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts index 9442e1ef8e9fc198137eb4e8f4e2f4521b8dacad..d5a026d243f5204d4bdd35d2fc5d720572a14aec 100644 --- a/dashboard/web/api.contract.ts +++ b/dashboard/web/api.contract.ts @@ -5,7 +5,7 @@ import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, L import type { User, Group, Session, Credential } from "./types/users.ts"; import type { Hono } from "hono"; import type { Health } from "./types/model.ts"; -import type { Domain, Image, History, NewDomain } from "./types/vms.ts"; +import type { Domain, History, NewDomain, VMLibrary } from "./types/vms.ts"; import type { Me, ServiceSummary, ServiceDetail, ServiceDefinition, HostInfo, Issue, Series, LogLine, TraceSummary, Trace } from "./types/model.ts"; import type { MapNode } from "./types/storage.index.ts"; import type { ProgressNode, PaperCloverStats } from "./types/paperClover.ts"; @@ -54,6 +54,8 @@ type Explorer = { type ExplorerRoutes = { [P in keyof Explorer as `${Prefix}${P}`]: Explorer[P] }; export type Api = Hono<{}, { + "/ai": { $get: Endpoint<{ endpoint: string; model: string; context: number }>; }; + "/ai/key": { $post: Endpoint<{ key: string }>; }; "/mcp": { $get: Endpoint; }; "/mcp/shale": { $get: Endpoint<{ linked: boolean; resources: Resources }>; $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; "/mcp/consent/:id": { @@ -220,6 +222,9 @@ export type Api = Hono<{}, { $patch: Endpoint; $delete: Endpoint; }; + "/users/groups": { + $put: Endpoint; + }; "/users/:id/groups/:group": { $put: Endpoint; $delete: Endpoint; @@ -274,7 +279,7 @@ export type Api = Hono<{}, { $get: Endpoint<{}, { param: { id: string; }; }, 200, string>; }; "/vms": { - $get: Endpoint<{ node: { cpus: number; memory: number }; domains: Domain[]; images: Image[] }>; + $get: Endpoint<{ node: { cpus: number; memory: number; availableMemory: number }; domains: Domain[]; library: VMLibrary; canPublish: boolean }>; $post: Endpoint; }; "/vms/history": { @@ -284,6 +289,15 @@ export type Api = Hono<{}, { $patch: Endpoint; $delete: Endpoint; }; + "/vms/:name/media": { + $put: Endpoint; + }; + "/vms/:name/access": { + $get: Endpoint<{ username: string; password: string; hostname: string } | null, { param: { name: string } }>; + }; + "/vms/:name/preset": { + $post: Endpoint; + }; "/vms/:name/:action": { $post: Endpoint; }; diff --git a/dashboard/web/components/Dialog.tsx b/dashboard/web/components/Dialog.tsx index d15a527075c454b8095670ecc6c46ae6d9f320a5..19872a085f8ec460d5679d1afb73d6f6048ebdb4 100644 --- a/dashboard/web/components/Dialog.tsx +++ b/dashboard/web/components/Dialog.tsx @@ -17,6 +17,7 @@ interface DialogOptions { /** Names the action, like "restart"; never "ok". */ confirmLabel: string; destructive?: boolean; + canConfirm?: () => boolean; /** Takes focus once the dialog closes, instead of the element that opened it. */ returnFocus?: HTMLElement; /** @@ -57,7 +58,7 @@ function open(options: DialogOptions, field?: TextDialogOptions) { const [value, setValue] = createSignal(field?.initialValue ?? ""); const [pending, setPending] = createSignal(false); const [error, setError] = createSignal(""); - const valid = () => !field || (field.validateInput ?? Boolean)(value()); + const valid = () => (!field || (field.validateInput ?? Boolean)(value())) && (options.canConfirm?.() ?? true); let dialog!: HTMLDialogElement; let pressedOutside = false; @@ -70,10 +71,11 @@ function open(options: DialogOptions, field?: TextDialogOptions) { const submit = async (event: SubmitEvent) => { event.preventDefault(); if (pending() || !valid()) return; + const form = new FormData(event.currentTarget as HTMLFormElement); setPending(true); setError(""); try { - await options.onConfirm(new FormData(event.currentTarget as HTMLFormElement)); + await options.onConfirm(form); setPending(false); close(); } catch (failure) { @@ -84,6 +86,7 @@ function open(options: DialogOptions, field?: TextDialogOptions) { }; onMount(() => dialog.showModal()); + const body = build(options.body); return ( )} - {build(options.body)} +
{body}
diff --git a/dashboard/web/components/Sidebar.tsx b/dashboard/web/components/Sidebar.tsx index 2651f9c881a4bdab0da55ba70567d07e8ad1bfb4..47c80634a5bff60bc22ea9118674ee0f17bcc933 100644 --- a/dashboard/web/components/Sidebar.tsx +++ b/dashboard/web/components/Sidebar.tsx @@ -14,7 +14,7 @@ import SquarePlay from "lucide-solid/icons/square-play"; import UserRound from "lucide-solid/icons/user-round"; import Plug from "lucide-solid/icons/plug"; import Users from "lucide-solid/icons/users"; -import { createSignal, For, type JSX, Show } from "solid-js"; +import { createSignal, For, type JSX, onCleanup, Show } from "solid-js"; import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; import { queries } from "../api.ts"; import snowflake from "../snowflake.svg"; @@ -46,7 +46,7 @@ interface Page { const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }]; const AFTER: Page[] = [ - { href: "/mcp", label: "mcp", icon: Plug, section: "launcher" }, + { href: "/mcp", label: "ai / mcp", icon: Plug, section: "ai" }, { href: "/storage", label: "storage", icon: HardDrive, section: "admin", tabs: STORAGE_TABS, @@ -67,7 +67,7 @@ const AFTER: Page[] = [ export const PAGES = [...BEFORE, ...AFTER]; /** Groups an admin can preview the dashboard as. */ -const PREVIEW_GROUPS = ["media", "media-manage", "metrics", "vm"]; +const PREVIEW_GROUPS = ["media", "media-manage", "metrics", "vm", "ai"]; /** How many apps need a look, on the overview's link, so it shows from every page. */ function IssueCount() { @@ -137,6 +137,33 @@ function NavLink(props: { page: Page; children?: JSX.Element }) { ); } +function VMNav() { + const location = useLocation(); + const [open, toggle] = remembered("sidebar.vms", true); + const [data, { refetch }] = queries.vms.use(() => open() || location.pathname === "/vms" ? undefined : false); + const loaded = lastGood(data); + const timer = setInterval(() => open() && !data.loading && refetch(), 15_000); + onCleanup(() => clearInterval(timer)); + const selected = () => location.pathname === "/vms" ? new URLSearchParams(location.search).get("vm") : null; + return <> + + + ; +} + /** Health states counted together in the collapsed group's summary, in order. */ const TALLY: [Health, Health[], string][] = [ ["healthy", ["healthy"], "up"], @@ -282,7 +309,7 @@ export function Sidebar(props: { me: Me }) { - {(page) => } + {(page) => page.section === "vms" ? : }
diff --git a/dashboard/web/components/VMConsole.tsx b/dashboard/web/components/VMConsole.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ef1aa59401aeea4cd5156dff7a7d9d538439d7bd --- /dev/null +++ b/dashboard/web/components/VMConsole.tsx @@ -0,0 +1,105 @@ +import { VMMenu } from "./VMMenu.tsx"; +import { createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from "solid-js"; +import { reason } from "../api.ts"; +import Keyboard from "lucide-solid/icons/keyboard"; +import Clipboard from "lucide-solid/icons/clipboard"; +import Maximize from "lucide-solid/icons/maximize"; +import RefreshCw from "lucide-solid/icons/refresh-cw"; + +export function VMConsole(props: { name: string; enabled: boolean; active: boolean; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) { + const enabled = createMemo(() => props.enabled); + let attempt = 0; + let screen!: HTMLDivElement; + let panel!: HTMLDivElement; + let connection: import("@novnc/novnc").default | undefined; + let disposed = false; + const [state, setState] = createSignal<"Connecting…" | "Connected" | "Disconnected">("Connecting…"); + const connected = () => state() === "Connected"; + const [problem, setProblem] = createSignal(""); + const [clipboard, setClipboard] = createSignal(""); + const [showClipboard, setShowClipboard] = createSignal(false); + const [tools, setTools] = createSignal(); + const connect = async () => { + const current = ++attempt; + const previous = connection; + connection = undefined; + previous?.disconnect(); + setProblem(""); + setState("Connecting…"); + try { + const { default: RFB } = await import("@novnc/novnc"); + if (disposed || !enabled() || current !== attempt) return; + const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/console`, location.href); + url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const rfb = new RFB(screen, url.href, { shared: true, wsProtocols: ["binary"] }); + connection = rfb; + rfb.scaleViewport = true; + rfb.background = "#101014"; + rfb.addEventListener("connect", () => { + if (connection !== rfb) return; + setState("Connected"); + }); + rfb.addEventListener("disconnect", () => { + if (connection !== rfb || disposed) return; + setState("Disconnected"); + setProblem("The screen disconnected. Check that the VM is running, then reconnect."); + }); + rfb.addEventListener("credentialsrequired", () => { + if (connection !== rfb || disposed) return; + setProblem("This screen requires a VNC password. Remove it in the VM's display settings, then reconnect."); + rfb.disconnect(); + }); + rfb.addEventListener("clipboard", (event: Event) => { + if (connection !== rfb || disposed) return; + setClipboard((event as CustomEvent<{ text: string }>).detail.text); + }); + } catch (failure) { + setProblem(`Unable to connect. ${reason(failure)}`); + setState("Disconnected"); + } + }; + createEffect(() => { + if (enabled()) void connect(); + else { + attempt++; + const previous = connection; + connection = undefined; + previous?.disconnect(); + setState("Disconnected"); + setProblem(""); + } + }); + createEffect(() => { if (props.active && connected()) connection?.focus(); }); + onCleanup(() => { disposed = true; connection?.disconnect(); }); + return
+
+ {props.toolbar} + + + + + + + + + + + {props.enabled ? state() : "Off"} + +
+ + +
+