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 if !cert_path.exists() {
26 return false;
27 }
28
29 #[cfg(target_os = "macos")]
30 {
31 is_ca_trusted_macos(cert_path)
32 }
33 #[cfg(target_os = "linux")]
34 {
35 is_ca_trusted_linux(cert_path)
36 }
37 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
38 {
39 false
40 }
41}
42
43#[cfg(target_os = "macos")]
44fn is_ca_trusted_macos(cert_path: &std::path::Path) -> bool {
45 use std::process::{Command, Stdio};
46 Command::new("security")
55 .args(["verify-cert", "-c", &cert_path.to_string_lossy()])
56 .stdout(Stdio::null())
57 .stderr(Stdio::null())
58 .status()
59 .map(|s| s.success())
60 .unwrap_or(false)
61}
62
63#[cfg(target_os = "linux")]
65struct LinuxCATrustConfig {
66 cert_dir: &'static str,
67 update_command: &'static [&'static str],
69}
70
71#[cfg(target_os = "linux")]
72fn get_linux_ca_trust_config() -> LinuxCATrustConfig {
73 let configs = [
74 LinuxCATrustConfig {
76 cert_dir: "/usr/local/share/ca-certificates",
77 update_command: &["update-ca-certificates"],
78 },
79 LinuxCATrustConfig {
81 cert_dir: "/etc/pki/ca-trust/source/anchors",
82 update_command: &["update-ca-trust"],
83 },
84 LinuxCATrustConfig {
86 cert_dir: "/etc/ca-certificates/trust-source/anchors",
87 update_command: &["trust", "extract-compat"],
88 },
89 LinuxCATrustConfig {
91 cert_dir: "/etc/pki/trust/anchors",
92 update_command: &["update-ca-certificates"],
93 },
94 ];
95
96 for config in &configs {
98 if std::path::Path::new(config.cert_dir).exists() {
99 return LinuxCATrustConfig {
100 cert_dir: config.cert_dir,
101 update_command: config.update_command,
102 };
103 }
104 }
105
106 configs.into_iter().next().unwrap()
108}
109
110#[cfg(target_os = "linux")]
111fn is_ca_trusted_linux(cert_path: &std::path::Path) -> bool {
112 let config = get_linux_ca_trust_config();
113 let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
114 if !installed_path.exists() {
115 return false;
116 }
117 let ours = std::fs::read(cert_path).unwrap_or_default();
119 let installed = std::fs::read(&installed_path).unwrap_or_default();
120 ours == installed
121}
122
123pub fn install_cert(cert_path: &std::path::Path) -> Result<()> {
135 if !cert_path.exists() {
136 miette::bail!(
137 "CA certificate not found at {}\n\
138 \n\
139 The proxy CA certificate is generated automatically when the proxy\n\
140 starts with `proxy.https = true`. Start the supervisor first:\n\
141 \n\
142 pitchfork supervisor start\n\
143 \n\
144 Or specify a custom certificate path with --cert.",
145 cert_path.display()
146 );
147 }
148
149 #[cfg(target_os = "macos")]
150 {
151 install_cert_macos(cert_path)?;
152 }
153 #[cfg(target_os = "linux")]
154 {
155 install_cert_linux(cert_path)?;
156 }
157 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
158 {
159 miette::bail!(
160 "Automatic certificate installation is not supported on this platform.\n\
161 Please manually install the certificate from:\n\
162 {}",
163 cert_path.display()
164 );
165 }
166
167 #[allow(unreachable_code)] Ok(())
169}
170
171#[cfg(target_os = "macos")]
172fn install_cert_macos(cert_path: &std::path::Path) -> Result<()> {
173 use std::process::Command;
174
175 let home = &*crate::env::HOME_DIR;
176 let keychain = format!("{}/Library/Keychains/login.keychain-db", home.display());
177
178 let status = Command::new("security")
179 .args([
180 "add-trusted-cert",
181 "-r",
182 "trustRoot",
183 "-k",
184 &keychain,
185 &cert_path.to_string_lossy(),
186 ])
187 .status()
188 .map_err(|e| miette::miette!("Failed to run `security` command: {e}"))?;
189
190 if !status.success() {
191 miette::bail!(
192 "Failed to install certificate (exit code: {}).\n\
193 \n\
194 Try running the command again.",
195 status.code().unwrap_or(-1)
196 );
197 }
198 Ok(())
199}
200
201#[cfg(target_os = "linux")]
202fn install_cert_linux(cert_path: &std::path::Path) -> Result<()> {
203 use std::ffi::CString;
204 use std::process::Command;
205
206 let config = get_linux_ca_trust_config();
207 let dest = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
208
209 let has_write_access = {
211 let path_cstr =
212 CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
213 unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
215 };
216
217 if !has_write_access {
218 miette::bail!(
219 "Installing certificates on Linux requires elevated privileges.\n\
220 \n\
221 Run with sudo:\n\
222 sudo pitchfork proxy trust\n\
223 \n\
224 This copies the certificate to {}/\n\
225 and runs `{}`.",
226 config.cert_dir,
227 config.update_command.join(" ")
228 );
229 }
230
231 std::fs::copy(cert_path, &dest)
232 .map_err(|e| miette::miette!("Failed to copy certificate to {}: {e}", dest.display()))?;
233
234 let status = Command::new(config.update_command[0])
235 .args(&config.update_command[1..])
236 .status()
237 .map_err(|e| miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" ")))?;
238
239 if !status.success() {
240 let _ = std::fs::remove_file(&dest);
243 miette::bail!(
244 "`{}` failed (exit code: {}).\n\
245 \n\
246 The system trust store was NOT updated.\n\
247 To install manually:\n\
248 sudo cp {} {}\n\
249 sudo {}",
250 config.update_command.join(" "),
251 status.code().unwrap_or(-1),
252 cert_path.display(),
253 dest.display(),
254 config.update_command.join(" ")
255 );
256 }
257 Ok(())
258}
259
260pub fn uninstall_cert(cert_path: &std::path::Path) -> Result<()> {
269 #[cfg(target_os = "macos")]
272 {
273 uninstall_cert_macos(cert_path)?;
274 }
275 #[cfg(target_os = "linux")]
276 {
277 uninstall_cert_linux(cert_path)?;
278 }
279 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
280 {
281 if !cert_path.exists() || !is_ca_trusted(cert_path) {
282 return Ok(());
283 }
284 miette::bail!("Automatic certificate removal is not supported on this platform.");
285 }
286
287 #[allow(unreachable_code)] Ok(())
289}
290
291#[cfg(target_os = "macos")]
292fn uninstall_cert_macos(cert_path: &std::path::Path) -> Result<()> {
293 use std::process::Command;
294
295 if cert_path.exists() {
297 let _ = Command::new("security")
298 .args(["remove-trusted-cert", &cert_path.to_string_lossy()])
299 .status();
300 }
301
302 let cn = if cert_path.exists() {
310 match cert_common_name_macos(cert_path) {
311 Some(cn) => Some(cn),
312 None => {
313 log::warn!(
314 "Could not determine certificate CN; skipping keychain deletion. \
315 The trust setting has been removed. To delete the certificate \
316 from the keychain manually, run:\n \
317 security delete-certificate -c \"<CN>\" ~/Library/Keychains/login.keychain-db"
318 );
319 None
320 }
321 }
322 } else {
323 Some("Pitchfork Local CA".to_string())
324 };
325
326 if let Some(cn) = cn {
327 let keychains = [
329 format!(
330 "{}/Library/Keychains/login.keychain-db",
331 crate::env::HOME_DIR.display()
332 ),
333 "/Library/Keychains/System.keychain".to_string(),
334 ];
335 for kc in &keychains {
336 for _ in 0..20 {
338 let status = Command::new("security")
339 .args(["delete-certificate", "-c", &cn, kc])
340 .status();
341 if status.map(|s| !s.success()).unwrap_or(true) {
342 break;
343 }
344 }
345 }
346 }
347
348 if cert_path.exists() && is_ca_trusted_macos(cert_path) {
350 miette::bail!("Could not remove CA from keychain. Try: sudo pitchfork proxy untrust");
351 }
352 Ok(())
353}
354
355#[cfg(target_os = "macos")]
357fn cert_common_name_macos(cert_path: &std::path::Path) -> Option<String> {
358 use std::process::Command;
359 let output = Command::new("openssl")
361 .args([
362 "x509",
363 "-noout",
364 "-subject",
365 "-nameopt",
366 "RFC2253",
367 "-in",
368 &cert_path.to_string_lossy(),
369 ])
370 .output()
371 .ok()?;
372 if !output.status.success() {
373 return None;
374 }
375 let subject = String::from_utf8_lossy(&output.stdout);
378 extract_cn_from_subject_rfc2253(&subject)
379}
380
381#[cfg(target_os = "macos")]
387fn extract_cn_from_subject_rfc2253(subject: &str) -> Option<String> {
388 let subject = subject.trim();
389 let subject = subject.strip_prefix("subject=").unwrap_or(subject);
390 for rdn in split_rdn(subject) {
391 let rdn = rdn.trim();
392 if let Some(rest) = rdn.strip_prefix("CN=") {
393 let cn = rest.trim();
394 if !cn.is_empty() {
395 return Some(cn.to_string());
396 }
397 }
398 }
399 None
400}
401
402#[cfg(target_os = "macos")]
406fn split_rdn(subject: &str) -> Vec<&str> {
407 let mut parts = Vec::new();
408 let mut start = 0;
409 let mut escaped = false;
410 for (i, ch) in subject.char_indices() {
411 if escaped {
412 escaped = false;
413 continue;
414 }
415 if ch == '\\' {
416 escaped = true;
417 continue;
418 }
419 if ch == ',' {
420 parts.push(&subject[start..i]);
421 start = i + ','.len_utf8();
422 }
423 }
424 if start < subject.len() {
425 parts.push(&subject[start..]);
426 }
427 parts
428}
429
430#[cfg(target_os = "linux")]
431fn uninstall_cert_linux(cert_path: &std::path::Path) -> Result<()> {
432 use std::ffi::CString;
433 use std::process::Command;
434
435 let config = get_linux_ca_trust_config();
436 let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
437
438 if !installed_path.exists() {
439 return Ok(());
440 }
441
442 let has_write_access = {
444 let path_cstr =
445 CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
446 unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
448 };
449
450 if !has_write_access {
451 miette::bail!(
452 "Removing certificates on Linux requires elevated privileges.\n\
453 \n\
454 Run with sudo:\n\
455 sudo pitchfork proxy untrust\n\
456 \n\
457 This removes the certificate from {}/\n\
458 and runs `{}`.",
459 config.cert_dir,
460 config.update_command.join(" ")
461 );
462 }
463
464 let should_remove = if cert_path.exists() {
468 let ours = std::fs::read(cert_path).unwrap_or_default();
469 let installed = std::fs::read(&installed_path).unwrap_or_default();
470 ours == installed
471 } else {
472 true
473 };
474
475 if should_remove {
476 std::fs::remove_file(&installed_path)
477 .map_err(|e| miette::miette!("Failed to remove {}: {e}", installed_path.display()))?;
478
479 let status = Command::new(config.update_command[0])
480 .args(&config.update_command[1..])
481 .status()
482 .map_err(|e| {
483 miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" "))
484 })?;
485 if !status.success() {
486 miette::bail!(
487 "`{}` failed (exit code: {}).\n\
488 The certificate was removed from {} but the system trust store was NOT updated.\n\
489 To complete the removal manually, run:\n\
490 sudo {}",
491 config.update_command.join(" "),
492 status.code().unwrap_or(-1),
493 config.cert_dir,
494 config.update_command.join(" ")
495 );
496 }
497 }
498
499 if cert_path.exists() && is_ca_trusted_linux(cert_path) {
501 miette::bail!(
502 "CA still trusted. Remove {}/{} manually and run `{}`.",
503 config.cert_dir,
504 INSTALLED_CERT_NAME,
505 config.update_command.join(" ")
506 );
507 }
508 Ok(())
509}
510
511pub enum AutoTrustResult {
517 AlreadyTrusted,
519 Trusted,
521 NotTrusted { reason: String },
523}
524
525pub fn auto_trust(cert_path: &std::path::Path) -> AutoTrustResult {
535 if !cert_path.exists() {
536 return AutoTrustResult::NotTrusted {
537 reason: "CA certificate not found".to_string(),
538 };
539 }
540
541 if is_ca_trusted(cert_path) {
542 return AutoTrustResult::AlreadyTrusted;
543 }
544
545 match install_cert(cert_path) {
546 Ok(()) => AutoTrustResult::Trusted,
547 Err(e) => AutoTrustResult::NotTrusted {
548 reason: e.to_string(),
549 },
550 }
551}