Skip to main content

ssh_mcp/
config.rs

1//! Configuration and CLI argument parsing for SSH MCP Server
2
3use clap::Parser;
4use std::path::PathBuf;
5
6use crate::error::{Result, SshMcpError};
7use crate::ssh::HostKeyCheckMode;
8
9/// Default timeout for command execution in milliseconds
10pub const DEFAULT_TIMEOUT_MS: u64 = 300_000; // 300 seconds
11
12/// Default max characters for command length (None = unlimited)
13pub const DEFAULT_MAX_CHARS: Option<usize> = Some(64_000);
14
15/// Connection timeout in seconds
16pub const CONNECTION_TIMEOUT_SECS: u64 = 30;
17
18/// Number of reconnect retries after the initial attempt
19pub const DEFAULT_RECONNECT_RETRIES: u64 = 3;
20
21/// Base reconnect backoff in milliseconds
22pub const DEFAULT_RECONNECT_BACKOFF_MS: u64 = 250;
23
24/// Health probe timeout in milliseconds
25pub const DEFAULT_HEALTH_PROBE_TIMEOUT_MS: u64 = 1500;
26
27/// Maximum reconnect retries allowed by configuration
28pub const MAX_RECONNECT_RETRIES: u64 = 10;
29
30/// Minimum reconnect backoff in milliseconds
31pub const MIN_RECONNECT_BACKOFF_MS: u64 = 10;
32
33/// Maximum reconnect backoff in milliseconds
34pub const MAX_RECONNECT_BACKOFF_MS: u64 = 30_000;
35
36/// Minimum health probe timeout in milliseconds
37pub const MIN_HEALTH_PROBE_TIMEOUT_MS: u64 = 100;
38
39/// Maximum health probe timeout in milliseconds
40pub const MAX_HEALTH_PROBE_TIMEOUT_MS: u64 = 30_000;
41
42/// SSH MCP Server CLI Arguments
43#[derive(Parser, Debug, Clone)]
44#[command(name = "ssh-mcp")]
45#[command(author = "0FL01")]
46#[command(version = env!("CARGO_PKG_VERSION"))]
47#[command(about = "MCP server exposing SSH control for Linux systems via Model Context Protocol")]
48pub struct Args {
49    /// SSH host to connect to
50    #[arg(long, env = "SSH_MCP_HOST")]
51    pub host: String,
52
53    /// SSH port
54    #[arg(long, default_value = "22", env = "SSH_MCP_PORT")]
55    pub port: u16,
56
57    /// SSH username
58    #[arg(long, env = "SSH_MCP_USER")]
59    pub user: String,
60
61    /// SSH password (alternative to key)
62    #[arg(long, env = "SSH_MCP_PASSWORD")]
63    pub password: Option<String>,
64
65    /// Path to SSH private key file (alternative to password)
66    #[arg(long, env = "SSH_MCP_KEY")]
67    pub key: Option<PathBuf>,
68
69    /// Password for `su` elevation
70    #[arg(long, env = "SSH_MCP_SU_PASSWORD")]
71    pub su_password: Option<String>,
72
73    /// Password for `sudo` commands (if different from su_password)
74    #[arg(long, env = "SSH_MCP_SUDO_PASSWORD")]
75    pub sudo_password: Option<String>,
76
77    /// Command execution timeout in milliseconds
78    #[arg(long, default_value = "300000", env = "SSH_MCP_TIMEOUT")]
79    pub timeout: u64,
80
81    /// Maximum characters for command length.
82    /// Use "none", "0", or negative value to disable limit.
83    /// Default: 64000
84    #[arg(long = "maxChars", env = "SSH_MCP_MAX_CHARS")]
85    pub max_chars: Option<String>,
86
87    /// Disable the sudo_shell and sudo_apply_patch tools
88    #[arg(long, default_value = "false", env = "SSH_MCP_DISABLE_SUDO")]
89    pub disable_sudo: bool,
90
91    /// Maximum output tokens for command execution.
92    /// Use "none" or "0" to disable limit.
93    /// Supports "k" suffix (e.g., "16k" for 16000).
94    /// Default: 16000 (approximately 64KB)
95    #[arg(long = "max-output-tokens", env = "SSH_MCP_MAX_OUTPUT_TOKENS")]
96    pub max_output_tokens: Option<String>,
97
98    /// Logging level: trace, debug, info, warn, error
99    #[arg(long, default_value = "info", env = "SSH_MCP_LOG_LEVEL", value_parser = clap::builder::PossibleValuesParser::new(["trace", "debug", "info", "warn", "error"]))]
100    pub log_level: String,
101
102    /// Log file path (default: stdout only)
103    #[arg(long, env = "SSH_MCP_LOG_FILE")]
104    pub log_file: Option<PathBuf>,
105
106    /// Log format: text or json
107    #[arg(long, default_value = "text", env = "SSH_MCP_LOG_FORMAT", value_parser = clap::builder::PossibleValuesParser::new(["text", "json"]))]
108    pub log_format: String,
109
110    /// Log rotation strategy: daily, hourly, never
111    #[arg(long, default_value = "daily", env = "SSH_MCP_LOG_ROTATION", value_parser = clap::builder::PossibleValuesParser::new(["daily", "hourly", "never"]))]
112    pub log_rotation: String,
113
114    /// Keepalive interval in seconds (default: 30)
115    /// Sends keepalive packets to maintain connection like a human user
116    #[arg(long, default_value = "30", env = "SSH_MCP_KEEPALIVE_INTERVAL")]
117    pub keepalive_interval: u64,
118
119    /// Maximum keepalive failures before disconnecting (default: 3)
120    /// Total idle timeout = keepalive_interval * keepalive_max
121    #[arg(long, default_value = "3", env = "SSH_MCP_KEEPALIVE_MAX")]
122    pub keepalive_max: u64,
123
124    /// Number of reconnect retries after the initial attempt (default: 3)
125    #[arg(long, default_value = "3", env = "SSH_MCP_RECONNECT_RETRIES")]
126    pub reconnect_retries: u64,
127
128    /// Base reconnect backoff in milliseconds (default: 250)
129    #[arg(long, default_value = "250", env = "SSH_MCP_RECONNECT_BACKOFF_MS")]
130    pub reconnect_backoff_ms: u64,
131
132    /// Health probe timeout in milliseconds for active session checks (default: 1500)
133    #[arg(long, default_value = "1500", env = "SSH_MCP_HEALTH_PROBE_TIMEOUT_MS")]
134    pub health_probe_timeout_ms: u64,
135
136    /// SSH host key checking mode: yes, accept-new, or no
137    #[arg(
138        long = "strict-host-key-checking",
139        env = "SSH_MCP_STRICT_HOST_KEY_CHECKING",
140        value_enum,
141        default_value_t = HostKeyCheckMode::AcceptNew
142    )]
143    pub strict_host_key_checking: HostKeyCheckMode,
144
145    /// Path to known_hosts file (default: OpenSSH user known_hosts)
146    #[arg(long = "known-hosts", env = "SSH_MCP_KNOWN_HOSTS")]
147    pub known_hosts: Option<PathBuf>,
148}
149
150/// Parsed and validated configuration
151#[derive(Debug, Clone)]
152pub struct Config {
153    /// SSH host
154    pub host: String,
155
156    /// SSH port
157    pub port: u16,
158
159    /// SSH username
160    pub user: String,
161
162    /// SSH password
163    pub password: Option<String>,
164
165    /// Path to SSH private key
166    pub key: Option<PathBuf>,
167
168    /// Password for su elevation
169    pub su_password: Option<String>,
170
171    /// Password for sudo commands
172    pub sudo_password: Option<String>,
173
174    /// Command timeout in milliseconds
175    pub timeout_ms: u64,
176
177    /// Maximum command length (None = unlimited)
178    pub max_chars: Option<usize>,
179
180    /// Maximum output tokens for command execution (None = unlimited)
181    pub max_output_tokens: Option<usize>,
182
183    /// Whether sudo_shell and sudo_apply_patch tools are disabled
184    pub disable_sudo: bool,
185
186    /// Keepalive interval in seconds
187    pub keepalive_interval: u64,
188
189    /// Maximum keepalive failures before disconnecting
190    pub keepalive_max: u64,
191
192    /// Number of reconnect retries after the initial attempt
193    pub reconnect_retries: u64,
194
195    /// Base reconnect backoff in milliseconds
196    pub reconnect_backoff_ms: u64,
197
198    /// Health probe timeout in milliseconds for active session checks
199    pub health_probe_timeout_ms: u64,
200
201    /// SSH host key checking mode
202    pub strict_host_key_checking: HostKeyCheckMode,
203
204    /// Optional known_hosts file path
205    pub known_hosts: Option<PathBuf>,
206}
207
208impl Config {
209    /// Create Config from CLI Args
210    pub fn from_args(args: Args) -> Result<Self> {
211        validate_args(&args)?;
212
213        let max_chars = parse_max_chars(args.max_chars.as_deref());
214        let max_output_tokens = parse_max_output_tokens(args.max_output_tokens.as_deref());
215
216        Ok(Config {
217            host: args.host,
218            port: args.port,
219            user: args.user,
220            password: sanitize_password(args.password),
221            key: args.key,
222            su_password: sanitize_password(args.su_password),
223            sudo_password: sanitize_password(args.sudo_password),
224            timeout_ms: args.timeout,
225            max_chars,
226            max_output_tokens,
227            disable_sudo: args.disable_sudo,
228            keepalive_interval: args.keepalive_interval,
229            keepalive_max: args.keepalive_max,
230            reconnect_retries: args.reconnect_retries,
231            reconnect_backoff_ms: args.reconnect_backoff_ms,
232            health_probe_timeout_ms: args.health_probe_timeout_ms,
233            strict_host_key_checking: args.strict_host_key_checking,
234            known_hosts: args.known_hosts,
235        })
236    }
237}
238
239/// Validate CLI arguments
240fn validate_args(args: &Args) -> Result<()> {
241    let mut errors = Vec::new();
242
243    if args.host.is_empty() {
244        errors.push("Missing required --host".to_string());
245    }
246
247    if args.user.is_empty() {
248        errors.push("Missing required --user".to_string());
249    }
250
251    // Must have either password or key
252    if args.password.is_none() && args.key.is_none() {
253        errors.push("Must provide either --password or --key".to_string());
254    }
255
256    // If key is provided, check if file exists
257    if let Some(ref key_path) = args.key
258        && !key_path.exists()
259    {
260        errors.push(format!("SSH key file not found: {}", key_path.display()));
261    }
262
263    if args.reconnect_retries > MAX_RECONNECT_RETRIES {
264        errors.push(format!(
265            "--reconnect-retries must be <= {MAX_RECONNECT_RETRIES}"
266        ));
267    }
268
269    if !(MIN_RECONNECT_BACKOFF_MS..=MAX_RECONNECT_BACKOFF_MS).contains(&args.reconnect_backoff_ms) {
270        errors.push(format!(
271            "--reconnect-backoff-ms must be between {MIN_RECONNECT_BACKOFF_MS} and {MAX_RECONNECT_BACKOFF_MS}"
272        ));
273    }
274
275    if !(MIN_HEALTH_PROBE_TIMEOUT_MS..=MAX_HEALTH_PROBE_TIMEOUT_MS)
276        .contains(&args.health_probe_timeout_ms)
277    {
278        errors.push(format!(
279            "--health-probe-timeout-ms must be between {MIN_HEALTH_PROBE_TIMEOUT_MS} and {MAX_HEALTH_PROBE_TIMEOUT_MS}"
280        ));
281    }
282
283    if !errors.is_empty() {
284        return Err(SshMcpError::Config(format!(
285            "Configuration error:\n{}",
286            errors.join("\n")
287        )));
288    }
289
290    Ok(())
291}
292
293/// Default max output tokens (16_000 ≈ 64KB)
294pub const DEFAULT_MAX_OUTPUT_TOKENS: Option<usize> = Some(16_000);
295
296/// Parse max_chars argument
297///
298/// - "none" (case-insensitive) → None (unlimited)
299/// - "0" or negative → None (unlimited)
300/// - positive integer → Some(value)
301/// - None (not provided) → DEFAULT_MAX_CHARS
302pub fn parse_max_chars(value: Option<&str>) -> Option<usize> {
303    match value {
304        None => DEFAULT_MAX_CHARS,
305        Some(s) => {
306            let lowered = s.to_lowercase();
307            if lowered == "none" {
308                return None;
309            }
310
311            match s.parse::<i64>() {
312                Ok(n) if n <= 0 => None,
313                Ok(n) => Some(n as usize),
314                Err(_) => DEFAULT_MAX_CHARS,
315            }
316        }
317    }
318}
319
320/// Parse max_output_tokens argument
321///
322/// - "none" (case-insensitive) → None (unlimited)
323/// - "0" or negative → None (unlimited)
324/// - positive integer with optional "k" suffix (e.g., "12k") → Some(value)
325/// - None (not provided) → DEFAULT_MAX_OUTPUT_TOKENS
326pub fn parse_max_output_tokens(value: Option<&str>) -> Option<usize> {
327    match value {
328        None => DEFAULT_MAX_OUTPUT_TOKENS,
329        Some(s) => {
330            let lowered = s.to_lowercase().replace(" ", "");
331            if lowered == "none" {
332                return None;
333            }
334
335            // Try to parse with k suffix
336            if lowered.ends_with('k') {
337                let num_part = &lowered[..lowered.len() - 1];
338                match num_part.parse::<i64>() {
339                    Ok(n) if n <= 0 => None,
340                    Ok(n) => Some((n as usize).saturating_mul(1_000)),
341                    Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
342                }
343            } else {
344                match lowered.parse::<i64>() {
345                    Ok(n) if n <= 0 => None,
346                    Ok(n) => Some(n as usize),
347                    Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
348                }
349            }
350        }
351    }
352}
353
354/// Sanitize password: return None if empty
355fn sanitize_password(password: Option<String>) -> Option<String> {
356    password.filter(|p| !p.is_empty())
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    fn base_args() -> Args {
364        Args {
365            host: "localhost".to_string(),
366            port: 22,
367            user: "test".to_string(),
368            password: Some("secret".to_string()),
369            key: None,
370            su_password: None,
371            sudo_password: None,
372            timeout: DEFAULT_TIMEOUT_MS,
373            max_chars: None,
374            disable_sudo: false,
375            max_output_tokens: None,
376            log_level: "info".to_string(),
377            log_file: None,
378            log_format: "text".to_string(),
379            log_rotation: "daily".to_string(),
380            keepalive_interval: 30,
381            keepalive_max: 3,
382            reconnect_retries: DEFAULT_RECONNECT_RETRIES,
383            reconnect_backoff_ms: DEFAULT_RECONNECT_BACKOFF_MS,
384            health_probe_timeout_ms: DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
385            strict_host_key_checking: HostKeyCheckMode::AcceptNew,
386            known_hosts: None,
387        }
388    }
389
390    #[test]
391    fn test_parse_max_chars_none_string() {
392        assert_eq!(parse_max_chars(Some("none")), None);
393        assert_eq!(parse_max_chars(Some("None")), None);
394        assert_eq!(parse_max_chars(Some("NONE")), None);
395    }
396
397    #[test]
398    fn test_parse_max_chars_zero_or_negative() {
399        assert_eq!(parse_max_chars(Some("0")), None);
400        assert_eq!(parse_max_chars(Some("-1")), None);
401        assert_eq!(parse_max_chars(Some("-100")), None);
402    }
403
404    #[test]
405    fn test_parse_max_chars_positive() {
406        assert_eq!(parse_max_chars(Some("500")), Some(500));
407        assert_eq!(parse_max_chars(Some("2000")), Some(2000));
408    }
409
410    #[test]
411    fn test_parse_max_chars_invalid() {
412        // Invalid strings should return default
413        assert_eq!(parse_max_chars(Some("abc")), DEFAULT_MAX_CHARS);
414        assert_eq!(parse_max_chars(Some("")), DEFAULT_MAX_CHARS);
415    }
416
417    #[test]
418    fn test_parse_max_chars_not_provided() {
419        assert_eq!(parse_max_chars(None), DEFAULT_MAX_CHARS);
420    }
421
422    #[test]
423    fn test_config_from_args_uses_default_max_chars() {
424        let config = Config::from_args(base_args()).unwrap();
425
426        assert_eq!(config.max_chars, Some(64_000));
427        assert_eq!(config.strict_host_key_checking, HostKeyCheckMode::AcceptNew);
428        assert!(config.known_hosts.is_none());
429    }
430
431    #[test]
432    fn test_args_parse_host_key_options() {
433        let args = Args::try_parse_from([
434            "ssh-mcp",
435            "--host",
436            "example.com",
437            "--user",
438            "alice",
439            "--password",
440            "secret",
441            "--strict-host-key-checking",
442            "yes",
443            "--known-hosts",
444            "/tmp/known_hosts",
445        ])
446        .unwrap();
447
448        assert_eq!(args.strict_host_key_checking, HostKeyCheckMode::Yes);
449        assert_eq!(args.known_hosts, Some(PathBuf::from("/tmp/known_hosts")));
450    }
451
452    #[test]
453    fn test_sanitize_password() {
454        assert_eq!(
455            sanitize_password(Some("secret".to_string())),
456            Some("secret".to_string())
457        );
458        assert_eq!(sanitize_password(Some(String::new())), None);
459        assert_eq!(sanitize_password(None), None);
460    }
461
462    #[test]
463    fn test_parse_max_output_tokens_none_string() {
464        assert_eq!(parse_max_output_tokens(Some("none")), None);
465        assert_eq!(parse_max_output_tokens(Some("None")), None);
466        assert_eq!(parse_max_output_tokens(Some("NONE")), None);
467    }
468
469    #[test]
470    fn test_parse_max_output_tokens_zero_or_negative() {
471        assert_eq!(parse_max_output_tokens(Some("0")), None);
472        assert_eq!(parse_max_output_tokens(Some("-1")), None);
473        assert_eq!(parse_max_output_tokens(Some("-100")), None);
474    }
475
476    #[test]
477    fn test_parse_max_output_tokens_positive() {
478        assert_eq!(parse_max_output_tokens(Some("500")), Some(500));
479        assert_eq!(parse_max_output_tokens(Some("12000")), Some(12_000));
480    }
481
482    #[test]
483    fn test_parse_max_output_tokens_with_k_suffix() {
484        assert_eq!(parse_max_output_tokens(Some("12k")), Some(12_000));
485        assert_eq!(parse_max_output_tokens(Some("5K")), Some(5_000));
486        assert_eq!(parse_max_output_tokens(Some("100k")), Some(100_000));
487    }
488
489    #[test]
490    fn test_parse_max_output_tokens_invalid() {
491        // Invalid strings should return default
492        assert_eq!(
493            parse_max_output_tokens(Some("abc")),
494            DEFAULT_MAX_OUTPUT_TOKENS
495        );
496        assert_eq!(parse_max_output_tokens(Some("")), DEFAULT_MAX_OUTPUT_TOKENS);
497    }
498
499    #[test]
500    fn test_parse_max_output_tokens_not_provided() {
501        assert_eq!(parse_max_output_tokens(None), DEFAULT_MAX_OUTPUT_TOKENS);
502    }
503
504    #[test]
505    fn test_validate_args_rejects_reconnect_retries_out_of_range() {
506        let mut args = base_args();
507        args.reconnect_retries = MAX_RECONNECT_RETRIES.saturating_add(1);
508
509        let result = validate_args(&args);
510        assert!(result.is_err());
511    }
512
513    #[test]
514    fn test_validate_args_rejects_reconnect_backoff_out_of_range() {
515        let mut args = base_args();
516        args.reconnect_backoff_ms = MIN_RECONNECT_BACKOFF_MS.saturating_sub(1);
517
518        let result = validate_args(&args);
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn test_validate_args_rejects_health_probe_timeout_out_of_range() {
524        let mut args = base_args();
525        args.health_probe_timeout_ms = MAX_HEALTH_PROBE_TIMEOUT_MS.saturating_add(1);
526
527        let result = validate_args(&args);
528        assert!(result.is_err());
529    }
530}