pitchfork_cli/proxy/
trust.rs1use crate::Result;
10
11#[cfg(target_os = "linux")]
13const INSTALLED_CERT_NAME: &str = "pitchfork-proxy.crt";
14
15pub fn is_ca_trusted(cert_path: &std::path::Path) -> bool {
25 ca_trust_state(cert_path) == Some(true)
26}
27
28pub fn ca_trust_state(cert_path: &std::path::Path) -> Option<bool> {
40 if !cert_path.exists() {
41 return Some(false);
42 }
43
44 #[cfg(target_os = "macos")]
45 {
46 is_ca_trusted_macos(cert_path)
47 }
48 #[cfg(target_os = "linux")]
49 {
50 Some(is_ca_trusted_linux(cert_path))
51 }
52 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
53 {
54 Some(false)
55 }
56}
57
58#[cfg(target_os = "macos")]
59fn is_ca_trusted_macos(cert_path: &std::path::Path) -> Option<bool> {
60 use std::process::{Command, Stdio};
61 let Ok(mut child) = Command::new("security")
76 .args(["verify-cert", "-c", &cert_path.to_string_lossy()])
77 .stdout(Stdio::null())
78 .stderr(Stdio::null())
79 .spawn()
80 else {
81 return None;
82 };
83 let deadline = std::time::Instant::now() + TRUST_PROBE_TIMEOUT;
84 loop {
85 match child.try_wait() {
86 Ok(Some(status)) => return Some(status.success()),
87 Ok(None) => {}
88 Err(_) => return None,
89 }
90 if std::time::Instant::now() >= deadline {
91 let _ = child.kill();
92 let _ = child.wait();
94 log::debug!(
95 "`security verify-cert` did not finish within {TRUST_PROBE_TIMEOUT:?}; \
96 the CA's trust state is unknown"
97 );
98 return None;
99 }
100 std::thread::sleep(std::time::Duration::from_millis(25));
101 }
102}
103
104pub const TRUST_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
112
113#[cfg(target_os = "linux")]
115struct LinuxCATrustConfig {
116 cert_dir: &'static str,
117 update_command: &'static [&'static str],
119}
120
121#[cfg(target_os = "linux")]
122fn get_linux_ca_trust_config() -> LinuxCATrustConfig {
123 let configs = [
124 LinuxCATrustConfig {
126 cert_dir: "/usr/local/share/ca-certificates",
127 update_command: &["update-ca-certificates"],
128 },
129 LinuxCATrustConfig {
131 cert_dir: "/etc/pki/ca-trust/source/anchors",
132 update_command: &["update-ca-trust"],
133 },
134 LinuxCATrustConfig {
136 cert_dir: "/etc/ca-certificates/trust-source/anchors",
137 update_command: &["trust", "extract-compat"],
138 },
139 LinuxCATrustConfig {
141 cert_dir: "/etc/pki/trust/anchors",
142 update_command: &["update-ca-certificates"],
143 },
144 ];
145
146 for config in &configs {
148 if std::path::Path::new(config.cert_dir).exists() {
149 return LinuxCATrustConfig {
150 cert_dir: config.cert_dir,
151 update_command: config.update_command,
152 };
153 }
154 }
155
156 configs.into_iter().next().unwrap()
158}
159
160#[cfg(target_os = "linux")]
161fn is_ca_trusted_linux(cert_path: &std::path::Path) -> bool {
162 let config = get_linux_ca_trust_config();
163 let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
164 if !installed_path.exists() {
165 return false;
166 }
167 let ours = std::fs::read(cert_path).unwrap_or_default();
169 let installed = std::fs::read(&installed_path).unwrap_or_default();
170 ours == installed
171}
172
173pub fn install_cert(cert_path: &std::path::Path) -> Result<()> {
185 if !cert_path.exists() {
186 miette::bail!(
187 "CA certificate not found at {}\n\
188 \n\
189 The proxy CA certificate is generated automatically when the proxy\n\
190 starts with `proxy.https = true`. Start the supervisor first:\n\
191 \n\
192 pitchfork supervisor start\n\
193 \n\
194 Or specify a custom certificate path with --cert.",
195 cert_path.display()
196 );
197 }
198
199 #[cfg(target_os = "macos")]
200 {
201 install_cert_macos(cert_path)?;
202 }
203 #[cfg(target_os = "linux")]
204 {
205 install_cert_linux(cert_path)?;
206 }
207 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
208 {
209 miette::bail!(
210 "Automatic certificate installation is not supported on this platform.\n\
211 Please manually install the certificate from:\n\
212 {}",
213 cert_path.display()
214 );
215 }
216
217 #[allow(unreachable_code)] Ok(())
219}
220
221#[cfg(target_os = "macos")]
222fn install_cert_macos(cert_path: &std::path::Path) -> Result<()> {
223 use std::process::Command;
224
225 let home = &*crate::env::HOME_DIR;
226 let keychain = format!("{}/Library/Keychains/login.keychain-db", home.display());
227
228 let status = Command::new("security")
229 .args([
230 "add-trusted-cert",
231 "-r",
232 "trustRoot",
233 "-k",
234 &keychain,
235 &cert_path.to_string_lossy(),
236 ])
237 .status()
238 .map_err(|e| miette::miette!("Failed to run `security` command: {e}"))?;
239
240 if !status.success() {
241 miette::bail!(
242 "Failed to install certificate (exit code: {}).\n\
243 \n\
244 Try running the command again.",
245 status.code().unwrap_or(-1)
246 );
247 }
248 Ok(())
249}
250
251#[cfg(target_os = "linux")]
252fn install_cert_linux(cert_path: &std::path::Path) -> Result<()> {
253 use std::ffi::CString;
254 use std::process::Command;
255
256 let config = get_linux_ca_trust_config();
257 let dest = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
258
259 let has_write_access = {
261 let path_cstr =
262 CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
263 unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
265 };
266
267 if !has_write_access {
268 miette::bail!(
269 "Installing certificates on Linux requires elevated privileges.\n\
270 \n\
271 Run with sudo:\n\
272 sudo pitchfork proxy trust\n\
273 \n\
274 This copies the certificate to {}/\n\
275 and runs `{}`.",
276 config.cert_dir,
277 config.update_command.join(" ")
278 );
279 }
280
281 std::fs::copy(cert_path, &dest)
282 .map_err(|e| miette::miette!("Failed to copy certificate to {}: {e}", dest.display()))?;
283
284 let status = Command::new(config.update_command[0])
285 .args(&config.update_command[1..])
286 .status()
287 .map_err(|e| miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" ")))?;
288
289 if !status.success() {
290 let _ = std::fs::remove_file(&dest);
293 miette::bail!(
294 "`{}` failed (exit code: {}).\n\
295 \n\
296 The system trust store was NOT updated.\n\
297 To install manually:\n\
298 sudo cp {} {}\n\
299 sudo {}",
300 config.update_command.join(" "),
301 status.code().unwrap_or(-1),
302 cert_path.display(),
303 dest.display(),
304 config.update_command.join(" ")
305 );
306 }
307 Ok(())
308}
309
310pub fn uninstall_cert(cert_path: &std::path::Path) -> Result<()> {
319 #[cfg(target_os = "macos")]
322 {
323 uninstall_cert_macos(cert_path)?;
324 }
325 #[cfg(target_os = "linux")]
326 {
327 uninstall_cert_linux(cert_path)?;
328 }
329 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
330 {
331 if !cert_path.exists() || !is_ca_trusted(cert_path) {
332 return Ok(());
333 }
334 miette::bail!("Automatic certificate removal is not supported on this platform.");
335 }
336
337 #[allow(unreachable_code)] Ok(())
339}
340
341#[cfg(target_os = "macos")]
342fn uninstall_cert_macos(cert_path: &std::path::Path) -> Result<()> {
343 use std::process::Command;
344
345 if cert_path.exists() {
347 let _ = Command::new("security")
348 .args(["remove-trusted-cert", &cert_path.to_string_lossy()])
349 .status();
350 }
351
352 let cn = if cert_path.exists() {
360 match cert_common_name_macos(cert_path) {
361 Some(cn) => Some(cn),
362 None => {
363 log::warn!(
364 "Could not determine certificate CN; skipping keychain deletion. \
365 The trust setting has been removed. To delete the certificate \
366 from the keychain manually, run:\n \
367 security delete-certificate -c \"<CN>\" ~/Library/Keychains/login.keychain-db"
368 );
369 None
370 }
371 }
372 } else {
373 Some("Pitchfork Local CA".to_string())
374 };
375
376 if let Some(cn) = cn {
377 let keychains = [
379 format!(
380 "{}/Library/Keychains/login.keychain-db",
381 crate::env::HOME_DIR.display()
382 ),
383 "/Library/Keychains/System.keychain".to_string(),
384 ];
385 for kc in &keychains {
386 for _ in 0..20 {
388 let status = Command::new("security")
389 .args(["delete-certificate", "-c", &cn, kc])
390 .status();
391 if status.map(|s| !s.success()).unwrap_or(true) {
392 break;
393 }
394 }
395 }
396 }
397
398 if cert_path.exists() && is_ca_trusted_macos(cert_path) == Some(true) {
402 miette::bail!("Could not remove CA from keychain. Try: sudo pitchfork proxy untrust");
403 }
404 Ok(())
405}
406
407#[cfg(target_os = "macos")]
409fn cert_common_name_macos(cert_path: &std::path::Path) -> Option<String> {
410 use std::process::Command;
411 let output = Command::new("openssl")
413 .args([
414 "x509",
415 "-noout",
416 "-subject",
417 "-nameopt",
418 "RFC2253",
419 "-in",
420 &cert_path.to_string_lossy(),
421 ])
422 .output()
423 .ok()?;
424 if !output.status.success() {
425 return None;
426 }
427 let subject = String::from_utf8_lossy(&output.stdout);
430 extract_cn_from_subject_rfc2253(&subject)
431}
432
433#[cfg(target_os = "macos")]
439fn extract_cn_from_subject_rfc2253(subject: &str) -> Option<String> {
440 let subject = subject.trim();
441 let subject = subject.strip_prefix("subject=").unwrap_or(subject);
442 for rdn in split_rdn(subject) {
443 let rdn = rdn.trim();
444 if let Some(rest) = rdn.strip_prefix("CN=") {
445 let cn = rest.trim();
446 if !cn.is_empty() {
447 return Some(cn.to_string());
448 }
449 }
450 }
451 None
452}
453
454#[cfg(target_os = "macos")]
458fn split_rdn(subject: &str) -> Vec<&str> {
459 let mut parts = Vec::new();
460 let mut start = 0;
461 let mut escaped = false;
462 for (i, ch) in subject.char_indices() {
463 if escaped {
464 escaped = false;
465 continue;
466 }
467 if ch == '\\' {
468 escaped = true;
469 continue;
470 }
471 if ch == ',' {
472 parts.push(&subject[start..i]);
473 start = i + ','.len_utf8();
474 }
475 }
476 if start < subject.len() {
477 parts.push(&subject[start..]);
478 }
479 parts
480}
481
482#[cfg(target_os = "linux")]
483fn uninstall_cert_linux(cert_path: &std::path::Path) -> Result<()> {
484 use std::ffi::CString;
485 use std::process::Command;
486
487 let config = get_linux_ca_trust_config();
488 let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
489
490 if !installed_path.exists() {
491 return Ok(());
492 }
493
494 let has_write_access = {
496 let path_cstr =
497 CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
498 unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
500 };
501
502 if !has_write_access {
503 miette::bail!(
504 "Removing certificates on Linux requires elevated privileges.\n\
505 \n\
506 Run with sudo:\n\
507 sudo pitchfork proxy untrust\n\
508 \n\
509 This removes the certificate from {}/\n\
510 and runs `{}`.",
511 config.cert_dir,
512 config.update_command.join(" ")
513 );
514 }
515
516 let should_remove = if cert_path.exists() {
520 let ours = std::fs::read(cert_path).unwrap_or_default();
521 let installed = std::fs::read(&installed_path).unwrap_or_default();
522 ours == installed
523 } else {
524 true
525 };
526
527 if should_remove {
528 std::fs::remove_file(&installed_path)
529 .map_err(|e| miette::miette!("Failed to remove {}: {e}", installed_path.display()))?;
530
531 let status = Command::new(config.update_command[0])
532 .args(&config.update_command[1..])
533 .status()
534 .map_err(|e| {
535 miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" "))
536 })?;
537 if !status.success() {
538 miette::bail!(
539 "`{}` failed (exit code: {}).\n\
540 The certificate was removed from {} but the system trust store was NOT updated.\n\
541 To complete the removal manually, run:\n\
542 sudo {}",
543 config.update_command.join(" "),
544 status.code().unwrap_or(-1),
545 config.cert_dir,
546 config.update_command.join(" ")
547 );
548 }
549 }
550
551 if cert_path.exists() && is_ca_trusted_linux(cert_path) {
553 miette::bail!(
554 "CA still trusted. Remove {}/{} manually and run `{}`.",
555 config.cert_dir,
556 INSTALLED_CERT_NAME,
557 config.update_command.join(" ")
558 );
559 }
560 Ok(())
561}
562
563pub enum AutoTrustResult {
569 AlreadyTrusted,
571 Trusted,
573 NotTrusted { reason: String },
575}
576
577pub fn auto_trust(cert_path: &std::path::Path) -> AutoTrustResult {
587 if !cert_path.exists() {
588 return AutoTrustResult::NotTrusted {
589 reason: "CA certificate not found".to_string(),
590 };
591 }
592
593 if is_ca_trusted(cert_path) {
594 return AutoTrustResult::AlreadyTrusted;
595 }
596
597 match install_cert(cert_path) {
598 Ok(()) => AutoTrustResult::Trusted,
599 Err(e) => AutoTrustResult::NotTrusted {
600 reason: e.to_string(),
601 },
602 }
603}