- SSH key pairs are generated with ssh-key (Ed25519/ECDSA P-256/RSA) and encrypted in-process, so the passphrase no longer appears as an ssh-keygen command-line argument visible in the process list. - open_tunnel's loopback bridge checks the accepted peer is our own client socket, so a local process racing for the ephemeral port can't be spliced into the IRC/VNC SSH tunnel. - run_command caps buffered output at 1 MiB; local-forward abort handles are pruned as connections finish; SOCKS5 host/credential lengths over 255 bytes are rejected instead of being truncated into a malformed request.
206 lines
8.5 KiB
Rust
206 lines
8.5 KiB
Rust
//! SSH Key Manager window: key pairs in ~/.ssh — copy public key, delete, generate (in-process, OpenSSH format).
|
|
|
|
use crate::settings::{Job, poll};
|
|
use crate::state::Shared;
|
|
use crate::theme::{self, Tokens};
|
|
use crate::widgets;
|
|
use egui::{RichText, vec2};
|
|
use egui_phosphor::regular as icon;
|
|
use nexus_core::service::SshKeyInfo;
|
|
|
|
struct Generate {
|
|
name: String,
|
|
kind: &'static str,
|
|
bits: u32,
|
|
pass: String,
|
|
confirm: String,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct SshKeys {
|
|
open: bool,
|
|
/// Reload the list on the next frame.
|
|
stale: bool,
|
|
keys: Vec<SshKeyInfo>,
|
|
load_error: Option<String>,
|
|
confirm_delete: Option<String>,
|
|
form: Option<Generate>,
|
|
job: Option<Job<Result<String, String>>>,
|
|
}
|
|
|
|
impl SshKeys {
|
|
pub fn open(&mut self) {
|
|
self.open = true;
|
|
self.stale = true;
|
|
}
|
|
|
|
/// Called every frame; draws its window(s) while open.
|
|
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
|
match poll(&mut self.job) {
|
|
Some(Ok(name)) => {
|
|
s.success(format!("Key \"{name}\" generated successfully"));
|
|
self.form = None;
|
|
self.stale = true;
|
|
}
|
|
Some(Err(e)) => s.error(format!("Generation failed: {e}")),
|
|
None => {}
|
|
}
|
|
if !self.open {
|
|
return;
|
|
}
|
|
if std::mem::take(&mut self.stale) {
|
|
match s.svc.ssh_key_pairs() {
|
|
Ok(k) => (self.keys, self.load_error) = (k, None),
|
|
Err(e) => self.load_error = Some(e.to_string()),
|
|
}
|
|
}
|
|
let t = Tokens::get(ctx);
|
|
let mut open = true;
|
|
egui::Window::new(RichText::new(format!("{} SSH Key Manager", icon::KEY)).font(theme::semibold(14.0)))
|
|
.id(egui::Id::new("ssh-keys-window"))
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.pivot(egui::Align2::CENTER_CENTER)
|
|
.default_pos(ctx.content_rect().center())
|
|
.resizable(true)
|
|
.default_size([620.0, 460.0])
|
|
.min_size([480.0, 300.0])
|
|
.show(ctx, |ui| {
|
|
widgets::section(ui, "Key pairs in ~/.ssh");
|
|
if let Some(e) = &self.load_error {
|
|
ui.label(RichText::new(format!("Failed to load keys: {e}")).color(t.danger));
|
|
} else if self.keys.is_empty() {
|
|
widgets::empty(ui, icon::KEY, "No SSH keys found", "Generate a key pair below.");
|
|
}
|
|
let list_h = if self.form.is_some() { 150.0 } else { (ui.available_height() - 40.0).max(120.0) };
|
|
egui::ScrollArea::vertical().id_salt("keys").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| {
|
|
for k in &self.keys {
|
|
egui::Frame::new().fill(t.field).corner_radius(t.radius).inner_margin(8).show(ui, |ui| {
|
|
ui.set_width(ui.available_width());
|
|
ui.horizontal(|ui| {
|
|
ui.label(RichText::new(&k.name).font(theme::semibold(13.0)));
|
|
widgets::chip(ui, &k.kind.to_uppercase(), t.accent);
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if widgets::icon_button(ui, icon::TRASH, "Delete").clicked() {
|
|
self.confirm_delete = Some(k.name.clone());
|
|
}
|
|
if widgets::icon_button(ui, icon::COPY, "Copy public key").clicked() {
|
|
ui.ctx().copy_text(k.public_key.clone());
|
|
s.success("Public key copied to clipboard");
|
|
}
|
|
});
|
|
});
|
|
ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate())
|
|
.on_hover_text(&k.public_key);
|
|
});
|
|
ui.add_space(2.0);
|
|
}
|
|
});
|
|
ui.add_space(6.0);
|
|
match &mut self.form {
|
|
None => {
|
|
if widgets::primary(ui, icon::PLUS, "Generate New Key").clicked() {
|
|
self.form = Some(Generate { name: String::new(), kind: "ed25519", bits: 4096, pass: String::new(), confirm: String::new() });
|
|
}
|
|
}
|
|
Some(f) => {
|
|
if generate_form(ui, f, self.job.is_some()) {
|
|
let name = f.name.trim().to_string();
|
|
if name.is_empty() {
|
|
s.error("Key name is required");
|
|
} else if f.pass != f.confirm {
|
|
s.error("Passphrases do not match");
|
|
} else {
|
|
let (svc, kind, bits, pass) = (s.svc.clone(), f.kind, f.bits, f.pass.clone());
|
|
self.job =
|
|
Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string())));
|
|
}
|
|
}
|
|
if self.job.is_none() && ui.button("Cancel").clicked() {
|
|
self.form = None;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
self.open = open;
|
|
self.delete_dialog(ctx, s);
|
|
}
|
|
|
|
fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
|
let Some(name) = self.confirm_delete.clone() else {
|
|
return;
|
|
};
|
|
let t = Tokens::get(ctx);
|
|
let mut done = false;
|
|
let r = egui::Modal::new(egui::Id::new("delete-key")).show(ctx, |ui| {
|
|
ui.set_width(360.0);
|
|
ui.label(RichText::new("Delete Key").font(theme::semibold(15.0)));
|
|
ui.label(format!("Delete key pair \"{name}\"? This cannot be undone."));
|
|
ui.add_space(8.0);
|
|
ui.horizontal(|ui| {
|
|
if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(t.accent_text)).fill(t.danger)).clicked() {
|
|
match s.svc.delete_ssh_key(&name) {
|
|
Ok(()) => s.success(format!("Key \"{name}\" deleted")),
|
|
Err(e) => s.error(format!("Delete failed: {e}")),
|
|
}
|
|
self.stale = true;
|
|
done = true;
|
|
}
|
|
if ui.button("Cancel").clicked() {
|
|
done = true;
|
|
}
|
|
});
|
|
});
|
|
if done || r.should_close() {
|
|
self.confirm_delete = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The generate form; true when Generate is clicked.
|
|
fn generate_form(ui: &mut egui::Ui, f: &mut Generate, busy: bool) -> bool {
|
|
widgets::section(ui, "Generate new key");
|
|
let mut go = false;
|
|
egui::Grid::new("gen-key").num_columns(2).spacing(vec2(14.0, 8.0)).show(ui, |ui| {
|
|
ui.label("Key name");
|
|
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(format!("id_{}_nexus", f.kind)).desired_width(260.0));
|
|
ui.end_row();
|
|
ui.label("Key type");
|
|
let label = |k: &str| match k {
|
|
"rsa" => "RSA",
|
|
"ecdsa" => "ECDSA",
|
|
_ => "Ed25519 (recommended)",
|
|
};
|
|
egui::ComboBox::from_id_salt("key-type").selected_text(label(f.kind)).width(260.0).show_ui(ui, |ui| {
|
|
for k in ["ed25519", "rsa", "ecdsa"] {
|
|
ui.selectable_value(&mut f.kind, k, label(k));
|
|
}
|
|
});
|
|
ui.end_row();
|
|
if f.kind == "rsa" {
|
|
ui.label("Key size");
|
|
ui.horizontal(|ui| {
|
|
ui.selectable_value(&mut f.bits, 4096, "4096");
|
|
ui.selectable_value(&mut f.bits, 2048, "2048");
|
|
});
|
|
ui.end_row();
|
|
}
|
|
ui.label("Passphrase");
|
|
ui.add(egui::TextEdit::singleline(&mut f.pass).password(true).hint_text("Leave blank for no passphrase").desired_width(260.0));
|
|
ui.end_row();
|
|
ui.label("Confirm");
|
|
ui.add(egui::TextEdit::singleline(&mut f.confirm).password(true).desired_width(260.0));
|
|
ui.end_row();
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
if busy {
|
|
ui.spinner();
|
|
ui.label("Generating…");
|
|
} else {
|
|
go = widgets::primary(ui, icon::KEY, "Generate").clicked();
|
|
}
|
|
});
|
|
go
|
|
}
|