From be8500a9deac56dc524f451a75743c47b42337a7 Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:51:51 -0500 Subject: [PATCH 1/6] Implement new tunnel creation wizard and full-tunnel warning feature --- src/app.rs | 240 +++++++++++++++++++++++++++++++++++++++++++++-- src/types.rs | 11 +++ src/ui.rs | 41 ++++++++ src/wireguard.rs | 87 ++++++++++++++++- 4 files changed, 372 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0efb4a5..183532c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,14 +12,14 @@ use ratatui::{ use crate::error::Error; use crate::{ - types::{Message, Tunnel}, + types::{Message, NewTunnelDraft, Tunnel}, ui::{ - bordered_block, label, peer_lines, render_add_menu, render_confirm, render_help, - render_input, section, truncate_key, + bordered_block, label, peer_lines, render_add_menu, render_confirm, + render_full_tunnel_warning, render_help, render_input, section, truncate_key, }, wireguard::{ - delete_tunnel, discover_tunnels, export_tunnels_to_zip, get_interface_info, import_tunnel, - is_interface_active, wg_quick, + create_tunnel, delete_tunnel, discover_tunnels, export_tunnels_to_zip, get_interface_info, + import_tunnel, is_full_tunnel_config, is_interface_active, wg_quick, }, }; @@ -29,9 +29,11 @@ pub struct App { show_details: bool, show_help: bool, confirm_delete: bool, + confirm_full_tunnel: Option, show_add_menu: bool, input_path: Option, export_path: Option, + new_tunnel: Option, message: Option, pub should_quit: bool, } @@ -50,9 +52,11 @@ impl App { show_details: false, show_help: false, confirm_delete: false, + confirm_full_tunnel: None, show_add_menu: false, input_path: None, export_path: None, + new_tunnel: None, message: None, should_quit: false, }; @@ -100,7 +104,23 @@ impl App { }; let (name, active) = (tunnel.name.clone(), tunnel.is_active); - match wg_quick(if active { "down" } else { "up" }, &name) { + if !active && is_full_tunnel_config(&name) { + self.confirm_full_tunnel = Some(name); + return; + } + + self.toggle_selected_with_name(&name); + } + + fn toggle_selected_with_name(&mut self, name: &str) { + let active = self + .tunnels + .iter() + .find(|t| t.name == name) + .map(|t| t.is_active) + .unwrap_or(false); + + match wg_quick(if active { "down" } else { "up" }, name) { Ok(()) => { self.message = Some(Message::Success(format!( "Tunnel '{name}' {}", @@ -160,6 +180,21 @@ impl App { return Ok(()); } + if let Some(ref name) = self.confirm_full_tunnel { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let name = name.clone(); + self.confirm_full_tunnel = None; + self.toggle_selected_with_name(&name); + } + _ => { + self.confirm_full_tunnel = None; + self.message = Some(Message::Info("Enable cancelled".into())); + } + } + return Ok(()); + } + if let Some(ref mut path) = self.input_path { match key.code { KeyCode::Enter => { @@ -220,12 +255,55 @@ impl App { return Ok(()); } + if let Some(ref mut wizard) = self.new_tunnel { + match key.code { + KeyCode::Enter => { + if let Some(err) = wizard.validate_current() { + self.message = Some(Message::Error(err)); + return Ok(()); + } + if let Some(next) = wizard.step.next() { + wizard.step = next; + } else { + let draft = wizard.draft.clone(); + self.new_tunnel = None; + match create_tunnel(&draft) { + Ok(()) => { + let name = draft.name; + self.message = + Some(Message::Success(format!("Tunnel '{name}' created"))); + self.refresh_tunnels(); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), + } + } + } + KeyCode::Esc => { + self.new_tunnel = None; + self.message = Some(Message::Info("Create cancelled".into())); + } + KeyCode::Backspace => { + wizard.current_value_mut().pop(); + } + KeyCode::Char(c) => { + wizard.current_value_mut().push(c); + } + _ => {} + } + return Ok(()); + } + if self.show_add_menu { match key.code { KeyCode::Char('i') | KeyCode::Char('1') => { self.show_add_menu = false; self.input_path = Some(String::new()); } + KeyCode::Char('c') | KeyCode::Char('2') => { + self.show_add_menu = false; + let name = self.default_tunnel_name(); + self.new_tunnel = Some(NewTunnelWizard::new(name)); + } KeyCode::Esc | KeyCode::Char('q') => { self.show_add_menu = false; } @@ -298,6 +376,9 @@ impl App { { render_confirm(frame, &tunnel.name); } + if let Some(ref name) = self.confirm_full_tunnel { + render_full_tunnel_warning(frame, name); + } if self.show_add_menu { render_add_menu(frame); } @@ -331,6 +412,16 @@ impl App { hint.as_deref(), ); } + if let Some(ref wizard) = self.new_tunnel { + let (title, prompt, hint) = wizard.ui(); + render_input( + frame, + &title, + prompt, + wizard.current_value(), + hint.as_deref(), + ); + } } fn render_header(&self, f: &mut Frame, area: Rect) { @@ -440,3 +531,140 @@ impl App { ); } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WizardStep { + Name, + PrivateKey, + Address, + Dns, + PeerPublicKey, + AllowedIps, + Endpoint, +} + +impl WizardStep { + fn next(self) -> Option { + match self { + Self::Name => Some(Self::PrivateKey), + Self::PrivateKey => Some(Self::Address), + Self::Address => Some(Self::Dns), + Self::Dns => Some(Self::PeerPublicKey), + Self::PeerPublicKey => Some(Self::AllowedIps), + Self::AllowedIps => Some(Self::Endpoint), + Self::Endpoint => None, + } + } + + fn index(self) -> usize { + match self { + Self::Name => 1, + Self::PrivateKey => 2, + Self::Address => 3, + Self::Dns => 4, + Self::PeerPublicKey => 5, + Self::AllowedIps => 6, + Self::Endpoint => 7, + } + } +} + +#[derive(Debug, Clone)] +struct NewTunnelWizard { + step: WizardStep, + draft: NewTunnelDraft, +} + +impl NewTunnelWizard { + fn new(name: String) -> Self { + Self { + step: WizardStep::Name, + draft: NewTunnelDraft { + name, + private_key: String::new(), + address: "10.0.0.2/32".into(), + dns: String::new(), + peer_public_key: String::new(), + allowed_ips: "0.0.0.0/0, ::/0".into(), + endpoint: String::new(), + }, + } + } + + fn current_value(&self) -> &str { + match self.step { + WizardStep::Name => &self.draft.name, + WizardStep::PrivateKey => &self.draft.private_key, + WizardStep::Address => &self.draft.address, + WizardStep::Dns => &self.draft.dns, + WizardStep::PeerPublicKey => &self.draft.peer_public_key, + WizardStep::AllowedIps => &self.draft.allowed_ips, + WizardStep::Endpoint => &self.draft.endpoint, + } + } + + fn current_value_mut(&mut self) -> &mut String { + match self.step { + WizardStep::Name => &mut self.draft.name, + WizardStep::PrivateKey => &mut self.draft.private_key, + WizardStep::Address => &mut self.draft.address, + WizardStep::Dns => &mut self.draft.dns, + WizardStep::PeerPublicKey => &mut self.draft.peer_public_key, + WizardStep::AllowedIps => &mut self.draft.allowed_ips, + WizardStep::Endpoint => &mut self.draft.endpoint, + } + } + + fn ui(&self) -> (String, &'static str, Option) { + let title = format!("New Tunnel ({}/7)", self.step.index()); + let (prompt, hint) = match self.step { + WizardStep::Name => ("Interface name:", Some("required".into())), + WizardStep::PrivateKey => ("Private key:", Some("required".into())), + WizardStep::Address => ("Interface address:", Some("example: 10.0.0.2/32".into())), + WizardStep::Dns => ("DNS (optional):", Some("comma-separated".into())), + WizardStep::PeerPublicKey => ("Peer public key:", Some("required".into())), + WizardStep::AllowedIps => { + ("Peer allowed IPs:", Some("default: 0.0.0.0/0, ::/0".into())) + } + WizardStep::Endpoint => ("Peer endpoint:", Some("host:port".into())), + }; + (title, prompt, hint) + } + + fn validate_current(&self) -> Option { + let value = self.current_value().trim(); + match self.step { + WizardStep::Name => { + if value.is_empty() { + return Some("Interface name is required".into()); + } + if value.chars().any(|c| c.is_whitespace() || c == '/') { + return Some("Interface name cannot contain spaces or '/'".into()); + } + } + WizardStep::PrivateKey + | WizardStep::Address + | WizardStep::PeerPublicKey + | WizardStep::AllowedIps + | WizardStep::Endpoint => { + if value.is_empty() { + return Some("Field is required".into()); + } + } + WizardStep::Dns => {} + } + None + } +} + +impl App { + fn default_tunnel_name(&self) -> String { + for i in 0..1000u32 { + let name = format!("wg{i}"); + if !self.tunnels.iter().any(|t| t.name == name) { + return name; + } + } + "wg0".into() + } +} diff --git a/src/types.rs b/src/types.rs index 2a88e1f..27804b3 100644 --- a/src/types.rs +++ b/src/types.rs @@ -27,6 +27,17 @@ pub struct PeerInfo { pub transfer_tx: u64, } +#[derive(Debug, Clone)] +pub struct NewTunnelDraft { + pub name: String, + pub private_key: String, + pub address: String, + pub dns: String, + pub peer_public_key: String, + pub allowed_ips: String, + pub endpoint: String, +} + #[derive(Clone)] pub enum Message { Info(String), diff --git a/src/ui.rs b/src/ui.rs index e89799e..863cc2a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -89,6 +89,41 @@ pub fn render_confirm(f: &mut Frame, name: &str) { ); } +pub fn render_full_tunnel_warning(f: &mut Frame, name: &str) { + let area = centered_rect(60, 30, f.area()); + f.render_widget(Clear, area); + + let lines = vec![ + Line::from("Full-tunnel warning".fg(Color::Yellow).bold()), + Line::raw(""), + Line::from(format!("'{name}'").fg(Color::Cyan)), + Line::raw(""), + Line::from("AllowedIPs includes a default route.".fg(Color::White)), + Line::from("If you're connected via SSH, enabling this".fg(Color::White)), + Line::from("may lock you out of the server.".fg(Color::White)), + Line::raw(""), + Line::from(vec![ + "y".fg(Color::Green).bold(), + " to enable anyway, ".into(), + "any key".fg(Color::Yellow), + " to cancel".into(), + ]), + ]; + + f.render_widget( + Paragraph::new(Text::from(lines)) + .block( + Block::default() + .title(" Warning ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .style(Style::default().bg(Color::Black)) + .alignment(ratatui::layout::Alignment::Center), + area, + ); +} + pub fn render_add_menu(f: &mut Frame) { let area = centered_rect(40, 25, f.area()); f.render_widget(Clear, area); @@ -102,6 +137,12 @@ pub fn render_add_menu(f: &mut Frame) { "1".fg(Color::Yellow).bold(), " Import from file".into(), ]), + Line::from(vec![ + "c".fg(Color::Yellow).bold(), + " / ".into(), + "2".fg(Color::Yellow).bold(), + " Create new".into(), + ]), Line::raw(""), Line::from("Esc".fg(Color::DarkGray).italic()), Line::from("to cancel".fg(Color::DarkGray).italic()), diff --git a/src/wireguard.rs b/src/wireguard.rs index 510bef8..8018873 100644 --- a/src/wireguard.rs +++ b/src/wireguard.rs @@ -9,7 +9,7 @@ use zip::{ZipWriter, write::SimpleFileOptions}; use crate::{ error::Error, - types::{InterfaceInfo, PeerInfo, Tunnel}, + types::{InterfaceInfo, NewTunnelDraft, PeerInfo, Tunnel}, }; const CONFIG_DIR: &str = "/etc/wireguard"; @@ -167,6 +167,82 @@ pub fn export_tunnels_to_zip(dest_path: &str) -> Result { Ok(dest) } +pub fn is_full_tunnel_config(name: &str) -> bool { + let path = Path::new(CONFIG_DIR).join(format!("{name}.conf")); + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + + content.lines().any(|line| { + let line = line.trim(); + if line.starts_with('#') || line.starts_with(';') { + return false; + } + let Some((key, value)) = line.split_once('=') else { + return false; + }; + if key.trim().eq_ignore_ascii_case("AllowedIPs") { + return value + .split(',') + .map(str::trim) + .any(|v| v == "0.0.0.0/0" || v == "::/0"); + } + false + }) +} + +pub fn create_tunnel(draft: &NewTunnelDraft) -> Result<(), Error> { + let name = draft.name.trim(); + if name.is_empty() { + return Err(Error::WgTui("Interface name is required".into())); + } + if name.chars().any(|c| c.is_whitespace() || c == '/') { + return Err(Error::WgTui( + "Interface name cannot contain spaces or '/'".into(), + )); + } + + let private_key = draft.private_key.trim(); + let address = draft.address.trim(); + let peer_public_key = draft.peer_public_key.trim(); + let allowed_ips = normalize_list(&draft.allowed_ips); + let endpoint = draft.endpoint.trim(); + + if private_key.is_empty() + || address.is_empty() + || peer_public_key.is_empty() + || allowed_ips.is_empty() + || endpoint.is_empty() + { + return Err(Error::WgTui("Missing required fields".into())); + } + + fs::create_dir_all(CONFIG_DIR)?; + + let path = Path::new(CONFIG_DIR).join(format!("{name}.conf")); + if path.exists() { + return Err(Error::WgTui(format!("Tunnel '{name}' already exists"))); + } + + let dns = normalize_list(&draft.dns); + + let mut content = String::new(); + content.push_str("[Interface]\n"); + content.push_str(&format!("PrivateKey = {private_key}\n")); + content.push_str(&format!("Address = {address}\n")); + if !dns.is_empty() { + content.push_str(&format!("DNS = {dns}\n")); + } + content.push('\n'); + content.push_str("[Peer]\n"); + content.push_str(&format!("PublicKey = {peer_public_key}\n")); + content.push_str(&format!("AllowedIPs = {allowed_ips}\n")); + content.push_str(&format!("Endpoint = {endpoint}\n")); + + fs::write(path, content)?; + Ok(()) +} + fn parse_wg_output(output: &str) -> InterfaceInfo { let mut info = InterfaceInfo::default(); let mut peer: Option = None; @@ -210,6 +286,15 @@ fn parse_wg_output(output: &str) -> InterfaceInfo { info } +fn normalize_list(value: &str) -> String { + value + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .collect::>() + .join(", ") +} + fn parse_bytes(s: &str) -> u64 { let s = s.replace(" received", "").replace(" sent", ""); let mut parts = s.split_whitespace(); From 61ddba3cf41d05ecbe33c207129a8d5c3d3adb0e Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:10:07 -0500 Subject: [PATCH 2/6] Add server tunnel creation functionality and UI updates --- src/app.rs | 347 +++++++++++++++++++++++++++++++++++++++-------- src/types.rs | 9 ++ src/ui.rs | 10 +- src/wireguard.rs | 180 +++++++++++++++++++++++- 4 files changed, 488 insertions(+), 58 deletions(-) diff --git a/src/app.rs b/src/app.rs index 183532c..fc7e7a5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,14 +12,16 @@ use ratatui::{ use crate::error::Error; use crate::{ - types::{Message, NewTunnelDraft, Tunnel}, + types::{Message, NewServerDraft, NewTunnelDraft, Tunnel}, ui::{ bordered_block, label, peer_lines, render_add_menu, render_confirm, render_full_tunnel_warning, render_help, render_input, section, truncate_key, }, wireguard::{ - create_tunnel, delete_tunnel, discover_tunnels, export_tunnels_to_zip, get_interface_info, - import_tunnel, is_full_tunnel_config, is_interface_active, wg_quick, + create_server_tunnel, create_tunnel, default_egress_interface, delete_tunnel, + discover_tunnels, export_tunnels_to_zip, generate_private_key, get_interface_info, + import_tunnel, is_full_tunnel_config, is_interface_active, suggest_server_address, + wg_quick, }, }; @@ -258,23 +260,42 @@ impl App { if let Some(ref mut wizard) = self.new_tunnel { match key.code { KeyCode::Enter => { - if let Some(err) = wizard.validate_current() { - self.message = Some(Message::Error(err)); - return Ok(()); - } - if let Some(next) = wizard.step.next() { - wizard.step = next; - } else { - let draft = wizard.draft.clone(); - self.new_tunnel = None; - match create_tunnel(&draft) { - Ok(()) => { - let name = draft.name; - self.message = - Some(Message::Success(format!("Tunnel '{name}' created"))); - self.refresh_tunnels(); + let finished = { + if let Some(err) = wizard.validate_current() { + self.message = Some(Message::Error(err)); + return Ok(()); + } + wizard.advance() + }; + if finished { + let wizard = self.new_tunnel.take().unwrap(); + match wizard { + NewTunnelWizard::Client(wizard) => { + let draft = wizard.draft; + match create_tunnel(&draft) { + Ok(()) => { + let name = draft.name; + self.message = Some(Message::Success(format!( + "Tunnel '{name}' created" + ))); + self.refresh_tunnels(); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), + } + } + NewTunnelWizard::Server(wizard) => { + let draft = wizard.draft; + match create_server_tunnel(&draft) { + Ok(()) => { + let name = draft.name; + self.message = Some(Message::Success(format!( + "Tunnel '{name}' created" + ))); + self.refresh_tunnels(); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), + } } - Err(e) => self.message = Some(Message::Error(e.to_string())), } } } @@ -302,7 +323,27 @@ impl App { KeyCode::Char('c') | KeyCode::Char('2') => { self.show_add_menu = false; let name = self.default_tunnel_name(); - self.new_tunnel = Some(NewTunnelWizard::new(name)); + self.new_tunnel = Some(NewTunnelWizard::client(name)); + } + KeyCode::Char('s') | KeyCode::Char('3') => { + self.show_add_menu = false; + let name = self.default_tunnel_name(); + let address = suggest_server_address(); + let egress = default_egress_interface().unwrap_or_default(); + let private_key = match generate_private_key() { + Ok(key) => key, + Err(e) => { + self.message = Some(Message::Error(e.to_string())); + return Ok(()); + } + }; + self.new_tunnel = Some(NewTunnelWizard::server( + name, + address, + "51820".into(), + private_key, + egress, + )); } KeyCode::Esc | KeyCode::Char('q') => { self.show_add_menu = false; @@ -532,8 +573,71 @@ impl App { } } +#[derive(Debug, Clone)] +enum NewTunnelWizard { + Client(NewClientWizard), + Server(NewServerWizard), +} + +impl NewTunnelWizard { + fn client(name: String) -> Self { + Self::Client(NewClientWizard::new(name)) + } + + fn server( + name: String, + address: String, + listen_port: String, + private_key: String, + egress_interface: String, + ) -> Self { + Self::Server(NewServerWizard::new( + name, + address, + listen_port, + private_key, + egress_interface, + )) + } + + fn current_value(&self) -> &str { + match self { + Self::Client(wizard) => wizard.current_value(), + Self::Server(wizard) => wizard.current_value(), + } + } + + fn current_value_mut(&mut self) -> &mut String { + match self { + Self::Client(wizard) => wizard.current_value_mut(), + Self::Server(wizard) => wizard.current_value_mut(), + } + } + + fn ui(&self) -> (String, &'static str, Option) { + match self { + Self::Client(wizard) => wizard.ui(), + Self::Server(wizard) => wizard.ui(), + } + } + + fn validate_current(&self) -> Option { + match self { + Self::Client(wizard) => wizard.validate_current(), + Self::Server(wizard) => wizard.validate_current(), + } + } + + fn advance(&mut self) -> bool { + match self { + Self::Client(wizard) => wizard.advance(), + Self::Server(wizard) => wizard.advance(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WizardStep { +enum ClientWizardStep { Name, PrivateKey, Address, @@ -543,7 +647,7 @@ enum WizardStep { Endpoint, } -impl WizardStep { +impl ClientWizardStep { fn next(self) -> Option { match self { Self::Name => Some(Self::PrivateKey), @@ -570,15 +674,15 @@ impl WizardStep { } #[derive(Debug, Clone)] -struct NewTunnelWizard { - step: WizardStep, +struct NewClientWizard { + step: ClientWizardStep, draft: NewTunnelDraft, } -impl NewTunnelWizard { +impl NewClientWizard { fn new(name: String) -> Self { Self { - step: WizardStep::Name, + step: ClientWizardStep::Name, draft: NewTunnelDraft { name, private_key: String::new(), @@ -593,40 +697,167 @@ impl NewTunnelWizard { fn current_value(&self) -> &str { match self.step { - WizardStep::Name => &self.draft.name, - WizardStep::PrivateKey => &self.draft.private_key, - WizardStep::Address => &self.draft.address, - WizardStep::Dns => &self.draft.dns, - WizardStep::PeerPublicKey => &self.draft.peer_public_key, - WizardStep::AllowedIps => &self.draft.allowed_ips, - WizardStep::Endpoint => &self.draft.endpoint, + ClientWizardStep::Name => &self.draft.name, + ClientWizardStep::PrivateKey => &self.draft.private_key, + ClientWizardStep::Address => &self.draft.address, + ClientWizardStep::Dns => &self.draft.dns, + ClientWizardStep::PeerPublicKey => &self.draft.peer_public_key, + ClientWizardStep::AllowedIps => &self.draft.allowed_ips, + ClientWizardStep::Endpoint => &self.draft.endpoint, } } fn current_value_mut(&mut self) -> &mut String { match self.step { - WizardStep::Name => &mut self.draft.name, - WizardStep::PrivateKey => &mut self.draft.private_key, - WizardStep::Address => &mut self.draft.address, - WizardStep::Dns => &mut self.draft.dns, - WizardStep::PeerPublicKey => &mut self.draft.peer_public_key, - WizardStep::AllowedIps => &mut self.draft.allowed_ips, - WizardStep::Endpoint => &mut self.draft.endpoint, + ClientWizardStep::Name => &mut self.draft.name, + ClientWizardStep::PrivateKey => &mut self.draft.private_key, + ClientWizardStep::Address => &mut self.draft.address, + ClientWizardStep::Dns => &mut self.draft.dns, + ClientWizardStep::PeerPublicKey => &mut self.draft.peer_public_key, + ClientWizardStep::AllowedIps => &mut self.draft.allowed_ips, + ClientWizardStep::Endpoint => &mut self.draft.endpoint, } } fn ui(&self) -> (String, &'static str, Option) { - let title = format!("New Tunnel ({}/7)", self.step.index()); + let title = format!("New Tunnel (Client {}/7)", self.step.index()); let (prompt, hint) = match self.step { - WizardStep::Name => ("Interface name:", Some("required".into())), - WizardStep::PrivateKey => ("Private key:", Some("required".into())), - WizardStep::Address => ("Interface address:", Some("example: 10.0.0.2/32".into())), - WizardStep::Dns => ("DNS (optional):", Some("comma-separated".into())), - WizardStep::PeerPublicKey => ("Peer public key:", Some("required".into())), - WizardStep::AllowedIps => { + ClientWizardStep::Name => ("Interface name:", Some("required".into())), + ClientWizardStep::PrivateKey => ("Private key:", Some("required".into())), + ClientWizardStep::Address => { + ("Interface address:", Some("example: 10.0.0.2/32".into())) + } + ClientWizardStep::Dns => ("DNS (optional):", Some("comma-separated".into())), + ClientWizardStep::PeerPublicKey => ("Peer public key:", Some("required".into())), + ClientWizardStep::AllowedIps => { ("Peer allowed IPs:", Some("default: 0.0.0.0/0, ::/0".into())) } - WizardStep::Endpoint => ("Peer endpoint:", Some("host:port".into())), + ClientWizardStep::Endpoint => ("Peer endpoint:", Some("host:port".into())), + }; + (title, prompt, hint) + } + + fn validate_current(&self) -> Option { + let value = self.current_value().trim(); + match self.step { + ClientWizardStep::Name => { + if value.is_empty() { + return Some("Interface name is required".into()); + } + if value.chars().any(|c| c.is_whitespace() || c == '/') { + return Some("Interface name cannot contain spaces or '/'".into()); + } + } + ClientWizardStep::PrivateKey + | ClientWizardStep::Address + | ClientWizardStep::PeerPublicKey + | ClientWizardStep::AllowedIps + | ClientWizardStep::Endpoint => { + if value.is_empty() { + return Some("Field is required".into()); + } + } + ClientWizardStep::Dns => {} + } + None + } + + fn advance(&mut self) -> bool { + if let Some(next) = self.step.next() { + self.step = next; + false + } else { + true + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ServerWizardStep { + Name, + Address, + ListenPort, + EgressInterface, +} + +impl ServerWizardStep { + fn next(self) -> Option { + match self { + Self::Name => Some(Self::Address), + Self::Address => Some(Self::ListenPort), + Self::ListenPort => Some(Self::EgressInterface), + Self::EgressInterface => None, + } + } + + fn index(self) -> usize { + match self { + Self::Name => 1, + Self::Address => 2, + Self::ListenPort => 3, + Self::EgressInterface => 4, + } + } +} + +#[derive(Debug, Clone)] +struct NewServerWizard { + step: ServerWizardStep, + draft: NewServerDraft, +} + +impl NewServerWizard { + fn new( + name: String, + address: String, + listen_port: String, + private_key: String, + egress_interface: String, + ) -> Self { + Self { + step: ServerWizardStep::Name, + draft: NewServerDraft { + name, + private_key, + address, + listen_port, + egress_interface, + }, + } + } + + fn current_value(&self) -> &str { + match self.step { + ServerWizardStep::Name => &self.draft.name, + ServerWizardStep::Address => &self.draft.address, + ServerWizardStep::ListenPort => &self.draft.listen_port, + ServerWizardStep::EgressInterface => &self.draft.egress_interface, + } + } + + fn current_value_mut(&mut self) -> &mut String { + match self.step { + ServerWizardStep::Name => &mut self.draft.name, + ServerWizardStep::Address => &mut self.draft.address, + ServerWizardStep::ListenPort => &mut self.draft.listen_port, + ServerWizardStep::EgressInterface => &mut self.draft.egress_interface, + } + } + + fn ui(&self) -> (String, &'static str, Option) { + let title = format!("New Tunnel (Server {}/4)", self.step.index()); + let (prompt, hint) = match self.step { + ServerWizardStep::Name => ("Interface name:", Some("required".into())), + ServerWizardStep::Address => ("Server address:", Some("example: 10.0.0.1/32".into())), + ServerWizardStep::ListenPort => ("Listen port:", Some("default: 51820".into())), + ServerWizardStep::EgressInterface => { + let hint = if self.draft.egress_interface.is_empty() { + "required".into() + } else { + format!("detected: {}", self.draft.egress_interface) + }; + ("Egress interface:", Some(hint)) + } }; (title, prompt, hint) } @@ -634,7 +865,7 @@ impl NewTunnelWizard { fn validate_current(&self) -> Option { let value = self.current_value().trim(); match self.step { - WizardStep::Name => { + ServerWizardStep::Name => { if value.is_empty() { return Some("Interface name is required".into()); } @@ -642,19 +873,25 @@ impl NewTunnelWizard { return Some("Interface name cannot contain spaces or '/'".into()); } } - WizardStep::PrivateKey - | WizardStep::Address - | WizardStep::PeerPublicKey - | WizardStep::AllowedIps - | WizardStep::Endpoint => { + ServerWizardStep::Address + | ServerWizardStep::ListenPort + | ServerWizardStep::EgressInterface => { if value.is_empty() { return Some("Field is required".into()); } } - WizardStep::Dns => {} } None } + + fn advance(&mut self) -> bool { + if let Some(next) = self.step.next() { + self.step = next; + false + } else { + true + } + } } impl App { diff --git a/src/types.rs b/src/types.rs index 27804b3..b575226 100644 --- a/src/types.rs +++ b/src/types.rs @@ -38,6 +38,15 @@ pub struct NewTunnelDraft { pub endpoint: String, } +#[derive(Debug, Clone)] +pub struct NewServerDraft { + pub name: String, + pub private_key: String, + pub address: String, + pub listen_port: String, + pub egress_interface: String, +} + #[derive(Clone)] pub enum Message { Info(String), diff --git a/src/ui.rs b/src/ui.rs index 863cc2a..4dbe23c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -125,7 +125,7 @@ pub fn render_full_tunnel_warning(f: &mut Frame, name: &str) { } pub fn render_add_menu(f: &mut Frame) { - let area = centered_rect(40, 25, f.area()); + let area = centered_rect(48, 32, f.area()); f.render_widget(Clear, area); let lines = vec![ @@ -141,7 +141,13 @@ pub fn render_add_menu(f: &mut Frame) { "c".fg(Color::Yellow).bold(), " / ".into(), "2".fg(Color::Yellow).bold(), - " Create new".into(), + " Create client".into(), + ]), + Line::from(vec![ + "s".fg(Color::Yellow).bold(), + " / ".into(), + "3".fg(Color::Yellow).bold(), + " Create server".into(), ]), Line::raw(""), Line::from("Esc".fg(Color::DarkGray).italic()), diff --git a/src/wireguard.rs b/src/wireguard.rs index 8018873..b3c8f69 100644 --- a/src/wireguard.rs +++ b/src/wireguard.rs @@ -1,6 +1,8 @@ use std::{ + collections::HashSet, fs, io::Write, + net::Ipv4Addr, path::{Path, PathBuf}, process::Command, }; @@ -9,7 +11,7 @@ use zip::{ZipWriter, write::SimpleFileOptions}; use crate::{ error::Error, - types::{InterfaceInfo, NewTunnelDraft, PeerInfo, Tunnel}, + types::{InterfaceInfo, NewServerDraft, NewTunnelDraft, PeerInfo, Tunnel}, }; const CONFIG_DIR: &str = "/etc/wireguard"; @@ -61,6 +63,127 @@ pub fn discover_tunnels() -> Vec { tunnels } +pub fn generate_private_key() -> Result { + let output = Command::new(CMD_WG).arg("genkey").output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = stderr.trim(); + return Err(Error::WgTui(if msg.is_empty() { + "wg genkey failed".into() + } else { + msg.to_string() + })); + } + let key = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if key.is_empty() { + return Err(Error::WgTui("wg genkey returned an empty key".into())); + } + Ok(key) +} + +pub fn default_egress_interface() -> Option { + let outputs = [ + Command::new(CMD_IP) + .args(["-4", "route", "show", "default"]) + .output() + .ok(), + Command::new(CMD_IP) + .args(["route", "show", "default"]) + .output() + .ok(), + ]; + + for output in outputs.into_iter().flatten() { + if !output.status.success() { + continue; + } + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(dev) = parse_default_route_dev(&stdout) { + return Some(dev); + } + } + None +} + +fn parse_default_route_dev(output: &str) -> Option { + for line in output.lines().map(str::trim) { + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + while let Some(part) = parts.next() { + if part == "dev" { + return parts.next().map(str::to_string); + } + } + } + None +} + +pub fn suggest_server_address() -> String { + let used = used_interface_ipv4_addresses(); + for i in 0u8..=255 { + let candidate = Ipv4Addr::new(10, 0, i, 1); + if !used.contains(&candidate) { + return format!("{candidate}/32"); + } + } + "10.0.0.1/32".into() +} + +fn used_interface_ipv4_addresses() -> HashSet { + let mut used = HashSet::new(); + for tunnel in discover_tunnels() { + if let Ok(content) = fs::read_to_string(&tunnel.config_path) { + for addr in parse_interface_addresses(&content) { + if let Some(ip) = parse_ipv4_address(&addr) { + used.insert(ip); + } + } + } + } + used +} + +fn parse_interface_addresses(content: &str) -> Vec { + let mut addrs = Vec::new(); + let mut in_interface = false; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_interface = line.eq_ignore_ascii_case("[Interface]"); + continue; + } + if !in_interface { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("Address") { + addrs.extend( + value + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string), + ); + } + } + + addrs +} + +fn parse_ipv4_address(value: &str) -> Option { + let value = value.trim(); + let ip = value.split_once('/').map(|(ip, _)| ip).unwrap_or(value); + ip.parse().ok() +} + pub fn is_interface_active(name: &str) -> bool { Command::new(CMD_IP) .arg("link") @@ -243,6 +366,61 @@ pub fn create_tunnel(draft: &NewTunnelDraft) -> Result<(), Error> { Ok(()) } +pub fn create_server_tunnel(draft: &NewServerDraft) -> Result<(), Error> { + let name = draft.name.trim(); + if name.is_empty() { + return Err(Error::WgTui("Interface name is required".into())); + } + if name.chars().any(|c| c.is_whitespace() || c == '/') { + return Err(Error::WgTui( + "Interface name cannot contain spaces or '/'".into(), + )); + } + + let private_key = draft.private_key.trim(); + let address = draft.address.trim(); + let listen_port = draft.listen_port.trim(); + let egress_interface = draft.egress_interface.trim(); + + if private_key.is_empty() + || address.is_empty() + || listen_port.is_empty() + || egress_interface.is_empty() + { + return Err(Error::WgTui("Missing required fields".into())); + } + + let listen_port: u16 = listen_port + .parse() + .map_err(|_| Error::WgTui("Listen port must be a valid number".into()))?; + + fs::create_dir_all(CONFIG_DIR)?; + + let path = Path::new(CONFIG_DIR).join(format!("{name}.conf")); + if path.exists() { + return Err(Error::WgTui(format!("Tunnel '{name}' already exists"))); + } + + let post_up = format!( + "iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o {egress_interface} -j MASQUERADE" + ); + let post_down = format!( + "iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o {egress_interface} -j MASQUERADE" + ); + + let mut content = String::new(); + content.push_str("[Interface]\n"); + content.push_str(&format!("Address = {address}\n")); + content.push_str("SaveConfig = true\n"); + content.push_str(&format!("PostUp = {post_up}\n")); + content.push_str(&format!("PostDown = {post_down}\n")); + content.push_str(&format!("ListenPort = {listen_port}\n")); + content.push_str(&format!("PrivateKey = {private_key}\n")); + + fs::write(path, content)?; + Ok(()) +} + fn parse_wg_output(output: &str) -> InterfaceInfo { let mut info = InterfaceInfo::default(); let mut peer: Option = None; From 94b99da02f463cf5857cec9be3d78b3be69dc9bd Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:00:18 -0500 Subject: [PATCH 3/6] feat: add peer configuration management and QR code support - Updated dependencies in Cargo.toml for clap, nix, ratatui, zip, and added qrcode and tui-qrcode. - Enhanced App struct to manage peer configuration inputs and states. - Implemented peer configuration saving functionality with file existence checks. - Added user input handling for peer endpoint and DNS configuration. - Integrated QR code generation for peer configurations. - Created new UI rendering functions for displaying peer configurations and QR codes. - Introduced new types for managing peer configuration state and pending peer configurations. - Added functionality to detect public IP and generate key pairs for new peers. - Implemented logic to add server peers to existing WireGuard configurations. --- Cargo.lock | 1214 ++++++++++++++++++++++++++++++++++++++-------- Cargo.toml | 9 +- src/app.rs | 253 +++++++++- src/types.rs | 7 + src/ui.rs | 87 +++- src/wireguard.rs | 274 ++++++++++- 6 files changed, 1615 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f89456c..86c5200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -59,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -70,18 +79,30 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] -name = "arbitrary" -version = "1.4.2" +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "atomic" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ - "derive_arbitrary", + "bytemuck", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -97,23 +118,65 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "cassowary" -version = "0.3.0" +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "castaway" @@ -138,9 +201,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", "clap_derive", @@ -148,9 +211,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstream", "anstyle", @@ -160,14 +223,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -211,9 +274,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -233,28 +296,21 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "cfg-if", + "libc", ] [[package]] -name = "crossterm" -version = "0.28.1" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", + "cfg-if", ] [[package]] @@ -263,14 +319,14 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", "futures-core", "mio", "parking_lot", - "rustix 1.1.2", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -285,6 +341,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.20.11" @@ -306,7 +382,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.114", ] [[package]] @@ -317,18 +393,22 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.114", ] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "deltae" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ - "proc-macro2", - "quote", - "syn", + "powerfmt", ] [[package]] @@ -349,7 +429,17 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -380,7 +470,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", ] [[package]] @@ -393,6 +492,39 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.8" @@ -412,9 +544,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures-core" @@ -422,6 +554,28 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gimli" version = "0.32.3" @@ -430,33 +584,45 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + [[package]] name = "indenter" version = "0.3.4" @@ -465,12 +631,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -492,7 +658,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -503,9 +669,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -516,6 +682,33 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -524,15 +717,18 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] [[package]] name = "linux-raw-sys" @@ -557,17 +753,27 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "hashbrown 0.15.5", + "nix 0.29.0", + "winapi", ] [[package]] @@ -576,6 +782,27 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -595,21 +822,89 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] name = "nix" -version = "0.30.1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -631,6 +926,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "owo-colors" version = "4.2.3" @@ -661,95 +965,315 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "pest" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "pest_derive" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ - "unicode-ident", + "pest", + "pest_generator", ] [[package]] -name = "quote" -version = "1.0.43" +name = "pest_generator" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ + "pest", + "pest_meta", "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "ratatui" -version = "0.29.0" +name = "pest_meta" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm 0.28.1", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "pest", + "sha2", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "bitflags", + "phf_macros", + "phf_shared", ] [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] [[package]] -name = "rustix" -version = "0.38.44" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "phf_shared", + "rand", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -770,6 +1294,47 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -811,9 +1376,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "smallvec" @@ -835,24 +1406,34 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -866,13 +1447,96 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -883,7 +1547,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -895,6 +1559,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tracing" version = "0.1.41" @@ -936,6 +1621,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -950,21 +1653,15 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.0" @@ -977,12 +1674,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -990,139 +1714,199 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wg-tui" -version = "0.1.15" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "clap", - "color-eyre", - "crossterm 0.29.0", - "nix", - "ratatui", - "thiserror", - "zip", + "wit-bindgen", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] [[package]] -name = "windows-link" -version = "0.2.1" +name = "wasm-bindgen-shared" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] [[package]] -name = "windows-sys" -version = "0.59.0" +name = "wezterm-bidi" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" dependencies = [ - "windows-targets", + "log", + "wezterm-dynamic", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "wezterm-blob-leases" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ - "windows-link", + "getrandom", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "wezterm-color-types" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "wezterm-dynamic" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "wezterm-dynamic-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "wg-tui" +version = "0.1.15" +dependencies = [ + "clap", + "color-eyre", + "crossterm", + "nix 0.31.1", + "qrcode", + "ratatui", + "thiserror 2.0.18", + "zip", +] + +[[package]] +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "zip" -version = "6.0.0" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap", "memchr", + "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index cab7c4f..693938c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,11 @@ readme = "README.md" license = "MIT" [dependencies] -clap = { version = "4.5.54", features = ["derive"] } +clap = { version = "4.5.56", features = ["derive"] } color-eyre = "0.6.5" crossterm = { version = "0.29.0", features = ["event-stream"] } -nix = { version = "0.30.1", features = ["user"] } -ratatui = "0.29.0" +nix = { version = "0.31.1", features = ["user"] } +qrcode = "0.14.1" +ratatui = "0.30.0" thiserror = "2.0.18" -zip = { version = "6.0.0", default-features = false, features = ["deflate"] } +zip = { version = "7.2.0", default-features = false, features = ["deflate"] } diff --git a/src/app.rs b/src/app.rs index fc7e7a5..e52c646 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ -use std::time::Duration; +use std::{fs, time::Duration}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use qrcode::QrCode; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -15,13 +16,14 @@ use crate::{ types::{Message, NewServerDraft, NewTunnelDraft, Tunnel}, ui::{ bordered_block, label, peer_lines, render_add_menu, render_confirm, - render_full_tunnel_warning, render_help, render_input, section, truncate_key, + render_full_tunnel_warning, render_help, render_input, render_peer_config, render_peer_qr, + section, truncate_key, }, wireguard::{ - create_server_tunnel, create_tunnel, default_egress_interface, delete_tunnel, - discover_tunnels, export_tunnels_to_zip, generate_private_key, get_interface_info, - import_tunnel, is_full_tunnel_config, is_interface_active, suggest_server_address, - wg_quick, + add_server_peer, create_server_tunnel, create_tunnel, default_egress_interface, + delete_tunnel, detect_public_ip, discover_tunnels, expand_path, export_tunnels_to_zip, + generate_private_key, get_interface_info, import_tunnel, is_full_tunnel_config, + is_interface_active, suggest_server_address, wg_quick, }, }; @@ -36,6 +38,11 @@ pub struct App { input_path: Option, export_path: Option, new_tunnel: Option, + pending_peer: Option, + peer_endpoint_input: Option, + peer_dns_input: Option, + peer_config: Option, + peer_save_path: Option, message: Option, pub should_quit: bool, } @@ -59,6 +66,11 @@ impl App { input_path: None, export_path: None, new_tunnel: None, + pending_peer: None, + peer_endpoint_input: None, + peer_dns_input: None, + peer_config: None, + peer_save_path: None, message: None, should_quit: false, }; @@ -197,6 +209,44 @@ impl App { return Ok(()); } + if let Some(ref mut path) = self.peer_save_path { + match key.code { + KeyCode::Enter => { + let path_str = path.clone(); + self.peer_save_path = None; + let Some(peer) = &self.peer_config else { + return Ok(()); + }; + let dest = expand_path(&path_str); + if dest.exists() { + self.message = Some(Message::Error("File already exists".into())); + return Ok(()); + } + match fs::write(&dest, &peer.config_text) { + Ok(()) => { + self.message = Some(Message::Success(format!( + "Peer config saved to {}", + dest.display() + ))); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), + } + } + KeyCode::Esc => { + self.peer_save_path = None; + self.message = Some(Message::Info("Save cancelled".into())); + } + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => { + path.push(c); + } + _ => {} + } + return Ok(()); + } + if let Some(ref mut path) = self.input_path { match key.code { KeyCode::Enter => { @@ -257,6 +307,73 @@ impl App { return Ok(()); } + if let Some(ref mut endpoint) = self.peer_endpoint_input { + match key.code { + KeyCode::Enter => { + let endpoint_str = endpoint.trim().to_string(); + if endpoint_str.is_empty() { + self.message = Some(Message::Error("Endpoint is required".into())); + return Ok(()); + } + if let Some(pending) = self.pending_peer.as_mut() { + pending.endpoint = endpoint_str; + } + self.peer_endpoint_input = None; + self.peer_dns_input = Some(String::new()); + } + KeyCode::Esc => { + self.peer_endpoint_input = None; + self.pending_peer = None; + self.message = Some(Message::Info("Peer config cancelled".into())); + } + KeyCode::Backspace => { + endpoint.pop(); + } + KeyCode::Char(c) => { + endpoint.push(c); + } + _ => {} + } + return Ok(()); + } + + if let Some(ref mut dns) = self.peer_dns_input { + match key.code { + KeyCode::Enter => { + let dns_str = dns.trim().to_string(); + let Some(pending) = self.pending_peer.take() else { + self.peer_dns_input = None; + return Ok(()); + }; + let dns_block = if dns_str.is_empty() { + String::new() + } else { + format!("DNS = {dns_str}\n") + }; + let config_text = pending + .template + .replace("__ENDPOINT__", &pending.endpoint) + .replace("__DNS_BLOCK__", &dns_block); + self.peer_config = + Some(PeerConfigState::new(config_text, pending.suggested_path)); + self.peer_dns_input = None; + } + KeyCode::Esc => { + self.peer_dns_input = None; + self.pending_peer = None; + self.message = Some(Message::Info("Peer config cancelled".into())); + } + KeyCode::Backspace => { + dns.pop(); + } + KeyCode::Char(c) => { + dns.push(c); + } + _ => {} + } + return Ok(()); + } + if let Some(ref mut wizard) = self.new_tunnel { match key.code { KeyCode::Enter => { @@ -314,6 +431,35 @@ impl App { return Ok(()); } + if let Some(ref mut peer) = self.peer_config { + match key.code { + KeyCode::Char('s') => { + self.peer_save_path = Some(peer.suggested_path.clone()); + peer.show_qr = false; + } + KeyCode::Char('q') => { + match QrCode::new(peer.config_text.as_bytes()) { + Ok(code) => { + peer.qr_code = Some(code); + peer.show_qr = true; + } + Err(_) => { + peer.show_qr = false; + self.message = Some(Message::Error("QR data is too large".into())); + } + }; + } + KeyCode::Char('b') => { + peer.show_qr = false; + } + KeyCode::Esc => { + self.peer_config = None; + } + _ => {} + } + return Ok(()); + } + if self.show_add_menu { match key.code { KeyCode::Char('i') | KeyCode::Char('1') => { @@ -370,6 +516,27 @@ impl App { } } (KeyCode::Char('a'), _) => self.show_add_menu = true, + (KeyCode::Char('p'), _) => { + let Some(tunnel) = self.selected() else { + return Ok(()); + }; + match add_server_peer(&tunnel.name) { + Ok(peer) => { + let endpoint = detect_public_ip() + .map(|ip| format!("{ip}:{}", peer.listen_port)) + .unwrap_or_default(); + self.pending_peer = Some(PendingPeerConfig::new( + peer.client_config_template, + peer.suggested_filename, + endpoint.clone(), + )); + self.peer_endpoint_input = Some(endpoint); + self.message = Some(Message::Success("Peer added".into())); + self.refresh_tunnels(); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), + } + } (KeyCode::Char('e'), _) => { if self.tunnels.is_empty() { self.message = Some(Message::Error("No tunnels to export".into())); @@ -463,6 +630,44 @@ impl App { hint.as_deref(), ); } + if let Some(ref endpoint) = self.peer_endpoint_input { + render_input( + frame, + "Peer Endpoint", + "Endpoint (host:port):", + endpoint, + Some("Confirm or edit the server address"), + ); + } + if let Some(ref dns) = self.peer_dns_input { + render_input( + frame, + "Peer DNS", + "DNS (optional):", + dns, + Some("Leave empty to skip"), + ); + } + if let Some(ref peer) = self.peer_config { + if peer.show_qr { + if let Some(code) = peer.qr_code.as_ref() { + render_peer_qr(frame, code); + } else { + render_peer_config(frame, &peer.config_text, &peer.suggested_path); + } + } else { + render_peer_config(frame, &peer.config_text, &peer.suggested_path); + } + } + if let Some(ref path) = self.peer_save_path { + render_input( + frame, + "Save Peer Config", + "Destination (.conf):", + path, + Some("Press Enter to save"), + ); + } } fn render_header(&self, f: &mut Frame, area: Rect) { @@ -573,6 +778,42 @@ impl App { } } +#[derive(Clone)] +struct PeerConfigState { + config_text: String, + suggested_path: String, + show_qr: bool, + qr_code: Option, +} + +impl PeerConfigState { + fn new(config_text: String, suggested_path: String) -> Self { + Self { + config_text, + suggested_path, + show_qr: false, + qr_code: None, + } + } +} + +#[derive(Debug, Clone)] +struct PendingPeerConfig { + template: String, + suggested_path: String, + endpoint: String, +} + +impl PendingPeerConfig { + fn new(template: String, suggested_path: String, endpoint: String) -> Self { + Self { + template, + suggested_path, + endpoint, + } + } +} + #[derive(Debug, Clone)] enum NewTunnelWizard { Client(NewClientWizard), diff --git a/src/types.rs b/src/types.rs index b575226..a318085 100644 --- a/src/types.rs +++ b/src/types.rs @@ -47,6 +47,13 @@ pub struct NewServerDraft { pub egress_interface: String, } +#[derive(Debug, Clone)] +pub struct PeerConfig { + pub client_config_template: String, + pub suggested_filename: String, + pub listen_port: u16, +} + #[derive(Clone)] pub enum Message { Info(String), diff --git a/src/ui.rs b/src/ui.rs index 4dbe23c..ab65368 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,10 @@ +use qrcode::{QrCode, render::unicode}; use ratatui::{ Frame, - layout::{Constraint, Layout, Rect}, + layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Text}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use crate::types::PeerInfo; @@ -204,6 +205,87 @@ pub fn render_input(f: &mut Frame, title: &str, prompt: &str, value: &str, hint: ); } +pub fn render_peer_config(f: &mut Frame, config: &str, suggested_path: &str) { + let area = centered_rect(80, 70, f.area()); + f.render_widget(Clear, area); + + let mut lines: Vec = vec![ + Line::from("New Peer Config".fg(Color::Cyan).bold()), + Line::raw(""), + ]; + lines.extend(config.lines().map(Line::raw)); + lines.push(Line::raw("")); + lines.push(Line::from(format!("Suggested file: {suggested_path}"))); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + "s".fg(Color::Green).bold(), + " save ".into(), + "q".fg(Color::Yellow).bold(), + " qr ".into(), + "Esc".fg(Color::DarkGray), + " close".into(), + ])); + + f.render_widget( + Paragraph::new(Text::from(lines)) + .block( + Block::default() + .title(" Peer ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .style(Style::default().bg(Color::Black)) + .wrap(Wrap { trim: false }), + area, + ); +} + +pub fn render_peer_qr(f: &mut Frame, qr: &QrCode) { + // Render QR code to string with proper aspect ratio using Dense1x2 + let qr_string = qr + .render::() + .dark_color(unicode::Dense1x2::Dark) + .light_color(unicode::Dense1x2::Light) + .build(); + + let qr_lines: Vec = qr_string.lines().map(Line::raw).collect(); + let qr_width = qr_lines.first().map(|l| l.width()).unwrap_or(0) as u16; + let qr_height = qr_lines.len() as u16; + + // Size the box to fit the QR code plus border and footer + let box_width = (qr_width + 4).min(f.area().width); + let box_height = (qr_height + 4).min(f.area().height); // +4 for border and footer line + + // Center the box + let x = f.area().x + (f.area().width.saturating_sub(box_width)) / 2; + let y = f.area().y + (f.area().height.saturating_sub(box_height)) / 2; + let area = Rect::new(x, y, box_width, box_height); + + f.render_widget(Clear, area); + + let mut lines = qr_lines; + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + "b".fg(Color::Yellow).bold(), + " back ".into(), + "Esc".fg(Color::DarkGray), + " close".into(), + ])); + + f.render_widget( + Paragraph::new(Text::from(lines)) + .block( + Block::default() + .title(" Peer Config QR ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .style(Style::default().bg(Color::Black)) + .alignment(Alignment::Center), + area, + ); +} + pub fn render_help(f: &mut Frame) { let area = centered_rect(50, 60, f.area()); f.render_widget(Clear, area); @@ -217,6 +299,7 @@ pub fn render_help(f: &mut Frame) { ("a", "Add tunnel"), ("e", "Export all tunnels to zip"), ("x", "Delete tunnel"), + ("p", "Add peer (server only)"), ("r", "Refresh"), ("?", "Help"), ("q", "Quit"), diff --git a/src/wireguard.rs b/src/wireguard.rs index b3c8f69..a56640f 100644 --- a/src/wireguard.rs +++ b/src/wireguard.rs @@ -2,7 +2,7 @@ use std::{ collections::HashSet, fs, io::Write, - net::Ipv4Addr, + net::{IpAddr, Ipv4Addr}, path::{Path, PathBuf}, process::Command, }; @@ -11,7 +11,7 @@ use zip::{ZipWriter, write::SimpleFileOptions}; use crate::{ error::Error, - types::{InterfaceInfo, NewServerDraft, NewTunnelDraft, PeerInfo, Tunnel}, + types::{InterfaceInfo, NewServerDraft, NewTunnelDraft, PeerConfig, PeerInfo, Tunnel}, }; const CONFIG_DIR: &str = "/etc/wireguard"; @@ -20,6 +20,10 @@ const CMD_WG: &str = "wg"; const CMD_WG_QUICK: &str = "wg-quick"; const CMD_IP: &str = "ip"; const CMD_WHICH: &str = "which"; +const CMD_CURL: &str = "curl"; +const CMD_WGET: &str = "wget"; +const ENDPOINT_PLACEHOLDER: &str = "__ENDPOINT__"; +const DNS_BLOCK_PLACEHOLDER: &str = "__DNS_BLOCK__"; const KIB: u64 = 1024; const MIB: u64 = KIB * 1024; @@ -41,6 +45,32 @@ fn command_exists(cmd: &str) -> bool { .is_ok_and(|o| o.status.success()) } +pub fn detect_public_ip() -> Option { + let output = if command_exists(CMD_CURL) { + Command::new(CMD_CURL) + .args(["-fsSL", "https://api.ipify.org"]) + .output() + .ok()? + } else if command_exists(CMD_WGET) { + Command::new(CMD_WGET) + .args(["-qO-", "https://api.ipify.org"]) + .output() + .ok()? + } else { + return None; + }; + + if !output.status.success() { + return None; + } + let ip = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if ip.parse::().is_ok() { + Some(ip) + } else { + None + } +} + pub fn discover_tunnels() -> Vec { let Ok(entries) = fs::read_dir(Path::new(CONFIG_DIR)) else { return vec![]; @@ -81,6 +111,38 @@ pub fn generate_private_key() -> Result { Ok(key) } +pub fn derive_public_key(private_key: &str) -> Result { + let mut child = Command::new(CMD_WG) + .arg("pubkey") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn()?; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(private_key.as_bytes())?; + } + let output = child.wait_with_output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = stderr.trim(); + return Err(Error::WgTui(if msg.is_empty() { + "wg pubkey failed".into() + } else { + msg.to_string() + })); + } + let key = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if key.is_empty() { + return Err(Error::WgTui("wg pubkey returned an empty key".into())); + } + Ok(key) +} + +pub fn generate_keypair() -> Result<(String, String), Error> { + let private = generate_private_key()?; + let public = derive_public_key(&private)?; + Ok((private, public)) +} + pub fn default_egress_interface() -> Option { let outputs = [ Command::new(CMD_IP) @@ -178,6 +240,30 @@ fn parse_interface_addresses(content: &str) -> Vec { addrs } +fn parse_interface_value(content: &str, key: &str) -> Option { + let mut in_interface = false; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_interface = line.eq_ignore_ascii_case("[Interface]"); + continue; + } + if !in_interface { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + if k.trim().eq_ignore_ascii_case(key) { + return Some(v.trim().to_string()); + } + } + None +} + fn parse_ipv4_address(value: &str) -> Option { let value = value.trim(); let ip = value.split_once('/').map(|(ip, _)| ip).unwrap_or(value); @@ -218,6 +304,49 @@ pub fn wg_quick(action: &str, name: &str) -> Result<(), Error> { Ok(()) } +fn sync_interface_with_content(name: &str, content: &str) -> Result<(), Error> { + let temp_dir = std::env::temp_dir(); + let temp_conf = temp_dir.join(format!("wg-tui-{name}.conf")); + let temp_stripped = temp_dir.join(format!("wg-tui-{name}.stripped")); + + fs::write(&temp_conf, content)?; + + let strip_output = Command::new(CMD_WG_QUICK) + .arg("strip") + .arg(&temp_conf) + .output()?; + if !strip_output.status.success() { + let stderr = String::from_utf8_lossy(&strip_output.stderr); + let msg = stderr.trim(); + return Err(Error::WgTui(if msg.is_empty() { + "wg-quick strip failed".into() + } else { + msg.to_string() + })); + } + + fs::write(&temp_stripped, &strip_output.stdout)?; + + let sync_output = Command::new(CMD_WG) + .arg("syncconf") + .arg(name) + .arg(&temp_stripped) + .output()?; + if !sync_output.status.success() { + let stderr = String::from_utf8_lossy(&sync_output.stderr); + let msg = stderr.trim(); + return Err(Error::WgTui(if msg.is_empty() { + "wg syncconf failed".into() + } else { + msg.to_string() + })); + } + + let _ = fs::remove_file(temp_conf); + let _ = fs::remove_file(temp_stripped); + Ok(()) +} + pub fn delete_tunnel(name: &str, is_active: bool) -> Result<(), Error> { if is_active { wg_quick("down", name)?; @@ -487,3 +616,144 @@ fn parse_bytes(s: &str) -> u64 { _ => 0, } } + +fn parse_peer_allowed_ips(content: &str) -> Vec { + let mut in_peer = false; + let mut allowed = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_peer = line.eq_ignore_ascii_case("[Peer]"); + continue; + } + if !in_peer { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("AllowedIPs") { + allowed.extend( + value + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string), + ); + } + } + + allowed +} + +fn is_server_config(content: &str) -> bool { + let mut in_interface = false; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_interface = line.eq_ignore_ascii_case("[Interface]"); + continue; + } + if !in_interface { + continue; + } + let Some((key, _)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.eq_ignore_ascii_case("PostUp") + || key.eq_ignore_ascii_case("PostDown") + || key.eq_ignore_ascii_case("SaveConfig") + { + return true; + } + } + false +} + +fn next_peer_ipv4(base: Ipv4Addr, used: &HashSet) -> Option { + let [a, b, c, d] = base.octets(); + for step in 1u8..=253 { + let candidate = d.wrapping_add(step); + if candidate == 0 || candidate == 255 { + continue; + } + let ip = Ipv4Addr::new(a, b, c, candidate); + if !used.contains(&ip) { + return Some(ip); + } + } + None +} + +pub fn add_server_peer(name: &str) -> Result { + let path = Path::new(CONFIG_DIR).join(format!("{name}.conf")); + let content = fs::read_to_string(&path) + .map_err(|_| Error::WgTui(format!("Could not read config for tunnel '{name}'")))?; + + if !is_server_config(&content) { + return Err(Error::WgTui( + "Selected tunnel is not a server config".into(), + )); + } + + let address = parse_interface_addresses(&content) + .into_iter() + .find(|addr| parse_ipv4_address(addr).is_some()) + .ok_or_else(|| Error::WgTui("Server config has no IPv4 address".into()))?; + let base_ip = parse_ipv4_address(&address) + .ok_or_else(|| Error::WgTui("Server IPv4 address is invalid".into()))?; + + let private_key = parse_interface_value(&content, "PrivateKey") + .ok_or_else(|| Error::WgTui("Server config missing PrivateKey".into()))?; + let listen_port = parse_interface_value(&content, "ListenPort") + .ok_or_else(|| Error::WgTui("Server config missing ListenPort".into()))?; + let listen_port: u16 = listen_port + .parse() + .map_err(|_| Error::WgTui("Listen port must be a valid number".into()))?; + + let mut used = HashSet::new(); + used.insert(base_ip); + for allowed in parse_peer_allowed_ips(&content) { + if let Some(ip) = parse_ipv4_address(&allowed) { + used.insert(ip); + } + } + + let peer_ip = next_peer_ipv4(base_ip, &used) + .ok_or_else(|| Error::WgTui("No available peer address in the server /24".into()))?; + let peer_address = format!("{peer_ip}/32"); + + let (peer_private_key, peer_public_key) = generate_keypair()?; + let server_public_key = derive_public_key(&private_key)?; + + let mut new_content = content.clone(); + if !new_content.ends_with('\n') { + new_content.push('\n'); + } + new_content.push('\n'); + new_content.push_str("[Peer]\n"); + new_content.push_str(&format!("PublicKey = {peer_public_key}\n")); + new_content.push_str(&format!("AllowedIPs = {peer_address}\n")); + if is_interface_active(name) { + sync_interface_with_content(name, &new_content)?; + } + fs::write(&path, new_content)?; + + let client_config = format!( + "[Interface]\nPrivateKey = {peer_private_key}\nAddress = {peer_address}\n{DNS_BLOCK_PLACEHOLDER}\n[Peer]\nPublicKey = {server_public_key}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = {ENDPOINT_PLACEHOLDER}\n" + ); + + Ok(PeerConfig { + client_config_template: client_config, + suggested_filename: format!("{name}-peer-{peer_ip}.conf"), + listen_port, + }) +} From a9b5c4fb9d60a26addb3eef69232a96e6a475990 Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:20:28 -0500 Subject: [PATCH 4/6] feat: update dependencies and refactor byte formatting --- Cargo.lock | 117 +++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 ++ src/ui.rs | 12 +---- src/wireguard.rs | 13 +----- 4 files changed, 120 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86c5200..c8e58f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -457,6 +478,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -564,6 +591,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -605,6 +643,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -721,6 +768,22 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -926,6 +989,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1228,6 +1297,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.2" @@ -1265,9 +1345,9 @@ checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -1344,6 +1424,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -1681,7 +1770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "atomic", - "getrandom", + "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] @@ -1783,7 +1872,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ - "getrandom", + "getrandom 0.3.4", "mac_address", "sha2", "thiserror 1.0.69", @@ -1846,13 +1935,27 @@ dependencies = [ "clap", "color-eyre", "crossterm", + "humansize", "nix 0.31.1", "qrcode", "ratatui", + "shellexpand", "thiserror 2.0.18", + "which", "zip", ] +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1890,6 +1993,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 693938c..2c632da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,11 @@ license = "MIT" clap = { version = "4.5.56", features = ["derive"] } color-eyre = "0.6.5" crossterm = { version = "0.29.0", features = ["event-stream"] } +humansize = "2.1.3" nix = { version = "0.31.1", features = ["user"] } qrcode = "0.14.1" ratatui = "0.30.0" +shellexpand = "3.1.1" thiserror = "2.0.18" +which = "8.0.0" zip = { version = "7.2.0", default-features = false, features = ["deflate"] } diff --git a/src/ui.rs b/src/ui.rs index ab65368..d2c7e58 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ +use humansize::{BINARY, format_size}; use qrcode::{QrCode, render::unicode}; use ratatui::{ Frame, @@ -9,10 +10,6 @@ use ratatui::{ use crate::types::PeerInfo; -const KIB: u64 = 1024; -const MIB: u64 = KIB * 1024; -const GIB: u64 = MIB * 1024; - pub fn bordered_block(title: Option<&str>) -> Block<'_> { let block = Block::default() .borders(Borders::ALL) @@ -356,10 +353,5 @@ pub fn truncate_key(key: &str) -> String { } pub fn format_bytes(b: u64) -> String { - match b { - _ if b >= GIB => format!("{:.2} GiB", b as f64 / GIB as f64), - _ if b >= MIB => format!("{:.2} MiB", b as f64 / MIB as f64), - _ if b >= KIB => format!("{:.2} KiB", b as f64 / KIB as f64), - _ => format!("{b} B"), - } + format_size(b, BINARY) } diff --git a/src/wireguard.rs b/src/wireguard.rs index a56640f..59ef704 100644 --- a/src/wireguard.rs +++ b/src/wireguard.rs @@ -19,7 +19,6 @@ const CONFIG_DIR: &str = "/etc/wireguard"; const CMD_WG: &str = "wg"; const CMD_WG_QUICK: &str = "wg-quick"; const CMD_IP: &str = "ip"; -const CMD_WHICH: &str = "which"; const CMD_CURL: &str = "curl"; const CMD_WGET: &str = "wget"; const ENDPOINT_PLACEHOLDER: &str = "__ENDPOINT__"; @@ -39,10 +38,7 @@ pub fn check_dependencies() -> Vec<&'static str> { } fn command_exists(cmd: &str) -> bool { - Command::new(CMD_WHICH) - .arg(cmd) - .output() - .is_ok_and(|o| o.status.success()) + which::which(cmd).is_ok() } pub fn detect_public_ip() -> Option { @@ -358,12 +354,7 @@ pub fn delete_tunnel(name: &str, is_active: bool) -> Result<(), Error> { pub fn expand_path(path: &str) -> PathBuf { let path = path.trim(); - if let Some(rest) = path.strip_prefix("~/") - && let Some(home) = std::env::var_os("HOME") - { - return PathBuf::from(home).join(rest); - } - PathBuf::from(path) + PathBuf::from(shellexpand::tilde(path).into_owned()) } pub fn import_tunnel(source_path: &str) -> Result { From aac1d8f1c53183ddb129d60f3672eda35237e9c3 Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:24:42 -0500 Subject: [PATCH 5/6] feat: enhance key handling and validation in App and WireGuard modules --- src/app.rs | 615 ++++++++++++++++++++++++++--------------------- src/wireguard.rs | 82 +++---- 2 files changed, 372 insertions(+), 325 deletions(-) diff --git a/src/app.rs b/src/app.rs index e52c646..5458f87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -173,332 +173,402 @@ impl App { return Ok(()); } + self.handle_key(key) + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Result<(), Error> { self.message = None; + if self.consume_help() { + return Ok(()); + } + if self.consume_confirm_delete(key) { + return Ok(()); + } + if self.consume_confirm_full_tunnel(key) { + return Ok(()); + } + if self.consume_peer_save_path(key) { + return Ok(()); + } + if self.consume_import_path(key) { + return Ok(()); + } + if self.consume_export_path(key) { + return Ok(()); + } + if self.consume_peer_endpoint_input(key) { + return Ok(()); + } + if self.consume_peer_dns_input(key) { + return Ok(()); + } + if self.consume_new_tunnel_wizard(key) { + return Ok(()); + } + if self.consume_peer_config(key) { + return Ok(()); + } + if self.consume_add_menu(key) { + return Ok(()); + } + + self.handle_global_key(key); + Ok(()) + } + + fn consume_help(&mut self) -> bool { if self.show_help { self.show_help = false; - return Ok(()); + return true; } + false + } - if self.confirm_delete { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - self.confirm_delete = false; - self.delete_selected(); - } - _ => { - self.confirm_delete = false; - self.message = Some(Message::Info("Delete cancelled".into())); - } + fn consume_confirm_delete(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.confirm_delete { + return false; + } + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.confirm_delete = false; + self.delete_selected(); + } + _ => { + self.confirm_delete = false; + self.message = Some(Message::Info("Delete cancelled".into())); } - return Ok(()); } + true + } - if let Some(ref name) = self.confirm_full_tunnel { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - let name = name.clone(); - self.confirm_full_tunnel = None; - self.toggle_selected_with_name(&name); - } - _ => { - self.confirm_full_tunnel = None; - self.message = Some(Message::Info("Enable cancelled".into())); - } + fn consume_confirm_full_tunnel(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref name) = self.confirm_full_tunnel else { + return false; + }; + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let name = name.clone(); + self.confirm_full_tunnel = None; + self.toggle_selected_with_name(&name); + } + _ => { + self.confirm_full_tunnel = None; + self.message = Some(Message::Info("Enable cancelled".into())); } - return Ok(()); } + true + } - if let Some(ref mut path) = self.peer_save_path { - match key.code { - KeyCode::Enter => { - let path_str = path.clone(); - self.peer_save_path = None; - let Some(peer) = &self.peer_config else { - return Ok(()); - }; - let dest = expand_path(&path_str); - if dest.exists() { - self.message = Some(Message::Error("File already exists".into())); - return Ok(()); - } - match fs::write(&dest, &peer.config_text) { - Ok(()) => { - self.message = Some(Message::Success(format!( - "Peer config saved to {}", - dest.display() - ))); - } - Err(e) => self.message = Some(Message::Error(e.to_string())), - } - } - KeyCode::Esc => { - self.peer_save_path = None; - self.message = Some(Message::Info("Save cancelled".into())); - } - KeyCode::Backspace => { - path.pop(); + fn consume_peer_save_path(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut path) = self.peer_save_path else { + return false; + }; + match key.code { + KeyCode::Enter => { + let path_str = path.clone(); + self.peer_save_path = None; + let Some(peer) = &self.peer_config else { + return true; + }; + let dest = expand_path(&path_str); + if dest.exists() { + self.message = Some(Message::Error("File already exists".into())); + return true; } - KeyCode::Char(c) => { - path.push(c); + match fs::write(&dest, &peer.config_text) { + Ok(()) => { + self.message = Some(Message::Success(format!( + "Peer config saved to {}", + dest.display() + ))); + } + Err(e) => self.message = Some(Message::Error(e.to_string())), } - _ => {} } - return Ok(()); + KeyCode::Esc => { + self.peer_save_path = None; + self.message = Some(Message::Info("Save cancelled".into())); + } + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => { + path.push(c); + } + _ => {} } + true + } - if let Some(ref mut path) = self.input_path { - match key.code { - KeyCode::Enter => { - let path_str = path.clone(); - self.input_path = None; - match import_tunnel(&path_str) { - Ok(name) => { - self.message = - Some(Message::Success(format!("Tunnel '{name}' imported"))); - self.refresh_tunnels(); - } - Err(e) => self.message = Some(Message::Error(e.to_string())), + fn consume_import_path(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut path) = self.input_path else { + return false; + }; + match key.code { + KeyCode::Enter => { + let path_str = path.clone(); + self.input_path = None; + match import_tunnel(&path_str) { + Ok(name) => { + self.message = Some(Message::Success(format!("Tunnel '{name}' imported"))); + self.refresh_tunnels(); } + Err(e) => self.message = Some(Message::Error(e.to_string())), } - KeyCode::Esc => { - self.input_path = None; - self.message = Some(Message::Info("Import cancelled".into())); - } - KeyCode::Backspace => { - path.pop(); - } - KeyCode::Char(c) => { - path.push(c); - } - _ => {} } - return Ok(()); + KeyCode::Esc => { + self.input_path = None; + self.message = Some(Message::Info("Import cancelled".into())); + } + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => { + path.push(c); + } + _ => {} } + true + } - if let Some(ref mut path) = self.export_path { - match key.code { - KeyCode::Enter => { - let path_str = path.clone(); - self.export_path = None; - match export_tunnels_to_zip(&path_str) { - Ok(dest) => { - self.message = Some(Message::Success(format!( - "Exported {} tunnels to {}", - self.tunnels.len(), - dest.display() - ))); - } - Err(e) => self.message = Some(Message::Error(e.to_string())), + fn consume_export_path(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut path) = self.export_path else { + return false; + }; + match key.code { + KeyCode::Enter => { + let path_str = path.clone(); + self.export_path = None; + match export_tunnels_to_zip(&path_str) { + Ok(dest) => { + self.message = Some(Message::Success(format!( + "Exported {} tunnels to {}", + self.tunnels.len(), + dest.display() + ))); } + Err(e) => self.message = Some(Message::Error(e.to_string())), } - KeyCode::Esc => { - self.export_path = None; - self.message = Some(Message::Info("Export cancelled".into())); - } - KeyCode::Backspace => { - path.pop(); - } - KeyCode::Char(c) => { - path.push(c); - } - _ => {} } - return Ok(()); + KeyCode::Esc => { + self.export_path = None; + self.message = Some(Message::Info("Export cancelled".into())); + } + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => { + path.push(c); + } + _ => {} } + true + } - if let Some(ref mut endpoint) = self.peer_endpoint_input { - match key.code { - KeyCode::Enter => { - let endpoint_str = endpoint.trim().to_string(); - if endpoint_str.is_empty() { - self.message = Some(Message::Error("Endpoint is required".into())); - return Ok(()); - } - if let Some(pending) = self.pending_peer.as_mut() { - pending.endpoint = endpoint_str; - } - self.peer_endpoint_input = None; - self.peer_dns_input = Some(String::new()); - } - KeyCode::Esc => { - self.peer_endpoint_input = None; - self.pending_peer = None; - self.message = Some(Message::Info("Peer config cancelled".into())); - } - KeyCode::Backspace => { - endpoint.pop(); + fn consume_peer_endpoint_input(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut endpoint) = self.peer_endpoint_input else { + return false; + }; + match key.code { + KeyCode::Enter => { + let endpoint_str = endpoint.trim().to_string(); + if endpoint_str.is_empty() { + self.message = Some(Message::Error("Endpoint is required".into())); + return true; } - KeyCode::Char(c) => { - endpoint.push(c); + if let Some(pending) = self.pending_peer.as_mut() { + pending.endpoint = endpoint_str; } - _ => {} + self.peer_endpoint_input = None; + self.peer_dns_input = Some(String::new()); } - return Ok(()); + KeyCode::Esc => { + self.peer_endpoint_input = None; + self.pending_peer = None; + self.message = Some(Message::Info("Peer config cancelled".into())); + } + KeyCode::Backspace => { + endpoint.pop(); + } + KeyCode::Char(c) => { + endpoint.push(c); + } + _ => {} } + true + } - if let Some(ref mut dns) = self.peer_dns_input { - match key.code { - KeyCode::Enter => { - let dns_str = dns.trim().to_string(); - let Some(pending) = self.pending_peer.take() else { - self.peer_dns_input = None; - return Ok(()); - }; - let dns_block = if dns_str.is_empty() { - String::new() - } else { - format!("DNS = {dns_str}\n") - }; - let config_text = pending - .template - .replace("__ENDPOINT__", &pending.endpoint) - .replace("__DNS_BLOCK__", &dns_block); - self.peer_config = - Some(PeerConfigState::new(config_text, pending.suggested_path)); - self.peer_dns_input = None; - } - KeyCode::Esc => { + fn consume_peer_dns_input(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut dns) = self.peer_dns_input else { + return false; + }; + match key.code { + KeyCode::Enter => { + let dns_str = dns.trim().to_string(); + let Some(pending) = self.pending_peer.take() else { self.peer_dns_input = None; - self.pending_peer = None; - self.message = Some(Message::Info("Peer config cancelled".into())); - } - KeyCode::Backspace => { - dns.pop(); - } - KeyCode::Char(c) => { - dns.push(c); - } - _ => {} + return true; + }; + let dns_block = if dns_str.is_empty() { + String::new() + } else { + format!("DNS = {dns_str}\n") + }; + let config_text = pending + .template + .replace("__ENDPOINT__", &pending.endpoint) + .replace("__DNS_BLOCK__", &dns_block); + self.peer_config = Some(PeerConfigState::new(config_text, pending.suggested_path)); + self.peer_dns_input = None; } - return Ok(()); + KeyCode::Esc => { + self.peer_dns_input = None; + self.pending_peer = None; + self.message = Some(Message::Info("Peer config cancelled".into())); + } + KeyCode::Backspace => { + dns.pop(); + } + KeyCode::Char(c) => { + dns.push(c); + } + _ => {} } + true + } - if let Some(ref mut wizard) = self.new_tunnel { - match key.code { - KeyCode::Enter => { - let finished = { - if let Some(err) = wizard.validate_current() { - self.message = Some(Message::Error(err)); - return Ok(()); - } - wizard.advance() - }; - if finished { - let wizard = self.new_tunnel.take().unwrap(); - match wizard { - NewTunnelWizard::Client(wizard) => { - let draft = wizard.draft; - match create_tunnel(&draft) { - Ok(()) => { - let name = draft.name; - self.message = Some(Message::Success(format!( - "Tunnel '{name}' created" - ))); - self.refresh_tunnels(); - } - Err(e) => self.message = Some(Message::Error(e.to_string())), + fn consume_new_tunnel_wizard(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut wizard) = self.new_tunnel else { + return false; + }; + match key.code { + KeyCode::Enter => { + let finished = { + if let Some(err) = wizard.validate_current() { + self.message = Some(Message::Error(err)); + return true; + } + wizard.advance() + }; + if finished { + let wizard = self.new_tunnel.take().unwrap(); + match wizard { + NewTunnelWizard::Client(wizard) => { + let draft = wizard.draft; + match create_tunnel(&draft) { + Ok(()) => { + let name = draft.name; + self.message = + Some(Message::Success(format!("Tunnel '{name}' created"))); + self.refresh_tunnels(); } + Err(e) => self.message = Some(Message::Error(e.to_string())), } - NewTunnelWizard::Server(wizard) => { - let draft = wizard.draft; - match create_server_tunnel(&draft) { - Ok(()) => { - let name = draft.name; - self.message = Some(Message::Success(format!( - "Tunnel '{name}' created" - ))); - self.refresh_tunnels(); - } - Err(e) => self.message = Some(Message::Error(e.to_string())), + } + NewTunnelWizard::Server(wizard) => { + let draft = wizard.draft; + match create_server_tunnel(&draft) { + Ok(()) => { + let name = draft.name; + self.message = + Some(Message::Success(format!("Tunnel '{name}' created"))); + self.refresh_tunnels(); } + Err(e) => self.message = Some(Message::Error(e.to_string())), } } } } - KeyCode::Esc => { - self.new_tunnel = None; - self.message = Some(Message::Info("Create cancelled".into())); - } - KeyCode::Backspace => { - wizard.current_value_mut().pop(); - } - KeyCode::Char(c) => { - wizard.current_value_mut().push(c); - } - _ => {} } - return Ok(()); + KeyCode::Esc => { + self.new_tunnel = None; + self.message = Some(Message::Info("Create cancelled".into())); + } + KeyCode::Backspace => { + wizard.current_value_mut().pop(); + } + KeyCode::Char(c) => { + wizard.current_value_mut().push(c); + } + _ => {} } + true + } - if let Some(ref mut peer) = self.peer_config { - match key.code { - KeyCode::Char('s') => { - self.peer_save_path = Some(peer.suggested_path.clone()); - peer.show_qr = false; - } - KeyCode::Char('q') => { - match QrCode::new(peer.config_text.as_bytes()) { - Ok(code) => { - peer.qr_code = Some(code); - peer.show_qr = true; - } - Err(_) => { - peer.show_qr = false; - self.message = Some(Message::Error("QR data is too large".into())); - } - }; + fn consume_peer_config(&mut self, key: crossterm::event::KeyEvent) -> bool { + let Some(ref mut peer) = self.peer_config else { + return false; + }; + match key.code { + KeyCode::Char('s') => { + self.peer_save_path = Some(peer.suggested_path.clone()); + peer.show_qr = false; + } + KeyCode::Char('q') => match QrCode::new(peer.config_text.as_bytes()) { + Ok(code) => { + peer.qr_code = Some(code); + peer.show_qr = true; } - KeyCode::Char('b') => { + Err(_) => { peer.show_qr = false; + self.message = Some(Message::Error("QR data is too large".into())); } - KeyCode::Esc => { - self.peer_config = None; - } - _ => {} + }, + KeyCode::Char('b') => { + peer.show_qr = false; } - return Ok(()); + KeyCode::Esc => { + self.peer_config = None; + } + _ => {} } + true + } - if self.show_add_menu { - match key.code { - KeyCode::Char('i') | KeyCode::Char('1') => { - self.show_add_menu = false; - self.input_path = Some(String::new()); - } - KeyCode::Char('c') | KeyCode::Char('2') => { - self.show_add_menu = false; - let name = self.default_tunnel_name(); - self.new_tunnel = Some(NewTunnelWizard::client(name)); - } - KeyCode::Char('s') | KeyCode::Char('3') => { - self.show_add_menu = false; - let name = self.default_tunnel_name(); - let address = suggest_server_address(); - let egress = default_egress_interface().unwrap_or_default(); - let private_key = match generate_private_key() { - Ok(key) => key, - Err(e) => { - self.message = Some(Message::Error(e.to_string())); - return Ok(()); - } - }; - self.new_tunnel = Some(NewTunnelWizard::server( - name, - address, - "51820".into(), - private_key, - egress, - )); - } - KeyCode::Esc | KeyCode::Char('q') => { - self.show_add_menu = false; - } - _ => {} + fn consume_add_menu(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.show_add_menu { + return false; + } + match key.code { + KeyCode::Char('i') | KeyCode::Char('1') => { + self.show_add_menu = false; + self.input_path = Some(String::new()); } - return Ok(()); + KeyCode::Char('c') | KeyCode::Char('2') => { + self.show_add_menu = false; + let name = self.default_tunnel_name(); + self.new_tunnel = Some(NewTunnelWizard::client(name)); + } + KeyCode::Char('s') | KeyCode::Char('3') => { + self.show_add_menu = false; + let name = self.default_tunnel_name(); + let address = suggest_server_address(); + let egress = default_egress_interface().unwrap_or_default(); + let private_key = match generate_private_key() { + Ok(key) => key, + Err(e) => { + self.message = Some(Message::Error(e.to_string())); + return true; + } + }; + self.new_tunnel = Some(NewTunnelWizard::server( + name, + address, + "51820".into(), + private_key, + egress, + )); + } + KeyCode::Esc | KeyCode::Char('q') => { + self.show_add_menu = false; + } + _ => {} } + true + } + fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) { match (key.code, key.modifiers) { (KeyCode::Char('q') | KeyCode::Esc, _) => self.should_quit = true, (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => self.should_quit = true, @@ -518,7 +588,7 @@ impl App { (KeyCode::Char('a'), _) => self.show_add_menu = true, (KeyCode::Char('p'), _) => { let Some(tunnel) = self.selected() else { - return Ok(()); + return; }; match add_server_peer(&tunnel.name) { Ok(peer) => { @@ -551,7 +621,6 @@ impl App { (KeyCode::Char('?'), _) => self.show_help = true, _ => {} } - Ok(()) } pub fn draw(&mut self, frame: &mut Frame) { diff --git a/src/wireguard.rs b/src/wireguard.rs index 59ef704..da38f3e 100644 --- a/src/wireguard.rs +++ b/src/wireguard.rs @@ -4,7 +4,7 @@ use std::{ io::Write, net::{IpAddr, Ipv4Addr}, path::{Path, PathBuf}, - process::Command, + process::{Command, Output}, }; use zip::{ZipWriter, write::SimpleFileOptions}; @@ -41,6 +41,28 @@ fn command_exists(cmd: &str) -> bool { which::which(cmd).is_ok() } +fn wg_error(output: &Output, default: &str) -> Error { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = stderr.trim(); + Error::WgTui(if msg.is_empty() { + default.into() + } else { + msg.to_string() + }) +} + +fn validate_interface_name(name: &str) -> Result<(), Error> { + if name.is_empty() { + return Err(Error::WgTui("Interface name is required".into())); + } + if name.chars().any(|c| c.is_whitespace() || c == '/') { + return Err(Error::WgTui( + "Interface name cannot contain spaces or '/'".into(), + )); + } + Ok(()) +} + pub fn detect_public_ip() -> Option { let output = if command_exists(CMD_CURL) { Command::new(CMD_CURL) @@ -92,13 +114,7 @@ pub fn discover_tunnels() -> Vec { pub fn generate_private_key() -> Result { let output = Command::new(CMD_WG).arg("genkey").output()?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = stderr.trim(); - return Err(Error::WgTui(if msg.is_empty() { - "wg genkey failed".into() - } else { - msg.to_string() - })); + return Err(wg_error(&output, "wg genkey failed")); } let key = String::from_utf8_lossy(&output.stdout).trim().to_string(); if key.is_empty() { @@ -118,13 +134,7 @@ pub fn derive_public_key(private_key: &str) -> Result { } let output = child.wait_with_output()?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = stderr.trim(); - return Err(Error::WgTui(if msg.is_empty() { - "wg pubkey failed".into() - } else { - msg.to_string() - })); + return Err(wg_error(&output, "wg pubkey failed")); } let key = String::from_utf8_lossy(&output.stdout).trim().to_string(); if key.is_empty() { @@ -288,13 +298,7 @@ pub fn wg_quick(action: &str, name: &str) -> Result<(), Error> { let output = Command::new(CMD_WG_QUICK).arg(action).arg(name).output()?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = stderr.trim(); - return Err(Error::WgTui(if msg.is_empty() { - format!("wg-quick {action} failed") - } else { - msg.to_string() - })); + return Err(wg_error(&output, &format!("wg-quick {action} failed"))); } Ok(()) @@ -312,13 +316,7 @@ fn sync_interface_with_content(name: &str, content: &str) -> Result<(), Error> { .arg(&temp_conf) .output()?; if !strip_output.status.success() { - let stderr = String::from_utf8_lossy(&strip_output.stderr); - let msg = stderr.trim(); - return Err(Error::WgTui(if msg.is_empty() { - "wg-quick strip failed".into() - } else { - msg.to_string() - })); + return Err(wg_error(&strip_output, "wg-quick strip failed")); } fs::write(&temp_stripped, &strip_output.stdout)?; @@ -329,13 +327,7 @@ fn sync_interface_with_content(name: &str, content: &str) -> Result<(), Error> { .arg(&temp_stripped) .output()?; if !sync_output.status.success() { - let stderr = String::from_utf8_lossy(&sync_output.stderr); - let msg = stderr.trim(); - return Err(Error::WgTui(if msg.is_empty() { - "wg syncconf failed".into() - } else { - msg.to_string() - })); + return Err(wg_error(&sync_output, "wg syncconf failed")); } let _ = fs::remove_file(temp_conf); @@ -436,14 +428,7 @@ pub fn is_full_tunnel_config(name: &str) -> bool { pub fn create_tunnel(draft: &NewTunnelDraft) -> Result<(), Error> { let name = draft.name.trim(); - if name.is_empty() { - return Err(Error::WgTui("Interface name is required".into())); - } - if name.chars().any(|c| c.is_whitespace() || c == '/') { - return Err(Error::WgTui( - "Interface name cannot contain spaces or '/'".into(), - )); - } + validate_interface_name(name)?; let private_key = draft.private_key.trim(); let address = draft.address.trim(); @@ -488,14 +473,7 @@ pub fn create_tunnel(draft: &NewTunnelDraft) -> Result<(), Error> { pub fn create_server_tunnel(draft: &NewServerDraft) -> Result<(), Error> { let name = draft.name.trim(); - if name.is_empty() { - return Err(Error::WgTui("Interface name is required".into())); - } - if name.chars().any(|c| c.is_whitespace() || c == '/') { - return Err(Error::WgTui( - "Interface name cannot contain spaces or '/'".into(), - )); - } + validate_interface_name(name)?; let private_key = draft.private_key.trim(); let address = draft.address.trim(); From b9b7103f2b7f8257a9a0aebda11f6505b71a7ef0 Mon Sep 17 00:00:00 2001 From: Leonard Excoffier <48970393+excoffierleonard@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:25:29 -0500 Subject: [PATCH 6/6] feat: update version to 0.2.0 and enhance README with new tunnel features --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8e58f6..1b2677e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1930,7 +1930,7 @@ dependencies = [ [[package]] name = "wg-tui" -version = "0.1.15" +version = "0.2.0" dependencies = [ "clap", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index 2c632da..1dd61b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wg-tui" -version = "0.1.15" +version = "0.2.0" edition = "2024" description = "A terminal user interface for managing WireGuard VPN tunnels" repository = "https://github.com/excoffierleonard/wg-tui" diff --git a/README.md b/README.md index 4acad64..afecff2 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,11 @@ A terminal user interface for managing WireGuard VPN tunnels. - List and manage WireGuard tunnels - Start/stop tunnels with a single keypress - View tunnel details (peers, endpoints, transfer statistics) +- Create new client and server tunnels +- Add peers to server configs and generate client configs - Import tunnels from `.conf` files - Export all tunnels to a zip archive +- Show peer configs and QR codes for easy onboarding - Delete tunnels ## Requirements @@ -46,7 +49,8 @@ wg-tui | `k` / `Up` | Move selection up | | `Enter` / `Space` | Toggle tunnel (start/stop) | | `d` | Toggle details panel | -| `a` | Add/import tunnel | +| `a` | Add/import tunnel (menu) | +| `p` | Add peer to selected server tunnel | | `e` | Export all tunnels to zip | | `x` | Delete selected tunnel | | `r` | Refresh tunnel list |