1use 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
148fn 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
201fn 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}