shellm/src/data/profile.rs

624 lines
18 KiB
Rust

//! 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 `<TARGET-HOST>` or `<LOCAL-HOST>` 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<String>, 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<Self> {
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 <algo>`.
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<Forward>,
pub rfs: Vec<Forward>,
}
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<Profile> {
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::<Profile>(&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<String> {
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<String> {
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<String, String> = BTreeMap::new();
let mut rfs: BTreeMap<String, String> = 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<Item = String> + '_ {
struct Tok<'a> {
s: &'a str,
pos: usize,
}
impl<'a> Iterator for Tok<'a> {
type Item = String;
fn next(&mut self) -> Option<String> {
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");
}
}