use std::{
collections::HashMap,
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
use crate::errors::{Result, SshError};
const DEFAULT_DENY: &[(&str, &str)] = &[
(
"rm-rf-root",
r#"(?im)\brm\b(?:\s+(?:-{1,2}[a-zA-Z\-]+|--))*\s+['"]?/+\*?[a-zA-Z]*/?['"]?(\s|$)"#,
),
(
"dd-disk",
r#"(?im)\bdd\b.*\bof\s*=\s*['"]?/dev/(sd|nvme|hd|vd)"#,
),
("mkfs", r#"(?im)\bmkfs(\.[a-z0-9]+)?\s+['"]?/dev/"#),
("forkbomb", r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:"),
("redirect-disk", r#">\s*['"]?/dev/(sd|nvme|hd|vd)"#),
(
"chmod-root",
r#"(?im)\bchmod\b(?:\s+(?:-{1,2}[a-zA-Z\-]+|--))*\s+[0-7]{3,4}\s+['"]?/+['"]?(\s|$)"#,
),
];
const DEFAULT_CONFIRM: &[(&str, &str)] = &[
("shutdown", r"(?im)\b(shutdown|halt|poweroff)\b"),
("reboot", r"(?im)\breboot\b"),
("sql-drop", r"(?i)\bDROP\s+(TABLE|DATABASE|SCHEMA)\b"),
("sql-truncate", r"(?i)\bTRUNCATE\s+TABLE\b"),
(
"systemctl-stop",
r"(?im)\bsystemctl\s+(stop|disable|mask)\b",
),
(
"docker-rm",
r"(?im)\bdocker\s+(rm|rmi|volume\s+rm|system\s+prune)\b",
),
];
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub defaults: Defaults,
#[serde(default, rename = "host")]
pub hosts: HashMap<String, Host>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Defaults {
#[serde(default)]
pub import_ssh_config: bool,
#[serde(default = "default_output")]
pub output: OutputFmt,
#[serde(default = "default_idle")]
pub session_idle_timeout: HumanDuration,
#[serde(default = "default_true")]
pub audit_log: bool,
#[serde(default = "default_audit_path")]
pub audit_log_path: PathBuf,
#[serde(default = "default_audit_max_bytes")]
pub audit_max_bytes: u64,
#[serde(default = "default_audit_keep_files")]
pub audit_keep_files: usize,
#[serde(default)]
pub guards: Guards,
#[serde(default = "default_keepalive")]
pub keepalive: HumanDuration,
#[serde(default = "default_connect_timeout")]
pub connect_timeout: HumanDuration,
#[serde(default = "default_truncate")]
pub truncate_bytes: usize,
#[serde(default = "default_max_capture")]
pub max_capture_bytes: usize,
#[serde(default = "default_max_channels")]
pub max_channels_per_host: usize,
#[serde(default)]
pub strict_host_key_checking: StrictHostKey,
#[serde(default = "default_confirm_ttl")]
pub confirm_ttl: HumanDuration,
#[serde(default)]
pub default_host: Option<String>,
}
impl Default for Defaults {
fn default() -> Self {
Self {
import_ssh_config: false,
output: OutputFmt::Toon,
session_idle_timeout: default_idle(),
audit_log: true,
audit_log_path: default_audit_path(),
audit_max_bytes: default_audit_max_bytes(),
audit_keep_files: default_audit_keep_files(),
guards: Guards::default(),
keepalive: default_keepalive(),
connect_timeout: default_connect_timeout(),
truncate_bytes: default_truncate(),
max_capture_bytes: default_max_capture(),
max_channels_per_host: default_max_channels(),
strict_host_key_checking: StrictHostKey::default(),
confirm_ttl: default_confirm_ttl(),
default_host: None,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StrictHostKey {
#[default]
Tofu,
Strict,
Off,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OutputFmt {
Toon,
Json,
Text,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Host {
pub addr: String,
pub user: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub auth: AuthMethod,
#[serde(default)]
pub key: Option<PathBuf>,
#[serde(default)]
pub keys: Option<Vec<PathBuf>>,
#[serde(default)]
pub guards: Option<Guards>,
#[serde(default)]
pub known_host_fingerprint: Option<String>,
#[serde(default)]
pub proxy_jump: Option<String>,
}
impl Host {
pub fn all_keys(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(k) = &self.key {
out.push(k.clone());
}
if let Some(extra) = &self.keys {
for k in extra {
if !out.iter().any(|p| p == k) {
out.push(k.clone());
}
}
}
out
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
#[default]
Key,
Agent,
Password,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Guards {
#[serde(default = "default_true")]
pub use_default_deny: bool,
#[serde(default = "default_true")]
pub use_default_confirm: bool,
#[serde(default)]
pub deny: Vec<NamedPattern>,
#[serde(default)]
pub confirm: Vec<NamedPattern>,
#[serde(default)]
pub read_only: bool,
}
impl Default for Guards {
fn default() -> Self {
Self {
use_default_deny: true,
use_default_confirm: true,
deny: Vec::new(),
confirm: Vec::new(),
read_only: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NamedPattern {
pub name: String,
pub pattern: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HumanDuration(pub Duration);
impl<'de> Deserialize<'de> for HumanDuration {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
parse_duration(&s)
.map(HumanDuration)
.map_err(serde::de::Error::custom)
}
}
impl Serialize for HumanDuration {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&format!("{}s", self.0.as_secs()))
}
}
fn parse_duration(s: &str) -> std::result::Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
let n: u64 = num
.parse()
.map_err(|e| format!("bad number in duration '{s}': {e}"))?;
let mult = match unit.trim() {
"" | "s" | "sec" | "secs" => 1,
"ms" => 0,
"m" | "min" | "mins" => 60,
"h" | "hr" | "hrs" => 3600,
"d" | "day" | "days" => 86400,
other => return Err(format!("unknown unit '{other}' in duration '{s}'")),
};
if mult == 0 {
Ok(Duration::from_millis(n))
} else {
let secs = n
.checked_mul(mult)
.ok_or_else(|| format!("duration '{s}' overflows"))?;
Ok(Duration::from_secs(secs))
}
}
fn default_true() -> bool {
true
}
fn default_port() -> u16 {
22
}
fn default_output() -> OutputFmt {
OutputFmt::Toon
}
fn default_idle() -> HumanDuration {
HumanDuration(Duration::from_secs(900))
}
fn default_keepalive() -> HumanDuration {
HumanDuration(Duration::from_secs(30))
}
fn default_connect_timeout() -> HumanDuration {
HumanDuration(Duration::from_secs(15))
}
fn default_truncate() -> usize {
32 * 1024
}
fn default_max_capture() -> usize {
256 * 1024
}
fn default_max_channels() -> usize {
8
}
fn default_confirm_ttl() -> HumanDuration {
HumanDuration(Duration::from_secs(900))
}
fn default_audit_max_bytes() -> u64 {
16 * 1024 * 1024
}
fn default_audit_keep_files() -> usize {
5
}
fn default_audit_path() -> PathBuf {
config_dir().join("audit.log")
}
pub fn config_dir() -> PathBuf {
if let Ok(env) = std::env::var("FAST_MCP_SSH_HOME") {
return PathBuf::from(env);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".fast-mcp-ssh")
}
pub fn default_config_path() -> PathBuf {
config_dir().join("hosts.toml")
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(SshError::Config(format!(
"config not found at {} — see hosts.example.toml",
path.display()
)));
}
warn_if_world_readable(path, "hosts.toml");
let raw = std::fs::read_to_string(path)?;
let mut cfg: Config = toml::from_str(&raw)?;
if cfg.defaults.import_ssh_config {
cfg.merge_ssh_config();
}
cfg.expand_paths();
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
if let Some(dh) = &self.defaults.default_host
&& !self.hosts.contains_key(dh)
{
return Err(SshError::Config(format!(
"default_host '{dh}' is not declared in [host.*]"
)));
}
for (name, h) in &self.hosts {
if matches!(h.auth, AuthMethod::Key) && h.all_keys().is_empty() {
return Err(SshError::Config(format!(
"host '{name}': auth = \"key\" but no `key` or `keys[]` set"
)));
}
if h.addr.trim().is_empty() {
return Err(SshError::Config(format!("host '{name}': addr is empty")));
}
if h.user.trim().is_empty() {
return Err(SshError::Config(format!("host '{name}': user is empty")));
}
if matches!(h.auth, AuthMethod::Key) {
for k in h.all_keys() {
check_key_file(name, &k)?;
}
}
if let Some(fp) = &h.known_host_fingerprint {
check_fingerprint(name, fp)?;
}
if let Some(pj) = &h.proxy_jump {
if !self.hosts.contains_key(pj) {
return Err(SshError::Config(format!(
"host '{name}': proxy_jump = '{pj}' is not declared in [host.*]"
)));
}
if pj == name {
return Err(SshError::Config(format!(
"host '{name}': proxy_jump cannot reference self"
)));
}
}
}
for name in self.hosts.keys() {
let mut seen = std::collections::HashSet::new();
let mut cur = name.as_str();
seen.insert(cur);
while let Some(next) = self.hosts.get(cur).and_then(|h| h.proxy_jump.as_deref()) {
if !seen.insert(next) {
return Err(SshError::Config(format!(
"host '{name}': proxy_jump cycle detected through '{next}'"
)));
}
cur = next;
}
}
Ok(())
}
pub fn host(&self, name: &str) -> Result<&Host> {
self.hosts
.get(name)
.ok_or_else(|| SshError::UnknownHost(name.to_string()))
}
pub fn host_names(&self) -> Vec<String> {
let mut v: Vec<_> = self.hosts.keys().cloned().collect();
v.sort();
v
}
fn expand_paths(&mut self) {
for h in self.hosts.values_mut() {
if let Some(k) = &h.key
&& let Some(s) = k.to_str()
&& let Ok(expanded) = shellexpand::full(s)
{
h.key = Some(PathBuf::from(expanded.into_owned()));
}
if let Some(extra) = h.keys.as_mut() {
for k in extra.iter_mut() {
if let Some(s) = k.to_str()
&& let Ok(expanded) = shellexpand::full(s)
{
*k = PathBuf::from(expanded.into_owned());
}
}
}
}
if let Some(s) = self.defaults.audit_log_path.to_str()
&& let Ok(expanded) = shellexpand::full(s)
{
self.defaults.audit_log_path = PathBuf::from(expanded.into_owned());
}
}
fn merge_ssh_config(&mut self) {
let p = match dirs::home_dir() {
Some(h) => h.join(".ssh").join("config"),
None => return,
};
if !p.exists() {
return;
}
let parsed = match crate::ssh_config::SshConfig::parse_file(&p) {
Ok(c) => c,
Err(e) => {
tracing::warn!(?e, "parse ~/.ssh/config failed");
return;
}
};
let mut jumps: Vec<(String, String)> = Vec::new();
for alias in parsed.list_aliases() {
if self.hosts.contains_key(&alias) {
continue;
}
let resolved = parsed.query(&alias);
let Some(addr) = resolved.host_name.clone() else {
continue;
};
let user = resolved.user.clone().unwrap_or_else(|| "root".into());
let port = resolved.port.unwrap_or(22);
let key = resolved
.identity_files
.iter()
.filter_map(|f| {
shellexpand::full(f)
.ok()
.map(|e| PathBuf::from(e.into_owned()))
})
.find(|p| p.is_file());
if let Some(pj) = resolved.proxy_jump.as_deref().and_then(jump_alias) {
jumps.push((alias.clone(), pj));
}
self.hosts.insert(
alias,
Host {
addr,
user,
port,
auth: if key.is_some() {
AuthMethod::Key
} else {
AuthMethod::Agent
},
key,
keys: None,
guards: None,
known_host_fingerprint: None,
proxy_jump: None,
},
);
}
for (alias, target) in jumps {
if alias == target || !self.hosts.contains_key(&target) {
continue;
}
if self.jump_would_cycle(&alias, &target) {
continue;
}
if let Some(h) = self.hosts.get_mut(&alias) {
h.proxy_jump = Some(target);
}
}
}
fn jump_would_cycle(&self, from: &str, to: &str) -> bool {
let mut cur = to;
for _ in 0..self.hosts.len() + 1 {
if cur == from {
return true;
}
match self.hosts.get(cur).and_then(|h| h.proxy_jump.as_deref()) {
Some(next) => cur = next,
None => return false,
}
}
true
}
}
fn check_key_file(host: &str, path: &Path) -> Result<()> {
let meta = std::fs::metadata(path).map_err(|e| {
SshError::Config(format!(
"host '{host}': key '{}' is unreadable: {e}",
path.display()
))
})?;
if !meta.is_file() {
return Err(SshError::Config(format!(
"host '{host}': key '{}' is not a regular file",
path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
host = %host,
path = %path.display(),
mode = format!("{:04o}", meta.permissions().mode() & 0o7777),
"private key is group- or world-accessible; chmod 600 it"
);
}
}
Ok(())
}
fn check_fingerprint(host: &str, fp: &str) -> Result<()> {
const SHAPE: &str = "expected 'SHA256:<43 base64 chars>' — \
the bare second field of `ssh-keygen -lf <key>`, no bits, no comment";
let Some(body) = fp.strip_prefix("SHA256:") else {
return Err(SshError::Config(format!(
"host '{host}': known_host_fingerprint '{fp}' has no SHA256: prefix — {SHAPE}"
)));
};
let body = body.trim_end_matches('=');
let ok = body.len() == 43
&& body
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
if !ok {
return Err(SshError::Config(format!(
"host '{host}': known_host_fingerprint '{fp}' is not a base64 SHA-256 digest — {SHAPE}"
)));
}
Ok(())
}
fn warn_if_world_readable(path: &Path, label: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{:04o}", mode & 0o7777),
"{label} is group- or world-readable; chmod 600 it"
);
}
}
}
#[cfg(not(unix))]
{
let _ = (path, label);
}
}
fn jump_alias(value: &str) -> Option<String> {
let first = value.split(',').next()?.trim();
if first.is_empty() || first.eq_ignore_ascii_case("none") {
return None;
}
let host = first.rsplit('@').next()?;
let host = match host.rsplit_once(':') {
Some((h, port)) if !h.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
_ => host,
};
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
pub fn default_deny_patterns() -> Vec<NamedPattern> {
DEFAULT_DENY
.iter()
.map(|(n, p)| NamedPattern {
name: (*n).into(),
pattern: (*p).into(),
})
.collect()
}
pub fn default_confirm_patterns() -> Vec<NamedPattern> {
DEFAULT_CONFIRM
.iter()
.map(|(n, p)| NamedPattern {
name: (*n).into(),
pattern: (*p).into(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_duration_units() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("15m").unwrap(), Duration::from_secs(900));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
assert!(parse_duration("xyz").is_err());
}
#[test]
fn parse_minimal_toml() {
let raw = r#"
[host.test]
addr = "1.2.3.4"
user = "root"
"#;
let c: Config = toml::from_str(raw).unwrap();
assert_eq!(c.hosts.len(), 1);
let h = &c.hosts["test"];
assert_eq!(h.port, 22);
assert_eq!(h.user, "root");
assert!(matches!(h.auth, AuthMethod::Key));
}
#[test]
fn validate_rejects_unknown_default_host() {
let raw = r#"
[defaults]
default_host = "nope"
[host.real]
addr = "1.2.3.4"
user = "root"
"#;
let c: Config = toml::from_str(raw).unwrap();
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("default_host"), "got: {err}");
}
#[test]
fn validate_rejects_auth_key_without_key() {
let raw = r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "key"
"#;
let c: Config = toml::from_str(raw).unwrap();
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("auth"), "got: {err}");
}
fn cfg_with_keys(dir: &tempfile::TempDir) -> Config {
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::write(&a, "k").unwrap();
std::fs::write(&b, "k").unwrap();
let raw = format!(
r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "key"
keys = [{:?}, {:?}]
"#,
a.to_string_lossy(),
b.to_string_lossy()
);
toml::from_str(&raw).unwrap()
}
#[test]
fn validate_accepts_multi_keys() {
let dir = tempfile::tempdir().unwrap();
let c = cfg_with_keys(&dir);
c.validate().expect("ok");
let h = &c.hosts["k"];
assert_eq!(h.all_keys().len(), 2);
}
#[test]
fn validate_rejects_missing_key_file() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope");
let raw = format!(
r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "key"
key = {:?}
"#,
missing.to_string_lossy()
);
let c: Config = toml::from_str(&raw).unwrap();
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("unreadable"), "got: {err}");
}
#[test]
fn validate_rejects_key_that_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
let raw = format!(
r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "key"
key = {:?}
"#,
dir.path().to_string_lossy()
);
let c: Config = toml::from_str(&raw).unwrap();
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("regular file"), "got: {err}");
}
#[test]
fn validate_ignores_key_path_for_agent_auth() {
let raw = r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "agent"
key = "/definitely/not/here"
"#;
let c: Config = toml::from_str(raw).unwrap();
c.validate().expect("agent auth never reads the key path");
}
fn cfg_with_fingerprint(fp: &str) -> Config {
let raw = format!(
r#"
[host.k]
addr = "1.2.3.4"
user = "root"
auth = "agent"
known_host_fingerprint = "{fp}"
"#
);
toml::from_str(&raw).unwrap()
}
#[test]
fn validate_accepts_well_formed_fingerprint() {
let fp = format!("SHA256:{}", "A".repeat(43));
cfg_with_fingerprint(&fp).validate().expect("ok");
}
#[test]
fn validate_rejects_md5_fingerprint() {
let c = cfg_with_fingerprint("MD5:aa:bb:cc:dd");
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("SHA256:"), "got: {err}");
}
#[test]
fn validate_rejects_fingerprint_with_comment() {
let fp = format!("SHA256:{} user@host (ED25519)", "A".repeat(43));
let c = cfg_with_fingerprint(&fp);
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("base64"), "got: {err}");
}
#[test]
fn parse_duration_rejects_overflow() {
assert!(parse_duration("999999999999999999d").is_err());
assert!(parse_duration("18446744073709551615h").is_err());
assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
}
#[test]
fn import_ssh_config_is_opt_in() {
let c: Config = toml::from_str("").unwrap();
assert!(!c.defaults.import_ssh_config);
assert!(!Defaults::default().import_ssh_config);
}
#[test]
fn audit_rotation_defaults() {
let c: Config = toml::from_str("").unwrap();
assert_eq!(c.defaults.audit_max_bytes, 16 * 1024 * 1024);
assert_eq!(c.defaults.audit_keep_files, 5);
}
#[test]
fn parse_full_toml() {
let raw = r#"
[defaults]
output = "json"
session_idle_timeout = "5m"
[host.box1]
addr = "10.0.0.1"
user = "ops"
port = 2222
auth = "agent"
"#;
let c: Config = toml::from_str(raw).unwrap();
assert_eq!(c.defaults.output, OutputFmt::Json);
assert_eq!(c.defaults.session_idle_timeout.0, Duration::from_secs(300));
assert_eq!(c.hosts["box1"].port, 2222);
assert!(matches!(c.hosts["box1"].auth, AuthMethod::Agent));
}
}