web/pages/account/
ssh_key.rs1use 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
191fn 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}