//! SSH key management API endpoints. use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::{Form, Json}; use serde::{Deserialize, Serialize}; use crate::auth::AuthUser; use crate::db::{self, SshKeyId}; use crate::error::{AppError, Result}; use crate::helpers::{hx_toast, is_htmx_request}; use crate::validation; use sqlx::PgPool; #[derive(Debug, Deserialize)] pub(crate) struct AddKeyRequest { pub public_key: String, #[serde(default)] pub label: String, } #[derive(Debug, Serialize)] pub(crate) struct SshKeyResponse { pub id: SshKeyId, pub fingerprint: String, pub label: String, pub created_at: String, } /// GET /api/users/me/ssh-keys/list: HTMX partial for the SSH keys list. #[tracing::instrument(skip_all, name = "ssh_keys::list_keys_html")] pub(super) async fn list_keys_html( State(db): State, AuthUser(user): AuthUser, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; Ok(axum::response::Html(html)) } /// GET /api/users/me/ssh-keys: list the authenticated user's SSH keys. #[tracing::instrument(skip_all, name = "ssh_keys::list_keys")] pub(super) async fn list_keys( State(db): State, AuthUser(user): AuthUser, ) -> Result { let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; let data: Vec = keys .into_iter() .map(|k| SshKeyResponse { id: k.id, fingerprint: k.fingerprint, label: k.label, created_at: k.created_at.format("%b %d, %Y").to_string(), }) .collect(); Ok(Json(crate::types::ListResponse { data })) } /// POST /api/users/me/ssh-keys: add a new SSH key. #[tracing::instrument(skip_all, name = "ssh_keys::add_key")] pub(super) async fn add_key( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Validate and normalize the key let (normalized_key, fingerprint) = validation::validate_ssh_public_key(&req.public_key)?; validation::validate_ssh_key_label(&req.label)?; // Insert (unique constraint on user_id + fingerprint handles races) let key = db::ssh_keys::add_key(&db, user.id, &normalized_key, &fingerprint, &req.label) .await .map_err(|e| { // Check for unique constraint violation (duplicate fingerprint) if let AppError::Database(ref db_err) = e { let msg = db_err.to_string(); if msg.contains("ssh_keys_user_id_fingerprint_key") { return AppError::validation( "This SSH key is already registered to your account".to_string(), ); } // Global uniqueness: the key is registered to a different account. // A fingerprint maps to exactly one identity, so we reject rather // than let a duplicate break the owner's CLI SSH auth. if msg.contains("ssh_keys_fingerprint_key") { return AppError::validation( "This SSH key is already registered to another account.".to_string(), ); } } e })?; // Trigger authorized_keys rebuild (best-effort, non-blocking) rebuild_authorized_keys(); if is_htmx_request(&headers) { // Re-render the SSH keys section via HTMX let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; return Ok(( [("HX-Trigger", hx_toast("SSH key added", "success"))], axum::response::Html(html), ) .into_response()); } Ok(Json(SshKeyResponse { id: key.id, fingerprint: key.fingerprint, label: key.label, created_at: key.created_at.format("%b %d, %Y").to_string(), }) .into_response()) } /// DELETE /api/users/me/ssh-keys/{id}: remove an SSH key. #[tracing::instrument(skip_all, name = "ssh_keys::delete_key")] pub(super) async fn delete_key( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(key_id): Path, ) -> Result { user.check_not_suspended()?; let deleted = db::ssh_keys::delete_key(&db, key_id, user.id).await?; if !deleted { return Err(AppError::NotFound); } // Trigger authorized_keys rebuild (best-effort, non-blocking) rebuild_authorized_keys(); if is_htmx_request(&headers) { let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; let ssh_keys: Vec = keys.iter().map(SshKeyView::from).collect(); let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; return Ok(( [("HX-Trigger", hx_toast("SSH key removed", "success"))], axum::response::Html(html), ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Ask for an `authorized_keys` rebuild. /// /// Writes the marker file `makenotwork-rebuild-keys.path` watches; the oneshot /// it starts runs `mnw-admin rebuild-keys` as root, deletes the marker and /// writes the file sshd reads. The rebuild cannot happen in this process or in /// a child of it: sshd's `StrictModes` refuses a group-writable /// `authorized_keys`, so the service user cannot own the write, and the unit's /// sandbox implies `NoNewPrivileges`, so `sudo` cannot raise privilege either. /// /// A key that is in the database but not yet in `authorized_keys` is a key the /// user believes works and does not, so every failure here logs at `warn` and /// names the marker path. Silence in `journalctl -u makenotwork -p warning` /// means the request was filed, not that the rebuild succeeded: that is /// `systemctl status makenotwork-rebuild-keys.service`. fn rebuild_authorized_keys() { let marker = std::env::var("MNW_KEYS_REBUILD_MARKER") .unwrap_or_else(|_| crate::constants::KEYS_REBUILD_MARKER.to_string()); let path = std::path::Path::new(&marker); if let Some(parent) = path.parent() && !parent.exists() { tracing::warn!( marker = %marker, "authorized_keys rebuild not requested: marker directory missing. \ The node predates makenotwork-rebuild-keys.path; re-run bootstrap-node.sh" ); return; } // Content is a timestamp rather than an empty file so a `.path` unit using // PathModified sees a change even when the marker outlives one rebuild. let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or_default(); if let Err(error) = std::fs::write(path, format!("{stamp}\n")) { tracing::warn!( error = %error, marker = %marker, "authorized_keys rebuild not requested: marker unwritable" ); } } /// View type for SSH key display in templates. #[derive(Clone)] pub struct SshKeyView { pub id: String, pub fingerprint: String, pub label: String, pub created_at: String, } impl From<&db::DbSshKey> for SshKeyView { fn from(k: &db::DbSshKey) -> Self { Self { id: k.id.to_string(), fingerprint: k.fingerprint.clone(), label: k.label.clone(), created_at: k.created_at.format("%b %d, %Y").to_string(), } } }