Skip to main content

max / makenotwork

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