//! Profile data model — the persistent SSH connection definition. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; /// A port-forward specification. `lfs` and `rfs` in the original Tcl profile /// are arrays whose key is `inport:host:outport` and value is an optional /// comment. We model each entry as a `Forward` record. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Forward { pub listen_port: u16, /// The `host` portion. May be a literal hostname, or one of the special /// placeholder strings `` or `` that shellm /// substitutes at connect time. pub host: String, pub target_port: u16, pub comment: String, } impl Forward { pub fn new(listen_port: u16, host: impl Into, target_port: u16) -> Self { Self { listen_port, host: host.into(), target_port, comment: String::new(), } } /// Render this forward as `inport:host:outport` (the key format the /// original used for its `lfs`/`rfs` arrays). pub fn to_key(&self) -> String { format!("{}:{}:{}", self.listen_port, self.host, self.target_port) } /// Render as the SSH argument portion: `inport:host:outport` /// (without the `-L`/`-R` prefix). pub fn to_ssh_arg(&self) -> String { self.to_key() } } /// SSH cipher selection — mirrors the original `algo` menu. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum Algo { #[default] Default, Idea, Des, #[serde(rename = "3des")] TripleDes, Blowfish, Arcfour, None, } impl Algo { pub const ALL: &'static [Algo] = &[ Algo::Default, Algo::Idea, Algo::Des, Algo::TripleDes, Algo::Blowfish, Algo::Arcfour, Algo::None, ]; pub fn as_str(&self) -> &'static str { match self { Algo::Default => "default", Algo::Idea => "idea", Algo::Des => "des", Algo::TripleDes => "3des", Algo::Blowfish => "blowfish", Algo::Arcfour => "arcfour", Algo::None => "none", } } pub fn from_str(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "default" | "" => Some(Algo::Default), "idea" => Some(Algo::Idea), "des" => Some(Algo::Des), "3des" => Some(Algo::TripleDes), "blowfish" => Some(Algo::Blowfish), "arcfour" => Some(Algo::Arcfour), "none" => Some(Algo::None), _ => None, } } /// Returns `true` when this algo should be emitted as `-c `. pub fn is_emittable(&self) -> bool { !matches!(self, Algo::Default) } } impl std::fmt::Display for Algo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::fmt::Display for SshVer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { SshVer::V1 => "SSH 1", SshVer::V2 => "SSH 2", }) } } impl std::fmt::Display for IpVer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { IpVer::V4 => "IPv4", IpVer::V6 => "IPv6", }) } } impl std::fmt::Display for KeyType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.display_label()) } } /// SSH protocol version. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum SshVer { #[serde(rename = "1")] V1, #[default] #[serde(rename = "2")] V2, } impl SshVer { pub fn flag(&self) -> &'static str { match self { SshVer::V1 => "-1", SshVer::V2 => "-2", } } } /// IP protocol version. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum IpVer { #[default] #[serde(rename = "4")] V4, #[serde(rename = "6")] V6, } impl IpVer { pub fn flag(&self) -> &'static str { match self { IpVer::V4 => "-4", IpVer::V6 => "-6", } } } /// Key types supported by `ssh-keygen`. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] pub enum KeyType { #[serde(rename = "rsa1")] Rsa1, #[default] #[serde(rename = "rsa")] Rsa, #[serde(rename = "dsa")] Dsa, } impl KeyType { pub fn ssh_keygen_arg(&self) -> &'static str { match self { KeyType::Rsa1 => "rsa1", KeyType::Rsa => "rsa", KeyType::Dsa => "dsa", } } pub fn default_filename(&self) -> &'static str { match self { KeyType::Rsa1 => "identity", KeyType::Rsa => "id_rsa", KeyType::Dsa => "id_dsa", } } pub fn display_label(&self) -> &'static str { match self { KeyType::Rsa1 => "SSH1 RSA1", KeyType::Rsa => "SSH2 RSA", KeyType::Dsa => "SSH2 DSA", } } } /// A shellm connection profile. /// /// Field names and semantics mirror the original `default.profile` (and the /// fields that `clear_profiles` and `save_profile` set in shellm proper). /// In particular: /// * `noagentforward` is the *negated* sense of the legacy `agentforward` /// field — true means "do NOT forward agent" → emits `-a`. /// * `x11forward` is also inverted — true means "no X11 forward" → `-x`; /// false + OpenSSH emits `-X`. /// * `compress` defaults to true (the original `default.profile` has /// `compress "1"`). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Profile { pub title: String, pub host: String, pub user: String, pub port: u16, pub command: String, pub subsys: String, pub identity: PathBuf, pub cfgfile: PathBuf, pub noagentforward: bool, pub x11forward: bool, pub stricthost: bool, pub nopriv: bool, pub verbose: bool, pub quiet: bool, pub fork: bool, pub gateway: bool, pub compress: bool, pub connwait: bool, pub termicon: bool, pub askuserspec: bool, pub noexec: bool, pub algo: Algo, pub compressval: u8, pub sshverconnect: SshVer, pub ipverconnect: IpVer, pub lfs: Vec, pub rfs: Vec, } impl Default for Profile { fn default() -> Self { Self { title: "Default Profile".to_string(), host: String::new(), user: String::new(), port: 22, command: String::new(), subsys: String::new(), identity: PathBuf::new(), cfgfile: PathBuf::new(), noagentforward: false, x11forward: false, stricthost: false, nopriv: false, verbose: false, quiet: false, fork: false, gateway: false, compress: true, connwait: false, termicon: false, askuserspec: false, noexec: false, algo: Algo::Default, compressval: 6, sshverconnect: SshVer::V2, ipverconnect: IpVer::V4, lfs: Vec::new(), rfs: Vec::new(), } } } impl Profile { /// Returns `true` if the profile is essentially blank (only the default /// title, no host/user). Used by the editor to decide whether we are /// editing a new profile. pub fn is_blank(&self) -> bool { self.host.is_empty() && self.user.is_empty() && self.title == "Default Profile" } /// What the connections list should show for this profile. pub fn display_line(&self) -> String { if self.host.is_empty() { self.title.clone() } else if self.user.is_empty() { format!("{} ({}:{})", self.title, self.host, self.port) } else { format!("{} ({}@{}:{})", self.title, self.user, self.host, self.port) } } /// Effective user, returning `None` if the profile is set to ask at /// connect time. pub fn effective_user(&self) -> Option<&str> { if self.askuserspec { None } else if self.user.is_empty() { None } else { Some(self.user.as_str()) } } /// Save the profile to disk in TOML format at the standard path. pub fn save(&self, name: &str) -> std::io::Result<()> { let path = crate::data::paths::profile_file(name); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let s = toml::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(&path, s) } /// Load a profile from disk. pub fn load(name: &str) -> anyhow::Result { let path = crate::data::paths::profile_file(name); let s = std::fs::read_to_string(&path)?; // Try TOML first (the new native format). if let Ok(p) = toml::from_str::(&s) { return Ok(p); } // Fall back to the legacy Tcl-style format. Ok(parse_legacy_profile(&s)) } /// Delete a profile file from disk. pub fn delete_file(name: &str) -> std::io::Result<()> { let path = crate::data::paths::profile_file(name); if path.exists() { std::fs::remove_file(path) } else { Ok(()) } } /// Enumerate all profile names (without the `.profile` extension) on disk. pub fn list_names() -> Vec { let dir = crate::data::paths::profiles_dir(); let mut out = Vec::new(); if let Ok(rd) = std::fs::read_dir(&dir) { for ent in rd.flatten() { let p = ent.path(); if p.extension().and_then(|e| e.to_str()) == Some("profile") { if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) { out.push(stem.to_string()); } } } } out.sort(); out } /// Try to find a profile whose title matches `title`. Returns its filename /// stem. This mirrors the original `retprof` proc. pub fn find_by_title(title: &str) -> Option { for name in Self::list_names() { if let Ok(p) = Self::load(&name) { if p.title == title { return Some(name); } } } None } } /// Parse a legacy Tcl-style profile (the format produced by the original /// secpanel's `save_profile` proc). Each line looks like: /// /// ```tcl /// set title "My Profile" /// set host "example.com" /// set port "22" /// array set lfs {8080:localhost:80 "web proxy"} /// ``` /// /// We do just enough parsing to recover the values. Comments and unknown /// `set` vars are ignored. pub fn parse_legacy_profile(src: &str) -> Profile { let mut p = Profile::default(); let mut lfs: BTreeMap = BTreeMap::new(); let mut rfs: BTreeMap = BTreeMap::new(); for raw in src.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some(rest) = line.strip_prefix("set ") else { continue }; let rest = rest.trim_start(); let (key, val) = match split_once_ws(rest) { Some(x) => x, None => continue, }; if key == "array" { // array set lfs {K V K V ...} let after_set = val.trim(); if let Some(rest) = after_set.strip_prefix("set ") { let rest = rest.trim_start(); let (arr, body) = match split_once_ws(rest) { Some(x) => x, None => continue, }; let body = body.trim(); let body = body.trim_start_matches('{').trim_end_matches('}'); let entries = parse_array_body(body); match arr { "lfs" => lfs.extend(entries), "rfs" => rfs.extend(entries), _ => {} } } continue; } // `set key value` — value may be a quoted string or a bare token. let v = unquote(val.trim()); apply_legacy_field(&mut p, key, &v); } p.lfs = lfs.iter().map(|(k, v)| parse_forward(k, v)).collect(); p.rfs = rfs.iter().map(|(k, v)| parse_forward(k, v)).collect(); p } fn apply_legacy_field(p: &mut Profile, key: &str, val: &str) { match key { "title" => p.title = val.to_string(), "host" => p.host = val.to_string(), "user" => p.user = val.to_string(), "port" => p.port = val.parse().unwrap_or(22), "command" => p.command = val.to_string(), "subsys" => p.subsys = val.to_string(), "identity" => p.identity = PathBuf::from(val), "cfgfile" => p.cfgfile = PathBuf::from(val), "noagentforward" | "agentforward" => { // The legacy `agentforward` field is the inverse of // `noagentforward`; the UI now uses `noagentforward` exclusively. if key == "agentforward" { p.noagentforward = val == "0"; } else { p.noagentforward = val == "1"; } } "x11forward" => p.x11forward = val == "1", "stricthost" => p.stricthost = val == "1", "nopriv" => p.nopriv = val == "1", "verbose" => p.verbose = val == "1", "quiet" => p.quiet = val == "1", "fork" => p.fork = val == "1", "gateway" => p.gateway = val == "1", "compress" => p.compress = val == "1", "connwait" => p.connwait = val == "1", "termicon" => p.termicon = val == "1", "askuserspec" => p.askuserspec = val == "1", "noexec" => p.noexec = val == "1", "algo" => p.algo = Algo::from_str(val).unwrap_or_default(), "compressval" => p.compressval = val.parse().unwrap_or(6), "sshverconnect" => { p.sshverconnect = if val == "1" { SshVer::V1 } else { SshVer::V2 } } "ipverconnect" => { p.ipverconnect = if val == "6" { IpVer::V6 } else { IpVer::V4 } } _ => {} } } fn split_once_ws(s: &str) -> Option<(&str, &str)> { let mut it = s.splitn(2, |c: char| c.is_whitespace()); let k = it.next()?.trim(); let v = it.next()?.trim(); if k.is_empty() { None } else { Some((k, v)) } } fn unquote(s: &str) -> String { let s = s.trim(); if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) { s[1..s.len() - 1].to_string() } else { s.to_string() } } /// Parse the body of `array set X {k1 v1 k2 v2 ...}` into (key, value) pairs. /// Values may be quoted. fn parse_array_body(body: &str) -> Vec<(String, String)> { let mut out = Vec::new(); let mut toks = tokenize(body); while let Some(k) = toks.next() { let v = toks.next().unwrap_or_default(); out.push((k, v)); } out } fn tokenize(s: &str) -> impl Iterator + '_ { struct Tok<'a> { s: &'a str, pos: usize, } impl<'a> Iterator for Tok<'a> { type Item = String; fn next(&mut self) -> Option { let bytes = self.s.as_bytes(); while self.pos < bytes.len() && bytes[self.pos].is_ascii_whitespace() { self.pos += 1; } if self.pos >= bytes.len() { return None; } let start = self.pos; if bytes[self.pos] == b'"' { self.pos += 1; let s = self.pos; while self.pos < bytes.len() && bytes[self.pos] != b'"' { self.pos += 1; } let out = self.s[s..self.pos].to_string(); if self.pos < bytes.len() { self.pos += 1; // skip closing " } let _ = start; // silence unused return Some(out); } while self.pos < bytes.len() && !bytes[self.pos].is_ascii_whitespace() { self.pos += 1; } Some(self.s[start..self.pos].to_string()) } } Tok { s, pos: 0 } } fn parse_forward(key: &str, comment: &str) -> Forward { let parts: Vec<&str> = key.splitn(3, ':').collect(); if parts.len() == 3 { Forward { listen_port: parts[0].parse().unwrap_or(0), host: parts[1].to_string(), target_port: parts[2].parse().unwrap_or(0), comment: comment.to_string(), } } else { Forward { listen_port: 0, host: key.to_string(), target_port: 0, comment: comment.to_string(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn round_trip_default() { let p = Profile::default(); let s = toml::to_string(&p).unwrap(); let p2: Profile = toml::from_str(&s).unwrap(); assert_eq!(p, p2); } #[test] fn legacy_parse_basic() { let src = r#" # secpanel-Profile set title "My Server" set host "example.com" set user "root" set port "2222" set compress "1" set compressval "9" set algo "blowfish" set sshverconnect "2" array set lfs {8080:localhost:80 "web proxy"} "#; let p = parse_legacy_profile(src); assert_eq!(p.title, "My Server"); assert_eq!(p.host, "example.com"); assert_eq!(p.user, "root"); assert_eq!(p.port, 2222); assert_eq!(p.compressval, 9); assert_eq!(p.algo, Algo::Blowfish); assert_eq!(p.lfs.len(), 1); assert_eq!(p.lfs[0].listen_port, 8080); assert_eq!(p.lfs[0].host, "localhost"); assert_eq!(p.lfs[0].target_port, 80); assert_eq!(p.lfs[0].comment, "web proxy"); } }