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(|e| {
143            log::error!("Failed to add SSH key: {e:?}");
144            (
145                StatusCode::INTERNAL_SERVER_ERROR,
146                Json(serde_json::json!({
147                    "status": "fail",
148                    "message": format!("{e:?}"),
149                })),
150            )
151        })?;
152
153    let location = format!("/account/ssh-key?added={}", encode_query(&fingerprint));
154    Ok((StatusCode::SEE_OTHER, [(header::LOCATION, location)], "").into_response())
155}
156
157#[derive(Deserialize)]
158pub struct RemoveKeyForm {
159    fingerprint: String,
160}
161
162pub async fn ssh_key_remove(
163    State(_data): State<Arc<AppState>>,
164    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
165    Form(form): Form<RemoveKeyForm>,
166) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
167    let user = &jwt_auth.user;
168    RemoveSshKey::new()
169        .user_id(user.id)
170        .fingerprint(form.fingerprint)
171        .run()
172        .await
173        .map_err(|e| {
174            log::error!("Failed to remove SSH key: {e:?}");
175            (
176                StatusCode::INTERNAL_SERVER_ERROR,
177                Json(serde_json::json!({
178                    "status": "fail",
179                    "message": format!("{e:?}"),
180                })),
181            )
182        })?;
183    Ok((
184        StatusCode::SEE_OTHER,
185        [(header::LOCATION, "/account/ssh-key")],
186        "",
187    )
188        .into_response())
189}
190
191/// Minimal percent-encoder for the `?added=` query value.
192/// Encodes everything outside the unreserved RFC-3986 set so an SSH
193/// key fingerprint (`SHA256:` plus base64) round-trips cleanly. Tiny
194/// enough to keep inline; adding `percent-encoding` as a direct dep
195/// for one call site is overkill.
196fn encode_query(raw: &str) -> String {
197    let mut out = String::with_capacity(raw.len() * 3);
198    for byte in raw.as_bytes() {
199        let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
200        if unreserved {
201            out.push(*byte as char);
202        } else {
203            out.push_str(&format!("%{byte:02X}"));
204        }
205    }
206    out
207}
208
209fn build_config_snippet(
210    ssh: &crate::config::SshConnectInfo,
211    user_name: &str,
212    fingerprint: &str,
213) -> String {
214    format!(
215        "Host nomisync\n\
216         \x20   HostName       {hostname}\n\
217         \x20   Port           {port}\n\
218         \x20   User           {user}\n\
219         \x20   IdentitiesOnly yes\n\
220         \x20   # IdentityAgent ${{SSH_AUTH_SOCK}}    # uncomment if using gpg-agent\n\
221         \x20   # IdentityFile  ~/.ssh/id_ed25519     # or point at your private key\n\
222         \n\
223         # Host key fingerprint (verify on first connect):\n\
224         #   {host_fp}\n\
225         \n\
226         # This key's fingerprint:\n\
227         #   {key_fp}\n",
228        hostname = ssh.hostname,
229        port = ssh.port,
230        user = user_name,
231        host_fp = ssh.host_fingerprint,
232        key_fp = fingerprint,
233    )
234}