1use clap::Parser;
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6
7use crate::error::{Result, SshMcpError};
8use crate::ssh::HostKeyCheckMode;
9
10pub const DEFAULT_TIMEOUT_MS: u64 = 300_000; pub const DEFAULT_MAX_CHARS: Option<usize> = Some(64_000);
15
16pub const CONNECTION_TIMEOUT_SECS: u64 = 30;
18
19pub const DEFAULT_RECONNECT_RETRIES: u64 = 3;
21
22pub const DEFAULT_RECONNECT_BACKOFF_MS: u64 = 250;
24
25pub const DEFAULT_HEALTH_PROBE_TIMEOUT_MS: u64 = 1500;
27
28pub const MAX_RECONNECT_RETRIES: u64 = 10;
30
31pub const MIN_RECONNECT_BACKOFF_MS: u64 = 10;
33
34pub const MAX_RECONNECT_BACKOFF_MS: u64 = 30_000;
36
37pub const MIN_HEALTH_PROBE_TIMEOUT_MS: u64 = 100;
39
40pub const MAX_HEALTH_PROBE_TIMEOUT_MS: u64 = 30_000;
42
43#[derive(Parser, Debug, Clone)]
45#[command(name = "ssh-mcp")]
46#[command(author = "0FL01")]
47#[command(version = env!("CARGO_PKG_VERSION"))]
48#[command(about = env!("CARGO_PKG_DESCRIPTION"))]
49pub struct Args {
50 #[arg(long, env = "SSH_MCP_HOST")]
52 pub host: String,
53
54 #[arg(long, default_value = "22", env = "SSH_MCP_PORT")]
56 pub port: u16,
57
58 #[arg(long, env = "SSH_MCP_USER")]
60 pub user: String,
61
62 #[arg(long, env = "SSH_MCP_PASSWORD")]
64 pub password: Option<String>,
65
66 #[arg(long, env = "SSH_MCP_KEY")]
68 pub key: Option<PathBuf>,
69
70 #[arg(long, env = "SSH_MCP_SPOOL_DIR")]
72 pub spool_dir: Option<PathBuf>,
73
74 #[arg(long, env = "SSH_MCP_SU_PASSWORD")]
76 pub su_password: Option<String>,
77
78 #[arg(long, env = "SSH_MCP_SUDO_PASSWORD")]
80 pub sudo_password: Option<String>,
81
82 #[arg(long, default_value = "300000", env = "SSH_MCP_TIMEOUT")]
84 pub timeout: u64,
85
86 #[arg(long = "maxChars", env = "SSH_MCP_MAX_CHARS")]
90 pub max_chars: Option<String>,
91
92 #[arg(long, default_value = "false", env = "SSH_MCP_DISABLE_SUDO")]
94 pub disable_sudo: bool,
95
96 #[arg(long = "max-output-tokens", env = "SSH_MCP_MAX_OUTPUT_TOKENS")]
101 pub max_output_tokens: Option<String>,
102
103 #[arg(long, default_value = "info", env = "SSH_MCP_LOG_LEVEL", value_parser = clap::builder::PossibleValuesParser::new(["trace", "debug", "info", "warn", "error"]))]
105 pub log_level: String,
106
107 #[arg(long, env = "SSH_MCP_LOG_FILE")]
109 pub log_file: Option<PathBuf>,
110
111 #[arg(long, default_value = "text", env = "SSH_MCP_LOG_FORMAT", value_parser = clap::builder::PossibleValuesParser::new(["text", "json"]))]
113 pub log_format: String,
114
115 #[arg(long, default_value = "daily", env = "SSH_MCP_LOG_ROTATION", value_parser = clap::builder::PossibleValuesParser::new(["daily", "hourly", "never"]))]
117 pub log_rotation: String,
118
119 #[arg(long, default_value = "30", env = "SSH_MCP_KEEPALIVE_INTERVAL")]
122 pub keepalive_interval: u64,
123
124 #[arg(long, default_value = "3", env = "SSH_MCP_KEEPALIVE_MAX")]
127 pub keepalive_max: u64,
128
129 #[arg(long, default_value = "3", env = "SSH_MCP_RECONNECT_RETRIES")]
131 pub reconnect_retries: u64,
132
133 #[arg(long, default_value = "250", env = "SSH_MCP_RECONNECT_BACKOFF_MS")]
135 pub reconnect_backoff_ms: u64,
136
137 #[arg(long, default_value = "1500", env = "SSH_MCP_HEALTH_PROBE_TIMEOUT_MS")]
139 pub health_probe_timeout_ms: u64,
140
141 #[arg(
143 long = "strict-host-key-checking",
144 env = "SSH_MCP_STRICT_HOST_KEY_CHECKING",
145 value_enum,
146 default_value_t = HostKeyCheckMode::AcceptNew
147 )]
148 pub strict_host_key_checking: HostKeyCheckMode,
149
150 #[arg(long = "known-hosts", env = "SSH_MCP_KNOWN_HOSTS")]
152 pub known_hosts: Option<PathBuf>,
153}
154
155#[derive(Debug, Clone)]
157pub struct Config {
158 pub host: String,
160
161 pub port: u16,
163
164 pub user: String,
166
167 pub password: Option<String>,
169
170 pub key: Option<PathBuf>,
172
173 pub su_password: Option<String>,
175
176 pub sudo_password: Option<String>,
178
179 pub timeout_ms: u64,
181
182 pub max_chars: Option<usize>,
184
185 pub max_output_tokens: Option<usize>,
187
188 pub disable_sudo: bool,
190
191 pub keepalive_interval: u64,
193
194 pub keepalive_max: u64,
196
197 pub reconnect_retries: u64,
199
200 pub reconnect_backoff_ms: u64,
202
203 pub health_probe_timeout_ms: u64,
205
206 pub strict_host_key_checking: HostKeyCheckMode,
208
209 pub known_hosts: Option<PathBuf>,
211}
212
213impl Config {
214 pub fn from_args(args: Args) -> Result<Self> {
216 let home = std::env::var_os("HOME");
217 Self::from_args_with_home(args, home.as_deref())
218 }
219
220 fn from_args_with_home(mut args: Args, home: Option<&OsStr>) -> Result<Self> {
221 args.key = args
222 .key
223 .map(|path| expand_key_path(path, home))
224 .transpose()?;
225 validate_args(&args)?;
226
227 let max_chars = parse_max_chars(args.max_chars.as_deref());
228 let max_output_tokens = parse_max_output_tokens(args.max_output_tokens.as_deref());
229
230 Ok(Config {
231 host: args.host,
232 port: args.port,
233 user: args.user,
234 password: sanitize_password(args.password),
235 key: args.key,
236 su_password: sanitize_password(args.su_password),
237 sudo_password: sanitize_password(args.sudo_password),
238 timeout_ms: args.timeout,
239 max_chars,
240 max_output_tokens,
241 disable_sudo: args.disable_sudo,
242 keepalive_interval: args.keepalive_interval,
243 keepalive_max: args.keepalive_max,
244 reconnect_retries: args.reconnect_retries,
245 reconnect_backoff_ms: args.reconnect_backoff_ms,
246 health_probe_timeout_ms: args.health_probe_timeout_ms,
247 strict_host_key_checking: args.strict_host_key_checking,
248 known_hosts: args.known_hosts,
249 })
250 }
251}
252
253fn expand_key_path(path: PathBuf, home: Option<&OsStr>) -> Result<PathBuf> {
254 if !path.as_os_str().as_encoded_bytes().starts_with(b"~/") {
255 return Ok(path);
256 }
257
258 let home = home.filter(|value| !value.is_empty()).ok_or_else(|| {
259 SshMcpError::Config(format!(
260 "Cannot expand SSH key path {}: HOME is not set",
261 path.display()
262 ))
263 })?;
264 let suffix = path
265 .strip_prefix("~")
266 .expect("leading ~/ path must have a tilde component");
267
268 Ok(Path::new(home).join(suffix))
269}
270
271fn validate_args(args: &Args) -> Result<()> {
273 let mut errors = Vec::new();
274
275 if args.host.is_empty() {
276 errors.push("Missing required --host".to_string());
277 }
278
279 if args.user.is_empty() {
280 errors.push("Missing required --user".to_string());
281 }
282
283 if args.password.is_none() && args.key.is_none() {
285 errors.push("Must provide either --password or --key".to_string());
286 }
287
288 if let Some(ref key_path) = args.key
290 && !key_path.exists()
291 {
292 errors.push(format!("SSH key file not found: {}", key_path.display()));
293 }
294
295 if args.reconnect_retries > MAX_RECONNECT_RETRIES {
296 errors.push(format!(
297 "--reconnect-retries must be <= {MAX_RECONNECT_RETRIES}"
298 ));
299 }
300
301 if !(MIN_RECONNECT_BACKOFF_MS..=MAX_RECONNECT_BACKOFF_MS).contains(&args.reconnect_backoff_ms) {
302 errors.push(format!(
303 "--reconnect-backoff-ms must be between {MIN_RECONNECT_BACKOFF_MS} and {MAX_RECONNECT_BACKOFF_MS}"
304 ));
305 }
306
307 if !(MIN_HEALTH_PROBE_TIMEOUT_MS..=MAX_HEALTH_PROBE_TIMEOUT_MS)
308 .contains(&args.health_probe_timeout_ms)
309 {
310 errors.push(format!(
311 "--health-probe-timeout-ms must be between {MIN_HEALTH_PROBE_TIMEOUT_MS} and {MAX_HEALTH_PROBE_TIMEOUT_MS}"
312 ));
313 }
314
315 if !errors.is_empty() {
316 return Err(SshMcpError::Config(format!(
317 "Configuration error:\n{}",
318 errors.join("\n")
319 )));
320 }
321
322 Ok(())
323}
324
325pub const DEFAULT_MAX_OUTPUT_TOKENS: Option<usize> = Some(16_000);
327
328pub fn parse_max_chars(value: Option<&str>) -> Option<usize> {
335 match value {
336 None => DEFAULT_MAX_CHARS,
337 Some(s) => {
338 let lowered = s.to_lowercase();
339 if lowered == "none" {
340 return None;
341 }
342
343 match s.parse::<i64>() {
344 Ok(n) if n <= 0 => None,
345 Ok(n) => Some(n as usize),
346 Err(_) => DEFAULT_MAX_CHARS,
347 }
348 }
349 }
350}
351
352pub fn parse_max_output_tokens(value: Option<&str>) -> Option<usize> {
359 match value {
360 None => DEFAULT_MAX_OUTPUT_TOKENS,
361 Some(s) => {
362 let lowered = s.to_lowercase().replace(" ", "");
363 if lowered == "none" {
364 return None;
365 }
366
367 if lowered.ends_with('k') {
369 let num_part = &lowered[..lowered.len() - 1];
370 match num_part.parse::<i64>() {
371 Ok(n) if n <= 0 => None,
372 Ok(n) => Some((n as usize).saturating_mul(1_000)),
373 Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
374 }
375 } else {
376 match lowered.parse::<i64>() {
377 Ok(n) if n <= 0 => None,
378 Ok(n) => Some(n as usize),
379 Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
380 }
381 }
382 }
383 }
384}
385
386fn sanitize_password(password: Option<String>) -> Option<String> {
388 password.filter(|p| !p.is_empty())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn base_args() -> Args {
396 Args {
397 host: "localhost".to_string(),
398 port: 22,
399 user: "test".to_string(),
400 password: Some("secret".to_string()),
401 key: None,
402 spool_dir: None,
403 su_password: None,
404 sudo_password: None,
405 timeout: DEFAULT_TIMEOUT_MS,
406 max_chars: None,
407 disable_sudo: false,
408 max_output_tokens: None,
409 log_level: "info".to_string(),
410 log_file: None,
411 log_format: "text".to_string(),
412 log_rotation: "daily".to_string(),
413 keepalive_interval: 30,
414 keepalive_max: 3,
415 reconnect_retries: DEFAULT_RECONNECT_RETRIES,
416 reconnect_backoff_ms: DEFAULT_RECONNECT_BACKOFF_MS,
417 health_probe_timeout_ms: DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
418 strict_host_key_checking: HostKeyCheckMode::AcceptNew,
419 known_hosts: None,
420 }
421 }
422
423 #[test]
424 fn test_parse_max_chars_none_string() {
425 assert_eq!(parse_max_chars(Some("none")), None);
426 assert_eq!(parse_max_chars(Some("None")), None);
427 assert_eq!(parse_max_chars(Some("NONE")), None);
428 }
429
430 #[test]
431 fn test_parse_max_chars_zero_or_negative() {
432 assert_eq!(parse_max_chars(Some("0")), None);
433 assert_eq!(parse_max_chars(Some("-1")), None);
434 assert_eq!(parse_max_chars(Some("-100")), None);
435 }
436
437 #[test]
438 fn test_parse_max_chars_positive() {
439 assert_eq!(parse_max_chars(Some("500")), Some(500));
440 assert_eq!(parse_max_chars(Some("2000")), Some(2000));
441 }
442
443 #[test]
444 fn test_parse_max_chars_invalid() {
445 assert_eq!(parse_max_chars(Some("abc")), DEFAULT_MAX_CHARS);
447 assert_eq!(parse_max_chars(Some("")), DEFAULT_MAX_CHARS);
448 }
449
450 #[test]
451 fn test_parse_max_chars_not_provided() {
452 assert_eq!(parse_max_chars(None), DEFAULT_MAX_CHARS);
453 }
454
455 #[test]
456 fn test_config_from_args_uses_default_max_chars() {
457 let config = Config::from_args(base_args()).unwrap();
458
459 assert_eq!(config.max_chars, Some(64_000));
460 assert_eq!(config.strict_host_key_checking, HostKeyCheckMode::AcceptNew);
461 assert!(config.known_hosts.is_none());
462 }
463
464 #[test]
465 fn test_config_expands_tilde_key_before_validation() {
466 let home = tempfile::tempdir().unwrap();
467 let key_path = home.path().join(".ssh/id_ed25519");
468 std::fs::create_dir_all(key_path.parent().unwrap()).unwrap();
469 std::fs::write(&key_path, "test key").unwrap();
470
471 let mut args = base_args();
472 args.password = None;
473 args.key = Some(PathBuf::from("~/.ssh/id_ed25519"));
474
475 let config = Config::from_args_with_home(args, Some(home.path().as_os_str())).unwrap();
476 assert_eq!(config.key, Some(key_path));
477 }
478
479 #[test]
480 fn test_expand_key_path_only_expands_leading_home_prefix() {
481 let home = OsStr::new("/home/test");
482 let cases = [
483 ("~", "~"),
484 ("~user/key", "~user/key"),
485 ("dir/~/key", "dir/~/key"),
486 ("$HOME/key", "$HOME/key"),
487 (r"~\key", r"~\key"),
488 ("/tmp/key", "/tmp/key"),
489 ];
490
491 for (input, expected) in cases {
492 assert_eq!(
493 expand_key_path(PathBuf::from(input), Some(home)).unwrap(),
494 PathBuf::from(expected)
495 );
496 }
497 assert_eq!(
498 expand_key_path(PathBuf::from("~/.ssh/id_ed25519"), Some(home)).unwrap(),
499 PathBuf::from("/home/test/.ssh/id_ed25519")
500 );
501 }
502
503 #[test]
504 fn test_expand_key_path_requires_home() {
505 for home in [None, Some(OsStr::new(""))] {
506 let error = expand_key_path(PathBuf::from("~/.ssh/id_ed25519"), home).unwrap_err();
507 assert!(error.to_string().contains("HOME is not set"));
508 }
509 }
510
511 #[test]
512 fn test_args_parse_host_key_options() {
513 let args = Args::try_parse_from([
514 "ssh-mcp",
515 "--host",
516 "example.com",
517 "--user",
518 "alice",
519 "--password",
520 "secret",
521 "--strict-host-key-checking",
522 "yes",
523 "--known-hosts",
524 "/tmp/known_hosts",
525 ])
526 .unwrap();
527
528 assert_eq!(args.strict_host_key_checking, HostKeyCheckMode::Yes);
529 assert_eq!(args.known_hosts, Some(PathBuf::from("/tmp/known_hosts")));
530 }
531
532 #[test]
533 fn test_args_parse_spool_dir() {
534 let args = Args::try_parse_from([
535 "ssh-mcp",
536 "--host",
537 "example.com",
538 "--user",
539 "alice",
540 "--password",
541 "secret",
542 "--spool-dir",
543 "/tmp/ssh-mcp-alice",
544 ])
545 .unwrap();
546
547 assert_eq!(args.spool_dir, Some(PathBuf::from("/tmp/ssh-mcp-alice")));
548 }
549
550 #[test]
551 fn test_sanitize_password() {
552 assert_eq!(
553 sanitize_password(Some("secret".to_string())),
554 Some("secret".to_string())
555 );
556 assert_eq!(sanitize_password(Some(String::new())), None);
557 assert_eq!(sanitize_password(None), None);
558 }
559
560 #[test]
561 fn test_parse_max_output_tokens_none_string() {
562 assert_eq!(parse_max_output_tokens(Some("none")), None);
563 assert_eq!(parse_max_output_tokens(Some("None")), None);
564 assert_eq!(parse_max_output_tokens(Some("NONE")), None);
565 }
566
567 #[test]
568 fn test_parse_max_output_tokens_zero_or_negative() {
569 assert_eq!(parse_max_output_tokens(Some("0")), None);
570 assert_eq!(parse_max_output_tokens(Some("-1")), None);
571 assert_eq!(parse_max_output_tokens(Some("-100")), None);
572 }
573
574 #[test]
575 fn test_parse_max_output_tokens_positive() {
576 assert_eq!(parse_max_output_tokens(Some("500")), Some(500));
577 assert_eq!(parse_max_output_tokens(Some("12000")), Some(12_000));
578 }
579
580 #[test]
581 fn test_parse_max_output_tokens_with_k_suffix() {
582 assert_eq!(parse_max_output_tokens(Some("12k")), Some(12_000));
583 assert_eq!(parse_max_output_tokens(Some("5K")), Some(5_000));
584 assert_eq!(parse_max_output_tokens(Some("100k")), Some(100_000));
585 }
586
587 #[test]
588 fn test_parse_max_output_tokens_invalid() {
589 assert_eq!(
591 parse_max_output_tokens(Some("abc")),
592 DEFAULT_MAX_OUTPUT_TOKENS
593 );
594 assert_eq!(parse_max_output_tokens(Some("")), DEFAULT_MAX_OUTPUT_TOKENS);
595 }
596
597 #[test]
598 fn test_parse_max_output_tokens_not_provided() {
599 assert_eq!(parse_max_output_tokens(None), DEFAULT_MAX_OUTPUT_TOKENS);
600 }
601
602 #[test]
603 fn test_validate_args_rejects_reconnect_retries_out_of_range() {
604 let mut args = base_args();
605 args.reconnect_retries = MAX_RECONNECT_RETRIES.saturating_add(1);
606
607 let result = validate_args(&args);
608 assert!(result.is_err());
609 }
610
611 #[test]
612 fn test_validate_args_rejects_reconnect_backoff_out_of_range() {
613 let mut args = base_args();
614 args.reconnect_backoff_ms = MIN_RECONNECT_BACKOFF_MS.saturating_sub(1);
615
616 let result = validate_args(&args);
617 assert!(result.is_err());
618 }
619
620 #[test]
621 fn test_validate_args_rejects_health_probe_timeout_out_of_range() {
622 let mut args = base_args();
623 args.health_probe_timeout_ms = MAX_HEALTH_PROBE_TIMEOUT_MS.saturating_add(1);
624
625 let result = validate_args(&args);
626 assert!(result.is_err());
627 }
628}