1use anyhow::{Context, Result};
2use log::{debug, error, info};
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7#[derive(Debug)]
9pub struct SignResult {
10 pub cert_path: PathBuf,
11}
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum CertStatus {
16 Valid {
17 expires_at: i64,
18 remaining_secs: i64,
19 total_secs: i64,
22 },
23 Expired,
24 Missing,
25 Invalid(String),
26}
27
28pub const RENEWAL_THRESHOLD_SECS: i64 = 300;
30
31pub const CERT_STATUS_CACHE_TTL_SECS: u64 = 300;
37
38pub const CERT_ERROR_BACKOFF_SECS: u64 = 30;
43
44pub fn is_valid_role(s: &str) -> bool {
47 !s.is_empty()
48 && s.len() <= 128
49 && s.chars()
50 .all(|c| c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-')
51}
52
53pub fn is_valid_vault_addr(s: &str) -> bool {
60 let trimmed = s.trim();
61 !trimmed.is_empty()
62 && trimmed.len() <= 512
63 && !trimmed.chars().any(|c| c.is_control() || c.is_whitespace())
64}
65
66pub fn normalize_vault_addr(s: &str) -> String {
73 let trimmed = s.trim();
74 let lower = trimmed.to_ascii_lowercase();
76 let (with_scheme, scheme_len) = if lower.starts_with("http://") || lower.starts_with("https://")
77 {
78 let len = if lower.starts_with("https://") { 8 } else { 7 };
79 (trimmed.to_string(), len)
80 } else if trimmed.contains("://") {
81 return trimmed.to_string();
83 } else {
84 (format!("https://{}", trimmed), 8)
85 };
86 let after_scheme = &with_scheme[scheme_len..];
88 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
89 let has_port = if let Some(bracket_end) = authority.rfind(']') {
92 authority[bracket_end..].contains(':')
93 } else {
94 authority.contains(':')
95 };
96 if has_port {
97 with_scheme
98 } else {
99 let default_port = if lower.starts_with("http://") {
102 80
103 } else if lower.starts_with("https://") {
104 443
105 } else {
106 8200
107 };
108 let path_start = scheme_len + authority.len();
109 format!(
110 "{}:{}{}",
111 &with_scheme[..path_start],
112 default_port,
113 &with_scheme[path_start..]
114 )
115 }
116}
117
118pub fn scrub_vault_stderr(raw: &str) -> String {
122 let filtered: String = raw
123 .lines()
124 .filter(|line| {
125 let lower = line.to_ascii_lowercase();
126 !(lower.contains("token")
127 || lower.contains("secret")
128 || lower.contains("x-vault-")
129 || lower.contains("cookie")
130 || lower.contains("authorization"))
131 })
132 .collect::<Vec<_>>()
133 .join(" ");
134 let trimmed = filtered.trim();
135 if trimmed.is_empty() {
136 return "Vault SSH signing failed. Check vault status and policy".to_string();
137 }
138 if trimmed.chars().count() > 200 {
139 trimmed.chars().take(200).collect::<String>() + "..."
140 } else {
141 trimmed.to_string()
142 }
143}
144
145pub fn cert_path_for(alias: &str) -> Result<PathBuf> {
147 anyhow::ensure!(
148 !alias.is_empty()
149 && !alias.contains('/')
150 && !alias.contains('\\')
151 && !alias.contains(':')
152 && !alias.contains('\0')
153 && !alias.contains(".."),
154 "Invalid alias for cert path: '{}'",
155 alias
156 );
157 let dir = dirs::home_dir()
158 .context("Could not determine home directory")?
159 .join(".purple/certs");
160 Ok(dir.join(format!("{}-cert.pub", alias)))
161}
162
163pub fn resolve_cert_path(alias: &str, certificate_file: &str) -> Result<PathBuf> {
166 if !certificate_file.is_empty() {
167 let expanded = if let Some(rest) = certificate_file.strip_prefix("~/") {
168 if let Some(home) = dirs::home_dir() {
169 home.join(rest)
170 } else {
171 PathBuf::from(certificate_file)
172 }
173 } else {
174 PathBuf::from(certificate_file)
175 };
176 Ok(expanded)
177 } else {
178 cert_path_for(alias)
179 }
180}
181
182pub fn sign_certificate(
192 role: &str,
193 pubkey_path: &Path,
194 alias: &str,
195 vault_addr: Option<&str>,
196) -> Result<SignResult> {
197 if !pubkey_path.exists() {
198 anyhow::bail!(
199 "Public key not found: {}. Set IdentityFile on the host or ensure ~/.ssh/id_ed25519.pub exists.",
200 pubkey_path.display()
201 );
202 }
203
204 if !is_valid_role(role) {
205 anyhow::bail!("Invalid Vault SSH role: '{}'", role);
206 }
207
208 let cert_dest = cert_path_for(alias)?;
209
210 if let Some(parent) = cert_dest.parent() {
211 std::fs::create_dir_all(parent)
212 .with_context(|| crate::messages::vault_create_dir_failed(&parent.display()))?;
213 }
214
215 let pubkey_str = pubkey_path.to_str().context(
219 "public key path contains non-UTF8 bytes; vault CLI requires a valid UTF-8 path",
220 )?;
221 if pubkey_str.contains('=') {
228 anyhow::bail!(
229 "Public key path '{}' contains '=' which is not supported by the Vault CLI argument format. Rename the key file or directory.",
230 pubkey_str
231 );
232 }
233 let pubkey_arg = format!("public_key=@{}", pubkey_str);
234 debug!(
235 "[external] Vault sign request: addr={} role={}",
236 vault_addr.unwrap_or("<env>"),
237 role
238 );
239 let mut cmd = Command::new("vault");
240 cmd.args(["write", "-field=signed_key", role, &pubkey_arg]);
241 if let Some(addr) = vault_addr {
248 anyhow::ensure!(
249 is_valid_vault_addr(addr),
250 "Invalid VAULT_ADDR '{}' for role '{}'. Check the Vault SSH Address field.",
251 addr,
252 role
253 );
254 cmd.env("VAULT_ADDR", addr);
255 }
256 let mut child = cmd
257 .stdout(std::process::Stdio::piped())
258 .stderr(std::process::Stdio::piped())
259 .spawn()
260 .context("Failed to run vault CLI. Is vault installed and in PATH?")?;
261
262 let stdout_handle = child.stdout.take();
266 let stderr_handle = child.stderr.take();
267 let stdout_thread = std::thread::spawn(move || -> Vec<u8> {
268 let mut buf = Vec::new();
269 if let Some(mut h) = stdout_handle {
270 if let Err(e) = std::io::Read::read_to_end(&mut h, &mut buf) {
271 log::warn!("[external] Failed to read vault stdout pipe: {e}");
272 }
273 }
274 buf
275 });
276 let stderr_thread = std::thread::spawn(move || -> Vec<u8> {
277 let mut buf = Vec::new();
278 if let Some(mut h) = stderr_handle {
279 if let Err(e) = std::io::Read::read_to_end(&mut h, &mut buf) {
280 log::warn!("[external] Failed to read vault stderr pipe: {e}");
281 }
282 }
283 buf
284 });
285
286 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
290 let status = loop {
291 match child.try_wait() {
292 Ok(Some(s)) => break s,
293 Ok(None) => {
294 if std::time::Instant::now() >= deadline {
295 let _ = child.kill();
296 let _ = child.wait();
297 error!(
302 "[external] Vault unreachable: {}: timed out after 30s",
303 vault_addr.unwrap_or("<env>")
304 );
305 anyhow::bail!("Vault SSH timed out. Server unreachable.");
306 }
307 std::thread::sleep(std::time::Duration::from_millis(100));
308 }
309 Err(e) => {
310 let _ = child.kill();
311 let _ = child.wait();
312 anyhow::bail!("Failed to wait for vault CLI: {}", e);
313 }
314 }
315 };
316
317 let stdout_bytes = stdout_thread.join().unwrap_or_default();
318 let stderr_bytes = stderr_thread.join().unwrap_or_default();
319 let output = std::process::Output {
320 status,
321 stdout: stdout_bytes,
322 stderr: stderr_bytes,
323 };
324
325 if !output.status.success() {
326 let stderr = String::from_utf8_lossy(&output.stderr);
327 if stderr.contains("permission denied") || stderr.contains("403") {
328 error!(
329 "[external] Vault auth failed: permission denied (role={} addr={})",
330 role,
331 vault_addr.unwrap_or("<env>")
332 );
333 anyhow::bail!("Vault SSH permission denied. Check token and policy.");
334 }
335 if stderr.contains("missing client token") || stderr.contains("token expired") {
336 error!(
337 "[external] Vault auth failed: token missing or expired (role={} addr={})",
338 role,
339 vault_addr.unwrap_or("<env>")
340 );
341 anyhow::bail!("Vault SSH token missing or expired. Run `vault login`.");
342 }
343 if stderr.contains("connection refused") {
346 error!(
347 "[external] Vault unreachable: {}: connection refused",
348 vault_addr.unwrap_or("<env>")
349 );
350 anyhow::bail!("Vault SSH connection refused.");
351 }
352 if stderr.contains("i/o timeout") || stderr.contains("dial tcp") {
353 error!(
354 "[external] Vault unreachable: {}: connection timed out",
355 vault_addr.unwrap_or("<env>")
356 );
357 anyhow::bail!("Vault SSH connection timed out.");
358 }
359 if stderr.contains("no such host") {
360 error!(
361 "[external] Vault unreachable: {}: no such host",
362 vault_addr.unwrap_or("<env>")
363 );
364 anyhow::bail!("Vault SSH host not found.");
365 }
366 if stderr.contains("server gave HTTP response to HTTPS client") {
367 error!(
368 "[external] Vault unreachable: {}: server returned HTTP on HTTPS connection",
369 vault_addr.unwrap_or("<env>")
370 );
371 anyhow::bail!("Vault SSH server uses HTTP, not HTTPS. Set address to http://.");
372 }
373 if stderr.contains("certificate signed by unknown authority")
374 || stderr.contains("tls:")
375 || stderr.contains("x509:")
376 {
377 error!(
378 "[external] Vault unreachable: {}: TLS error",
379 vault_addr.unwrap_or("<env>")
380 );
381 anyhow::bail!("Vault SSH TLS error. Check certificate or use http://.");
382 }
383 error!(
384 "[external] Vault SSH signing failed: {}",
385 scrub_vault_stderr(&stderr)
386 );
387 anyhow::bail!("Vault SSH failed: {}", scrub_vault_stderr(&stderr));
388 }
389
390 let signed_key = String::from_utf8_lossy(&output.stdout).trim().to_string();
391 if signed_key.is_empty() {
392 anyhow::bail!("Vault returned empty certificate for role '{}'", role);
393 }
394
395 crate::fs_util::atomic_write(&cert_dest, signed_key.as_bytes())
396 .with_context(|| crate::messages::vault_write_cert_failed(&cert_dest.display()))?;
397
398 info!("Vault SSH certificate signed for {}", alias);
399 Ok(SignResult {
400 cert_path: cert_dest,
401 })
402}
403
404pub fn check_cert_validity(cert_path: &Path) -> CertStatus {
412 if !cert_path.exists() {
413 return CertStatus::Missing;
414 }
415
416 let output = match Command::new("ssh-keygen")
417 .args(["-L", "-f"])
418 .arg(cert_path)
419 .output()
420 {
421 Ok(o) => o,
422 Err(e) => return CertStatus::Invalid(crate::messages::vault_ssh_keygen_run_failed(&e)),
423 };
424
425 if !output.status.success() {
426 return CertStatus::Invalid("ssh-keygen could not read certificate".to_string());
427 }
428
429 let stdout = String::from_utf8_lossy(&output.stdout);
430
431 for line in stdout.lines() {
433 let t = line.trim();
434 if t == "Valid: forever" || t.starts_with("Valid: from ") && t.ends_with(" to forever") {
435 return CertStatus::Valid {
436 expires_at: i64::MAX,
437 remaining_secs: i64::MAX,
438 total_secs: i64::MAX,
439 };
440 }
441 }
442
443 for line in stdout.lines() {
444 if let Some((from, to)) = parse_valid_line(line) {
445 let ttl = to - from; if ttl <= 0 {
450 return CertStatus::Invalid(
451 "certificate has non-positive validity window".to_string(),
452 );
453 }
454
455 let signed_at = match std::fs::metadata(cert_path)
457 .and_then(|m| m.modified())
458 .ok()
459 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
460 {
461 Some(d) => d.as_secs() as i64,
462 None => {
463 return CertStatus::Expired;
465 }
466 };
467
468 let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
469 Ok(d) => d.as_secs() as i64,
470 Err(_) => {
471 return CertStatus::Invalid("system clock before unix epoch".to_string());
472 }
473 };
474
475 let elapsed = now - signed_at;
476 let remaining = ttl - elapsed;
477
478 if remaining <= 0 {
479 return CertStatus::Expired;
480 }
481 let expires_at = now + remaining;
482 return CertStatus::Valid {
483 expires_at,
484 remaining_secs: remaining,
485 total_secs: ttl,
486 };
487 }
488 }
489
490 CertStatus::Invalid("No Valid: line found in certificate".to_string())
491}
492
493fn parse_valid_line(line: &str) -> Option<(i64, i64)> {
495 let trimmed = line.trim();
496 let rest = trimmed.strip_prefix("Valid:")?;
497 let rest = rest.trim();
498 let rest = rest.strip_prefix("from ")?;
499 let (from_str, rest) = rest.split_once(" to ")?;
500 let to_str = rest.trim();
501
502 let from = parse_ssh_datetime(from_str)?;
503 let to = parse_ssh_datetime(to_str)?;
504 Some((from, to))
505}
506
507fn parse_ssh_datetime(s: &str) -> Option<i64> {
512 let s = s.trim();
513 if s.len() < 19 {
514 return None;
515 }
516 let year: i64 = s.get(0..4)?.parse().ok()?;
517 let month: i64 = s.get(5..7)?.parse().ok()?;
518 let day: i64 = s.get(8..10)?.parse().ok()?;
519 let hour: i64 = s.get(11..13)?.parse().ok()?;
520 let min: i64 = s.get(14..16)?.parse().ok()?;
521 let sec: i64 = s.get(17..19)?.parse().ok()?;
522
523 if s.as_bytes().get(4) != Some(&b'-')
524 || s.as_bytes().get(7) != Some(&b'-')
525 || s.as_bytes().get(10) != Some(&b'T')
526 || s.as_bytes().get(13) != Some(&b':')
527 || s.as_bytes().get(16) != Some(&b':')
528 {
529 return None;
530 }
531
532 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
533 return None;
534 }
535 if !(0..=23).contains(&hour) || !(0..=59).contains(&min) || !(0..=59).contains(&sec) {
536 return None;
537 }
538
539 let mut y = year;
541 let m = if month <= 2 {
542 y -= 1;
543 month + 9
544 } else {
545 month - 3
546 };
547 let era = if y >= 0 { y } else { y - 399 } / 400;
548 let yoe = y - era * 400;
549 let doy = (153 * m + 2) / 5 + day - 1;
550 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
551 let days = era * 146097 + doe - 719468;
552
553 Some(days * 86400 + hour * 3600 + min * 60 + sec)
554}
555
556pub fn needs_renewal(status: &CertStatus) -> bool {
563 match status {
564 CertStatus::Missing | CertStatus::Expired | CertStatus::Invalid(_) => true,
565 CertStatus::Valid {
566 remaining_secs,
567 total_secs,
568 ..
569 } => {
570 let threshold = if *total_secs > 0 && *total_secs <= RENEWAL_THRESHOLD_SECS {
571 *total_secs / 2
572 } else {
573 RENEWAL_THRESHOLD_SECS
574 };
575 *remaining_secs < threshold
576 }
577 }
578}
579
580pub fn ensure_cert(
583 role: &str,
584 pubkey_path: &Path,
585 alias: &str,
586 certificate_file: &str,
587 vault_addr: Option<&str>,
588) -> Result<PathBuf> {
589 let check_path = resolve_cert_path(alias, certificate_file)?;
590 let status = check_cert_validity(&check_path);
591
592 if !needs_renewal(&status) {
593 info!(
594 "Vault SSH certificate cache hit: alias={} role={} path={}",
595 alias,
596 role,
597 check_path.display()
598 );
599 return Ok(check_path);
600 }
601
602 log::debug!(
603 "Vault SSH certificate cache miss: alias={} role={} status={:?} -> signing",
604 alias,
605 role,
606 status
607 );
608 let result = sign_certificate(role, pubkey_path, alias, vault_addr)?;
609 Ok(result.cert_path)
610}
611
612pub fn resolve_pubkey_path(identity_file: &str) -> Result<PathBuf> {
619 let home = dirs::home_dir().context("Could not determine home directory")?;
620 let fallback = home.join(".ssh/id_ed25519.pub");
621
622 if identity_file.is_empty() {
623 return Ok(fallback);
624 }
625
626 let expanded = if let Some(rest) = identity_file.strip_prefix("~/") {
627 home.join(rest)
628 } else {
629 PathBuf::from(identity_file)
630 };
631
632 let canonical_home = match std::fs::canonicalize(&home) {
638 Ok(p) => p,
639 Err(_) => return Ok(fallback),
640 };
641 if expanded.exists() {
642 match std::fs::canonicalize(&expanded) {
643 Ok(canonical) if canonical.starts_with(&canonical_home) => {}
644 _ => return Ok(fallback),
645 }
646 } else if !expanded.starts_with(&home) {
647 return Ok(fallback);
648 }
649
650 if expanded.extension().is_some_and(|ext| ext == "pub") {
651 Ok(expanded)
652 } else {
653 let mut s = expanded.into_os_string();
654 s.push(".pub");
655 Ok(PathBuf::from(s))
656 }
657}
658
659pub fn resolve_vault_role(
665 host_vault_ssh: Option<&str>,
666 provider_name: Option<&str>,
667 provider_label: Option<&str>,
668 provider_config: &crate::providers::config::ProviderConfig,
669) -> Option<String> {
670 if let Some(role) = host_vault_ssh {
671 if !role.is_empty() {
672 return Some(role.to_string());
673 }
674 }
675
676 if let Some(name) = provider_name {
677 let id = crate::providers::config::ProviderConfigId {
678 provider: name.to_string(),
679 label: provider_label.map(|s| s.to_string()),
680 };
681 let section = provider_config
682 .section_by_id(&id)
683 .or_else(|| provider_config.section(name));
684 if let Some(section) = section {
685 if !section.vault_role.is_empty() {
686 return Some(section.vault_role.clone());
687 }
688 }
689 }
690
691 None
692}
693
694pub fn resolve_vault_addr(
707 host_vault_addr: Option<&str>,
708 provider_name: Option<&str>,
709 provider_label: Option<&str>,
710 provider_config: &crate::providers::config::ProviderConfig,
711) -> Option<String> {
712 if let Some(addr) = host_vault_addr {
713 let trimmed = addr.trim();
714 if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
715 return Some(normalize_vault_addr(trimmed));
716 }
717 }
718
719 if let Some(name) = provider_name {
720 let id = crate::providers::config::ProviderConfigId {
721 provider: name.to_string(),
722 label: provider_label.map(|s| s.to_string()),
723 };
724 let section = provider_config
725 .section_by_id(&id)
726 .or_else(|| provider_config.section(name));
727 if let Some(section) = section {
728 let trimmed = section.vault_addr.trim();
729 if !trimmed.is_empty() && is_valid_vault_addr(trimmed) {
730 return Some(normalize_vault_addr(trimmed));
731 }
732 }
733 }
734
735 None
736}
737
738pub fn resolve_proxy_chain(config_path: &Path, alias: &str) -> Vec<String> {
751 let mut chain: Vec<String> = Vec::new();
752 let mut visited: HashSet<String> = HashSet::new();
753 let mut queue: Vec<String> = vec![alias.to_string()];
754
755 while let Some(current) = queue.pop() {
756 if !visited.insert(current.clone()) {
757 continue;
758 }
759 chain.push(current.clone());
760
761 let output = Command::new("ssh")
762 .args(["-G", "-F"])
763 .arg(config_path)
764 .arg("--")
765 .arg(¤t)
766 .output();
767
768 let Ok(output) = output else {
769 debug!("[external] ssh -G failed for {}: spawn error", current);
770 continue;
771 };
772 if !output.status.success() {
773 debug!(
774 "[external] ssh -G non-zero exit for {} (code {:?})",
775 current,
776 output.status.code()
777 );
778 continue;
779 }
780
781 let stdout = String::from_utf8_lossy(&output.stdout);
782 for line in stdout.lines() {
783 let lower = line.to_ascii_lowercase();
784 let Some(rest) = lower.strip_prefix("proxyjump ") else {
785 continue;
786 };
787 if rest.trim() == "none" {
789 continue;
790 }
791 let value = &line["proxyjump ".len()..];
795 for jump in value.split(',') {
796 let host = parse_proxy_jump_host(jump.trim());
797 if !host.is_empty() {
798 queue.push(host.to_string());
799 }
800 }
801 }
802 }
803
804 chain.reverse();
805 chain
806}
807
808fn parse_proxy_jump_host(jump: &str) -> &str {
811 let trimmed = jump.trim();
812 let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
813 if let Some(rest) = after_user.strip_prefix('[') {
814 if let Some(end) = rest.find(']') {
815 return &rest[..end];
816 }
817 }
818 after_user.split(':').next().unwrap_or(after_user)
819}
820
821pub fn format_remaining(remaining_secs: i64) -> String {
823 if remaining_secs <= 0 {
824 return "expired".to_string();
825 }
826 let hours = remaining_secs / 3600;
827 let mins = (remaining_secs % 3600) / 60;
828 if hours > 0 {
829 format!("{}h {}m", hours, mins)
830 } else {
831 format!("{}m", mins)
832 }
833}
834
835#[cfg(test)]
839#[path = "vault_ssh_tests.rs"]
840pub(crate) mod tests;