use crate::{
core::{config_cache::ConfigCache, settings_manager::agent_dir},
utils::paths::read_json_file,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::RwLock,
};
const CONFIG_SUBDIR: &str = "extensions";
const EXTENSION_DIR: &str = "ssh-remote";
const CONFIG_FILE: &str = "config.json";
const KNOWN_HOSTS_FILE: &str = "known-hosts.json";
static CONFIG_LOCK: RwLock<()> = RwLock::new(());
static CONFIG_CACHE: ConfigCache = ConfigCache::new();
static KNOWN_HOSTS_LOCK: RwLock<()> = RwLock::new(());
static KNOWN_HOSTS_CACHE: ConfigCache = ConfigCache::new();
pub const FALLBACK_REMOTE_CWD: &str = "~";
pub const DEFAULT_TIMEOUT_SECONDS: u32 = 30;
pub const MAX_TIMEOUT_SECONDS: u32 = 2_147_483;
fn root_dir() -> PathBuf {
#[cfg(any(test, feature = "test-support"))]
if let Some(dir) = TEST_ROOT.lock().unwrap().clone() {
return dir;
}
agent_dir()
}
pub fn config_dir() -> PathBuf {
root_dir().join(CONFIG_SUBDIR).join(EXTENSION_DIR)
}
pub fn config_path() -> PathBuf {
config_dir().join(CONFIG_FILE)
}
pub fn known_hosts_path() -> PathBuf {
config_dir().join(KNOWN_HOSTS_FILE)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Endpoint {
#[serde(default)]
pub command: String,
#[serde(default)]
pub host: String,
#[serde(default)]
pub user: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jump: Option<String>,
#[serde(default)]
pub note: String,
#[serde(default = "fallback_cwd")]
pub cwd: String,
}
fn default_port() -> u16 {
22
}
fn fallback_cwd() -> String {
FALLBACK_REMOTE_CWD.to_string()
}
impl Endpoint {
pub fn key(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
pub fn display(&self) -> String {
if self.note.trim().is_empty() {
self.key()
} else {
format!("{} ({})", self.note.trim(), self.key())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Limits {
#[serde(default = "default_read_lines")]
pub read_max_lines: usize,
#[serde(default = "default_read_bytes")]
pub read_max_bytes: usize,
#[serde(default = "default_exec_lines")]
pub exec_max_lines: usize,
#[serde(default = "default_exec_bytes")]
pub exec_max_bytes: usize,
#[serde(default = "default_turn_bytes")]
pub turn_max_bytes: usize,
#[serde(default = "default_timeout")]
pub timeout_seconds: u32,
}
fn default_read_lines() -> usize {
400
}
fn default_read_bytes() -> usize {
16 * 1024
}
fn default_exec_lines() -> usize {
200
}
fn default_exec_bytes() -> usize {
8 * 1024
}
fn default_turn_bytes() -> usize {
32 * 1024
}
fn default_timeout() -> u32 {
DEFAULT_TIMEOUT_SECONDS
}
impl Default for Limits {
fn default() -> Self {
Self {
read_max_lines: default_read_lines(),
read_max_bytes: default_read_bytes(),
exec_max_lines: default_exec_lines(),
exec_max_bytes: default_exec_bytes(),
turn_max_bytes: default_turn_bytes(),
timeout_seconds: default_timeout(),
}
}
}
impl Limits {
fn from_value(v: &Value) -> Self {
let d = Self::default();
let get = |key: &str, fallback: usize, max: usize| -> usize {
v.get(key)
.and_then(|x| x.as_u64())
.map(|n| (n as usize).clamp(1, max))
.unwrap_or(fallback)
};
Self {
read_max_lines: get("readMaxLines", d.read_max_lines, 100_000),
read_max_bytes: get("readMaxBytes", d.read_max_bytes, 64 * 1024 * 1024),
exec_max_lines: get("execMaxLines", d.exec_max_lines, 100_000),
exec_max_bytes: get("execMaxBytes", d.exec_max_bytes, 64 * 1024 * 1024),
turn_max_bytes: get("turnMaxBytes", d.turn_max_bytes, 512 * 1024 * 1024),
timeout_seconds: v
.get("timeoutSeconds")
.and_then(|x| x.as_u64())
.map(|n| n.clamp(1, MAX_TIMEOUT_SECONDS as u64) as u32)
.unwrap_or(d.timeout_seconds),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TunnelSpec {
#[serde(default)]
pub host: String,
pub local_port: u16,
pub remote_host: String,
pub remote_port: u16,
}
impl TunnelSpec {
pub fn parse(text: &str) -> Option<Self> {
let parts: Vec<&str> = text.split(':').collect();
let (host, rest) = match parts.len() {
3 => (String::new(), &parts[..]),
4 => (parts[0].to_string(), &parts[1..]),
_ => return None,
};
let local_port: u16 = rest[0].parse().ok()?;
let remote_host = rest[1].to_string();
let remote_port: u16 = rest[2].parse().ok()?;
if remote_host.is_empty() {
return None;
}
Some(Self {
host,
local_port,
remote_host,
remote_port,
})
}
pub fn to_spec_text(&self) -> String {
let core = format!(
"{}:{}:{}",
self.local_port, self.remote_host, self.remote_port
);
if self.host.is_empty() {
core
} else {
format!("{}:{core}", self.host)
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Endpoint>,
#[serde(default)]
pub known: Vec<Endpoint>,
#[serde(default)]
pub tunnels: Vec<TunnelSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
#[serde(flatten, default)]
pub limits: Limits,
}
impl RemoteConfig {
pub fn load() -> Self {
let path = config_path();
let _guard = CONFIG_LOCK.read().unwrap_or_else(|e| e.into_inner());
let value = CONFIG_CACHE.read(&path, || read_json_file(&path));
Self::from_value(&value)
}
fn from_value(value: &Value) -> Self {
let endpoint = |raw: &Value| -> Option<Endpoint> {
let mut ep = serde_json::from_value::<Endpoint>(raw.clone()).ok()?;
if ep.port == 0 {
ep.port = 22;
}
if ep.cwd.trim().is_empty() {
ep.cwd = FALLBACK_REMOTE_CWD.to_string();
}
Some(ep)
};
let default = value.get("default").and_then(endpoint);
let mut known = Vec::new();
if let Some(items) = value.get("known").and_then(|v| v.as_array()) {
for raw in items {
if let Some(ep) = endpoint(raw)
&& !ep.host.is_empty()
{
known.push(ep);
}
}
}
let mut tunnels = Vec::new();
if let Some(items) = value.get("tunnels").and_then(|v| v.as_array()) {
for raw in items {
if let Ok(spec) = serde_json::from_value::<TunnelSpec>(raw.clone()) {
tunnels.push(spec);
}
}
}
let proxy = value
.get("proxy")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Self {
default,
known,
tunnels,
proxy,
limits: Limits::from_value(value),
}
}
pub fn save(&self) -> Result<(), String> {
let path = config_path();
let _guard = CONFIG_LOCK.write().unwrap_or_else(|e| e.into_inner());
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
}
let value = serde_json::to_value(self).map_err(|e| format!("serialize config: {e}"))?;
let text =
serde_json::to_string_pretty(&value).map_err(|e| format!("serialize config: {e}"))?;
std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))?;
CONFIG_CACHE.store(&path, value);
Ok(())
}
pub fn remember(&mut self, ep: &Endpoint) -> bool {
let key = ep.key();
match self.known.iter_mut().find(|e| e.key() == key) {
Some(existing) => {
if existing == ep {
return false;
}
*existing = ep.clone();
true
}
None => {
self.known.push(ep.clone());
true
}
}
}
pub fn set_default(&mut self, ep: Endpoint) {
self.remember(&ep);
self.default = Some(ep);
}
pub fn default_key(&self) -> Option<String> {
self.default.as_ref().map(|e| e.key())
}
}
pub fn load_known_hosts() -> BTreeMap<String, String> {
let path = known_hosts_path();
let _guard = KNOWN_HOSTS_LOCK.read().unwrap_or_else(|e| e.into_inner());
read_known_hosts(&path)
}
fn read_known_hosts(path: &Path) -> BTreeMap<String, String> {
let value = KNOWN_HOSTS_CACHE.read(path, || read_json_file(path));
serde_json::from_value(value).unwrap_or_default()
}
fn write_known_hosts(path: &Path, hosts: &BTreeMap<String, String>) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
}
let value = serde_json::to_value(hosts).map_err(|e| format!("serialize known hosts: {e}"))?;
let text =
serde_json::to_string_pretty(&value).map_err(|e| format!("serialize known hosts: {e}"))?;
std::fs::write(path, text).map_err(|e| format!("write {}: {e}", path.display()))?;
KNOWN_HOSTS_CACHE.store(path, value);
Ok(())
}
pub fn save_known_host(id: &str, openssh_key: &str) -> Result<(), String> {
let path = known_hosts_path();
let _guard = KNOWN_HOSTS_LOCK.write().unwrap_or_else(|e| e.into_inner());
let mut hosts = read_known_hosts(&path);
hosts.insert(id.to_string(), openssh_key.to_string());
write_known_hosts(&path, &hosts)
}
pub fn forget_known_host(id: &str) -> bool {
let path = known_hosts_path();
let _guard = KNOWN_HOSTS_LOCK.write().unwrap_or_else(|e| e.into_inner());
let mut hosts = read_known_hosts(&path);
if hosts.remove(id).is_none() {
return false;
}
write_known_hosts(&path, &hosts).is_ok()
}
pub fn forget_all_known_hosts() -> usize {
let path = known_hosts_path();
let _guard = KNOWN_HOSTS_LOCK.write().unwrap_or_else(|e| e.into_inner());
let count = read_known_hosts(&path).len();
if count == 0 {
return 0;
}
match write_known_hosts(&path, &BTreeMap::new()) {
Ok(()) => count,
Err(_) => 0, }
}
#[cfg(any(test, feature = "test-support"))]
#[allow(dead_code)]
pub struct TestRootGuard {
prev: Option<PathBuf>,
_dir: Option<tempfile::TempDir>,
}
#[cfg(any(test, feature = "test-support"))]
#[allow(dead_code)]
impl TestRootGuard {
pub fn temp() -> Self {
let dir = tempfile::tempdir().expect("tempdir for ssh-remote state");
let prev = TEST_ROOT.lock().unwrap().replace(dir.path().to_path_buf());
Self {
prev,
_dir: Some(dir),
}
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestRootGuard {
fn drop(&mut self) {
*TEST_ROOT.lock().unwrap() = self.prev.take();
}
}
#[cfg(any(test, feature = "test-support"))]
static TEST_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
#[cfg(any(test, feature = "test-support"))]
#[allow(dead_code)]
pub static TEST_ROOT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_key_and_display() {
let ep = Endpoint {
user: "root".into(),
host: "gpu".into(),
port: 2202,
note: "H100".into(),
cwd: "/srv/x".into(),
..Default::default()
};
assert_eq!(ep.key(), "root@gpu:2202");
assert_eq!(ep.display(), "H100 (root@gpu:2202)");
}
#[test]
fn tunnel_spec_roundtrip() {
let t = TunnelSpec::parse("8080:localhost:80").unwrap();
assert_eq!(t.local_port, 8080);
assert_eq!(t.remote_host, "localhost");
assert_eq!(t.remote_port, 80);
assert!(t.host.is_empty());
assert_eq!(t.to_spec_text(), "8080:localhost:80");
let t = TunnelSpec::parse("root@gpu:5432:db:5432").unwrap();
assert_eq!(t.host, "root@gpu");
assert_eq!(t.remote_host, "db");
assert_eq!(t.to_spec_text(), "root@gpu:5432:db:5432");
assert!(TunnelSpec::parse("8080:localhost").is_none());
assert!(TunnelSpec::parse("a:b:c:d:e").is_none());
}
#[test]
fn limits_clamp_from_value() {
let limits = Limits::from_value(
&serde_json::json!({ "readMaxLines": 0, "timeoutSeconds": 999999999 }),
);
assert_eq!(limits.read_max_lines, 1);
assert_eq!(limits.timeout_seconds, MAX_TIMEOUT_SECONDS);
}
#[test]
fn remember_dedups_by_key() {
let _l = TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
let ep = Endpoint {
host: "gpu".into(),
user: "root".into(),
port: 22,
..Default::default()
};
let mut cfg = RemoteConfig::default();
assert!(cfg.remember(&ep));
assert!(!cfg.remember(&ep), "同样的端点不应重复记录");
let mut moved = ep.clone();
moved.cwd = "/srv".into();
assert!(cfg.remember(&moved));
assert_eq!(cfg.known.len(), 1);
assert_eq!(cfg.known[0].cwd, "/srv");
cfg.set_default(moved);
assert_eq!(cfg.default_key().as_deref(), Some("root@gpu:22"));
}
#[test]
fn proxy_roundtrips_and_absent_stays_absent() {
let _l = TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
let cfg = RemoteConfig {
proxy: Some("socks5://127.0.0.1:1080".into()),
..Default::default()
};
cfg.save().unwrap();
assert_eq!(
RemoteConfig::load().proxy.as_deref(),
Some("socks5://127.0.0.1:1080")
);
RemoteConfig::default().save().unwrap();
let text = std::fs::read_to_string(config_path()).unwrap();
assert!(!text.contains("proxy"), "未配置代理时不应写出该键");
assert!(RemoteConfig::load().proxy.is_none());
}
#[test]
fn config_cache_follows_external_edits() {
let _l = TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
let mut cfg = RemoteConfig::default();
cfg.set_default(Endpoint {
host: "gpu".into(),
user: "root".into(),
port: 22,
..Default::default()
});
cfg.save().unwrap();
assert_eq!(
RemoteConfig::load().default_key().as_deref(),
Some("root@gpu:22")
);
std::fs::write(
config_path(),
r#"{"known":[{"host":"h2","user":"u","port":2200}]}"#,
)
.unwrap();
let reloaded = RemoteConfig::load();
assert!(reloaded.default.is_none());
assert_eq!(reloaded.known.len(), 1);
assert_eq!(reloaded.known[0].key(), "u@h2:2200");
std::fs::remove_file(config_path()).unwrap();
assert!(RemoteConfig::load().known.is_empty());
}
#[test]
fn known_hosts_roundtrip_and_forget() {
let _l = TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
save_known_host("h:22", "ssh-ed25519 AAAA").unwrap();
assert_eq!(
load_known_hosts().get("h:22").map(String::as_str),
Some("ssh-ed25519 AAAA")
);
assert!(forget_known_host("h:22"));
assert!(load_known_hosts().is_empty());
assert!(!forget_known_host("h:22"), "重复删除应返回 false");
}
}