Skip to main content

web/pages/account/
ssh_key.rs

1//! Self-service SSH key management.
2//!
3//! - GET `/account/ssh-key`
4//!   Renders the list of registered keys plus the add-key form.
5//!   If the request carries `?added=<fp>`, also renders a
6//!   ready-to-paste `~/.ssh/config` snippet — the same one
7//!   `nomisync-sshd` writes back after a successful `ssh-copy-id`
8//!   bootstrap.
9//! - POST `/api/account/ssh-key/add` (`Form<AddKeyForm>`)
10//!   Calls [`AddSshKey`] and redirects to the manage page with
11//!   `?added=<fingerprint>` so the snippet panel appears.
12//! - POST `/api/account/ssh-key/remove` (`Form<RemoveKeyForm>`)
13//!   Calls [`RemoveSshKey`] and redirects back without the panel.
14//!
15//! All three handlers reuse server-side commands so the CLI and
16//! the web surface stay in sync on validation + side-effects (e.g.
17//! flipping `users.ssh_enabled`).
18
19use askama::Template;
20use axum::{
21    Extension, Form, Json,
22    extract::{Query, State},
23    http::{StatusCode, header},
24    response::{IntoResponse, Response},
25};
26use cli_core::ssh_keys::parse_authorized_keys_line;
27use serde::Deserialize;
28use server::command::{
29    CmdResult,
30    ssh_key::{AddSshKey, ListSshKeys, RemoveSshKey},
31};
32use std::sync::Arc;
33
34use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
35
36struct KeyRow {
37    fingerprint: String,
38    key_type: String,
39    annotation: String,
40    last_used: Option<String>,
41}
42
43#[derive(Template)]
44#[template(path = "pages/account/ssh_key.html")]
45struct SshKeyPage {
46    keys: Vec<KeyRow>,
47    config_snippet: Option<String>,
48}
49
50#[derive(Deserialize)]
51pub struct PageQuery {
52    added: Option<String>,
53}
54
55pub async fn ssh_key_page(
56    State(data): State<Arc<AppState>>,
57    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
58    Query(q): Query<PageQuery>,
59) -> Result<impl IntoResponse, (StatusCode, String)> {
60    let user = &jwt_auth.user;
61    let keys = match ListSshKeys::new().user_id(user.id).run().await {
62        Ok(Some(CmdResult::SshKeys(rows))) => rows
63            .into_iter()
64            .map(|r| KeyRow {
65                fingerprint: r.fingerprint,
66                key_type: r.key_type,
67                annotation: r.annotation,
68                last_used: r
69                    .last_used_at
70                    .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()),
71            })
72            .collect(),
73        Ok(_) => Vec::new(),
74        Err(e) => {
75            log::error!("Failed to list SSH keys: {e:?}");
76            return Err((
77                StatusCode::INTERNAL_SERVER_ERROR,
78                "Failed to list SSH keys".to_string(),
79            ));
80        }
81    };
82
83    let config_snippet = q
84        .added
85        .as_deref()
86        .map(|fp| build_config_snippet(&data.conf.ssh, &user.name, fp));
87
88    Ok(HtmlTemplate(SshKeyPage {
89        keys,
90        config_snippet,
91    }))
92}
93
94#[derive(Deserialize)]
95pub struct AddKeyForm {
96    pubkey: String,
97    annotation: Option<String>,
98}
99
100pub async fn ssh_key_add(
101    State(_data): State<Arc<AppState>>,
102    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
103    Form(form): Form<AddKeyForm>,
104) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
105    let user = &jwt_auth.user;
106    let pubkey = form.pubkey.trim();
107    if pubkey.is_empty() {
108        return Err((
109            StatusCode::BAD_REQUEST,
110            Json(serde_json::json!({"status": "fail", "message": "pubkey required"})),
111        ));
112    }
113    let parsed = parse_authorized_keys_line(pubkey).map_err(|e| {
114        log::warn!("rejecting malformed SSH pubkey: {e}");
115        (
116            StatusCode::BAD_REQUEST,
117            Json(serde_json::json!({
118                "status": "fail",
119                "message": format!("invalid pubkey: {e}"),
120            })),
121        )
122    })?;
123    let annotation = form
124        .annotation
125        .filter(|s| !s.trim().is_empty())
126        .unwrap_or_else(|| {
127            if parsed.comment.is_empty() {
128                "web".to_string()
129            } else {
130                parsed.comment.clone()
131            }
132        });
133    let fingerprint = parsed.fingerprint.clone();
134    let _ = AddSshKey::new()
135        .user_id(user.id)
136        .key_type(parsed.key_type)
137        .key_blob(parsed.key_blob)
138        .fingerprint(parsed.fingerprint)
139        .annotation(annotation)
140        .run()
141        .await
142        .map_err(add_key_failure)?;
143
144    let location = format!("/account/ssh-key?added={}", encode_query(&fingerprint));
145    Ok((StatusCode::SEE_OTHER, [(header::LOCATION, location)], "").into_response())
146}
147
148/// A key the user cannot add (`CmdError::Args`, e.g. one another account
149/// already holds) is their mistake: a 400 carrying the reason. Anything else is
150/// ours and stays a 500.
151fn add_key_failure(e: server::command::CmdError) -> (StatusCode, Json<serde_json::Value>) {
152    match e {
153        server::command::CmdError::Args(message) => (
154            StatusCode::BAD_REQUEST,
155            Json(serde_json::json!({ "status": "fail", "message": message })),
156        ),
157        other => {
158            log::error!("Failed to add SSH key: {other:?}");
159            (
160                StatusCode::INTERNAL_SERVER_ERROR,
161                Json(serde_json::json!({ "status": "error", "message": other.to_string() })),
162            )
163        }
164    }
165}
166
167#[derive(Deserialize)]
168pub struct RemoveKeyForm {
169    fingerprint: String,
170}
171
172pub async fn ssh_key_remove(
173    State(_data): State<Arc<AppState>>,
174    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
175    Form(form): Form<RemoveKeyForm>,
176) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
177    let user = &jwt_auth.user;
178    RemoveSshKey::new()
179        .user_id(user.id)
180        .fingerprint(form.fingerprint)
181        .run()
182        .await
183        .map_err(|e| {
184            log::error!("Failed to remove SSH key: {e:?}");
185            (
186                StatusCode::INTERNAL_SERVER_ERROR,
187                Json(serde_json::json!({
188                    "status": "fail",
189                    "message": format!("{e:?}"),
190                })),
191            )
192        })?;
193    Ok((
194        StatusCode::SEE_OTHER,
195        [(header::LOCATION, "/account/ssh-key")],
196        "",
197    )
198        .into_response())
199}
200
201/// Minimal percent-encoder for the `?added=` query value.
202/// Encodes everything outside the unreserved RFC-3986 set so an SSH
203/// key fingerprint (`SHA256:` plus base64) round-trips cleanly. Tiny
204/// enough to keep inline; adding `percent-encoding` as a direct dep
205/// for one call site is overkill.
206fn encode_query(raw: &str) -> String {
207    let mut out = String::with_capacity(raw.len() * 3);
208    for byte in raw.as_bytes() {
209        let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
210        if unreserved {
211            out.push(*byte as char);
212        } else {
213            out.push_str(&format!("%{byte:02X}"));
214        }
215    }
216    out
217}
218
219fn build_config_snippet(
220    ssh: &crate::config::SshConnectInfo,
221    user_name: &str,
222    fingerprint: &str,
223) -> String {
224    format!(
225        "Host nomisync\n\
226         \x20   HostName       {hostname}\n\
227         \x20   Port           {port}\n\
228         \x20   User           {user}\n\
229         \x20   IdentitiesOnly yes\n\
230         \x20   # IdentityAgent ${{SSH_AUTH_SOCK}}    # uncomment if using gpg-agent\n\
231         \x20   # IdentityFile  ~/.ssh/id_ed25519     # or point at your private key\n\
232         \n\
233         # Host key fingerprint (verify on first connect):\n\
234         #   {host_fp}\n\
235         \n\
236         # This key's fingerprint:\n\
237         #   {key_fp}\n",
238        hostname = ssh.hostname,
239        port = ssh.port,
240        user = user_name,
241        host_fp = ssh.host_fingerprint,
242        key_fp = fingerprint,
243    )
244}
245
246#[cfg(test)]
247mod add_key_failure_tests {
248    use super::add_key_failure;
249    use axum::http::StatusCode;
250    use server::command::CmdError;
251
252    #[test]
253    fn a_key_the_user_cannot_add_is_a_400_with_the_reason() {
254        let reason = "this SSH key is already registered to another account";
255        let (status, body) = add_key_failure(CmdError::Args(reason.to_string()));
256        assert_eq!(status, StatusCode::BAD_REQUEST);
257        assert_eq!(body.0["message"], reason);
258    }
259
260    #[test]
261    fn any_other_failure_is_still_a_500() {
262        let (status, _) = add_key_failure(CmdError::Disabled);
263        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
264    }
265}