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, HealthCheckRequest, 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> {
83 if crate::cli::is_no_input() {
84 return Err(SshCliError::InvalidArgument(
85 "--no-input forbids reading secrets from stdin; pass the value via flag \
86 or drop --no-input"
87 .to_string(),
88 ));
89 }
90 use std::io::Read;
91 use zeroize::Zeroizing;
92 let mut limited = std::io::stdin().take(MAX_SECRET_STDIN_BYTES + 1);
93 let mut buf = Zeroizing::new(String::new());
94 limited.read_to_string(&mut buf)?;
95 if buf.len() as u64 > MAX_SECRET_STDIN_BYTES {
96 return Err(SshCliError::InvalidArgument(format!(
97 "stdin secret exceeds max size of {MAX_SECRET_STDIN_BYTES} bytes"
98 )));
99 }
100 let trimmed = buf.trim_end_matches(['\r', '\n']);
101 Ok(SecretString::from(trimmed.to_owned()))
102}
103
104#[derive(Debug, Default, Clone)]
119pub(crate) struct AuthOverrides {
120 pub password: Option<SecretString>,
122 pub sudo_password: Option<SecretString>,
124 pub su_password: Option<SecretString>,
126 pub timeout: Option<crate::domain::TimeoutMs>,
128 pub key_path: Option<String>,
130 pub key_passphrase: Option<SecretString>,
132 pub use_agent: bool,
134 pub agent_socket: Option<String>,
136}
137
138pub(crate) fn apply_overrides(vps: &mut VpsRecord, overrides: AuthOverrides) {
140 use crate::domain::KeyPath;
141 let AuthOverrides {
142 password,
143 sudo_password,
144 su_password,
145 timeout,
146 key_path,
147 key_passphrase,
148 use_agent,
149 agent_socket,
150 } = overrides;
151 if let Some(pwd) = password {
152 vps.password = pwd;
153 }
154 if let Some(spwd) = sudo_password {
155 vps.sudo_password = Some(spwd);
156 }
157 if let Some(sp) = su_password {
158 vps.su_password = Some(sp);
159 }
160 if let Some(t) = timeout {
162 vps.timeout_ms = t;
163 }
164 if let Some(k) = key_path {
165 if let Ok(kp) = KeyPath::try_new(k) {
166 vps.key_path = Some(kp);
167 }
168 }
169 if let Some(kp) = key_passphrase {
170 vps.key_passphrase = Some(kp);
171 }
172 if use_agent {
173 vps.use_agent = true;
174 }
175 if let Some(sock) = agent_socket {
176 vps.agent_socket = Some(sock);
177 vps.use_agent = true;
178 }
179}
180
181pub(crate) fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
182 let lim = effective_limit(max_command_chars);
183 let len = command.chars().count();
184 if len > lim {
185 return Err(SshCliError::CommandTooLong {
186 max: max_command_chars,
187 len,
188 });
189 }
190 if command.trim().is_empty() {
191 return Err(SshCliError::InvalidArgument("empty command".to_string()));
192 }
193 if command.as_bytes().contains(&0) {
197 return Err(SshCliError::InvalidArgument(
198 "command contains null byte".to_string(),
199 ));
200 }
201 Ok(())
202}
203
204pub async fn run_connect(
208 name: &str,
209 config_override: Option<PathBuf>,
210 format: OutputFormat,
211) -> Result<()> {
212 let path = resolve_config_path(config_override.as_deref())?;
213 let file = load(&path)?;
214 if !file.hosts.contains_key(name) {
215 return Err(SshCliError::VpsNotFound(name.to_string()).into());
216 }
217
218 let active_file = path
219 .parent()
220 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
221 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
222 if let Some(parent_dir) = active_file.parent() {
223 std::fs::create_dir_all(parent_dir)?;
224 }
225 let parent_dir = active_file
227 .parent()
228 .map(Path::to_path_buf)
229 .unwrap_or_else(|| PathBuf::from("."));
230 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
231 tmp.write_all(name.as_bytes())?;
232 tmp.as_file().sync_data()?;
233 tmp.persist(&active_file)
234 .map_err(|e| SshCliError::Io(e.error))?;
235 crate::output::emit_success(
236 "vps-connected",
237 serde_json::json!({ "name": name }),
238 &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
239 name: name.to_string(),
240 }),
241 format == OutputFormat::Json,
242 )?;
243 Ok(())
244}
245
246pub fn find_by_name(config_override: Option<&Path>, name: &str) -> SshCliResult<Option<VpsRecord>> {
251 let path = resolve_config_path(config_override)?;
252 let file = load(&path)?;
253 Ok(file.hosts.get(name).cloned())
254}
255
256pub fn read_active_vps(config_override: Option<&Path>) -> SshCliResult<Option<String>> {
258 let path = resolve_config_path(config_override)?;
259 let active_file = path
260 .parent()
261 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
262 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
263 if !active_file.exists() {
264 return Ok(None);
265 }
266 let name = std::fs::read_to_string(&active_file)?;
267 Ok(Some(name.trim().to_string()))
268}
269
270pub fn build_connection_config(
272 vps: &VpsRecord,
273 config_toml: Option<&Path>,
274 replace_host_key: bool,
275) -> ConnectionConfig {
276 let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
277 let tls = if vps.tls {
278 let sni = vps
279 .tls_sni
280 .as_deref()
281 .filter(|s| !s.trim().is_empty())
282 .unwrap_or_else(|| vps.host.as_str());
283 let client_cert = vps
284 .tls_client_cert
285 .as_ref()
286 .map(|p| std::path::PathBuf::from(p.as_str()));
287 let client_key = vps
288 .tls_client_key
289 .as_ref()
290 .map(|p| std::path::PathBuf::from(p.as_str()));
291 match crate::tls::TlsConnectOptions::try_new(sni, client_cert, client_key) {
292 Ok(o) => Some(o),
293 Err(e) => {
294 tracing::warn!(err = %e, "invalid TLS options on VPS record; plain SSH");
295 None
296 }
297 }
298 } else {
299 None
300 };
301 ConnectionConfig {
302 host: vps.host.clone(),
303 port: vps.port,
304 username: vps.username.clone(),
305 password: vps.password.clone(),
306 key_path: vps.key_path.clone(),
307 key_passphrase: vps.key_passphrase.clone(),
308 timeout_ms: vps.timeout_ms,
309 known_hosts_path,
310 replace_host_key,
311 tls,
312 use_agent: vps.use_agent,
313 agent_socket: vps.agent_socket.as_ref().map(std::path::PathBuf::from),
314 }
315}
316
317#[cfg(test)]
322#[path = "tests.rs"]
323mod tests;