Skip to main content

max / makenotwork

7.9 KB · 226 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 /// Ask for an `authorized_keys` rebuild.
163 ///
164 /// Writes the marker file `makenotwork-rebuild-keys.path` watches; the oneshot
165 /// it starts runs `mnw-admin rebuild-keys` as root, deletes the marker and
166 /// writes the file sshd reads. The rebuild cannot happen in this process or in
167 /// a child of it: sshd's `StrictModes` refuses a group-writable
168 /// `authorized_keys`, so the service user cannot own the write, and the unit's
169 /// sandbox implies `NoNewPrivileges`, so `sudo` cannot raise privilege either.
170 ///
171 /// A key that is in the database but not yet in `authorized_keys` is a key the
172 /// user believes works and does not, so every failure here logs at `warn` and
173 /// names the marker path. Silence in `journalctl -u makenotwork -p warning`
174 /// means the request was filed, not that the rebuild succeeded: that is
175 /// `systemctl status makenotwork-rebuild-keys.service`.
176 fn rebuild_authorized_keys() {
177 let marker = std::env::var("MNW_KEYS_REBUILD_MARKER")
178 .unwrap_or_else(|_| crate::constants::KEYS_REBUILD_MARKER.to_string());
179 let path = std::path::Path::new(&marker);
180
181 if let Some(parent) = path.parent()
182 && !parent.exists()
183 {
184 tracing::warn!(
185 marker = %marker,
186 "authorized_keys rebuild not requested: marker directory missing. \
187 The node predates makenotwork-rebuild-keys.path; re-run bootstrap-node.sh"
188 );
189 return;
190 }
191
192 // Content is a timestamp rather than an empty file so a `.path` unit using
193 // PathModified sees a change even when the marker outlives one rebuild.
194 let stamp = std::time::SystemTime::now()
195 .duration_since(std::time::UNIX_EPOCH)
196 .map(|d| d.as_secs())
197 .unwrap_or_default();
198 if let Err(error) = std::fs::write(path, format!("{stamp}\n")) {
199 tracing::warn!(
200 error = %error,
201 marker = %marker,
202 "authorized_keys rebuild not requested: marker unwritable"
203 );
204 }
205 }
206
207 /// View type for SSH key display in templates.
208 #[derive(Clone)]
209 pub struct SshKeyView {
210 pub id: String,
211 pub fingerprint: String,
212 pub label: String,
213 pub created_at: String,
214 }
215
216 impl From<&db::DbSshKey> for SshKeyView {
217 fn from(k: &db::DbSshKey) -> Self {
218 Self {
219 id: k.id.to_string(),
220 fingerprint: k.fingerprint.clone(),
221 label: k.label.clone(),
222 created_at: k.created_at.format("%b %d, %Y").to_string(),
223 }
224 }
225 }
226