From cb0795617f1b2d459c050a6a91be50f64b242254 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 08:54:55 +0000 Subject: [PATCH] feat: git push deploy (roadmap step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full self-contained git push flow — no GitHub required: git remote add hiy ssh://hiy@myserver/myapp git push hiy main What was added: - Bare git repo per app (HIY_DATA_DIR/repos/.git) Initialised automatically on app create; removed on app delete. post-receive hook is written into each repo and calls the internal API to queue a build using the same pipeline as webhook deploys. - SSH key management New ssh_keys DB table. Admin UI (/admin/users) now shows SSH keys per user with add/remove. New API routes: GET/POST /api/users/:id/ssh-keys DELETE /api/ssh-keys/:key_id On every change, HIY rewrites HIY_SSH_AUTHORIZED_KEYS with command= restricted entries pointing at hiy-git-shell. - scripts/git-shell SSH command= override installed at HIY_GIT_SHELL (default /usr/local/bin/hiy-git-shell). Validates the push via GET /internal/git/auth, then exec's git-receive-pack on the correct bare repo. - Internal API routes (authenticated by shared internal_token) GET /internal/git/auth -- git-shell permission check POST /internal/git/:app_id/push -- post-receive build trigger - Builder: git-push deploys use file:// path to the local bare repo instead of the app's remote repo_url. - internal_token persists across restarts in HIY_DATA_DIR/internal-token. New env vars: HIY_SSH_AUTHORIZED_KEYS path to the authorized_keys file to manage HIY_GIT_SHELL path to the git-shell script on the host Both webhook and git-push deploys feed the same build queue. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- scripts/git-shell | 67 +++++++++++++++ server/src/builder.rs | 9 +- server/src/db.rs | 12 +++ server/src/main.rs | 33 +++++++- server/src/models.rs | 15 ++++ server/src/routes/apps.rs | 62 ++++++++++++++ server/src/routes/git.rs | 115 +++++++++++++++++++++++++ server/src/routes/mod.rs | 2 + server/src/routes/ssh_keys.rs | 154 ++++++++++++++++++++++++++++++++++ server/src/routes/ui.rs | 50 +++++++++++ 10 files changed, 517 insertions(+), 2 deletions(-) create mode 100755 scripts/git-shell create mode 100644 server/src/routes/git.rs create mode 100644 server/src/routes/ssh_keys.rs diff --git a/scripts/git-shell b/scripts/git-shell new file mode 100755 index 0000000..9f794fd --- /dev/null +++ b/scripts/git-shell @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# HIY git-shell — SSH authorized_keys command= override +# +# Install at: /usr/local/bin/hiy-git-shell (or set HIY_GIT_SHELL in .env) +# Each authorized_keys entry is written by HIY in this form: +# +# command="/usr/local/bin/hiy-git-shell ", +# no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty +# +# OpenSSH sets SSH_ORIGINAL_COMMAND to what the developer actually ran, e.g.: +# git-receive-pack '/myapp' +# This script validates the push and exec's git-receive-pack on the bare repo. + +set -euo pipefail + +USER_ID="${1:-}" +API_URL="${2:-http://localhost:3000}" +TOKEN="${3:-}" +REPOS_DIR="${4:-/data/repos}" + +if [ -z "$USER_ID" ] || [ -z "$TOKEN" ]; then + echo "hiy: internal configuration error — contact your administrator." >&2 + exit 1 +fi + +ORIG="${SSH_ORIGINAL_COMMAND:-}" + +# Only git-receive-pack (push) is supported; reject everything else. +if [[ "$ORIG" != git-receive-pack* ]]; then + echo "hiy: only 'git push' is supported on this host." >&2 + exit 1 +fi + +# Parse the app name from: git-receive-pack '/myapp' or git-receive-pack 'myapp' +APP_NAME=$(echo "$ORIG" | sed "s/git-receive-pack '\\///;s/git-receive-pack '//;s/'.*//") +APP_NAME="${APP_NAME##/}" # strip leading slash if still present +APP_NAME="${APP_NAME%/}" # strip trailing slash + +if [ -z "$APP_NAME" ]; then + echo "hiy: could not parse app name from: $ORIG" >&2 + exit 1 +fi + +# Ask HIY whether this user may push to this app. +RESPONSE=$(curl -sf \ + -H "X-Hiy-Token: $TOKEN" \ + "${API_URL}/internal/git/auth?user_id=${USER_ID}&app=${APP_NAME}" \ + 2>/dev/null) || { + echo "hiy: push denied (cannot reach server or access denied for '${APP_NAME}')." >&2 + exit 1 +} + +APP_ID=$(echo "$RESPONSE" | python3 -c \ + "import sys, json; print(json.load(sys.stdin)['app_id'])" 2>/dev/null) + +if [ -z "$APP_ID" ]; then + echo "hiy: push denied for '${APP_NAME}'." >&2 + exit 1 +fi + +REPO_PATH="${REPOS_DIR}/${APP_ID}.git" +if [ ! -d "$REPO_PATH" ]; then + echo "hiy: repository not found for app '${APP_NAME}' (expected ${REPO_PATH})." >&2 + exit 1 +fi + +exec git-receive-pack "$REPO_PATH" diff --git a/server/src/builder.rs b/server/src/builder.rs index e9df54d..48aab28 100644 --- a/server/src/builder.rs +++ b/server/src/builder.rs @@ -98,6 +98,13 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> { let build_script = std::env::var("HIY_BUILD_SCRIPT") .unwrap_or_else(|_| "./builder/build.sh".into()); + // For git-push deploys, use the local bare repo instead of the remote URL. + let repo_url = if deploy.triggered_by == "git-push" { + format!("file://{}/repos/{}.git", state.data_dir, app.id) + } else { + app.repo_url.clone() + }; + let build_dir = format!("{}/builds/{}", state.data_dir, app.id); // Log diagnostics before spawning so even a spawn failure leaves a breadcrumb. @@ -125,7 +132,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> { .arg(&build_script) .env("APP_ID", &app.id) .env("APP_NAME", &app.name) - .env("REPO_URL", &app.repo_url) + .env("REPO_URL", &repo_url) .env("BRANCH", &app.branch) .env("PORT", app.port.to_string()) .env("ENV_FILE", &env_file) diff --git a/server/src/db.rs b/server/src/db.rs index b170564..8641cfd 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -77,5 +77,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { .execute(pool) .await?; + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS ssh_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at TEXT NOT NULL + )"#, + ) + .execute(pool) + .await?; + Ok(()) } diff --git a/server/src/main.rs b/server/src/main.rs index e930b21..1257d16 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -31,6 +31,13 @@ pub struct AppState { pub admin_pass: Option, /// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs. pub domain_suffix: String, + /// Shared secret used by the git-shell and post-receive hook to call internal API routes. + pub internal_token: String, + /// Path to the SSH authorized_keys file managed by HIY (e.g. /home/hiy/.ssh/authorized_keys). + /// Empty string means SSH key management is disabled. + pub ssh_authorized_keys_file: String, + /// Path to the hiy-git-shell binary on the host (embedded in authorized_keys command= lines). + pub git_shell_path: String, } #[tokio::main] @@ -97,6 +104,21 @@ async fn main() -> anyhow::Result<()> { let build_queue = Arc::new(Mutex::new(VecDeque::::new())); + // Internal token: load from file (persists across restarts) or generate fresh. + let token_file = format!("{}/internal-token", data_dir); + let internal_token = if let Ok(t) = std::fs::read_to_string(&token_file) { + t.trim().to_string() + } else { + let t = uuid::Uuid::new_v4().to_string(); + let _ = std::fs::write(&token_file, &t); + t + }; + + let ssh_authorized_keys_file = std::env::var("HIY_SSH_AUTHORIZED_KEYS") + .unwrap_or_default(); + let git_shell_path = std::env::var("HIY_GIT_SHELL") + .unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into()); + let state = AppState { db, build_queue, @@ -106,6 +128,9 @@ async fn main() -> anyhow::Result<()> { admin_user, admin_pass, domain_suffix, + internal_token, + ssh_authorized_keys_file, + git_shell_path, }; // Single background worker — sequential builds to avoid saturating the Pi. @@ -139,6 +164,9 @@ async fn main() -> anyhow::Result<()> { .route("/api/users", get(routes::users::list).post(routes::users::create)) .route("/api/users/:id", put(routes::users::update).delete(routes::users::delete)) .route("/api/users/:id/apps/:app_id", post(routes::users::grant_app).delete(routes::users::revoke_app)) + // SSH key management (admin only) + .route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add)) + .route("/api/ssh-keys/:key_id", delete(routes::ssh_keys::remove)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware)); // ── Public routes ───────────────────────────────────────────────────────── @@ -149,7 +177,10 @@ async fn main() -> anyhow::Result<()> { // Called by Caddy forward_auth for every deployed-app request. .route("/auth/verify", get(auth::verify)) // GitHub webhooks use HMAC-SHA256 — no session needed. - .route("/webhook/:app_id", post(routes::webhooks::github)); + .route("/webhook/:app_id", post(routes::webhooks::github)) + // Internal routes: called by hiy-git-shell and post-receive hooks. + .route("/internal/git/auth", get(routes::git::auth_check)) + .route("/internal/git/:app_id/push", post(routes::git::push_trigger)); let app = Router::new() .merge(protected) diff --git a/server/src/models.rs b/server/src/models.rs index 88f17c6..1418d17 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -74,3 +74,18 @@ pub struct UpdateUser { pub password: Option, pub is_admin: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SshKey { + pub id: String, + pub user_id: String, + pub label: String, + pub public_key: String, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateSshKey { + pub label: String, + pub public_key: String, +} diff --git a/server/src/routes/apps.rs b/server/src/routes/apps.rs index f99a020..9856d92 100644 --- a/server/src/routes/apps.rs +++ b/server/src/routes/apps.rs @@ -4,6 +4,7 @@ use axum::{ Json, }; use chrono::Utc; +use std::os::unix::fs::PermissionsExt; use uuid::Uuid; use crate::{ @@ -48,6 +49,11 @@ pub async fn create( StatusCode::UNPROCESSABLE_ENTITY })?; + // Initialise a bare git repo for git-push deploys. + if let Err(e) = init_bare_repo(&s, &id).await { + tracing::warn!("Failed to init bare repo for {}: {}", id, e); + } + fetch_app(&s, &id).await.map(|a| (StatusCode::CREATED, Json(a))) } @@ -100,6 +106,13 @@ pub async fn delete( if res.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } + + // Clean up the bare repo (best-effort). + let repo_path = format!("{}/repos/{}.git", s.data_dir, id); + if let Err(e) = std::fs::remove_dir_all(&repo_path) { + tracing::warn!("Failed to remove bare repo {}: {}", repo_path, e); + } + Ok(StatusCode::NO_CONTENT) } @@ -147,3 +160,52 @@ async fn fetch_app(s: &AppState, id: &str) -> Result { .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND) } + +/// Initialise a bare git repo and write the post-receive hook for git-push deploys. +async fn init_bare_repo(s: &AppState, app_id: &str) -> anyhow::Result<()> { + let repos_dir = format!("{}/repos", s.data_dir); + std::fs::create_dir_all(&repos_dir)?; + + let repo_path = format!("{}/{}.git", repos_dir, app_id); + + let out = tokio::process::Command::new("git") + .args(["init", "--bare", &repo_path]) + .output() + .await?; + + if !out.status.success() { + anyhow::bail!( + "git init --bare failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + // Write the post-receive hook that calls HIY's internal push endpoint. + let hook_path = format!("{}/hooks/post-receive", repo_path); + let hook = format!( + r#"#!/usr/bin/env bash +# HIY post-receive hook — queues a build after git push. +set -euo pipefail +HIY_API_URL="http://localhost:3000" +HIY_INTERNAL_TOKEN="{token}" +APP_ID="{app_id}" +while read OLD_SHA NEW_SHA REF; do + BRANCH="${{REF##refs/heads/}}" + curl -sf -X POST \ + -H "X-Hiy-Token: $HIY_INTERNAL_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{{\"sha\":\"$NEW_SHA\",\"branch\":\"$BRANCH\"}}" \ + "$HIY_API_URL/internal/git/$APP_ID/push" || true +done +echo "[hiy] Build queued. Watch progress in the dashboard." +"#, + token = s.internal_token, + app_id = app_id, + ); + + std::fs::write(&hook_path, hook)?; + std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?; + + tracing::info!("Initialised bare repo at {}", repo_path); + Ok(()) +} diff --git a/server/src/routes/git.rs b/server/src/routes/git.rs new file mode 100644 index 0000000..7b34e54 --- /dev/null +++ b/server/src/routes/git.rs @@ -0,0 +1,115 @@ +/// Internal routes called by hiy-git-shell and post-receive hooks. +/// Authenticated by the shared `internal_token` (X-Hiy-Token header). +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{builder, AppState}; + +// ── Token check ─────────────────────────────────────────────────────────────── + +fn check_token(state: &AppState, headers: &HeaderMap) -> bool { + headers + .get("x-hiy-token") + .and_then(|v| v.to_str().ok()) + .map(|t| t == state.internal_token) + .unwrap_or(false) +} + +// ── GET /internal/git/auth — can this user push to this app? ───────────────── + +#[derive(Deserialize)] +pub struct AuthParams { + pub user_id: String, + pub app: String, +} + +#[derive(Serialize)] +pub struct AuthResponse { + pub app_id: String, +} + +pub async fn auth_check( + State(s): State, + headers: HeaderMap, + Query(params): Query, +) -> impl IntoResponse { + if !check_token(&s, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Resolve app by name (the app ID is the lowercased name slug). + let app_id: Option = + sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?") + .bind(¶ms.app) + .bind(¶ms.app) + .fetch_optional(&s.db) + .await + .unwrap_or(None); + + let app_id = match app_id { + Some(id) => id, + None => return StatusCode::NOT_FOUND.into_response(), + }; + + // Admins can push to any app. + let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?") + .bind(¶ms.user_id) + .fetch_optional(&s.db) + .await + .unwrap_or(None) + .unwrap_or(0); + + if is_admin != 0 { + return Json(AuthResponse { app_id }).into_response(); + } + + // Non-admin: check user_apps grant. + let granted: Option = + sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?") + .bind(¶ms.user_id) + .bind(&app_id) + .fetch_optional(&s.db) + .await + .unwrap_or(None); + + if granted.is_some() { + Json(AuthResponse { app_id }).into_response() + } else { + StatusCode::FORBIDDEN.into_response() + } +} + +// ── POST /internal/git/:app_id/push — post-receive hook triggers a build ───── + +#[derive(Deserialize)] +pub struct PushBody { + pub sha: Option, + pub branch: Option, +} + +pub async fn push_trigger( + State(s): State, + headers: HeaderMap, + Path(app_id): Path, + body: Option>, +) -> impl IntoResponse { + if !check_token(&s, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let sha = body.and_then(|b| b.sha.clone()); + + match builder::enqueue_deploy(&s, &app_id, "git-push", sha).await { + Ok(deploy) => (StatusCode::OK, Json(json!({"deploy_id": deploy.id}))).into_response(), + Err(e) => { + tracing::error!("git push trigger for {}: {}", app_id, e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response() + } + } +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index b8b5172..b1787a3 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,6 +1,8 @@ pub mod apps; pub mod deploys; pub mod envvars; +pub mod git; +pub mod ssh_keys; pub mod ui; pub mod users; pub mod webhooks; diff --git a/server/src/routes/ssh_keys.rs b/server/src/routes/ssh_keys.rs new file mode 100644 index 0000000..177028e --- /dev/null +++ b/server/src/routes/ssh_keys.rs @@ -0,0 +1,154 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use serde_json::json; +use uuid::Uuid; + +use crate::{models::{CreateSshKey, SshKey}, AppState}; + +// ── List SSH keys for a user ────────────────────────────────────────────────── + +pub async fn list( + State(s): State, + Path(user_id): Path, +) -> impl IntoResponse { + let keys: Vec = + sqlx::query_as("SELECT * FROM ssh_keys WHERE user_id = ? ORDER BY created_at") + .bind(&user_id) + .fetch_all(&s.db) + .await + .unwrap_or_default(); + Json(keys) +} + +// ── Add SSH key ─────────────────────────────────────────────────────────────── + +pub async fn add( + State(s): State, + Path(user_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let public_key = body.public_key.trim().to_string(); + let label = body.label.trim().to_string(); + + if label.is_empty() || public_key.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "label and public_key required"})), + ) + .into_response(); + } + + // Basic sanity check: must start with a known key type. + let valid_prefix = ["ssh-rsa", "ssh-ed25519", "ssh-ecdsa", "ecdsa-sha2-", "sk-"] + .iter() + .any(|p| public_key.starts_with(p)); + if !valid_prefix { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "public_key does not look like an SSH public key"})), + ) + .into_response(); + } + + // Reject keys that would break the authorized_keys command= line. + if public_key.contains('\n') || public_key.contains('"') { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "public_key contains invalid characters"})), + ) + .into_response(); + } + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + let res = sqlx::query( + "INSERT INTO ssh_keys (id, user_id, label, public_key, created_at) VALUES (?,?,?,?,?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&label) + .bind(&public_key) + .bind(&now) + .execute(&s.db) + .await; + + match res { + Ok(_) => { + if let Err(e) = regenerate_authorized_keys(&s).await { + tracing::warn!("authorized_keys regen failed: {}", e); + } + (StatusCode::CREATED, Json(json!({"id": id}))).into_response() + } + Err(e) => { + tracing::error!("add ssh key: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "db error"}))).into_response() + } + } +} + +// ── Remove SSH key ──────────────────────────────────────────────────────────── + +pub async fn remove( + State(s): State, + Path(key_id): Path, +) -> impl IntoResponse { + sqlx::query("DELETE FROM ssh_keys WHERE id = ?") + .bind(&key_id) + .execute(&s.db) + .await + .ok(); + if let Err(e) = regenerate_authorized_keys(&s).await { + tracing::warn!("authorized_keys regen failed: {}", e); + } + StatusCode::NO_CONTENT +} + +// ── Regenerate authorized_keys ──────────────────────────────────────────────── + +/// Rewrites the authorized_keys file from all SSH keys in the database. +/// Each entry uses a `command=` override so pushes are routed through hiy-git-shell. +/// No-ops silently if HIY_SSH_AUTHORIZED_KEYS is not configured. +pub async fn regenerate_authorized_keys(s: &AppState) -> anyhow::Result<()> { + if s.ssh_authorized_keys_file.is_empty() { + return Ok(()); + } + + let keys: Vec = sqlx::query_as("SELECT * FROM ssh_keys ORDER BY user_id, created_at") + .fetch_all(&s.db) + .await?; + + let mut lines = Vec::new(); + for key in &keys { + // Embed user_id, api url (always localhost:3000), token, and repos dir into the + // command= so hiy-git-shell needs no separate config file. + let api_url = format!("http://localhost:3000"); + let repos_dir = format!("{}/repos", s.data_dir); + let cmd = format!( + "command=\"{shell} {uid} {api} {token} {repos}\",\ + no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {pk}", + shell = s.git_shell_path, + uid = key.user_id, + api = api_url, + token = s.internal_token, + repos = repos_dir, + pk = key.public_key, + ); + lines.push(cmd); + } + + let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" }; + + // Create parent directory if needed. + if let Some(parent) = std::path::Path::new(&s.ssh_authorized_keys_file).parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&s.ssh_authorized_keys_file, content)?; + tracing::info!("Wrote {} SSH key(s) to {}", lines.len(), s.ssh_authorized_keys_file); + Ok(()) +} diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index af24f77..35ed4e7 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -688,7 +688,20 @@ pub async fn users_page(State(state): State) -> impl IntoResponse { ` : ''}} +
+ SSH keys: + loading… +
+
+ + + +
`).join(''); + // Load SSH keys for each user asynchronously. + for (const u of users) loadSshKeys(u.id); }} async function addUser() {{ @@ -751,6 +764,43 @@ pub async fn users_page(State(state): State) -> impl IntoResponse { load(); }} + async function loadSshKeys(userId) {{ + const res = await fetch('/api/users/' + userId + '/ssh-keys'); + const keys = await res.json(); + const el = document.getElementById('ssh-keys-' + userId); + if (!el) return; + if (!keys.length) {{ + el.innerHTML = 'SSH keys: none'; + }} else {{ + el.innerHTML = 'SSH keys: ' + + keys.map(k => ` + + ${{k.label}} + + `).join(''); + }} + }} + + async function addSshKey(userId) {{ + const label = document.getElementById('key-label-' + userId).value.trim(); + const publicKey = document.getElementById('key-value-' + userId).value.trim(); + if (!label || !publicKey) return; + await fetch('/api/users/' + userId + '/ssh-keys', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{label, public_key: publicKey}}) + }}); + document.getElementById('key-label-' + userId).value = ''; + document.getElementById('key-value-' + userId).value = ''; + loadSshKeys(userId); + }} + + async function removeSshKey(keyId, userId) {{ + await fetch('/api/ssh-keys/' + keyId, {{method: 'DELETE'}}); + loadSshKeys(userId); + }} + load(); "# );