Lines
0 %
Functions
Branches
100 %
//! Self-service SSH key management.
//!
//! - GET `/account/ssh-key`
//! Renders the list of registered keys plus the add-key form.
//! If the request carries `?added=<fp>`, also renders a
//! ready-to-paste `~/.ssh/config` snippet — the same one
//! `nomisync-sshd` writes back after a successful `ssh-copy-id`
//! bootstrap.
//! - POST `/api/account/ssh-key/add` (`Form<AddKeyForm>`)
//! Calls [`AddSshKey`] and redirects to the manage page with
//! `?added=<fingerprint>` so the snippet panel appears.
//! - POST `/api/account/ssh-key/remove` (`Form<RemoveKeyForm>`)
//! Calls [`RemoveSshKey`] and redirects back without the panel.
//! All three handlers reuse server-side commands so the CLI and
//! the web surface stay in sync on validation + side-effects (e.g.
//! flipping `users.ssh_enabled`).
use askama::Template;
use axum::{
Extension, Form, Json,
extract::{Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use cli_core::ssh_keys::parse_authorized_keys_line;
use serde::Deserialize;
use server::command::{
CmdResult,
ssh_key::{AddSshKey, ListSshKeys, RemoveSshKey},
use std::sync::Arc;
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
struct KeyRow {
fingerprint: String,
key_type: String,
annotation: String,
last_used: Option<String>,
}
#[derive(Template)]
#[template(path = "pages/account/ssh_key.html")]
struct SshKeyPage {
keys: Vec<KeyRow>,
config_snippet: Option<String>,
#[derive(Deserialize)]
pub struct PageQuery {
added: Option<String>,
pub async fn ssh_key_page(
State(data): State<Arc<AppState>>,
Extension(jwt_auth): Extension<JWTAuthMiddleware>,
Query(q): Query<PageQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = &jwt_auth.user;
let keys = match ListSshKeys::new().user_id(user.id).run().await {
Ok(Some(CmdResult::SshKeys(rows))) => rows
.into_iter()
.map(|r| KeyRow {
fingerprint: r.fingerprint,
key_type: r.key_type,
annotation: r.annotation,
last_used: r
.last_used_at
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()),
})
.collect(),
Ok(_) => Vec::new(),
Err(e) => {
log::error!("Failed to list SSH keys: {e:?}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list SSH keys".to_string(),
));
let config_snippet = q
.added
.as_deref()
.map(|fp| build_config_snippet(&data.conf.ssh, &user.name, fp));
Ok(HtmlTemplate(SshKeyPage {
keys,
config_snippet,
}))
pub struct AddKeyForm {
pubkey: String,
annotation: Option<String>,
pub async fn ssh_key_add(
State(_data): State<Arc<AppState>>,
Form(form): Form<AddKeyForm>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let pubkey = form.pubkey.trim();
if pubkey.is_empty() {
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"status": "fail", "message": "pubkey required"})),
let parsed = parse_authorized_keys_line(pubkey).map_err(|e| {
log::warn!("rejecting malformed SSH pubkey: {e}");
(
Json(serde_json::json!({
"status": "fail",
"message": format!("invalid pubkey: {e}"),
})),
)
})?;
let annotation = form
.annotation
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
if parsed.comment.is_empty() {
"web".to_string()
} else {
parsed.comment.clone()
});
let fingerprint = parsed.fingerprint.clone();
let _ = AddSshKey::new()
.user_id(user.id)
.key_type(parsed.key_type)
.key_blob(parsed.key_blob)
.fingerprint(parsed.fingerprint)
.annotation(annotation)
.run()
.await
.map_err(|e| {
log::error!("Failed to add SSH key: {e:?}");
"message": format!("{e:?}"),
let location = format!("/account/ssh-key?added={}", encode_query(&fingerprint));
Ok((StatusCode::SEE_OTHER, [(header::LOCATION, location)], "").into_response())
pub struct RemoveKeyForm {
pub async fn ssh_key_remove(
Form(form): Form<RemoveKeyForm>,
RemoveSshKey::new()
.fingerprint(form.fingerprint)
log::error!("Failed to remove SSH key: {e:?}");
Ok((
StatusCode::SEE_OTHER,
[(header::LOCATION, "/account/ssh-key")],
"",
.into_response())
/// Minimal percent-encoder for the `?added=` query value.
/// Encodes everything outside the unreserved RFC-3986 set so an SSH
/// key fingerprint (`SHA256:` plus base64) round-trips cleanly. Tiny
/// enough to keep inline; adding `percent-encoding` as a direct dep
/// for one call site is overkill.
fn encode_query(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() * 3);
for byte in raw.as_bytes() {
let unreserved = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
if unreserved {
out.push(*byte as char);
out.push_str(&format!("%{byte:02X}"));
out
fn build_config_snippet(
ssh: &crate::config::SshConnectInfo,
user_name: &str,
fingerprint: &str,
) -> String {
format!(
"Host nomisync\n\
\x20 HostName {hostname}\n\
\x20 Port {port}\n\
\x20 User {user}\n\
\x20 IdentitiesOnly yes\n\
\x20 # IdentityAgent ${{SSH_AUTH_SOCK}} # uncomment if using gpg-agent\n\
\x20 # IdentityFile ~/.ssh/id_ed25519 # or point at your private key\n\
\n\
# Host key fingerprint (verify on first connect):\n\
# {host_fp}\n\
# This key's fingerprint:\n\
# {key_fp}\n",
hostname = ssh.hostname,
port = ssh.port,
user = user_name,
host_fp = ssh.host_fingerprint,
key_fp = fingerprint,