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