Skip to main content

max / makenotwork

6.5 KB · 206 lines History Blame Raw
1 //! SSH key management API endpoints.
2
3 use axum::extract::{Path, State};
4 use axum::http::{HeaderMap, StatusCode};
5 use axum::response::{IntoResponse, Response};
6 use axum::{Form, Json};
7 use serde::{Deserialize, Serialize};
8
9 use crate::auth::AuthUser;
10 use crate::db::{self, SshKeyId};
11 use crate::error::{AppError, Result};
12 use crate::helpers::{hx_toast, is_htmx_request};
13 use crate::validation;
14 use crate::AppState;
15
16 #[derive(Debug, Deserialize)]
17 pub struct AddKeyRequest {
18 pub public_key: String,
19 #[serde(default)]
20 pub label: String,
21 }
22
23 #[derive(Debug, Serialize)]
24 pub struct SshKeyResponse {
25 pub id: SshKeyId,
26 pub fingerprint: String,
27 pub label: String,
28 pub created_at: String,
29 }
30
31 /// GET /api/users/me/ssh-keys/list: HTMX partial for the SSH keys list.
32 #[tracing::instrument(skip_all, name = "ssh_keys::list_keys_html")]
33 pub(super) async fn list_keys_html(
34 State(state): State<AppState>,
35 AuthUser(user): AuthUser,
36 ) -> Result<impl IntoResponse> {
37 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
38 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
39 let html = crate::templates::SshKeysListTemplate { ssh_keys }
40 .render()
41 .unwrap_or_default();
42 Ok(axum::response::Html(html))
43 }
44
45 /// GET /api/users/me/ssh-keys: list the authenticated user's SSH keys.
46 #[tracing::instrument(skip_all, name = "ssh_keys::list_keys")]
47 pub(super) async fn list_keys(
48 State(state): State<AppState>,
49 AuthUser(user): AuthUser,
50 ) -> Result<impl IntoResponse> {
51 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
52
53 let data: Vec<SshKeyResponse> = keys
54 .into_iter()
55 .map(|k| SshKeyResponse {
56 id: k.id,
57 fingerprint: k.fingerprint,
58 label: k.label,
59 created_at: k.created_at.format("%b %d, %Y").to_string(),
60 })
61 .collect();
62
63 Ok(Json(crate::types::ListResponse { data }))
64 }
65
66 /// POST /api/users/me/ssh-keys: add a new SSH key.
67 #[tracing::instrument(skip_all, name = "ssh_keys::add_key")]
68 pub(super) async fn add_key(
69 State(state): State<AppState>,
70 headers: HeaderMap,
71 AuthUser(user): AuthUser,
72 Form(req): Form<AddKeyRequest>,
73 ) -> Result<Response> {
74 user.check_not_suspended()?;
75 user.check_not_sandbox()?;
76
77 // Validate and normalize the key
78 let (normalized_key, fingerprint) = validation::validate_ssh_public_key(&req.public_key)?;
79 validation::validate_ssh_key_label(&req.label)?;
80
81 // Insert (unique constraint on user_id + fingerprint handles races)
82 let key = db::ssh_keys::add_key(&state.db, user.id, &normalized_key, &fingerprint, &req.label)
83 .await
84 .map_err(|e| {
85 // Check for unique constraint violation (duplicate fingerprint)
86 if let AppError::Database(ref db_err) = e
87 && db_err
88 .to_string()
89 .contains("ssh_keys_user_id_fingerprint_key")
90 {
91 return AppError::validation(
92 "This SSH key is already registered to your account".to_string(),
93 );
94 }
95 e
96 })?;
97
98 // Trigger authorized_keys rebuild (best-effort, non-blocking)
99 rebuild_authorized_keys();
100
101 if is_htmx_request(&headers) {
102 // Re-render the SSH keys section via HTMX
103 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
104 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
105 let html = crate::templates::SshKeysListTemplate { ssh_keys }
106 .render()
107 .unwrap_or_default();
108 return Ok((
109 [("HX-Trigger", hx_toast("SSH key added", "success"))],
110 axum::response::Html(html),
111 )
112 .into_response());
113 }
114
115 Ok(Json(SshKeyResponse {
116 id: key.id,
117 fingerprint: key.fingerprint,
118 label: key.label,
119 created_at: key.created_at.format("%b %d, %Y").to_string(),
120 })
121 .into_response())
122 }
123
124 /// DELETE /api/users/me/ssh-keys/{id}: remove an SSH key.
125 #[tracing::instrument(skip_all, name = "ssh_keys::delete_key")]
126 pub(super) async fn delete_key(
127 State(state): State<AppState>,
128 headers: HeaderMap,
129 AuthUser(user): AuthUser,
130 Path(key_id): Path<SshKeyId>,
131 ) -> Result<Response> {
132 user.check_not_suspended()?;
133
134 let deleted = db::ssh_keys::delete_key(&state.db, key_id, user.id).await?;
135 if !deleted {
136 return Err(AppError::NotFound);
137 }
138
139 // Trigger authorized_keys rebuild (best-effort, non-blocking)
140 rebuild_authorized_keys();
141
142 if is_htmx_request(&headers) {
143 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
144 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
145 let html = crate::templates::SshKeysListTemplate { ssh_keys }
146 .render()
147 .unwrap_or_default();
148 return Ok((
149 [("HX-Trigger", hx_toast("SSH key removed", "success"))],
150 axum::response::Html(html),
151 )
152 .into_response());
153 }
154
155 Ok(StatusCode::NO_CONTENT.into_response())
156 }
157
158 /// Trigger authorized_keys rebuild via mnw-admin.
159 ///
160 /// Spawns the rebuild as a background process and does not wait for it.
161 /// If sudo/mnw-admin is not available (e.g., dev environment), logs a warning.
162 fn rebuild_authorized_keys() {
163 std::thread::spawn(|| {
164 let result = std::process::Command::new("sudo")
165 .args(["-u", "git", "/opt/mnw/current/mnw-admin", "rebuild-keys"])
166 .output();
167
168 match result {
169 Ok(output) if !output.status.success() => {
170 let stderr = String::from_utf8_lossy(&output.stderr);
171 tracing::warn!(
172 status = %output.status,
173 stderr = %stderr,
174 "authorized_keys rebuild failed"
175 );
176 }
177 Err(e) => {
178 tracing::debug!(error = %e, "authorized_keys rebuild skipped (mnw-admin not available)");
179 }
180 _ => {}
181 }
182 });
183 }
184
185 use askama::Template;
186
187 /// View type for SSH key display in templates.
188 #[derive(Clone)]
189 pub struct SshKeyView {
190 pub id: String,
191 pub fingerprint: String,
192 pub label: String,
193 pub created_at: String,
194 }
195
196 impl From<&db::DbSshKey> for SshKeyView {
197 fn from(k: &db::DbSshKey) -> Self {
198 Self {
199 id: k.id.to_string(),
200 fingerprint: k.fingerprint.clone(),
201 label: k.label.clone(),
202 created_at: k.created_at.format("%b %d, %Y").to_string(),
203 }
204 }
205 }
206