1#![forbid(unsafe_code)]
4mod config_io;
10mod crud;
12mod doctor;
14mod exec_ops;
16mod health;
18mod import_export;
20pub mod model;
21mod secrets_cmd;
23pub mod selection;
25
26pub(crate) use config_io::validate_key_path_exists;
27pub use config_io::{
28 default_config_path, load, resolve_config_path, save, winning_layer, write_atomic, ConfigFile,
29 ConfigLayer,
30};
31pub use crud::run_vps_command;
32pub use exec_ops::{
33 run_exec, run_exec_with_client, run_su_exec, run_sudo_exec, run_sudo_exec_with_client,
34 ExecOptions, HostExecResult,
35};
36pub use health::{run_health_check, HostHealthResult};
37pub use import_export::parse_import_payload;
38pub use secrets_cmd::run_secrets_command;
39pub use selection::{dedupe_host_names, resolve_host_jobs, HostSelection};
40
41use crate::cli::OutputFormat;
42use crate::errors::{SshCliError, SshCliResult};
43use crate::ssh::client::ConnectionConfig;
44use crate::ssh::known_hosts::KnownHosts;
45use anyhow::Result;
46use model::{effective_limit, VpsRecord};
47use secrecy::SecretString;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50
51#[must_use]
53pub fn use_json(json_local: bool, format: OutputFormat) -> bool {
54 json_local || format == OutputFormat::Json
55}
56
57pub const MAX_SECRET_STDIN_BYTES: u64 = 64 * 1024;
61
62const _: () = assert!(MAX_SECRET_STDIN_BYTES >= 1024);
63const _: () = assert!(MAX_SECRET_STDIN_BYTES <= 1024 * 1024);
64
65pub fn read_secret_stdin() -> SshCliResult<SecretString> {
74 use std::io::Read;
75 use zeroize::Zeroizing;
76 let mut limited = std::io::stdin().take(MAX_SECRET_STDIN_BYTES + 1);
77 let mut buf = Zeroizing::new(String::new());
78 limited.read_to_string(&mut buf)?;
79 if buf.len() as u64 > MAX_SECRET_STDIN_BYTES {
80 return Err(SshCliError::InvalidArgument(format!(
81 "stdin secret exceeds max size of {MAX_SECRET_STDIN_BYTES} bytes"
82 )));
83 }
84 let trimmed = buf.trim_end_matches(['\r', '\n']);
85 Ok(SecretString::from(trimmed.to_owned()))
86}
87
88#[allow(clippy::too_many_arguments)]
95pub(crate) fn apply_overrides(
96 vps: &mut VpsRecord,
97 password_override: Option<SecretString>,
98 sudo_password_override: Option<SecretString>,
99 su_password_override: Option<SecretString>,
100 timeout_override: Option<crate::domain::TimeoutMs>,
101 key_path_override: Option<String>,
102 key_passphrase_override: Option<SecretString>,
103 use_agent: bool,
104 agent_socket: Option<String>,
105) {
106 use crate::domain::KeyPath;
107 if let Some(pwd) = password_override {
108 vps.password = pwd;
109 }
110 if let Some(spwd) = sudo_password_override {
111 vps.sudo_password = Some(spwd);
112 }
113 if let Some(sp) = su_password_override {
114 vps.su_password = Some(sp);
115 }
116 if let Some(t) = timeout_override {
118 vps.timeout_ms = t;
119 }
120 if let Some(k) = key_path_override {
121 if let Ok(kp) = KeyPath::try_new(k) {
122 vps.key_path = Some(kp);
123 }
124 }
125 if let Some(kp) = key_passphrase_override {
126 vps.key_passphrase = Some(kp);
127 }
128 if use_agent {
129 vps.use_agent = true;
130 }
131 if let Some(sock) = agent_socket {
132 vps.agent_socket = Some(sock);
133 vps.use_agent = true;
134 }
135}
136
137pub(crate) fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
138 let lim = effective_limit(max_command_chars);
139 let len = command.chars().count();
140 if len > lim {
141 return Err(SshCliError::CommandTooLong {
142 max: max_command_chars,
143 len,
144 });
145 }
146 if command.trim().is_empty() {
147 return Err(SshCliError::InvalidArgument("empty command".to_string()));
148 }
149 if command.as_bytes().contains(&0) {
153 return Err(SshCliError::InvalidArgument(
154 "command contains null byte".to_string(),
155 ));
156 }
157 Ok(())
158}
159
160pub async fn run_connect(
164 name: &str,
165 config_override: Option<PathBuf>,
166 format: OutputFormat,
167) -> Result<()> {
168 let path = resolve_config_path(config_override.as_deref())?;
169 let file = load(&path)?;
170 if !file.hosts.contains_key(name) {
171 return Err(SshCliError::VpsNotFound(name.to_string()).into());
172 }
173
174 let active_file = path
175 .parent()
176 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
177 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
178 if let Some(parent_dir) = active_file.parent() {
179 std::fs::create_dir_all(parent_dir)?;
180 }
181 let parent_dir = active_file
183 .parent()
184 .map(Path::to_path_buf)
185 .unwrap_or_else(|| PathBuf::from("."));
186 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
187 tmp.write_all(name.as_bytes())?;
188 tmp.as_file().sync_data()?;
189 tmp.persist(&active_file)
190 .map_err(|e| SshCliError::Io(e.error))?;
191 crate::output::emit_success(
192 "vps-connected",
193 serde_json::json!({ "name": name }),
194 &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
195 name: name.to_string(),
196 }),
197 format == OutputFormat::Json,
198 )?;
199 Ok(())
200}
201
202pub fn find_by_name(config_override: Option<&Path>, name: &str) -> SshCliResult<Option<VpsRecord>> {
207 let path = resolve_config_path(config_override)?;
208 let file = load(&path)?;
209 Ok(file.hosts.get(name).cloned())
210}
211
212pub fn read_active_vps(config_override: Option<&Path>) -> SshCliResult<Option<String>> {
214 let path = resolve_config_path(config_override)?;
215 let active_file = path
216 .parent()
217 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
218 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
219 if !active_file.exists() {
220 return Ok(None);
221 }
222 let name = std::fs::read_to_string(&active_file)?;
223 Ok(Some(name.trim().to_string()))
224}
225
226pub fn build_connection_config(
228 vps: &VpsRecord,
229 config_toml: Option<&Path>,
230 replace_host_key: bool,
231) -> ConnectionConfig {
232 let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
233 let tls = if vps.tls {
234 let sni = vps
235 .tls_sni
236 .as_deref()
237 .filter(|s| !s.trim().is_empty())
238 .unwrap_or_else(|| vps.host.as_str());
239 let client_cert = vps
240 .tls_client_cert
241 .as_ref()
242 .map(|p| std::path::PathBuf::from(p.as_str()));
243 let client_key = vps
244 .tls_client_key
245 .as_ref()
246 .map(|p| std::path::PathBuf::from(p.as_str()));
247 match crate::tls::TlsConnectOptions::try_new(sni, client_cert, client_key) {
248 Ok(o) => Some(o),
249 Err(e) => {
250 tracing::warn!(err = %e, "invalid TLS options on VPS record; plain SSH");
251 None
252 }
253 }
254 } else {
255 None
256 };
257 ConnectionConfig {
258 host: vps.host.clone(),
259 port: vps.port,
260 username: vps.username.clone(),
261 password: vps.password.clone(),
262 key_path: vps.key_path.clone(),
263 key_passphrase: vps.key_passphrase.clone(),
264 timeout_ms: vps.timeout_ms,
265 known_hosts_path,
266 replace_host_key,
267 tls,
268 use_agent: vps.use_agent,
269 agent_socket: vps.agent_socket.as_ref().map(std::path::PathBuf::from),
270 }
271}
272
273#[cfg(test)]
278#[path = "tests.rs"]
279mod tests;