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

            
19
use askama::Template;
20
use axum::{
21
    Extension, Form, Json,
22
    extract::{Query, State},
23
    http::{StatusCode, header},
24
    response::{IntoResponse, Response},
25
};
26
use cli_core::ssh_keys::parse_authorized_keys_line;
27
use serde::Deserialize;
28
use server::command::{
29
    CmdResult,
30
    ssh_key::{AddSshKey, ListSshKeys, RemoveSshKey},
31
};
32
use std::sync::Arc;
33

            
34
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
35

            
36
struct 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")]
45
struct SshKeyPage {
46
    keys: Vec<KeyRow>,
47
    config_snippet: Option<String>,
48
}
49

            
50
#[derive(Deserialize)]
51
pub struct PageQuery {
52
    added: Option<String>,
53
}
54

            
55
pub 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)]
95
pub struct AddKeyForm {
96
    pubkey: String,
97
    annotation: Option<String>,
98
}
99

            
100
pub 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)]
158
pub struct RemoveKeyForm {
159
    fingerprint: String,
160
}
161

            
162
pub 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.
196
fn 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

            
209
fn 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
}