1use std::path::{Path, PathBuf};
13
14use crate::Result;
15
16const MARKER_START: &str = "# pitchfork-start";
18const MARKER_END: &str = "# pitchfork-end";
19
20const OWNED_HEADER: &str = "# Managed by pitchfork (pitchfork proxy setup)";
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub enum Platform {
26 MacOs,
27 Linux,
28 Other,
29}
30
31impl Platform {
32 pub fn current() -> Self {
34 if cfg!(target_os = "macos") {
35 Platform::MacOs
36 } else if cfg!(target_os = "linux") {
37 Platform::Linux
38 } else {
39 Platform::Other
40 }
41 }
42}
43
44fn default_generated_ca() -> PathBuf {
46 crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
47}
48
49#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
57pub struct SetupContext {
58 pub platform: Platform,
59 pub tld: String,
61 pub dns_port: u16,
63 pub proxy_port: u16,
65 pub https: bool,
67 pub dns_enabled: bool,
69 pub pac: bool,
71 pub systemd_resolved: bool,
73 pub systemd_version: Option<u32>,
75 pub lan: bool,
77 pub contact_host: String,
82 pub ca_path: PathBuf,
84 pub ca_trusted: bool,
86 pub custom_cert: bool,
88 #[serde(default = "default_generated_ca")]
97 pub generated_ca: PathBuf,
98 pub binary: PathBuf,
100 pub resolver_dir: PathBuf,
102 pub resolved_dropin_dir: PathBuf,
104 pub pf_conf: PathBuf,
106 pub pf_anchor: PathBuf,
108 pub network_services: Vec<String>,
110 pub gnome: bool,
112 #[serde(default)]
123 pub prior_auto_proxy: Vec<PriorAutoProxy>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub struct PriorAutoProxy {
129 pub target: String,
131 pub url: String,
133 pub state: String,
136}
137
138impl SetupContext {
139 fn standard_port(&self) -> u16 {
141 if self.https { 443 } else { 80 }
142 }
143
144 fn needs_port_redirect(&self) -> bool {
150 self.proxy_port >= 1024 && self.proxy_port != self.standard_port()
151 }
152
153 fn resolver_file(&self) -> PathBuf {
155 self.resolver_dir.join(&self.tld)
156 }
157
158 fn resolved_dropin(&self) -> PathBuf {
160 self.resolved_dropin_dir.join("pitchfork.conf")
161 }
162
163 fn redirect_target(&self) -> &str {
166 self.contact_host
167 .trim_start_matches('[')
168 .trim_end_matches(']')
169 }
170
171 fn ipv6(&self) -> bool {
173 self.redirect_target().contains(':')
174 }
175
176 fn uses_pac(&self) -> bool {
184 self.pac && !self.lan
185 }
186
187 fn pac_url(&self) -> String {
189 super::pac::url(&self.contact_host, self.proxy_port)
190 }
191}
192
193#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct Probe {
199 pub argv: Vec<String>,
200 pub expect: Vec<ProbeExpect>,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum ProbeExpect {
207 Contains(String),
209}
210
211impl Probe {
212 fn status(argv: Vec<String>) -> Self {
214 Probe {
215 argv,
216 expect: vec![],
217 }
218 }
219
220 fn output(argv: Vec<String>, expect: impl Into<String>) -> Self {
222 Probe {
223 argv,
224 expect: vec![ProbeExpect::Contains(expect.into())],
225 }
226 }
227}
228
229#[derive(Clone, Debug, PartialEq, Eq)]
231pub enum Action {
232 WriteFile {
234 path: PathBuf,
235 content: String,
236 sudo: bool,
237 },
238 RemoveFile {
244 path: PathBuf,
245 sudo: bool,
246 still_referenced_by: Option<PathBuf>,
254 },
255 EnsureBlock {
257 path: PathBuf,
258 content: String,
259 sudo: bool,
260 pf_order: bool,
263 requires: Option<PathBuf>,
275 },
276 RemoveBlock { path: PathBuf, sudo: bool },
278 Run {
280 argv: Vec<String>,
281 sudo: bool,
282 skip_if: Option<Probe>,
285 },
286 RunIfPresent {
294 probe: Probe,
296 argv: Vec<String>,
297 sudo: bool,
298 },
299 RevokeBindCapability { binary: PathBuf },
304 GrantBindCapability { binary: PathBuf },
310 EnablePf { pf_conf: PathBuf, token: PathBuf },
318 ReleasePf { pf_conf: PathBuf, token: PathBuf },
323 GenerateCa { cert: PathBuf, key: PathBuf },
326 TrustCa { path: PathBuf },
328 UntrustCa { path: PathBuf, sudo: bool },
334 Note,
336}
337
338#[derive(Clone, Debug, PartialEq, Eq, Hash)]
345pub enum Resource {
346 File(PathBuf),
348 Block(PathBuf),
350 Redirect { from: u16, to: u16, ipv6: bool },
352 BindCapability(PathBuf),
354 TrustedCa(PathBuf),
356 AutoProxy { service: String, url: String },
358 ServiceReload(String),
360}
361
362#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct Step {
365 pub summary: String,
367 pub action: Action,
368 pub resource: Option<Resource>,
370}
371
372impl Step {
373 fn note(summary: impl Into<String>) -> Self {
374 Step {
375 summary: summary.into(),
376 action: Action::Note,
377 resource: None,
378 }
379 }
380
381 pub fn needs_sudo(&self) -> bool {
383 match &self.action {
384 Action::WriteFile { sudo, .. }
385 | Action::RemoveFile { sudo, .. }
386 | Action::EnsureBlock { sudo, .. }
387 | Action::RemoveBlock { sudo, .. }
388 | Action::Run { sudo, .. }
389 | Action::RunIfPresent { sudo, .. } => *sudo,
390 Action::RevokeBindCapability { .. }
391 | Action::GrantBindCapability { .. }
392 | Action::EnablePf { .. }
393 | Action::ReleasePf { .. } => true,
394 Action::UntrustCa { sudo, .. } => *sudo,
397 Action::GenerateCa { .. } | Action::TrustCa { .. } | Action::Note => false,
398 }
399 }
400}
401
402#[derive(Clone, Debug, Default, PartialEq, Eq)]
404pub struct Plan {
405 pub steps: Vec<Step>,
406 pub manual: Vec<String>,
408}
409
410impl Plan {
411 pub fn describe(&self) -> Vec<String> {
413 self.steps
414 .iter()
415 .map(|s| {
416 if s.needs_sudo() {
417 format!("[sudo] {}", s.summary)
418 } else {
419 s.summary.clone()
420 }
421 })
422 .collect()
423 }
424
425 pub fn needs_sudo(&self) -> bool {
427 self.steps.iter().any(Step::needs_sudo)
428 }
429
430 pub fn is_empty(&self) -> bool {
432 self.steps.iter().all(|s| s.action == Action::Note)
433 }
434}
435
436fn macos_resolver_file(dns_port: u16) -> String {
440 format!("{OWNED_HEADER}\nnameserver 127.0.0.1\nport {dns_port}\n")
441}
442
443fn resolved_dropin(tld: &str, dns_port: u16) -> String {
449 format!("{OWNED_HEADER}\n[Resolve]\nDNS=127.0.0.1:{dns_port}\nDomains=~{tld}\n")
450}
451
452fn pf_anchor_rules(standard_port: u16, proxy_port: u16, target: &str) -> String {
458 let family = if target.contains(':') {
459 "inet6"
460 } else {
461 "inet"
462 };
463 format!(
464 "{OWNED_HEADER}\n\
465 rdr pass on lo0 {family} proto tcp from any to any port {standard_port} -> {target} port {proxy_port}\n"
466 )
467}
468
469pub fn validate_proxy_port(port: i64) -> Result<()> {
478 if u16::try_from(port).ok().filter(|&p| p > 0).is_some() {
479 return Ok(());
480 }
481 miette::bail!(
482 "proxy.port is {port}, which is not a usable port. \
483 Set it between 1 and 65535 before running setup."
484 )
485}
486
487pub fn validate_tld(tld: &str) -> Result<()> {
495 if !super::pac::is_valid_tld(tld) {
496 miette::bail!(
497 "proxy.tld {tld:?} is not a valid host name suffix.\n\
498 It must be a DNS suffix such as `localhost` or `test`: \
499 dot-separated labels of ASCII letters, digits and `-`, each at \
500 most 63 bytes and not starting or ending with `-`.\n\
501 `proxy setup` writes it into privileged system files, so it is \
502 refused rather than escaped."
503 );
504 }
505 Ok(())
506}
507
508pub fn plan(ctx: &SetupContext) -> Plan {
510 let mut plan = Plan::default();
511 if ctx.uses_pac() {
512 plan_pac(ctx, &mut plan);
513 } else {
514 plan_resolver(ctx, &mut plan);
515 }
516 plan_ca(ctx, &mut plan);
517 plan_ports(ctx, &mut plan);
518 plan
519}
520
521fn plan_resolver(ctx: &SetupContext, plan: &mut Plan) {
523 if !ctx.dns_enabled {
524 plan.steps.push(Step::note(
525 "proxy.dns is false, so no resolver is running — skipping resolver setup",
526 ));
527 return;
528 }
529 if ctx.lan {
530 plan.steps.push(Step::note(
535 "LAN mode resolves *.local over mDNS — leaving the .local namespace alone",
536 ));
537 return;
538 }
539 match ctx.platform {
540 Platform::MacOs => {
541 plan.steps.push(Step {
542 summary: format!(
543 "write {} pointing *.{} at 127.0.0.1:{}",
544 ctx.resolver_file().display(),
545 ctx.tld,
546 ctx.dns_port
547 ),
548 action: Action::WriteFile {
549 path: ctx.resolver_file(),
550 content: macos_resolver_file(ctx.dns_port),
551 sudo: true,
552 },
553 resource: Some(Resource::File(ctx.resolver_file())),
554 });
555 if ctx.tld.eq_ignore_ascii_case("localhost") {
556 plan.manual.push(
561 concat!(
562 "Note: /etc/resolver/localhost hands *.localhost lookups to ",
563 "pitchfork, so subdomains such as api.localhost resolve only while ",
564 "the supervisor is running. Plain `localhost` keeps resolving from ",
565 "/etc/hosts either way. Undo this with `pitchfork proxy setup --undo`.",
566 )
567 .to_string(),
568 );
569 }
570 }
571 Platform::Linux if ctx.systemd_resolved && ctx.tld.eq_ignore_ascii_case("localhost") => {
572 plan.steps.push(Step::note(
573 "systemd-resolved already answers *.localhost with 127.0.0.1 — no resolver change needed",
574 ));
575 }
576 Platform::Linux if ctx.systemd_resolved => {
577 plan.steps.push(Step {
578 summary: format!(
579 "write {} routing *.{} to 127.0.0.1:{}",
580 ctx.resolved_dropin().display(),
581 ctx.tld,
582 ctx.dns_port
583 ),
584 action: Action::WriteFile {
585 path: ctx.resolved_dropin(),
586 content: resolved_dropin(&ctx.tld, ctx.dns_port),
587 sudo: true,
588 },
589 resource: Some(Resource::File(ctx.resolved_dropin())),
590 });
591 plan.steps.push(Step {
592 summary: "restart systemd-resolved to pick up the route (interrupts DNS briefly)"
593 .to_string(),
594 action: Action::Run {
595 argv: vec![
596 "systemctl".into(),
597 "restart".into(),
598 "systemd-resolved".into(),
599 ],
600 sudo: true,
601 skip_if: None,
617 },
618 resource: Some(Resource::ServiceReload("systemd-resolved".into())),
619 });
620 if let Some(v) = ctx.systemd_version
625 && v < 247
626 {
627 plan.manual.push(format!(
628 "Warning: systemd {v} is too old to route a domain at a resolver on a \
629 non-standard port. That needs 246 for `DNS=127.0.0.1:{port}` and 247 \
630 for `Domains=~{tld}`.\n\
631 The drop-in will be written but will not take effect. Use \
632 `pitchfork proxy setup --pac`, or point dnsmasq at \
633 127.0.0.1#{port} and make it your system resolver.",
634 port = ctx.dns_port,
635 tld = ctx.tld
636 ));
637 }
638 }
639 Platform::Linux => {
640 plan.manual.push(format!(
641 "systemd-resolved is not active, so pitchfork cannot route *.{tld} for you.\n\
642 Point a local resolver at pitchfork instead, for example with dnsmasq:\n\
643 \x20 # /etc/dnsmasq.d/pitchfork\n\
644 \x20 server=/{tld}/127.0.0.1#{port}\n\
645 then make dnsmasq your system resolver and restart it.\n\
646 Alternatively run `pitchfork proxy setup --pac`, which needs no root access.",
647 tld = ctx.tld,
648 port = ctx.dns_port
649 ));
650 }
651 Platform::Other => {
652 plan.manual.push(format!(
653 "pitchfork cannot configure this platform's resolver automatically.\n\
654 Point your system resolver at 127.0.0.1:{port} for *.{tld}, \
655 or run `pitchfork proxy setup --pac`.",
656 port = ctx.dns_port,
657 tld = ctx.tld
658 ));
659 }
660 }
661}
662
663fn plan_pac(ctx: &SetupContext, plan: &mut Plan) {
665 let url = ctx.pac_url();
666 plan.steps.push(Step::note(format!(
667 "the supervisor serves the PAC file at {url} while the proxy is running"
668 )));
669 match ctx.platform {
670 Platform::MacOs if !ctx.network_services.is_empty() => {
671 for service in &ctx.network_services {
672 plan.steps.push(Step {
673 summary: format!("set the automatic proxy URL for \"{service}\" to {url}"),
674 action: Action::Run {
675 argv: vec![
676 "networksetup".into(),
677 "-setautoproxyurl".into(),
678 service.clone(),
679 url.clone(),
680 ],
681 sudo: false,
682 skip_if: None,
683 },
684 resource: Some(Resource::AutoProxy {
685 service: service.clone(),
686 url: url.clone(),
687 }),
688 });
689 }
690 }
691 Platform::MacOs => {
692 plan.manual.push(format!(
693 "No active network services were found, so the automatic proxy URL was not set.\n\
694 Set it by hand in System Settings → Network → Details → Proxies → \
695 Automatic proxy configuration, using {url}."
696 ));
697 }
698 Platform::Linux if ctx.gnome => {
699 plan.steps.push(Step {
700 summary: format!("set the GNOME automatic proxy URL to {url}"),
701 action: Action::Run {
702 argv: vec![
703 "gsettings".into(),
704 "set".into(),
705 "org.gnome.system.proxy".into(),
706 "autoconfig-url".into(),
707 url.clone(),
708 ],
709 sudo: false,
710 skip_if: None,
711 },
712 resource: Some(Resource::AutoProxy {
713 service: "gnome".into(),
714 url: url.clone(),
715 }),
716 });
717 plan.steps.push(Step {
718 summary: "switch the GNOME proxy mode to automatic".to_string(),
719 action: Action::Run {
720 argv: vec![
721 "gsettings".into(),
722 "set".into(),
723 "org.gnome.system.proxy".into(),
724 "mode".into(),
725 "auto".into(),
726 ],
727 sudo: false,
728 skip_if: None,
729 },
730 resource: None,
731 });
732 }
733 _ => {
734 plan.manual.push(format!(
735 "pitchfork cannot set this system's automatic proxy URL for you.\n\
736 Point your browser or desktop proxy settings at {url}."
737 ));
738 }
739 }
740}
741
742fn plan_ca(ctx: &SetupContext, plan: &mut Plan) {
744 if !ctx.https {
745 return;
746 }
747 if ctx.custom_cert {
748 plan.steps.push(Step::note(
749 "proxy.tls_cert is set, so pitchfork serves your certificate and installs no CA",
750 ));
751 return;
752 }
753 if ctx.ca_trusted {
754 plan.steps.push(Step {
758 summary: format!(
759 "the pitchfork CA at {} is already trusted",
760 ctx.ca_path.display()
761 ),
762 action: Action::Note,
763 resource: Some(Resource::TrustedCa(ctx.ca_path.clone())),
764 });
765 return;
766 }
767 plan.steps.push(Step {
772 summary: format!("generate the pitchfork CA at {}", ctx.ca_path.display()),
773 action: Action::GenerateCa {
774 cert: ctx.ca_path.clone(),
775 key: ctx.ca_path.with_file_name("ca-key.pem"),
776 },
777 resource: None,
778 });
779 let summary = format!(
780 "install the pitchfork CA at {} into the system trust store",
781 ctx.ca_path.display()
782 );
783 let action = if ctx.platform == Platform::Linux {
787 Action::Run {
788 argv: vec![
793 ctx.binary.to_string_lossy().into_owned(),
794 "proxy".into(),
795 "trust".into(),
796 "--cert".into(),
797 ctx.ca_path.to_string_lossy().into_owned(),
798 ],
799 sudo: true,
800 skip_if: None,
801 }
802 } else {
803 Action::TrustCa {
804 path: ctx.ca_path.clone(),
805 }
806 };
807 plan.steps.push(Step {
808 summary,
809 action,
810 resource: Some(Resource::TrustedCa(ctx.ca_path.clone())),
811 });
812}
813
814fn plan_ports(ctx: &SetupContext, plan: &mut Plan) {
816 let standard = ctx.standard_port();
817 if ctx.proxy_port < 1024 {
818 match ctx.platform {
821 Platform::Linux => plan.steps.push(Step {
822 summary: format!(
823 "grant {} permission to bind ports below 1024 (cap_net_bind_service)",
824 ctx.binary.display()
825 ),
826 action: Action::GrantBindCapability {
827 binary: ctx.binary.clone(),
828 },
829 resource: Some(Resource::BindCapability(ctx.binary.clone())),
830 }),
831 _ => plan.manual.push(format!(
832 "proxy.port is {port}, which an unprivileged process cannot bind on macOS, \
833 and pitchfork will not run the supervisor as root.\n\
834 Set an unprivileged port and re-run setup, which then redirects \
835 {port} to it through pf:\n\
836 \x20 pitchfork settings set proxy.port {suggested}",
837 port = ctx.proxy_port,
838 suggested = if ctx.https { 8443 } else { 8080 }
839 )),
840 }
841 return;
842 }
843 if !ctx.needs_port_redirect() {
844 plan.steps.push(Step::note(format!(
845 "the proxy listens on port {}, which needs no redirect",
846 ctx.proxy_port
847 )));
848 return;
849 }
850 if ctx.uses_pac() {
851 plan.steps.push(Step::note(format!(
854 "the PAC file sends requests directly to port {}, so no port redirect is needed",
855 ctx.proxy_port
856 )));
857 return;
858 }
859 match ctx.platform {
860 Platform::MacOs => {
861 plan.steps.push(Step {
862 summary: format!(
863 "write {} redirecting port {standard} to {}",
864 ctx.pf_anchor.display(),
865 ctx.proxy_port
866 ),
867 action: Action::WriteFile {
868 path: ctx.pf_anchor.clone(),
869 content: pf_anchor_rules(standard, ctx.proxy_port, ctx.redirect_target()),
870 sudo: true,
871 },
872 resource: Some(Resource::File(ctx.pf_anchor.clone())),
873 });
874 plan.steps.push(Step {
875 summary: format!("load the pitchfork anchor into {}", ctx.pf_conf.display()),
876 action: Action::EnsureBlock {
877 path: ctx.pf_conf.clone(),
878 content: format!(
879 "rdr-anchor \"pitchfork\"\nload anchor \"pitchfork\" from \"{}\"",
880 ctx.pf_anchor.display()
881 ),
882 sudo: true,
883 pf_order: true,
884 requires: Some(ctx.pf_anchor.clone()),
887 },
888 resource: Some(Resource::Block(ctx.pf_conf.clone())),
889 });
890 plan.steps.push(Step {
891 summary: "enable pf and load the new rules".to_string(),
892 action: Action::EnablePf {
893 pf_conf: ctx.pf_conf.clone(),
894 token: pf_token_path(),
895 },
896 resource: Some(Resource::ServiceReload("pf".into())),
897 });
898 }
899 Platform::Linux => {
900 plan.steps.push(Step {
901 summary: format!(
902 "redirect loopback traffic for port {standard} to {} ({})",
903 ctx.proxy_port,
904 iptables_program(ctx.ipv6())
905 ),
906 action: Action::Run {
907 argv: iptables_redirect_argv("-A", standard, ctx.proxy_port, ctx.ipv6()),
908 sudo: true,
909 skip_if: Some(Probe::status(iptables_redirect_argv(
912 "-C",
913 standard,
914 ctx.proxy_port,
915 ctx.ipv6(),
916 ))),
917 },
918 resource: Some(Resource::Redirect {
919 from: standard,
920 to: ctx.proxy_port,
921 ipv6: ctx.ipv6(),
922 }),
923 });
924 }
925 Platform::Other => plan.manual.push(format!(
926 "Redirect port {standard} to {} yourself, or use the port in the URL.",
927 ctx.proxy_port
928 )),
929 }
930}
931
932fn iptables_program(ipv6: bool) -> &'static str {
934 if ipv6 { "ip6tables" } else { "iptables" }
935}
936
937fn iptables_redirect_argv(op: &str, from: u16, to: u16, ipv6: bool) -> Vec<String> {
942 [
943 iptables_program(ipv6),
944 "-t",
945 "nat",
946 op,
947 "OUTPUT",
948 "-p",
949 "tcp",
950 "-o",
951 "lo",
952 "--dport",
953 &from.to_string(),
954 "-j",
955 "REDIRECT",
956 "--to-ports",
957 &to.to_string(),
958 ]
959 .iter()
960 .map(|s| s.to_string())
961 .collect()
962}
963
964fn managed_resolver_files(dir: &Path, tld: &str, include_current: bool) -> Vec<PathBuf> {
970 let current = dir.join(tld);
971 let mut found = if include_current {
972 vec![current.clone()]
973 } else {
974 vec![]
975 };
976 let Ok(entries) = std::fs::read_dir(dir) else {
977 return found;
978 };
979 let mut others: Vec<PathBuf> = entries
980 .filter_map(|e| e.ok())
981 .map(|e| e.path())
982 .filter(|p| p != ¤t && p.is_file() && is_managed_file(p, false))
983 .collect();
984 others.sort();
985 found.extend(others);
986 found
987}
988
989fn pf_token_path() -> PathBuf {
992 crate::env::PITCHFORK_STATE_DIR
993 .join("proxy")
994 .join("pf-token")
995}
996
997fn record_path() -> PathBuf {
998 crate::env::PITCHFORK_STATE_DIR
999 .join("proxy")
1000 .join("setup.toml")
1001}
1002
1003#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
1005struct SetupRecords {
1006 #[serde(default)]
1007 setups: Vec<SetupContext>,
1008}
1009
1010const MAX_RECORDS: usize = 32;
1016
1017fn undo_key(ctx: &SetupContext) -> String {
1022 plan_undo(ctx).describe().join("\n")
1023}
1024
1025pub struct SetupLock(#[allow(dead_code)] Option<xx::fslock::LockFile>);
1036
1037pub fn lock_setup() -> miette::Result<SetupLock> {
1058 let path = record_path();
1059 if let Some(parent) = path.parent() {
1060 std::fs::create_dir_all(parent)
1061 .map_err(|e| miette::miette!("Could not create {}: {e}", parent.display()))?;
1062 }
1063 let lock = xx::fslock::get(&path, false).map_err(|e| {
1066 miette::miette!(
1067 "Could not lock {}: {e}\n\
1068 `pitchfork proxy setup` changes system configuration and will not \
1069 run without the lock.",
1070 path.display()
1071 )
1072 })?;
1073 Ok(SetupLock(lock))
1074}
1075
1076pub fn save_record(ctx: &SetupContext) {
1081 let path = record_path();
1082 if let Some(parent) = path.parent()
1083 && std::fs::create_dir_all(parent).is_err()
1084 {
1085 return;
1086 }
1087 let records = SetupRecords {
1088 setups: merged_records(load_records(), ctx),
1089 };
1090
1091 let Ok(text) = toml::to_string_pretty(&records) else {
1092 return;
1093 };
1094 if let Err(e) = write_file(&path, &text, false) {
1101 log::debug!(
1102 "Could not record the setup state at {}: {e}",
1103 path.display()
1104 );
1105 }
1106}
1107
1108fn merged_records(existing: Vec<SetupContext>, ctx: &SetupContext) -> Vec<SetupContext> {
1115 let key = undo_key(ctx);
1116 let mut merged = existing;
1117 merged.retain(|r| undo_key(r) != key);
1118 merged.push(ctx.clone());
1119 if merged.len() > MAX_RECORDS {
1120 let excess = merged.len() - MAX_RECORDS;
1121 merged.drain(..excess);
1122 }
1123 merged
1124}
1125
1126fn sanitize_record(mut ctx: SetupContext) -> Option<SetupContext> {
1144 if let Err(e) = validate_tld(&ctx.tld) {
1145 log::warn!("Ignoring a setup record with an unusable proxy.tld: {e}");
1146 return None;
1147 }
1148 let fixed = fixed_paths();
1149 ctx.resolver_dir = fixed.resolver_dir;
1150 ctx.resolved_dropin_dir = fixed.resolved_dropin_dir;
1151 ctx.pf_conf = fixed.pf_conf;
1152 ctx.pf_anchor = fixed.pf_anchor;
1153 ctx.generated_ca = default_generated_ca();
1154 ctx.binary = current_binary();
1155 ctx.prior_auto_proxy.retain(|p| {
1161 let plain = |v: &str| !v.is_empty() && !v.starts_with('-');
1162 let known_state = matches!(p.state.as_str(), "on" | "off" | "none" | "auto" | "manual");
1163 let usable_url = p.url.starts_with("http://")
1164 || p.url.starts_with("https://")
1165 || p.url.starts_with("file://");
1166 let ok = plain(&p.target) && plain(&p.url) && known_state && usable_url;
1167 if !ok {
1168 log::warn!(
1169 "Ignoring an unusable recorded automatic-proxy value for {:?}",
1170 p.target
1171 );
1172 }
1173 ok
1174 });
1175 Some(ctx)
1176}
1177
1178fn current_binary() -> PathBuf {
1180 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pitchfork"))
1181}
1182
1183struct FixedPaths {
1185 resolver_dir: PathBuf,
1186 resolved_dropin_dir: PathBuf,
1187 pf_conf: PathBuf,
1188 pf_anchor: PathBuf,
1189}
1190
1191fn fixed_paths() -> FixedPaths {
1192 FixedPaths {
1193 resolver_dir: PathBuf::from("/etc/resolver"),
1194 resolved_dropin_dir: PathBuf::from("/etc/systemd/resolved.conf.d"),
1195 pf_conf: PathBuf::from("/etc/pf.conf"),
1196 pf_anchor: PathBuf::from("/etc/pf.anchors/pitchfork"),
1197 }
1198}
1199
1200pub fn load_records() -> Vec<SetupContext> {
1202 let Ok(text) = std::fs::read_to_string(record_path()) else {
1203 return vec![];
1204 };
1205 if let Ok(records) = toml::from_str::<SetupRecords>(&text)
1210 && !records.setups.is_empty()
1211 {
1212 return records
1213 .setups
1214 .into_iter()
1215 .filter_map(sanitize_record)
1216 .collect();
1217 }
1218 match toml::from_str::<SetupContext>(&text) {
1219 Ok(ctx) => sanitize_record(ctx).into_iter().collect(),
1220 Err(e) => {
1223 log::debug!("No usable setup record: {e}");
1224 vec![]
1225 }
1226 }
1227}
1228
1229pub fn clear_record() {
1231 let path = record_path();
1232 if let Err(e) = std::fs::remove_file(&path)
1233 && e.kind() != std::io::ErrorKind::NotFound
1234 {
1235 log::debug!("Could not clear {}: {e}", path.display());
1236 }
1237}
1238
1239pub fn plan_undo_from(current: Option<&SetupContext>, recorded: &[SetupContext]) -> Plan {
1253 let mut plan = Plan::default();
1254 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1255 for ctx in recorded.iter().rev().chain(current) {
1258 let part = plan_undo(ctx);
1259 for step in part.steps {
1260 if seen.insert(step.summary.clone()) {
1261 plan.steps.push(step);
1262 }
1263 }
1264 for note in part.manual {
1265 if !plan.manual.contains(¬e) {
1266 plan.manual.push(note);
1267 }
1268 }
1269 }
1270 plan
1271}
1272
1273pub fn plan_with_reconcile(ctx: &SetupContext, recorded: &[SetupContext]) -> Plan {
1281 let forward = plan(ctx);
1282 let wanted: std::collections::HashSet<&Resource> = forward
1287 .steps
1288 .iter()
1289 .filter_map(|s| s.resource.as_ref())
1290 .collect();
1291
1292 let mut reconciled = Plan::default();
1293 let mut seen: std::collections::HashSet<Resource> = std::collections::HashSet::new();
1294 for old in recorded {
1295 for step in plan_undo(old).steps {
1296 let Some(resource) = step.resource.clone() else {
1297 continue;
1300 };
1301 if wanted.contains(&resource) || !seen.insert(resource) {
1302 continue;
1303 }
1304 reconciled.steps.push(step);
1305 }
1306 }
1307 reconciled.steps.extend(forward.steps);
1308 reconciled.manual = forward.manual;
1309 reconciled
1310}
1311
1312pub fn plan_undo(ctx: &SetupContext) -> Plan {
1317 let mut plan = Plan::default();
1318
1319 let wrote_resolver = ctx.dns_enabled && !ctx.lan && !ctx.uses_pac();
1327 let wrote_dropin =
1328 wrote_resolver && ctx.systemd_resolved && !ctx.tld.eq_ignore_ascii_case("localhost");
1329
1330 match ctx.platform {
1331 Platform::MacOs => {
1332 for path in managed_resolver_files(&ctx.resolver_dir, &ctx.tld, wrote_resolver) {
1340 plan.steps.push(Step {
1341 summary: format!("remove {}", path.display()),
1342 resource: Some(Resource::File(path.clone())),
1343 action: Action::RemoveFile {
1344 path,
1345 sudo: true,
1346 still_referenced_by: None,
1347 },
1348 });
1349 }
1350 }
1351 Platform::Linux if wrote_dropin => {
1352 plan.steps.push(Step {
1353 summary: format!("remove {}", ctx.resolved_dropin().display()),
1354 action: Action::RemoveFile {
1355 path: ctx.resolved_dropin(),
1356 sudo: true,
1357 still_referenced_by: None,
1358 },
1359 resource: Some(Resource::File(ctx.resolved_dropin())),
1360 });
1361 if ctx.systemd_resolved {
1362 plan.steps.push(Step {
1363 summary: "restart systemd-resolved to drop the route".to_string(),
1364 action: Action::Run {
1365 argv: vec![
1366 "systemctl".into(),
1367 "restart".into(),
1368 "systemd-resolved".into(),
1369 ],
1370 sudo: true,
1371 skip_if: None,
1372 },
1373 resource: Some(Resource::ServiceReload("systemd-resolved".into())),
1374 });
1375 }
1376 }
1377 Platform::Linux | Platform::Other => {}
1378 }
1379
1380 let granted_capability = ctx.proxy_port < 1024;
1386 let installed_redirect = !granted_capability && ctx.needs_port_redirect() && !ctx.uses_pac();
1387 match ctx.platform {
1388 Platform::MacOs if installed_redirect => {
1389 plan.steps.push(Step {
1390 summary: format!("remove the pitchfork anchor from {}", ctx.pf_conf.display()),
1391 action: Action::RemoveBlock {
1392 path: ctx.pf_conf.clone(),
1393 sudo: true,
1394 },
1395 resource: Some(Resource::Block(ctx.pf_conf.clone())),
1396 });
1397 plan.steps.push(Step {
1398 summary: format!("remove {}", ctx.pf_anchor.display()),
1399 action: Action::RemoveFile {
1400 path: ctx.pf_anchor.clone(),
1401 sudo: true,
1402 still_referenced_by: Some(ctx.pf_conf.clone()),
1404 },
1405 resource: Some(Resource::File(ctx.pf_anchor.clone())),
1406 });
1407 plan.steps.push(Step {
1408 summary: format!(
1409 "reload pf rules from {} and release pitchfork's hold on pf",
1410 ctx.pf_conf.display()
1411 ),
1412 action: Action::ReleasePf {
1413 pf_conf: ctx.pf_conf.clone(),
1414 token: pf_token_path(),
1415 },
1416 resource: Some(Resource::ServiceReload("pf".into())),
1417 });
1418 }
1419 Platform::Linux => {
1420 if installed_redirect {
1421 plan.steps.push(Step {
1422 summary: format!(
1426 "drop the {} redirect from port {} to {}",
1427 iptables_program(ctx.ipv6()),
1428 ctx.standard_port(),
1429 ctx.proxy_port
1430 ),
1431 action: Action::RunIfPresent {
1432 probe: Probe::status(iptables_redirect_argv(
1436 "-C",
1437 ctx.standard_port(),
1438 ctx.proxy_port,
1439 ctx.ipv6(),
1440 )),
1441 argv: iptables_redirect_argv(
1442 "-D",
1443 ctx.standard_port(),
1444 ctx.proxy_port,
1445 ctx.ipv6(),
1446 ),
1447 sudo: true,
1448 },
1449 resource: Some(Resource::Redirect {
1450 from: ctx.standard_port(),
1451 to: ctx.proxy_port,
1452 ipv6: ctx.ipv6(),
1453 }),
1454 });
1455 }
1456 if granted_capability {
1457 plan.steps.push(Step {
1458 summary: format!("revoke cap_net_bind_service from {}", ctx.binary.display()),
1459 action: Action::RevokeBindCapability {
1460 binary: ctx.binary.clone(),
1461 },
1462 resource: Some(Resource::BindCapability(ctx.binary.clone())),
1463 });
1464 }
1465 }
1466 Platform::MacOs | Platform::Other => {}
1467 }
1468
1469 match ctx.platform {
1471 Platform::MacOs => {
1472 for service in &ctx.network_services {
1473 plan.steps.push(Step {
1474 summary: format!(
1477 "turn off the automatic proxy URL {} for \"{service}\"",
1478 ctx.pac_url()
1479 ),
1480 action: Action::RunIfPresent {
1481 probe: Probe::output(
1485 vec![
1486 "networksetup".into(),
1487 "-getautoproxyurl".into(),
1488 service.clone(),
1489 ],
1490 ctx.pac_url(),
1491 ),
1492 argv: vec![
1493 "networksetup".into(),
1494 "-setautoproxystate".into(),
1495 service.clone(),
1496 "off".into(),
1497 ],
1498 sudo: false,
1499 },
1500 resource: Some(Resource::AutoProxy {
1501 service: service.clone(),
1502 url: ctx.pac_url(),
1503 }),
1504 });
1505 for prior in ctx.prior_auto_proxy.iter().filter(|p| &p.target == service) {
1519 let geturl = vec![
1520 "networksetup".into(),
1521 "-getautoproxyurl".into(),
1522 service.clone(),
1523 ];
1524 for (summary, expect, argv) in [
1525 (
1526 format!(
1527 "restore the automatic proxy URL for \"{service}\" to {}",
1528 prior.url
1529 ),
1530 ctx.pac_url(),
1531 vec![
1532 "networksetup".into(),
1533 "-setautoproxyurl".into(),
1534 service.clone(),
1535 prior.url.clone(),
1536 ],
1537 ),
1538 (
1539 format!(
1540 "restore the automatic proxy switch for \"{service}\" to {}",
1541 prior.state
1542 ),
1543 prior.url.clone(),
1544 vec![
1545 "networksetup".into(),
1546 "-setautoproxystate".into(),
1547 service.clone(),
1548 prior.state.clone(),
1549 ],
1550 ),
1551 ] {
1552 plan.steps.push(Step {
1553 summary,
1554 action: Action::RunIfPresent {
1555 probe: Probe::output(geturl.clone(), expect),
1556 argv,
1557 sudo: false,
1558 },
1559 resource: None,
1560 });
1561 }
1562 }
1563 }
1564 }
1565 Platform::Linux if ctx.gnome => {
1566 plan.steps.push(Step {
1567 summary: "switch the GNOME proxy mode back to none".to_string(),
1568 action: Action::RunIfPresent {
1569 probe: Probe::output(
1571 vec![
1572 "gsettings".into(),
1573 "get".into(),
1574 "org.gnome.system.proxy".into(),
1575 "autoconfig-url".into(),
1576 ],
1577 ctx.pac_url(),
1578 ),
1579 argv: vec![
1580 "gsettings".into(),
1581 "set".into(),
1582 "org.gnome.system.proxy".into(),
1583 "mode".into(),
1584 "none".into(),
1585 ],
1586 sudo: false,
1587 },
1588 resource: Some(Resource::AutoProxy {
1589 service: "gnome".into(),
1590 url: ctx.pac_url(),
1591 }),
1592 });
1593 for prior in ctx.prior_auto_proxy.iter().filter(|p| p.target == "gnome") {
1596 let geturl = vec![
1597 "gsettings".into(),
1598 "get".into(),
1599 "org.gnome.system.proxy".into(),
1600 "autoconfig-url".into(),
1601 ];
1602 for (summary, expect, key, value) in [
1603 (
1604 format!("restore the GNOME automatic proxy URL to {}", prior.url),
1605 ctx.pac_url(),
1606 "autoconfig-url",
1607 prior.url.clone(),
1608 ),
1609 (
1610 format!("restore the GNOME proxy mode to {}", prior.state),
1611 prior.url.clone(),
1612 "mode",
1613 prior.state.clone(),
1614 ),
1615 ] {
1616 plan.steps.push(Step {
1617 summary,
1618 action: Action::RunIfPresent {
1619 probe: Probe::output(geturl.clone(), expect),
1620 argv: vec![
1621 "gsettings".into(),
1622 "set".into(),
1623 "org.gnome.system.proxy".into(),
1624 key.into(),
1625 value,
1626 ],
1627 sudo: false,
1628 },
1629 resource: None,
1630 });
1631 }
1632 }
1633 }
1634 _ => {}
1635 }
1636
1637 if ctx.https && !ctx.generated_ca.as_os_str().is_empty() {
1652 plan.steps.push(Step {
1653 summary: format!(
1654 "remove the pitchfork CA at {} from the system trust store",
1655 ctx.generated_ca.display()
1656 ),
1657 action: Action::UntrustCa {
1658 path: ctx.generated_ca.clone(),
1659 sudo: ctx.platform == Platform::Linux,
1660 },
1661 resource: Some(Resource::TrustedCa(ctx.generated_ca.clone())),
1662 });
1663 }
1664
1665 plan
1666}
1667
1668pub fn invoked_through_sudo() -> Option<String> {
1675 sudo_user(is_root(), std::env::var("SUDO_USER").ok())
1676}
1677
1678fn sudo_user(root: bool, sudo_user: Option<String>) -> Option<String> {
1679 sudo_user.filter(|u| root && !u.is_empty() && u != "root")
1680}
1681
1682fn is_root() -> bool {
1683 #[cfg(unix)]
1684 {
1685 unsafe { libc::geteuid() == 0 }
1687 }
1688 #[cfg(not(unix))]
1689 {
1690 false
1691 }
1692}
1693
1694fn run(argv: &[String], sudo: bool) -> Result<()> {
1696 let (program, args): (&str, &[String]) = if sudo && !is_root() {
1697 ("sudo", argv)
1698 } else {
1699 (argv[0].as_str(), &argv[1..])
1700 };
1701 let status = std::process::Command::new(program)
1702 .args(args)
1703 .status()
1704 .map_err(|e| miette::miette!("Failed to run `{}`: {e}", argv.join(" ")))?;
1705 if !status.success() {
1706 miette::bail!(
1707 "`{}` failed with exit code {}",
1708 argv.join(" "),
1709 status.code().unwrap_or(-1)
1710 );
1711 }
1712 Ok(())
1713}
1714
1715fn write_file(path: &Path, content: &str, sudo: bool) -> Result<()> {
1729 let parent = path
1730 .parent()
1731 .ok_or_else(|| miette::miette!("{} has no parent directory", path.display()))?;
1732 let tmp = parent.join(format!(
1735 ".{}.pitchfork-{}.tmp",
1736 path.file_name()
1737 .map(|n| n.to_string_lossy().into_owned())
1738 .unwrap_or_else(|| "file".to_string()),
1739 std::process::id()
1740 ));
1741
1742 if !sudo || is_root() {
1743 std::fs::create_dir_all(parent)
1744 .map_err(|e| miette::miette!("Failed to create {}: {e}", parent.display()))?;
1745 std::fs::write(&tmp, content).map_err(|e| {
1746 let _ = std::fs::remove_file(&tmp);
1750 miette::miette!("Failed to write {}: {e}", tmp.display())
1751 })?;
1752 return std::fs::rename(&tmp, path).map_err(|e| {
1753 let _ = std::fs::remove_file(&tmp);
1755 miette::miette!("Failed to replace {}: {e}", path.display())
1756 });
1757 }
1758
1759 run(
1760 &[
1761 "mkdir".into(),
1762 "-p".into(),
1763 parent.to_string_lossy().into_owned(),
1764 ],
1765 true,
1766 )?;
1767
1768 let write_tmp = || -> Result<()> {
1769 use std::io::Write;
1770 let mut child = std::process::Command::new("sudo")
1771 .arg("tee")
1772 .arg(&tmp)
1773 .stdout(std::process::Stdio::null())
1774 .stdin(std::process::Stdio::piped())
1775 .spawn()
1776 .map_err(|e| miette::miette!("Failed to write {} via sudo: {e}", path.display()))?;
1777 let written = child
1781 .stdin
1782 .take()
1783 .ok_or_else(|| miette::miette!("Failed to open stdin for sudo tee"))
1784 .and_then(|mut stdin| {
1785 stdin
1786 .write_all(content.as_bytes())
1787 .map_err(|e| miette::miette!("Failed to write {}: {e}", path.display()))
1788 });
1789 let status = child
1790 .wait()
1791 .map_err(|e| miette::miette!("Failed to write {}: {e}", path.display()))?;
1792 written?;
1793 if !status.success() {
1794 miette::bail!(
1795 "Failed to write {}: sudo tee exited nonzero",
1796 path.display()
1797 );
1798 }
1799 run(
1804 &[
1805 "chmod".into(),
1806 "644".into(),
1807 tmp.to_string_lossy().into_owned(),
1808 ],
1809 true,
1810 )?;
1811 run(
1814 &[
1815 "mv".into(),
1816 "-f".into(),
1817 tmp.to_string_lossy().into_owned(),
1818 path.to_string_lossy().into_owned(),
1819 ],
1820 true,
1821 )
1822 };
1823
1824 write_tmp().inspect_err(|_| {
1825 let _ = run(
1828 &["rm".into(), "-f".into(), tmp.to_string_lossy().into_owned()],
1829 true,
1830 );
1831 })
1832}
1833
1834fn is_managed_file(path: &Path, sudo: bool) -> bool {
1839 read_maybe_privileged(path, sudo)
1840 .map(|c| c.contains(OWNED_HEADER))
1841 .unwrap_or(false)
1842}
1843
1844fn probe_matches(probe: &Probe, sudo: bool) -> bool {
1846 let argv = &probe.argv;
1847 let expect = &probe.expect;
1848 let (program, args): (&str, &[String]) = if sudo && !is_root() {
1849 ("sudo", argv)
1850 } else {
1851 (argv[0].as_str(), &argv[1..])
1852 };
1853 let Ok(out) = std::process::Command::new(program)
1854 .args(args)
1855 .stderr(std::process::Stdio::null())
1856 .output()
1857 else {
1858 return false;
1859 };
1860 if !out.status.success() {
1861 return false;
1862 }
1863 let stdout = String::from_utf8_lossy(&out.stdout);
1864 expect.iter().all(|e| match e {
1865 ProbeExpect::Contains(text) => stdout.contains(text.as_str()),
1866 })
1867}
1868
1869fn remove_file(path: &Path, sudo: bool) -> Result<()> {
1874 if !path.exists() {
1875 return Ok(());
1876 }
1877 if !is_managed_file(path, sudo) {
1878 return Ok(());
1882 }
1883 if !sudo || is_root() {
1884 return std::fs::remove_file(path)
1885 .map_err(|e| miette::miette!("Failed to remove {}: {e}", path.display()));
1886 }
1887 run(
1888 &[
1889 "rm".into(),
1890 "-f".into(),
1891 path.to_string_lossy().into_owned(),
1892 ],
1893 true,
1894 )
1895}
1896
1897fn pf_keyword(line: &str) -> Option<&str> {
1904 let first = line.split_whitespace().next()?;
1905 match first.split_once('=') {
1907 Some(_) => None,
1908 None => Some(first),
1909 }
1910}
1911
1912fn is_pf_translation(line: &str) -> bool {
1914 pf_keyword(line).is_some_and(|kw| {
1915 matches!(
1916 kw,
1917 "rdr-anchor" | "nat-anchor" | "binat-anchor" | "rdr" | "nat" | "binat"
1918 )
1919 })
1920}
1921
1922fn is_pf_filter(line: &str) -> bool {
1927 pf_keyword(line)
1928 .is_some_and(|kw| matches!(kw, "anchor" | "pass" | "block" | "match" | "antispoof"))
1929}
1930
1931pub(crate) fn splice_pf_block(text: &str, block: &str) -> String {
1939 let stripped = splice_block(text, "");
1942 if block.is_empty() {
1943 return stripped;
1944 }
1945 let mut out: Vec<&str> = stripped.lines().collect();
1946 let at = out
1951 .iter()
1952 .rposition(|l| is_pf_translation(l))
1953 .map(|i| i + 1)
1954 .or_else(|| out.iter().position(|l| is_pf_filter(l)))
1955 .unwrap_or(out.len());
1956 let managed = format!("{MARKER_START}\n{block}\n{MARKER_END}");
1957 out.insert(at, &managed);
1958 let mut joined = out.join("\n");
1959 if stripped.ends_with('\n') {
1960 joined.push('\n');
1961 }
1962 joined
1963}
1964
1965pub(crate) fn splice_block(text: &str, block: &str) -> String {
1969 let stripped = match (text.find(MARKER_START), text.find(MARKER_END)) {
1970 (Some(start), Some(end)) if end > start => {
1971 let end = end + MARKER_END.len();
1972 let mut out = String::with_capacity(text.len());
1973 out.push_str(&text[..start]);
1974 out.push_str(text[end..].trim_start_matches('\n'));
1975 out
1976 }
1977 _ => text.to_string(),
1978 };
1979 if block.is_empty() {
1980 let trimmed = stripped.trim_end();
1981 if trimmed.is_empty() {
1982 return String::new();
1983 }
1984 return format!("{trimmed}\n");
1985 }
1986 let body = stripped.trim_end();
1987 let prefix = if body.is_empty() {
1988 String::new()
1989 } else {
1990 format!("{body}\n\n")
1991 };
1992 format!("{prefix}{MARKER_START}\n{block}\n{MARKER_END}\n")
1993}
1994
1995fn read_maybe_privileged(path: &Path, sudo: bool) -> Result<String> {
1997 match std::fs::read_to_string(path) {
1998 Ok(c) => Ok(c),
1999 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
2000 Err(_) if sudo && !is_root() => {
2001 let out = std::process::Command::new("sudo")
2002 .arg("cat")
2003 .arg(path)
2004 .output()
2005 .map_err(|e| miette::miette!("Failed to read {}: {e}", path.display()))?;
2006 if !out.status.success() {
2007 miette::bail!("Failed to read {}", path.display());
2008 }
2009 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
2010 }
2011 Err(e) => Err(miette::miette!("Failed to read {}: {e}", path.display())),
2012 }
2013}
2014
2015fn already_done(action: &Action) -> bool {
2017 match action {
2018 Action::WriteFile { path, content, .. } => {
2019 std::fs::read_to_string(path).is_ok_and(|c| &c == content)
2020 }
2021 Action::RemoveFile { path, sudo, .. } => !path.exists() || !is_managed_file(path, *sudo),
2023 Action::EnsureBlock {
2027 path,
2028 content,
2029 pf_order: true,
2030 ..
2031 } => std::fs::read_to_string(path).is_ok_and(|c| splice_pf_block(&c, content) == c),
2032 Action::EnsureBlock { path, content, .. } => std::fs::read_to_string(path)
2033 .is_ok_and(|c| c.contains(MARKER_START) && c.contains(content.as_str())),
2034 Action::RemoveBlock { path, .. } => {
2035 std::fs::read_to_string(path).is_ok_and(|c| !c.contains(MARKER_START))
2036 }
2037 Action::Note => true,
2038 Action::Run { skip_if, sudo, .. } => {
2041 skip_if.as_ref().is_some_and(|p| probe_matches(p, *sudo))
2042 }
2043 Action::RunIfPresent { probe, sudo, .. } => !probe_matches(probe, *sudo),
2045 Action::RevokeBindCapability { binary } => {
2046 revoke_already_done(file_capabilities(binary).as_deref())
2047 }
2048 Action::GrantBindCapability { binary } => {
2051 file_capabilities(binary).is_some_and(|caps| caps.iter().any(|c| c == BIND_CAPABILITY))
2052 }
2053 Action::EnablePf { .. } | Action::ReleasePf { .. } => false,
2055 Action::GenerateCa { cert, key } => ca_pair_problem(cert, key).is_none(),
2056 Action::TrustCa { path } => crate::proxy::trust::is_ca_trusted(path),
2057 Action::UntrustCa { path, .. } => {
2066 untrust_already_done(path.exists(), crate::proxy::trust::ca_trust_state(path))
2067 }
2068 }
2069}
2070
2071fn untrust_already_done(pem_exists: bool, trusted: Option<bool>) -> bool {
2077 pem_exists && trusted == Some(false)
2083}
2084
2085fn revoke_already_done(caps: Option<&[String]>) -> bool {
2093 matches!(caps, Some(caps) if !caps.iter().any(|c| c == BIND_CAPABILITY))
2094}
2095
2096fn skip_reason(action: &Action) -> &'static str {
2098 match action {
2099 Action::RemoveFile { path, sudo, .. } if path.exists() && !is_managed_file(path, *sudo) => {
2100 "not written by pitchfork, left alone"
2101 }
2102 Action::RemoveFile { .. } => "nothing there",
2103 Action::RunIfPresent { .. } => "not configured by pitchfork",
2104 _ => "already done",
2105 }
2106}
2107
2108fn execute(step: &Step) -> Result<()> {
2110 match &step.action {
2111 Action::Note => Ok(()),
2112 Action::WriteFile {
2113 path,
2114 content,
2115 sudo,
2116 } => {
2117 if content.contains(OWNED_HEADER) && path.exists() && !is_managed_file(path, *sudo) {
2128 return Err(miette::miette!(
2129 "{} already exists and was not written by pitchfork, so it \
2130 was left alone. Move it aside if it is no longer needed, \
2131 or choose a different proxy.tld.",
2132 path.display()
2133 ));
2134 }
2135 write_file(path, content, *sudo)
2136 }
2137 Action::RemoveFile {
2138 path,
2139 sudo,
2140 still_referenced_by,
2141 } => {
2142 if let Some(referrer) = still_referenced_by {
2143 let blocked = if referrer.exists() {
2153 match read_maybe_privileged(referrer, *sudo) {
2154 Ok(contents) => contents
2155 .contains(MARKER_START)
2156 .then(|| format!("still names {}", path.display())),
2157 Err(e) => Some(format!("could not be read ({e})")),
2158 }
2159 } else {
2160 None
2161 };
2162 if let Some(why) = blocked {
2163 return Err(miette::miette!(
2164 "{} {why}, so {} was left in place",
2165 referrer.display(),
2166 path.display()
2167 ));
2168 }
2169 }
2170 remove_file(path, *sudo)
2171 }
2172 Action::EnsureBlock {
2173 path,
2174 content,
2175 sudo,
2176 pf_order,
2177 requires,
2178 } => {
2179 if let Some(required) = requires
2187 && !is_managed_file(required, *sudo)
2188 {
2189 let why = if required.exists() {
2190 "was not written by pitchfork"
2191 } else {
2192 "does not exist"
2193 };
2194 return Err(miette::miette!(
2195 "{} {why}, so the block naming it was not written to {}",
2196 required.display(),
2197 path.display()
2198 ));
2199 }
2200 let current = read_maybe_privileged(path, *sudo)?;
2201 let spliced = if *pf_order {
2202 splice_pf_block(¤t, content)
2203 } else {
2204 splice_block(¤t, content)
2205 };
2206 write_file(path, &spliced, *sudo)
2207 }
2208 Action::RemoveBlock { path, sudo } => {
2209 if !path.exists() {
2210 return Ok(());
2211 }
2212 let current = read_maybe_privileged(path, *sudo)?;
2213 write_file(path, &splice_block(¤t, ""), *sudo)
2214 }
2215 Action::Run { argv, sudo, .. } | Action::RunIfPresent { argv, sudo, .. } => {
2216 run(argv, *sudo)
2217 }
2218 Action::RevokeBindCapability { binary } => revoke_bind_capability(binary),
2219 Action::EnablePf { pf_conf, token } => enable_pf(pf_conf, token),
2220 Action::ReleasePf { pf_conf, token } => {
2221 run(
2222 &[
2223 "pfctl".into(),
2224 "-f".into(),
2225 pf_conf.to_string_lossy().into_owned(),
2226 ],
2227 true,
2228 )?;
2229 release_pf(token)
2230 }
2231 Action::GrantBindCapability { binary } => grant_bind_capability(binary),
2232 Action::GenerateCa { cert, key } => generate_ca(cert, key),
2233 Action::TrustCa { path } => crate::proxy::trust::install_cert(path),
2234 Action::UntrustCa { path, sudo } => {
2235 if *sudo && !is_root() {
2236 run(
2239 &[
2240 std::env::current_exe()
2241 .unwrap_or_else(|_| PathBuf::from("pitchfork"))
2242 .to_string_lossy()
2243 .into_owned(),
2244 "proxy".into(),
2245 "untrust".into(),
2246 "--cert".into(),
2247 path.to_string_lossy().into_owned(),
2248 ],
2249 true,
2250 )
2251 } else {
2252 crate::proxy::trust::uninstall_cert(path)
2253 }
2254 }
2255 }
2256}
2257
2258const BIND_CAPABILITY: &str = "cap_net_bind_service";
2260
2261fn file_capabilities(path: &Path) -> Option<Vec<String>> {
2272 let out = ["getcap", "/usr/sbin/getcap", "/sbin/getcap"]
2277 .into_iter()
2278 .find_map(|prog| {
2279 std::process::Command::new(prog)
2280 .arg(path)
2281 .stderr(std::process::Stdio::null())
2282 .output()
2283 .ok()
2284 })?;
2285 if !out.status.success() {
2286 return None;
2287 }
2288 capabilities_from_getcap(
2289 path.to_string_lossy().as_ref(),
2290 &String::from_utf8_lossy(&out.stdout),
2291 )
2292}
2293
2294fn capabilities_from_getcap(path: &str, stdout: &str) -> Option<Vec<String>> {
2304 if let Some(line) = stdout.lines().find_map(|l| l.trim_end().strip_prefix(path)) {
2305 return Some(parse_capabilities(line));
2306 }
2307 stdout.trim().is_empty().then(Vec::new)
2322}
2323
2324fn parse_capabilities(spec: &str) -> Vec<String> {
2332 let mut names: Vec<String> = Vec::new();
2333 for clause in spec.split_whitespace() {
2334 let Some(list) = clause.split(['=', '+', '-']).next() else {
2337 continue;
2338 };
2339 for name in list.split(',').map(str::trim) {
2340 if name.starts_with("cap_") && !names.iter().any(|n| n == name) {
2341 names.push(name.to_string());
2342 }
2343 }
2344 }
2345 names
2346}
2347
2348fn grant_bind_capability(path: &Path) -> Result<()> {
2354 check_grant(path, file_capabilities(path).as_deref())?;
2355 run(
2356 &[
2357 "setcap".into(),
2358 format!("{BIND_CAPABILITY}=+ep"),
2359 path.to_string_lossy().into_owned(),
2360 ],
2361 true,
2362 )
2363}
2364
2365fn check_grant(path: &Path, caps: Option<&[String]>) -> Result<()> {
2372 let Some(caps) = caps else {
2375 miette::bail!(
2376 "Could not read the capabilities on {} — `getcap` was not found or failed, \
2377 so {BIND_CAPABILITY} was not granted.\n\
2378 Check with: sudo getcap {}\n\
2379 Grant with: sudo setcap {BIND_CAPABILITY}=+ep {}",
2380 path.display(),
2381 path.display(),
2382 path.display()
2383 );
2384 };
2385 let others: Vec<&str> = caps
2386 .iter()
2387 .map(String::as_str)
2388 .filter(|c| *c != BIND_CAPABILITY)
2389 .collect();
2390 if !others.is_empty() {
2391 miette::bail!(
2392 "{} already carries {}, which pitchfork did not grant. \
2393 `setcap` replaces the whole set, so granting {BIND_CAPABILITY} here \
2394 would clear those; it has not been granted.\n\
2395 Add it alongside them by hand, keeping their flags as `sudo getcap {}` prints them.",
2396 path.display(),
2397 others.join(", "),
2398 path.display()
2399 );
2400 }
2401 Ok(())
2402}
2403
2404fn ca_pair_problem(cert: &Path, key: &Path) -> Option<String> {
2406 match (cert.exists(), key.exists()) {
2407 (false, false) => return Some("not generated yet".to_string()),
2408 (true, false) => return Some(format!("{} is missing", key.display())),
2409 (false, true) => return Some(format!("{} is missing", cert.display())),
2410 (true, true) => {}
2411 }
2412 #[cfg(feature = "proxy-tls")]
2413 {
2414 crate::proxy::server::ca_pair_problem(cert, key)
2415 }
2416 #[cfg(not(feature = "proxy-tls"))]
2417 {
2418 None
2419 }
2420}
2421
2422fn generate_ca(cert: &Path, key: &Path) -> Result<()> {
2430 #[cfg(feature = "proxy-tls")]
2431 {
2432 crate::proxy::server::ensure_ca(cert, key, || match ca_pair_problem(cert, key) {
2433 None => true,
2434 Some(problem) => {
2435 if cert.exists() || key.exists() {
2438 println!(" the existing CA cannot be used ({problem}); generating a new one");
2439 println!(
2444 " restart the supervisor so it signs with the new CA: \
2445 pitchfork supervisor start --force"
2446 );
2447 }
2448 false
2449 }
2450 })
2451 .map(|_| ())
2452 }
2453 #[cfg(not(feature = "proxy-tls"))]
2454 {
2455 let _ = (cert, key);
2456 miette::bail!("HTTPS proxy support requires the `proxy-tls` feature")
2457 }
2458}
2459
2460fn enable_pf(pf_conf: &Path, token: &Path) -> Result<()> {
2466 let argv: Vec<String> = vec![
2467 "pfctl".into(),
2468 "-Ef".into(),
2469 pf_conf.to_string_lossy().into_owned(),
2470 ];
2471 let (program, args): (&str, &[String]) = if is_root() {
2472 (argv[0].as_str(), &argv[1..])
2473 } else {
2474 ("sudo", &argv[..])
2475 };
2476 let out = std::process::Command::new(program)
2479 .args(args)
2480 .stderr(std::process::Stdio::piped())
2481 .output()
2482 .map_err(|e| miette::miette!("Failed to run `{}`: {e}", argv.join(" ")))?;
2483 let stderr = String::from_utf8_lossy(&out.stderr);
2484 eprint!("{stderr}");
2485 if !out.status.success() {
2486 miette::bail!(
2487 "`{}` failed with exit code {}",
2488 argv.join(" "),
2489 out.status.code().unwrap_or(-1)
2490 );
2491 }
2492
2493 let previous = read_pf_token(token);
2494 match (parse_pf_token(&stderr), boot_time()) {
2495 (Some(new), Some(boot)) => {
2496 if let Some(parent) = token.parent() {
2497 let _ = std::fs::create_dir_all(parent);
2498 }
2499 std::fs::write(token, format!("{new}\n{boot}\n"))
2500 .map_err(|e| miette::miette!("Failed to write {}: {e}", token.display()))?;
2501 }
2502 _ => {
2504 let _ = std::fs::remove_file(token);
2505 }
2506 }
2507 if let Some(old) = previous {
2508 let _ = run(&["pfctl".into(), "-X".into(), old], true);
2510 }
2511 Ok(())
2512}
2513
2514fn release_pf(token: &Path) -> Result<()> {
2516 let Some(held) = read_pf_token(token) else {
2517 let _ = std::fs::remove_file(token);
2519 return Ok(());
2520 };
2521 run(&["pfctl".into(), "-X".into(), held], true)?;
2522 let _ = std::fs::remove_file(token);
2523 Ok(())
2524}
2525
2526fn read_pf_token(path: &Path) -> Option<String> {
2531 let content = std::fs::read_to_string(path).ok()?;
2532 let mut lines = content.lines();
2533 let token = lines.next()?.trim();
2534 let boot = lines.next()?.trim();
2535 (token.bytes().all(|b| b.is_ascii_digit())
2536 && !token.is_empty()
2537 && Some(boot) == boot_time().as_deref())
2538 .then(|| token.to_string())
2539}
2540
2541fn parse_pf_token(stderr: &str) -> Option<String> {
2543 stderr.lines().find_map(|line| {
2544 let (key, value) = line.split_once(':')?;
2545 let value = value.trim();
2546 (key.trim() == "Token" && !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
2547 .then(|| value.to_string())
2548 })
2549}
2550
2551fn boot_time() -> Option<String> {
2553 let out = std::process::Command::new("sysctl")
2554 .args(["-n", "kern.boottime"])
2555 .stderr(std::process::Stdio::null())
2556 .output()
2557 .ok()?;
2558 let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
2559 (out.status.success() && !text.is_empty()).then_some(text)
2560}
2561
2562fn revoke_bind_capability(path: &Path) -> Result<()> {
2568 let Some(caps) = file_capabilities(path) else {
2574 miette::bail!(
2575 "Could not read the capabilities on {} — `getcap` was not found or failed, \
2576 so {BIND_CAPABILITY} was left in place.\n\
2577 Check with: sudo getcap {}\n\
2578 Remove with: sudo setcap -r {}",
2579 path.display(),
2580 path.display(),
2581 path.display()
2582 );
2583 };
2584 if !caps.iter().any(|c| c == BIND_CAPABILITY) {
2585 return Ok(());
2586 }
2587 let others: Vec<&String> = caps.iter().filter(|c| *c != BIND_CAPABILITY).collect();
2588 if !others.is_empty() {
2589 miette::bail!(
2590 "{} also carries {}, which pitchfork did not grant. \
2591 Removing {BIND_CAPABILITY} here would clear those too, so it has been left alone.\n\
2592 Remove it by hand with: sudo setcap {}=ep {}",
2593 path.display(),
2594 others
2595 .iter()
2596 .map(|c| c.as_str())
2597 .collect::<Vec<_>>()
2598 .join(", "),
2599 others
2600 .iter()
2601 .map(|c| c.as_str())
2602 .collect::<Vec<_>>()
2603 .join(","),
2604 path.display()
2605 );
2606 }
2607 run(
2608 &[
2609 "setcap".into(),
2610 "-r".into(),
2611 path.to_string_lossy().into_owned(),
2612 ],
2613 true,
2614 )
2615}
2616
2617#[derive(Debug, Default)]
2619pub struct RunReport {
2620 pub applied: usize,
2621 pub skipped: usize,
2622 pub failed: Vec<(String, String)>,
2623}
2624
2625pub fn apply(plan: &Plan) -> RunReport {
2630 let mut report = RunReport::default();
2631 for step in &plan.steps {
2632 if step.action == Action::Note {
2633 continue;
2634 }
2635 if already_done(&step.action) {
2636 println!(" · {} ({})", step.summary, skip_reason(&step.action));
2637 report.skipped += 1;
2638 continue;
2639 }
2640 print!(" → {} ... ", step.summary);
2641 use std::io::Write;
2642 let _ = std::io::stdout().flush();
2643 match execute(step) {
2644 Ok(()) => {
2645 println!("ok");
2646 report.applied += 1;
2647 }
2648 Err(e) => {
2649 println!("failed");
2650 report.failed.push((step.summary.clone(), e.to_string()));
2651 }
2652 }
2653 }
2654 report
2655}
2656
2657pub fn systemd_resolved_active() -> bool {
2661 if !cfg!(target_os = "linux") {
2662 return false;
2663 }
2664 std::process::Command::new("systemctl")
2665 .args(["is-active", "--quiet", "systemd-resolved"])
2666 .stdout(std::process::Stdio::null())
2667 .stderr(std::process::Stdio::null())
2668 .status()
2669 .map(|s| s.success())
2670 .unwrap_or(false)
2671}
2672
2673pub fn systemd_version() -> Option<u32> {
2678 if !cfg!(target_os = "linux") {
2679 return None;
2680 }
2681 let out = std::process::Command::new("systemctl")
2682 .arg("--version")
2683 .stderr(std::process::Stdio::null())
2684 .output()
2685 .ok()?;
2686 String::from_utf8_lossy(&out.stdout)
2688 .lines()
2689 .next()?
2690 .split_whitespace()
2691 .nth(1)?
2692 .parse()
2693 .ok()
2694}
2695
2696pub fn gnome_proxy_available() -> bool {
2698 std::process::Command::new("gsettings")
2699 .args(["get", "org.gnome.system.proxy", "mode"])
2700 .stdout(std::process::Stdio::null())
2701 .stderr(std::process::Stdio::null())
2702 .status()
2703 .map(|s| s.success())
2704 .unwrap_or(false)
2705}
2706
2707pub fn read_prior_auto_proxy(
2718 platform: Platform,
2719 services: &[String],
2720 gnome: bool,
2721 our_url: &str,
2722) -> Vec<PriorAutoProxy> {
2723 let run = |argv: &[&str]| -> Option<String> {
2724 let out = std::process::Command::new(argv[0])
2725 .args(&argv[1..])
2726 .stderr(std::process::Stdio::null())
2727 .output()
2728 .ok()?;
2729 out.status
2730 .success()
2731 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
2732 };
2733 let mut prior = vec![];
2734 match platform {
2735 Platform::MacOs => {
2736 for service in services {
2737 let Some(out) = run(&["networksetup", "-getautoproxyurl", service]) else {
2738 continue;
2739 };
2740 let field = |name: &str| {
2741 out.lines()
2742 .filter_map(|l| l.split_once(':'))
2743 .find(|(k, _)| k.trim().eq_ignore_ascii_case(name))
2744 .map(|(_, v)| v.trim().to_string())
2745 };
2746 let url = field("URL").unwrap_or_default();
2747 if url.is_empty() || url == "(null)" || url == our_url {
2749 continue;
2750 }
2751 let enabled = field("Enabled").is_some_and(|v| v.eq_ignore_ascii_case("yes"));
2752 prior.push(PriorAutoProxy {
2753 target: service.clone(),
2754 url,
2755 state: if enabled { "on" } else { "off" }.to_string(),
2756 });
2757 }
2758 }
2759 Platform::Linux if gnome => {
2760 let unquote = |v: String| v.trim().trim_matches('\'').to_string();
2761 let url = run(&[
2762 "gsettings",
2763 "get",
2764 "org.gnome.system.proxy",
2765 "autoconfig-url",
2766 ])
2767 .map(unquote)
2768 .unwrap_or_default();
2769 if !url.is_empty() && url != our_url {
2770 let mode = run(&["gsettings", "get", "org.gnome.system.proxy", "mode"])
2771 .map(unquote)
2772 .unwrap_or_else(|| "none".to_string());
2773 prior.push(PriorAutoProxy {
2774 target: "gnome".to_string(),
2775 url,
2776 state: mode,
2777 });
2778 }
2779 }
2780 Platform::Linux | Platform::Other => {}
2781 }
2782 prior
2783}
2784
2785pub fn macos_network_services() -> Vec<String> {
2786 if !cfg!(target_os = "macos") {
2787 return vec![];
2788 }
2789 let Ok(out) = std::process::Command::new("networksetup")
2790 .arg("-listallnetworkservices")
2791 .output()
2792 else {
2793 return vec![];
2794 };
2795 String::from_utf8_lossy(&out.stdout)
2796 .lines()
2797 .skip(1) .filter(|l| !l.trim().is_empty() && !l.starts_with('*'))
2800 .map(|l| l.trim().to_string())
2801 .collect()
2802}
2803
2804fn contact_host(proxy_host: &str) -> String {
2810 match proxy_host.parse::<std::net::IpAddr>() {
2811 Ok(std::net::IpAddr::V6(ip)) => {
2812 let ip = if ip.is_unspecified() {
2813 std::net::Ipv6Addr::LOCALHOST
2814 } else {
2815 ip
2816 };
2817 format!("[{ip}]")
2818 }
2819 Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => "127.0.0.1".to_string(),
2820 Ok(std::net::IpAddr::V4(ip)) => ip.to_string(),
2821 Err(_) => "127.0.0.1".to_string(),
2823 }
2824}
2825
2826pub fn context_from_settings(s: &crate::settings::Settings, pac: bool) -> SetupContext {
2828 let fixed = fixed_paths();
2831 let platform = Platform::current();
2832 let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
2833 let tld = crate::proxy::effective_tld(s).to_string();
2834 let custom_cert = !s.proxy.tls_cert.is_empty();
2835 let ca_path = if custom_cert {
2836 PathBuf::from(&s.proxy.tls_cert)
2837 } else {
2838 crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
2839 };
2840 let mut ctx = SetupContext {
2841 platform,
2842 tld,
2843 dns_port: super::dns::dns_port(s),
2844 proxy_port: u16::try_from(s.proxy.port)
2848 .ok()
2849 .filter(|&p| p > 0)
2850 .unwrap_or(443),
2851 https: s.proxy.https,
2852 dns_enabled: s.proxy.dns,
2853 pac,
2854 systemd_resolved: systemd_resolved_active(),
2855 systemd_version: systemd_version(),
2856 lan: lan_enabled,
2857 contact_host: contact_host(&s.proxy.host),
2858 ca_trusted: crate::proxy::trust::is_ca_trusted(&ca_path),
2859 ca_path,
2860 generated_ca: default_generated_ca(),
2861 custom_cert,
2862 binary: current_binary(),
2863 resolver_dir: fixed.resolver_dir,
2864 resolved_dropin_dir: fixed.resolved_dropin_dir,
2865 pf_conf: fixed.pf_conf,
2866 pf_anchor: fixed.pf_anchor,
2867 network_services: if platform == Platform::MacOs {
2870 macos_network_services()
2871 } else {
2872 vec![]
2873 },
2874 gnome: platform == Platform::Linux && gnome_proxy_available(),
2875 prior_auto_proxy: vec![],
2876 };
2877 if pac {
2881 ctx.prior_auto_proxy =
2882 read_prior_auto_proxy(platform, &ctx.network_services, ctx.gnome, &ctx.pac_url());
2883 }
2884 ctx
2885}
2886
2887#[cfg(test)]
2888mod tests {
2889 use super::*;
2890
2891 fn ctx(platform: Platform) -> SetupContext {
2892 SetupContext {
2893 platform,
2894 tld: "test".into(),
2895 dns_port: 15353,
2896 proxy_port: 8443,
2897 https: true,
2898 dns_enabled: true,
2899 pac: false,
2900 systemd_resolved: true,
2901 systemd_version: Some(255),
2902 lan: false,
2903 contact_host: "127.0.0.1".to_string(),
2904 ca_path: PathBuf::from("/state/proxy/ca.pem"),
2905 generated_ca: PathBuf::from("/state/proxy/ca.pem"),
2906 ca_trusted: false,
2907 custom_cert: false,
2908 binary: PathBuf::from("/usr/local/bin/pitchfork"),
2909 resolver_dir: PathBuf::from("/etc/resolver"),
2910 resolved_dropin_dir: PathBuf::from("/etc/systemd/resolved.conf.d"),
2911 pf_conf: PathBuf::from("/etc/pf.conf"),
2912 pf_anchor: PathBuf::from("/etc/pf.anchors/pitchfork"),
2913 network_services: vec![],
2914 gnome: false,
2915 prior_auto_proxy: vec![],
2916 }
2917 }
2918
2919 #[test]
2920 fn a_tld_that_could_escape_the_resolver_directory_is_refused() {
2921 for bad in [
2924 "../../etc/passwd",
2925 "..",
2926 "a/b",
2927 "test\nDNS=8.8.8.8",
2928 "",
2929 ".test",
2930 "te st",
2931 ] {
2932 assert!(validate_tld(bad).is_err(), "expected {bad:?} to be refused");
2933 }
2934 for good in ["localhost", "test", "dev.internal", "my-tld"] {
2935 assert!(
2936 validate_tld(good).is_ok(),
2937 "expected {good:?} to be allowed"
2938 );
2939 }
2940 }
2941
2942 #[test]
2943 fn lan_mode_leaves_the_mdns_local_namespace_alone() {
2944 let mut c = ctx(Platform::MacOs);
2947 c.lan = true;
2948 c.tld = "local".into();
2949 let lines = plan(&c).describe();
2950 assert!(lines.iter().any(|l| l.contains("mDNS")));
2951 assert!(!lines.iter().any(|l| l.contains("/etc/resolver")));
2952
2953 let mut c = ctx(Platform::Linux);
2954 c.lan = true;
2955 c.tld = "local".into();
2956 let lines = plan(&c).describe();
2957 assert!(!lines.iter().any(|l| l.contains("resolved.conf.d")));
2958 }
2959
2960 #[test]
2961 fn the_sudo_ca_step_names_the_certificate_it_should_trust() {
2962 let c = ctx(Platform::Linux);
2965 let step = plan(&c)
2966 .steps
2967 .into_iter()
2968 .find(|s| s.summary.contains("trust store"))
2969 .expect("linux plans a CA install");
2970 let Action::Run { argv, .. } = step.action else {
2971 panic!("expected a command");
2972 };
2973 assert!(argv.contains(&"--cert".to_string()));
2974 assert!(argv.contains(&"/state/proxy/ca.pem".to_string()));
2975 }
2976
2977 #[test]
2978 fn undo_only_reverts_proxy_settings_that_point_at_pitchfork() {
2979 let mut c = ctx(Platform::MacOs);
2980 c.network_services = vec!["Wi-Fi".into()];
2981 let step = plan_undo(&c)
2982 .steps
2983 .into_iter()
2984 .find(|s| s.summary.contains("automatic proxy URL"))
2985 .expect("undo turns the PAC URL off");
2986 let Action::RunIfPresent { probe, .. } = step.action else {
2987 panic!("expected a guarded command");
2988 };
2989 assert!(probe.argv.contains(&"-getautoproxyurl".to_string()));
2990 assert_eq!(
2992 probe.expect,
2993 vec![ProbeExpect::Contains(
2994 "http://127.0.0.1:8443/proxy.pac".to_string()
2995 )]
2996 );
2997 }
2998
2999 #[test]
3000 fn undo_revokes_the_capability_through_a_checked_action() {
3001 let mut privileged = ctx(Platform::Linux);
3006 privileged.proxy_port = 443;
3007 let step = plan_undo(&privileged)
3008 .steps
3009 .into_iter()
3010 .find(|s| s.summary.contains("cap_net_bind_service"))
3011 .expect("undo revokes the capability it granted");
3012 assert!(matches!(step.action, Action::RevokeBindCapability { .. }));
3013
3014 assert!(
3016 !plan_undo(&ctx(Platform::Linux))
3017 .describe()
3018 .iter()
3019 .any(|l| l.contains("cap_net_bind_service"))
3020 );
3021 }
3022
3023 #[test]
3024 fn every_capability_clause_is_read_not_just_the_last() {
3025 assert_eq!(
3029 parse_capabilities("cap_net_bind_service=ep"),
3030 vec!["cap_net_bind_service"]
3031 );
3032 assert_eq!(
3034 parse_capabilities("cap_sys_admin=ei cap_net_bind_service=ep"),
3035 vec!["cap_sys_admin", "cap_net_bind_service"]
3036 );
3037 assert_eq!(
3039 parse_capabilities("cap_net_bind_service=ep cap_sys_admin=ei"),
3040 vec!["cap_net_bind_service", "cap_sys_admin"]
3041 );
3042 assert_eq!(
3044 parse_capabilities("cap_net_bind_service,cap_sys_admin=ep"),
3045 vec!["cap_net_bind_service", "cap_sys_admin"]
3046 );
3047 assert_eq!(
3048 parse_capabilities("cap_net_bind_service+ep"),
3049 vec!["cap_net_bind_service"]
3050 );
3051 assert_eq!(
3053 parse_capabilities("= cap_net_bind_service+ep"),
3054 vec!["cap_net_bind_service"]
3055 );
3056 assert!(parse_capabilities("").is_empty());
3057
3058 assert!(
3060 parse_capabilities("cap_net_bind_service=ep")
3061 .iter()
3062 .all(|c| c == BIND_CAPABILITY)
3063 );
3064 let shared = parse_capabilities("cap_sys_admin=ei cap_net_bind_service=ep");
3065 assert!(shared.iter().any(|c| c == BIND_CAPABILITY));
3066 assert!(shared.iter().any(|c| c != BIND_CAPABILITY));
3067 }
3068
3069 #[test]
3070 fn macos_plan_covers_resolver_ca_and_port_redirect() {
3071 let c = ctx(Platform::MacOs);
3072 let resolver = c.resolver_file().display().to_string();
3075 assert_eq!(
3076 plan(&c).describe(),
3077 vec![
3078 format!("[sudo] write {resolver} pointing *.test at 127.0.0.1:15353"),
3079 "generate the pitchfork CA at /state/proxy/ca.pem".to_string(),
3080 "install the pitchfork CA at /state/proxy/ca.pem into the system trust store"
3081 .to_string(),
3082 "[sudo] write /etc/pf.anchors/pitchfork redirecting port 443 to 8443".to_string(),
3083 "[sudo] load the pitchfork anchor into /etc/pf.conf".to_string(),
3084 "[sudo] enable pf and load the new rules".to_string(),
3085 ]
3086 );
3087 }
3088
3089 #[test]
3090 fn linux_plan_writes_a_resolved_dropin_and_sudoes_the_ca() {
3091 let c = ctx(Platform::Linux);
3092 let dropin = c.resolved_dropin().display().to_string();
3095 let ca = c.ca_path.display().to_string();
3096 assert_eq!(
3097 plan(&c).describe(),
3098 vec![
3099 format!("[sudo] write {dropin} routing *.test to 127.0.0.1:15353"),
3100 "[sudo] restart systemd-resolved to pick up the route (interrupts DNS briefly)"
3101 .to_string(),
3102 format!("generate the pitchfork CA at {ca}"),
3103 format!("[sudo] install the pitchfork CA at {ca} into the system trust store"),
3104 "[sudo] redirect loopback traffic for port 443 to 8443 (iptables)".to_string(),
3105 ]
3106 );
3107 }
3108
3109 #[test]
3110 fn macos_says_what_taking_over_localhost_costs() {
3111 let mut c = ctx(Platform::MacOs);
3112 c.tld = "localhost".into();
3113 let p = plan(&c);
3114 assert!(
3115 p.manual
3116 .iter()
3117 .any(|m| m.contains("only while the supervisor is running")),
3118 "expected a note about the dependency: {:?}",
3119 p.manual
3120 );
3121 assert!(plan(&ctx(Platform::MacOs)).manual.is_empty());
3123 }
3124
3125 #[test]
3126 fn linux_with_localhost_tld_needs_no_resolver_change() {
3127 let mut c = ctx(Platform::Linux);
3128 c.tld = "localhost".into();
3129 let lines = plan(&c).describe();
3130 assert!(lines[0].contains("systemd-resolved already answers *.localhost"));
3131 assert!(!lines[0].starts_with("[sudo]"));
3132 }
3133
3134 #[test]
3135 fn linux_without_systemd_resolved_falls_back_to_dnsmasq_advice() {
3136 let mut c = ctx(Platform::Linux);
3137 c.systemd_resolved = false;
3138 let p = plan(&c);
3139 assert!(!p.describe().iter().any(|l| l.contains("resolved.conf.d")));
3140 assert!(p.manual[0].contains("dnsmasq"));
3141 assert!(p.manual[0].contains("server=/test/127.0.0.1#15353"));
3142 }
3143
3144 #[test]
3145 fn pac_plan_needs_no_sudo_for_resolution_or_ports() {
3146 let mut c = ctx(Platform::MacOs);
3147 c.pac = true;
3148 c.ca_trusted = true;
3149 c.network_services = vec!["Wi-Fi".into()];
3150 let p = plan(&c);
3151 assert!(
3152 !p.needs_sudo(),
3153 "PAC setup must not require sudo: {:?}",
3154 p.describe()
3155 );
3156 assert!(p.describe().iter().any(|l| l.contains(
3157 "set the automatic proxy URL for \"Wi-Fi\" to http://127.0.0.1:8443/proxy.pac"
3158 )));
3159 assert!(
3161 p.describe()
3162 .iter()
3163 .any(|l| l.contains("no port redirect is needed"))
3164 );
3165 }
3166
3167 #[test]
3168 fn pac_does_not_excuse_a_privileged_port() {
3169 let mut c = ctx(Platform::Linux);
3174 c.pac = true;
3175 c.proxy_port = 443;
3176 c.ca_trusted = true;
3177 let lines = plan(&c).describe();
3178 assert!(
3179 lines
3180 .iter()
3181 .any(|l| l.starts_with("[sudo]") && l.contains("cap_net_bind_service")),
3182 "expected the capability grant: {lines:?}"
3183 );
3184
3185 let mut c = ctx(Platform::MacOs);
3187 c.pac = true;
3188 c.proxy_port = 443;
3189 c.ca_trusted = true;
3190 let p = plan(&c);
3191 assert!(!p.needs_sudo());
3192 assert!(
3193 p.manual
3194 .iter()
3195 .any(|m| m.contains("will not run the supervisor as root"))
3196 );
3197 }
3198
3199 #[test]
3200 fn pac_on_linux_still_needs_sudo_only_for_the_ca() {
3201 let mut c = ctx(Platform::Linux);
3205 c.pac = true;
3206 c.gnome = true;
3207 let sudo_steps: Vec<String> = plan(&c)
3208 .describe()
3209 .into_iter()
3210 .filter(|l| l.starts_with("[sudo]"))
3211 .collect();
3212 assert_eq!(sudo_steps.len(), 1, "unexpected sudo steps: {sudo_steps:?}");
3213 assert!(sudo_steps[0].contains("trust store"));
3214
3215 let mut trusted = c.clone();
3217 trusted.ca_trusted = true;
3218 assert!(!plan(&trusted).needs_sudo());
3219 let mut plain = c.clone();
3220 plain.https = false;
3221 assert!(!plan(&plain).needs_sudo());
3222 }
3223
3224 #[test]
3225 fn an_ipv6_proxy_host_is_advertised_as_an_ipv6_url() {
3226 assert_eq!(contact_host("::1"), "[::1]");
3229 assert_eq!(contact_host("::"), "[::1]");
3230 assert_eq!(contact_host("0.0.0.0"), "127.0.0.1");
3231 assert_eq!(contact_host("127.0.0.1"), "127.0.0.1");
3232 assert_eq!(contact_host("192.168.1.5"), "192.168.1.5");
3233 assert_eq!(contact_host("not-an-address"), "127.0.0.1");
3234
3235 let mut c = ctx(Platform::MacOs);
3236 c.pac = true;
3237 c.contact_host = "[::1]".to_string();
3238 c.network_services = vec!["Wi-Fi".into()];
3239 assert!(
3240 plan(&c)
3241 .describe()
3242 .iter()
3243 .any(|l| l.contains("http://[::1]:8443/proxy.pac"))
3244 );
3245 }
3246
3247 #[test]
3248 fn gnome_pac_plan_sets_the_autoconfig_url() {
3249 let mut c = ctx(Platform::Linux);
3250 c.pac = true;
3251 c.gnome = true;
3252 c.https = false;
3253 let lines = plan(&c).describe();
3254 assert!(
3255 lines
3256 .iter()
3257 .any(|l| l.contains("GNOME automatic proxy URL"))
3258 );
3259 assert!(lines.iter().any(|l| l.contains("proxy mode to automatic")));
3260 }
3261
3262 #[test]
3263 fn privileged_proxy_port_uses_setcap_on_linux_and_advice_on_macos() {
3264 let mut c = ctx(Platform::Linux);
3265 c.proxy_port = 443;
3266 assert!(
3267 plan(&c)
3268 .describe()
3269 .iter()
3270 .any(|l| l.contains("cap_net_bind_service"))
3271 );
3272
3273 let mut c = ctx(Platform::MacOs);
3274 c.proxy_port = 443;
3275 let p = plan(&c);
3276 assert!(!p.describe().iter().any(|l| l.contains("pf.anchors")));
3277 assert!(p.manual[0].contains("will not run the supervisor as root"));
3278 }
3279
3280 #[test]
3281 fn a_trusted_ca_and_a_custom_cert_both_skip_the_trust_step() {
3282 let mut c = ctx(Platform::MacOs);
3283 c.ca_trusted = true;
3284 assert!(
3285 plan(&c)
3286 .describe()
3287 .iter()
3288 .any(|l| l.contains("is already trusted"))
3289 );
3290
3291 let mut c = ctx(Platform::MacOs);
3292 c.custom_cert = true;
3293 assert!(
3294 plan(&c)
3295 .describe()
3296 .iter()
3297 .any(|l| l.contains("installs no CA"))
3298 );
3299 }
3300
3301 #[test]
3302 fn a_privileged_port_is_never_papered_over_with_a_redirect() {
3303 let mut c = ctx(Platform::Linux);
3306 c.https = false;
3307 c.proxy_port = 443;
3308 let lines = plan(&c).describe();
3309 assert!(lines.iter().any(|l| l.contains("cap_net_bind_service")));
3310 assert!(!lines.iter().any(|l| l.contains("iptables")));
3311 }
3312
3313 #[test]
3314 fn plain_http_plans_no_ca_step_and_redirects_port_80() {
3315 let mut c = ctx(Platform::Linux);
3316 c.https = false;
3317 let lines = plan(&c).describe();
3318 assert!(!lines.iter().any(|l| l.contains("trust store")));
3319 assert!(lines.iter().any(|l| l.contains("port 80 to 8443")));
3320 }
3321
3322 #[test]
3323 fn disabled_resolver_skips_resolver_steps() {
3324 let mut c = ctx(Platform::MacOs);
3325 c.dns_enabled = false;
3326 let lines = plan(&c).describe();
3327 assert!(lines[0].contains("proxy.dns is false"));
3328 assert!(!lines.iter().any(|l| l.contains("/etc/resolver")));
3329 }
3330
3331 #[test]
3332 fn the_iptables_redirect_is_guarded_against_stacking_duplicates() {
3333 let c = ctx(Platform::Linux);
3334 let step = plan(&c)
3335 .steps
3336 .into_iter()
3337 .find(|s| s.summary.contains("iptables"))
3338 .expect("linux plans an iptables redirect");
3339 let Action::Run { skip_if, argv, .. } = step.action else {
3340 panic!("expected a command");
3341 };
3342 assert!(argv.contains(&"-A".to_string()));
3343 let guard = skip_if.expect("the append must carry a guard").argv;
3344 assert!(guard.contains(&"-C".to_string()));
3345 assert_eq!(guard.len(), argv.len());
3347 assert_eq!(guard[guard.len() - 1], argv[argv.len() - 1]);
3348 }
3349
3350 #[test]
3351 fn undo_reverses_what_setup_recorded_not_what_settings_now_say() {
3352 let mut recorded = ctx(Platform::Linux);
3356 recorded.proxy_port = 8443;
3357 recorded.contact_host = "127.0.0.1".into();
3358
3359 let mut current = ctx(Platform::Linux);
3360 current.proxy_port = 9999;
3361 current.contact_host = "127.0.0.1".into();
3362
3363 let lines = plan_undo_from(Some(¤t), std::slice::from_ref(&recorded)).describe();
3364 assert!(
3365 lines.iter().any(|l| l.contains("port 443 to 8443")),
3366 "the installed redirect must be reversed: {lines:?}"
3367 );
3368 assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
3371 let mut sorted = lines.clone();
3373 sorted.sort();
3374 sorted.dedup();
3375 assert_eq!(sorted.len(), lines.len(), "duplicate steps in {lines:?}");
3376 }
3377
3378 #[test]
3379 fn a_failed_setup_does_not_erase_what_an_earlier_one_installed() {
3380 let mut first = ctx(Platform::Linux);
3384 first.proxy_port = 8443;
3385 let mut second = ctx(Platform::Linux);
3386 second.proxy_port = 9999;
3387
3388 let after_first = merged_records(vec![], &first);
3389 let after_failed_second = merged_records(after_first, &second);
3390 assert_eq!(after_failed_second.len(), 2);
3391 assert_eq!(after_failed_second[0].proxy_port, 8443);
3392 assert_eq!(after_failed_second[1].proxy_port, 9999);
3393
3394 let lines = plan_undo_from(Some(&second), &after_failed_second).describe();
3396 assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
3397 assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
3398 }
3399
3400 #[test]
3401 fn re_recording_the_same_setup_does_not_grow_the_list() {
3402 let c = ctx(Platform::Linux);
3403 let once = merged_records(vec![], &c);
3404 let twice = merged_records(once.clone(), &c);
3405 assert_eq!(twice.len(), 1, "an identical setup replaces its entry");
3406 assert_eq!(undo_key(&twice[0]), undo_key(&c));
3407 }
3408
3409 #[test]
3410 fn the_record_list_is_capped() {
3411 let mut records = vec![];
3412 for port in 0..(MAX_RECORDS as u16 + 5) {
3413 let mut c = ctx(Platform::Linux);
3414 c.proxy_port = 2000 + port;
3415 records = merged_records(records, &c);
3416 }
3417 assert_eq!(records.len(), MAX_RECORDS);
3418 assert_eq!(
3420 records.last().unwrap().proxy_port,
3421 2000 + MAX_RECORDS as u16 + 4
3422 );
3423 }
3424
3425 #[test]
3426 fn a_second_setup_removes_the_redirect_it_supersedes() {
3427 let mut first = ctx(Platform::Linux);
3431 first.proxy_port = 8443;
3432 let mut second = ctx(Platform::Linux);
3433 second.proxy_port = 9999;
3434
3435 let lines = plan_with_reconcile(&second, std::slice::from_ref(&first)).describe();
3436 let drop_old = lines
3437 .iter()
3438 .position(|l| l.contains("drop the iptables redirect from port 443 to 8443"))
3439 .expect("the superseded redirect is removed");
3440 let add_new = lines
3441 .iter()
3442 .position(|l| l.contains("redirect loopback traffic for port 443 to 9999"))
3443 .expect("the new redirect is installed");
3444 assert!(drop_old < add_new, "remove before install: {lines:?}");
3445 }
3446
3447 #[test]
3448 fn re_running_setup_never_untrusts_the_ca_it_still_needs() {
3449 let mut first = ctx(Platform::Linux);
3453 first.proxy_port = 8443;
3454 first.ca_trusted = true;
3455 let mut second = ctx(Platform::Linux);
3456 second.proxy_port = 9999;
3457 second.ca_trusted = true;
3458
3459 let lines = plan_with_reconcile(&second, std::slice::from_ref(&first)).describe();
3460 assert!(
3461 !lines
3462 .iter()
3463 .any(|l| l.contains("from the system trust store")),
3464 "the CA must survive a re-run: {lines:?}"
3465 );
3466 assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
3468 }
3469
3470 #[test]
3471 fn switching_to_pac_removes_the_resolver_it_no_longer_uses() {
3472 let dns = ctx(Platform::Linux);
3476 let mut pac = ctx(Platform::Linux);
3477 pac.pac = true;
3478 pac.ca_trusted = true;
3479
3480 let lines = plan_with_reconcile(&pac, std::slice::from_ref(&dns)).describe();
3481 assert!(
3482 lines
3483 .iter()
3484 .any(|l| l.contains("remove") && l.contains("resolved.conf.d")),
3485 "the superseded resolver drop-in must be removed: {lines:?}"
3486 );
3487 }
3488
3489 #[test]
3490 fn re_running_the_same_setup_removes_nothing() {
3491 let c = ctx(Platform::Linux);
3494 assert_eq!(
3495 plan_with_reconcile(&c, std::slice::from_ref(&c)).describe(),
3496 plan(&c).describe()
3497 );
3498 }
3499
3500 #[test]
3501 fn undo_reverses_every_recorded_setup_not_just_the_last() {
3502 let mut first = ctx(Platform::Linux);
3505 first.proxy_port = 8443;
3506 let mut second = ctx(Platform::Linux);
3507 second.proxy_port = 9999;
3508 let current = second.clone();
3509
3510 let lines = plan_undo_from(Some(¤t), &[first, second]).describe();
3511 assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
3512 assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
3513 let mut sorted = lines.clone();
3514 sorted.sort();
3515 sorted.dedup();
3516 assert_eq!(sorted.len(), lines.len(), "duplicate steps in {lines:?}");
3517 }
3518
3519 #[test]
3520 fn undo_works_from_records_alone_when_the_tld_no_longer_validates() {
3521 let recorded = ctx(Platform::Linux);
3525 let lines = plan_undo_from(None, std::slice::from_ref(&recorded)).describe();
3526 assert_eq!(lines, plan_undo(&recorded).describe());
3527 assert!(lines.iter().any(|l| l.contains("resolved.conf.d")));
3528
3529 assert!(plan_undo_from(None, &[]).steps.is_empty());
3531 }
3532
3533 #[test]
3534 fn undo_without_a_record_still_uses_the_current_settings() {
3535 let current = ctx(Platform::Linux);
3536 assert_eq!(
3537 plan_undo_from(Some(¤t), &[]).describe(),
3538 plan_undo(¤t).describe()
3539 );
3540 }
3541
3542 #[test]
3543 fn a_tampered_record_cannot_steer_a_privileged_write() {
3544 let mut evil = ctx(Platform::MacOs);
3549 evil.tld = "../../etc/passwd".into();
3550 assert!(
3551 sanitize_record(evil).is_none(),
3552 "an unusable TLD is dropped"
3553 );
3554
3555 let mut redirected = ctx(Platform::MacOs);
3556 redirected.resolver_dir = PathBuf::from("/tmp/attacker");
3557 redirected.pf_conf = PathBuf::from("/etc/shadow");
3558 redirected.pf_anchor = PathBuf::from("/tmp/anchor");
3559 redirected.resolved_dropin_dir = PathBuf::from("/tmp/dropins");
3560 redirected.generated_ca = PathBuf::from("/tmp/ca.pem");
3561
3562 redirected.binary = PathBuf::from("/usr/bin/some-other-daemon");
3563
3564 let clean = sanitize_record(redirected).expect("a valid TLD is kept");
3565 assert_eq!(clean.binary, current_binary());
3570 assert_ne!(clean.binary, PathBuf::from("/usr/bin/some-other-daemon"));
3571 let fixed = fixed_paths();
3572 assert_eq!(clean.resolver_dir, fixed.resolver_dir);
3573 assert_eq!(clean.pf_conf, fixed.pf_conf);
3574 assert_eq!(clean.pf_anchor, fixed.pf_anchor);
3575 assert_eq!(clean.resolved_dropin_dir, fixed.resolved_dropin_dir);
3576 assert_eq!(clean.generated_ca, default_generated_ca());
3577
3578 let lines = plan_undo(&clean).describe().join("\n");
3580 assert!(!lines.contains("/usr/bin/some-other-daemon"));
3581 assert!(!lines.contains("/tmp/attacker"));
3582 assert!(!lines.contains("/etc/shadow"));
3583 assert!(!lines.contains("/tmp/ca.pem"));
3584 }
3585
3586 #[test]
3587 fn a_record_in_the_previous_single_context_format_still_loads() {
3588 let ctx = ctx(Platform::Linux);
3592 let legacy = toml::to_string_pretty(&ctx).expect("serializes");
3593 assert!(
3594 toml::from_str::<SetupRecords>(&legacy)
3595 .expect("parses as records")
3596 .setups
3597 .is_empty(),
3598 "the empty parse is what makes the fallback necessary"
3599 );
3600 let back: SetupContext = toml::from_str(&legacy).expect("parses as one context");
3601 assert_eq!(undo_key(&back), undo_key(&ctx));
3602
3603 let records = SetupRecords {
3605 setups: vec![ctx.clone()],
3606 };
3607 let text = toml::to_string_pretty(&records).expect("serializes");
3608 let back = toml::from_str::<SetupRecords>(&text).expect("parses");
3609 assert_eq!(back.setups.len(), 1);
3610 assert_eq!(undo_key(&back.setups[0]), undo_key(&ctx));
3611 }
3612
3613 #[test]
3614 fn a_record_with_no_systemd_version_survives_a_round_trip() {
3615 let mut ctx = ctx(Platform::MacOs);
3619 ctx.systemd_version = None;
3620 ctx.network_services = vec![];
3621 let text = toml::to_string_pretty(&ctx).expect("serializes");
3622 let back: SetupContext = toml::from_str(&text).expect("deserializes without the field");
3623 assert_eq!(back.systemd_version, None);
3624 assert_eq!(plan_undo(&back).describe(), plan_undo(&ctx).describe());
3625 }
3626
3627 #[test]
3628 fn a_setup_record_survives_a_round_trip() {
3629 let ctx = ctx(Platform::MacOs);
3630 let text = toml::to_string_pretty(&ctx).expect("serializes");
3631 let back: SetupContext = toml::from_str(&text).expect("deserializes");
3632 assert_eq!(back.tld, ctx.tld);
3633 assert_eq!(back.proxy_port, ctx.proxy_port);
3634 assert_eq!(back.ca_path, ctx.ca_path);
3635 assert_eq!(back.platform, ctx.platform);
3636 assert_eq!(plan_undo(&back).describe(), plan_undo(&ctx).describe());
3637 }
3638
3639 #[test]
3640 fn a_record_from_before_the_generated_ca_field_still_names_one() {
3641 let ctx = ctx(Platform::Linux);
3645 let mut table = toml::Table::try_from(&ctx).expect("serializes");
3646 table.remove("generated_ca");
3647 let legacy = toml::to_string_pretty(&table).expect("re-serializes");
3648 assert!(!legacy.contains("generated_ca"));
3649
3650 let back: SetupContext = toml::from_str(&legacy).expect("parses without the field");
3651 assert_eq!(back.generated_ca, default_generated_ca());
3652 assert!(!back.generated_ca.as_os_str().is_empty());
3653 assert!(
3654 plan_undo(&back)
3655 .describe()
3656 .iter()
3657 .any(|l| l.contains("trust store")),
3658 "a legacy record still plans the CA removal"
3659 );
3660 }
3661
3662 #[test]
3663 fn an_untrust_step_runs_when_the_certificate_file_is_gone() {
3664 let missing = PathBuf::from("/nonexistent/pitchfork-ca.pem");
3668 assert!(!missing.exists());
3669 assert!(!already_done(&Action::UntrustCa {
3670 path: missing,
3671 sudo: false,
3672 }));
3673 }
3674
3675 #[test]
3676 fn a_setup_that_wrote_no_resolver_file_never_claims_one() {
3677 let mut no_resolved = ctx(Platform::Linux);
3681 no_resolved.systemd_resolved = false;
3682 assert!(
3683 !plan_undo(&no_resolved)
3684 .describe()
3685 .iter()
3686 .any(|l| l.contains("resolved.conf.d")),
3687 "nothing was installed, so nothing is claimed"
3688 );
3689
3690 let mut dns_off = ctx(Platform::Linux);
3692 dns_off.dns_enabled = false;
3693 assert!(
3694 !plan_undo(&dns_off)
3695 .describe()
3696 .iter()
3697 .any(|l| l.contains("resolved.conf.d"))
3698 );
3699 let mut localhost = ctx(Platform::Linux);
3700 localhost.tld = "localhost".into();
3701 assert!(
3702 !plan_undo(&localhost)
3703 .describe()
3704 .iter()
3705 .any(|l| l.contains("resolved.conf.d"))
3706 );
3707 let mut lan = ctx(Platform::Linux);
3708 lan.lan = true;
3709 assert!(
3710 !plan_undo(&lan)
3711 .describe()
3712 .iter()
3713 .any(|l| l.contains("resolved.conf.d"))
3714 );
3715
3716 assert!(
3718 plan_undo(&ctx(Platform::Linux))
3719 .describe()
3720 .iter()
3721 .any(|l| l.contains("resolved.conf.d"))
3722 );
3723
3724 let mut second = no_resolved.clone();
3726 second.proxy_port = 9999;
3727 let lines = plan_with_reconcile(&second, std::slice::from_ref(&no_resolved)).describe();
3728 assert!(!lines.iter().any(|l| l.contains("resolved.conf.d")));
3729 assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
3731 }
3732
3733 #[test]
3734 fn an_http_only_setup_never_claims_the_ca() {
3735 let mut http = ctx(Platform::Linux);
3739 http.https = false;
3740 assert!(
3741 !plan_undo(&http)
3742 .describe()
3743 .iter()
3744 .any(|l| l.contains("trust store")),
3745 "an HTTP-only setup has no CA to remove"
3746 );
3747
3748 let mut second = http.clone();
3750 second.proxy_port = 9999;
3751 let lines = plan_with_reconcile(&second, std::slice::from_ref(&http)).describe();
3752 assert!(!lines.iter().any(|l| l.contains("trust store")));
3753 assert!(lines.iter().any(|l| l.contains("port 80 to 8443")));
3755 }
3756
3757 #[test]
3758 fn undo_always_queues_the_ca_removal_and_probes_for_it() {
3759 let mut custom = ctx(Platform::MacOs);
3764 custom.custom_cert = true;
3765 custom.ca_path = PathBuf::from("/etc/mycert.pem");
3766
3767 let step = plan_undo(&custom)
3768 .steps
3769 .into_iter()
3770 .find(|s| s.summary.contains("trust store"))
3771 .expect("the CA removal is queued even with a custom certificate");
3772 let Action::UntrustCa { path, .. } = &step.action else {
3773 panic!("expected the probing action");
3774 };
3775 assert_eq!(path, &custom.generated_ca);
3778 assert_ne!(path, &custom.ca_path);
3779
3780 assert!(!step.needs_sudo());
3782 let mut linux = ctx(Platform::Linux);
3783 linux.custom_cert = true;
3784 assert!(
3785 plan_undo(&linux)
3786 .steps
3787 .iter()
3788 .find(|s| s.summary.contains("trust store"))
3789 .expect("queued on linux too")
3790 .needs_sudo()
3791 );
3792 }
3793
3794 #[test]
3795 fn undo_reverses_each_platform_step() {
3796 let mac = ctx(Platform::MacOs);
3797 let resolver = mac.resolver_file().display().to_string();
3798 assert_eq!(
3799 plan_undo(&mac).describe(),
3800 vec![
3801 format!("[sudo] remove {resolver}"),
3802 "[sudo] remove the pitchfork anchor from /etc/pf.conf".to_string(),
3803 "[sudo] remove /etc/pf.anchors/pitchfork".to_string(),
3804 "[sudo] reload pf rules from /etc/pf.conf and release pitchfork's hold on pf"
3805 .to_string(),
3806 "remove the pitchfork CA at /state/proxy/ca.pem from the system trust store"
3807 .to_string(),
3808 ]
3809 );
3810
3811 let linux = ctx(Platform::Linux);
3812 let dropin = linux.resolved_dropin().display().to_string();
3813 let lines = plan_undo(&linux).describe();
3814 assert!(lines.contains(&format!("[sudo] remove {dropin}")));
3815 assert!(
3818 lines
3819 .iter()
3820 .any(|l| l.contains("drop the iptables redirect"))
3821 );
3822 assert!(
3823 !lines
3824 .iter()
3825 .any(|l| l.contains("revoke cap_net_bind_service"))
3826 );
3827 }
3828
3829 #[test]
3830 fn undo_turns_off_a_pac_url_even_without_the_pac_flag() {
3831 let mut c = ctx(Platform::MacOs);
3833 c.pac = false;
3834 c.network_services = vec!["Wi-Fi".into()];
3835 assert!(
3836 plan_undo(&c)
3837 .describe()
3838 .iter()
3839 .any(|l| l.contains("turn off the automatic proxy URL") && l.contains("Wi-Fi"))
3840 );
3841 }
3842
3843 const APPLE_PF_CONF: &str = r#"#
3845# Default PF configuration file.
3846#
3847# This file contains the main ruleset, which gets automatically loaded
3848# at startup. PF will not be automatically enabled, however. Instead,
3849# each component which utilizes PF is responsible for enabling and disabling
3850# PF via -E and -X as documented in pfctl(8).
3851#
3852
3853#
3854# com.apple anchor point
3855#
3856scrub-anchor "com.apple/*"
3857nat-anchor "com.apple/*"
3858rdr-anchor "com.apple/*"
3859dummynet-anchor "com.apple/*"
3860anchor "com.apple/*"
3861load anchor "com.apple" from "/etc/pf.anchors/com.apple"
3862"#;
3863
3864 #[test]
3865 fn the_pf_block_lands_before_the_filter_anchor() {
3866 let block = "rdr-anchor \"pitchfork\"\nload anchor \"pitchfork\" from \"/etc/pf.anchors/pitchfork\"";
3871 let out = splice_pf_block(APPLE_PF_CONF, block);
3872
3873 let lines: Vec<&str> = out.lines().collect();
3874 let ours = lines
3875 .iter()
3876 .position(|l| l.contains("rdr-anchor \"pitchfork\""))
3877 .expect("our rdr-anchor is present");
3878 let apple_rdr = lines
3879 .iter()
3880 .position(|l| l.contains("rdr-anchor \"com.apple/*\""))
3881 .unwrap();
3882 let apple_filter = lines
3883 .iter()
3884 .position(|l| l.trim() == "anchor \"com.apple/*\"")
3885 .unwrap();
3886 assert!(apple_rdr < ours, "must follow the existing rdr-anchor");
3887 assert!(ours < apple_filter, "must precede the filter anchor");
3888 assert_eq!(out.matches(MARKER_START).count(), 1);
3889 assert!(out.ends_with('\n'));
3890
3891 let again = splice_pf_block(&out, block);
3893 assert_eq!(again, out);
3894
3895 assert_eq!(splice_pf_block(&again, ""), APPLE_PF_CONF);
3897 }
3898
3899 #[test]
3900 fn a_pf_conf_with_no_rdr_anchor_still_lands_before_the_filters() {
3901 let custom = "set skip on lo0\nblock in all\npass out all\n";
3905 let out = splice_pf_block(custom, "rdr-anchor \"pitchfork\"");
3906 let lines: Vec<&str> = out.lines().collect();
3907 let ours = lines
3908 .iter()
3909 .position(|l| l.contains("rdr-anchor \"pitchfork\""))
3910 .unwrap();
3911 let first_filter = lines
3912 .iter()
3913 .position(|l| l.trim() == "block in all")
3914 .unwrap();
3915 assert!(ours < first_filter, "translation must precede filtering");
3916 assert_eq!(splice_pf_block(&out, ""), custom);
3917
3918 let out = splice_pf_block("# empty ruleset\n", "rdr-anchor \"pitchfork\"");
3920 assert!(out.contains("rdr-anchor \"pitchfork\""));
3921 assert_eq!(out.matches(MARKER_START).count(), 1);
3922 }
3923
3924 #[test]
3925 fn a_pf_block_in_the_wrong_place_is_repositioned_not_left_alone() {
3926 let stale = format!(
3929 "{}{MARKER_START}\nrdr-anchor \"pitchfork\"\n{MARKER_END}\n",
3930 APPLE_PF_CONF
3931 );
3932 let action = Action::EnsureBlock {
3933 path: PathBuf::from("/nonexistent"),
3934 content: "rdr-anchor \"pitchfork\"".to_string(),
3935 sudo: false,
3936 pf_order: true,
3937 requires: None,
3938 };
3939 assert_ne!(splice_pf_block(&stale, "rdr-anchor \"pitchfork\""), stale);
3941 assert!(!already_done(&action), "a missing file is not already done");
3942
3943 let fixed = splice_pf_block(&stale, "rdr-anchor \"pitchfork\"");
3944 let lines: Vec<&str> = fixed.lines().collect();
3945 let ours = lines
3946 .iter()
3947 .position(|l| l.contains("rdr-anchor \"pitchfork\""))
3948 .unwrap();
3949 let filter = lines
3950 .iter()
3951 .position(|l| l.trim() == "anchor \"com.apple/*\"")
3952 .unwrap();
3953 assert!(ours < filter, "the stale block should have moved up");
3954 assert_eq!(fixed.matches(MARKER_START).count(), 1);
3955 }
3956
3957 #[test]
3958 fn a_pac_setup_claims_no_resolver_file_on_undo() {
3959 for platform in [Platform::MacOs, Platform::Linux] {
3965 let dir = tempfile::tempdir().unwrap();
3969 let mut c = ctx(platform);
3970 c.pac = true;
3971 c.resolver_dir = dir.path().to_path_buf();
3972
3973 let resolver = c.resolver_file().display().to_string();
3974 let dropin = c.resolved_dropin().display().to_string();
3975 let names_resolver = |l: &String| l.contains(&resolver) || l.contains(&dropin);
3976
3977 let forward = plan(&c).describe();
3978 assert!(
3979 !forward.iter().any(names_resolver),
3980 "{platform:?}: pac setup wrote resolver configuration: {forward:?}"
3981 );
3982
3983 let undo = plan_undo(&c).describe();
3984 assert!(
3985 !undo.iter().any(names_resolver),
3986 "{platform:?}: pac undo claimed a resolver file it never wrote: {undo:?}"
3987 );
3988 assert!(
3989 !undo.iter().any(|l| l.contains("systemd-resolved")),
3990 "{platform:?}: pac undo restarted the resolver for nothing: {undo:?}"
3991 );
3992 }
3993 }
3994
3995 #[test]
3996 fn undo_restores_an_automatic_proxy_url_that_setup_replaced() {
3997 let mut mac = ctx(Platform::MacOs);
4000 mac.pac = true;
4001 mac.network_services = vec!["Wi-Fi".into()];
4002 mac.prior_auto_proxy = vec![PriorAutoProxy {
4003 target: "Wi-Fi".into(),
4004 url: "https://corp.example/proxy.pac".into(),
4005 state: "on".into(),
4006 }];
4007 let lines = plan_undo(&mac).describe();
4008 let at = |needle: &str| lines.iter().position(|l| l.contains(needle));
4009
4010 let off = at("turn off the automatic proxy URL").expect("no disable step");
4011 let url = at("restore the automatic proxy URL").expect("no url restore");
4012 let state = at("restore the automatic proxy switch").expect("no state restore");
4013 assert!(
4014 lines[url].contains("https://corp.example/proxy.pac"),
4015 "the restore step did not name the recorded URL: {:?}",
4016 lines[url]
4017 );
4018 assert!(off < url && url < state, "restore steps are out of order");
4022
4023 let mut disabled = mac.clone();
4026 disabled.prior_auto_proxy[0].state = "off".into();
4027 let lines = plan_undo(&disabled).describe();
4028 let last_switch = lines
4029 .iter()
4030 .rposition(|l| l.contains("automatic proxy switch"))
4031 .expect("no state restore");
4032 assert!(
4033 lines[last_switch].ends_with("off"),
4034 "the last word on the switch was not the recorded state: {:?}",
4035 lines[last_switch]
4036 );
4037
4038 let mut linux = ctx(Platform::Linux);
4041 linux.pac = true;
4042 linux.gnome = true;
4043 linux.prior_auto_proxy = vec![PriorAutoProxy {
4044 target: "gnome".into(),
4045 url: "http://wpad.corp/proxy.pac".into(),
4046 state: "manual".into(),
4047 }];
4048 let lines = plan_undo(&linux).describe();
4049 let mode = lines
4050 .iter()
4051 .position(|l| l.contains("restore the GNOME proxy mode to manual"))
4052 .expect("no mode restore");
4053 let url = lines
4054 .iter()
4055 .position(|l| l.contains("http://wpad.corp/proxy.pac"))
4056 .expect("no url restore");
4057 assert!(url < mode, "the GNOME restore steps are out of order");
4058
4059 let mut bare = ctx(Platform::MacOs);
4061 bare.pac = true;
4062 bare.network_services = vec!["Wi-Fi".into()];
4063 assert!(
4064 !plan_undo(&bare)
4065 .describe()
4066 .iter()
4067 .any(|l| l.contains("restore the automatic proxy")),
4068 "a restore was planned with nothing recorded to restore"
4069 );
4070 }
4071
4072 #[test]
4073 fn a_recorded_automatic_proxy_value_that_could_steer_a_command_is_dropped() {
4074 let mut c = ctx(Platform::MacOs);
4078 c.prior_auto_proxy = vec![
4079 PriorAutoProxy {
4080 target: "-setairportpower".into(),
4081 url: "https://corp.example/proxy.pac".into(),
4082 state: "on".into(),
4083 },
4084 PriorAutoProxy {
4085 target: "Wi-Fi".into(),
4086 url: "-setautoproxystate".into(),
4087 state: "on".into(),
4088 },
4089 PriorAutoProxy {
4090 target: "Wi-Fi".into(),
4091 url: "https://corp.example/proxy.pac".into(),
4092 state: "; rm -rf /".into(),
4093 },
4094 PriorAutoProxy {
4095 target: "Wi-Fi".into(),
4096 url: "/etc/passwd".into(),
4097 state: "on".into(),
4098 },
4099 PriorAutoProxy {
4100 target: "Wi-Fi".into(),
4101 url: "https://corp.example/proxy.pac".into(),
4102 state: "on".into(),
4103 },
4104 ];
4105 let kept = sanitize_record(c)
4106 .expect("the record itself is usable")
4107 .prior_auto_proxy;
4108 assert_eq!(
4109 kept,
4110 vec![PriorAutoProxy {
4111 target: "Wi-Fi".into(),
4112 url: "https://corp.example/proxy.pac".into(),
4113 state: "on".into(),
4114 }],
4115 "an unusable recorded value survived"
4116 );
4117 }
4118
4119 #[test]
4120 fn a_write_replaces_a_file_whole_and_leaves_no_temporary_behind() {
4121 let dir = tempfile::tempdir().unwrap();
4124 let target = dir.path().join("pf.conf");
4125 std::fs::write(&target, "original\n").unwrap();
4126
4127 write_file(&target, "replacement\n", false).unwrap();
4128 assert_eq!(std::fs::read_to_string(&target).unwrap(), "replacement\n");
4129
4130 let strays: Vec<_> = std::fs::read_dir(dir.path())
4133 .unwrap()
4134 .filter_map(|e| e.ok())
4135 .map(|e| e.file_name().to_string_lossy().into_owned())
4136 .filter(|n| n != "pf.conf")
4137 .collect();
4138 assert!(strays.is_empty(), "left behind: {strays:?}");
4139
4140 let nested = dir.path().join("resolver").join("test");
4142 write_file(&nested, "nameserver 127.0.0.1\n", false).unwrap();
4143 assert_eq!(
4144 std::fs::read_to_string(&nested).unwrap(),
4145 "nameserver 127.0.0.1\n"
4146 );
4147 }
4148
4149 #[test]
4150 fn setting_up_does_not_replace_a_resolver_file_somebody_else_wrote() {
4151 let dir = tempfile::tempdir().unwrap();
4156 let theirs = dir.path().join("test");
4157 let original = "nameserver 127.0.0.1\nport 20560\n";
4158 std::fs::write(&theirs, original).unwrap();
4159
4160 let step = Step {
4161 summary: "write the resolver file".into(),
4162 action: Action::WriteFile {
4163 path: theirs.clone(),
4164 content: macos_resolver_file(15353),
4165 sudo: false,
4166 },
4167 resource: None,
4168 };
4169
4170 let err = execute(&step).expect_err("somebody else's resolver file was replaced");
4171 assert!(
4172 err.to_string().contains(&theirs.display().to_string()),
4173 "the refusal did not name the file: {err}"
4174 );
4175 assert_eq!(
4176 std::fs::read_to_string(&theirs).unwrap(),
4177 original,
4178 "the file was modified despite the refusal"
4179 );
4180
4181 std::fs::write(&theirs, macos_resolver_file(20000)).unwrap();
4183 execute(&step).expect("pitchfork refused to update its own file");
4184 assert_eq!(
4185 std::fs::read_to_string(&theirs).unwrap(),
4186 macos_resolver_file(15353)
4187 );
4188
4189 let fresh = dir.path().join("fresh");
4191 let step = Step {
4192 summary: "write a new resolver file".into(),
4193 action: Action::WriteFile {
4194 path: fresh.clone(),
4195 content: macos_resolver_file(15353),
4196 sudo: false,
4197 },
4198 resource: None,
4199 };
4200 execute(&step).expect("a new file was refused");
4201 assert!(fresh.exists());
4202 }
4203
4204 #[test]
4205 fn a_trust_store_that_will_not_answer_does_not_cancel_the_ca_removal() {
4206 assert!(
4211 !untrust_already_done(true, None),
4212 "an unreadable trust store cancelled the removal"
4213 );
4214 assert!(!untrust_already_done(false, Some(false)));
4217 assert!(!untrust_already_done(false, None));
4218 assert!(!untrust_already_done(false, Some(true)));
4219 assert!(!untrust_already_done(true, Some(true)));
4221 assert!(untrust_already_done(true, Some(false)));
4223 }
4224
4225 #[test]
4226 fn a_capability_that_cannot_be_read_is_not_taken_for_absent() {
4227 let bind = BIND_CAPABILITY.to_string();
4241 let other = "cap_net_raw".to_string();
4242
4243 assert!(
4245 !revoke_already_done(None),
4246 "an unreadable probe was taken for absent"
4247 );
4248
4249 assert!(revoke_already_done(Some(&[])));
4251 assert!(revoke_already_done(Some(std::slice::from_ref(&other))));
4252
4253 assert!(!revoke_already_done(Some(std::slice::from_ref(&bind))));
4255 assert!(!revoke_already_done(Some(&[bind, other])));
4256 }
4257
4258 #[test]
4259 fn getcap_output_is_read_the_way_getcap_writes_it() {
4260 assert_eq!(
4263 parse_capabilities(&format!(" {BIND_CAPABILITY}=ep")),
4264 vec![BIND_CAPABILITY.to_string()]
4265 );
4266 assert!(parse_capabilities("").is_empty());
4267 }
4268
4269 #[test]
4270 fn output_that_does_not_name_the_binary_is_no_answer() {
4271 let matched = |stdout: &str| capabilities_from_getcap("/usr/local/bin/pitchfork", stdout);
4277
4278 assert_eq!(matched(""), Some(vec![]), "empty output is no capabilities");
4279 assert_eq!(matched(" \n"), Some(vec![]));
4280 assert_eq!(
4281 matched(&format!("/usr/local/bin/pitchfork {BIND_CAPABILITY}=ep\n")),
4282 Some(vec![BIND_CAPABILITY.to_string()])
4283 );
4284 assert_eq!(
4285 matched("/usr/local/bin/pitchfor\u{fffd}k cap_net_bind_service=ep\n"),
4286 None,
4287 "output naming a different path was read as an empty list"
4288 );
4289 assert_eq!(matched("something unexpected\n"), None);
4290 }
4291
4292 #[test]
4293 fn an_unusable_proxy_port_is_refused_rather_than_coerced() {
4294 for bad in [0, -1, 65536, i64::MAX, i64::MIN] {
4299 assert!(
4300 validate_proxy_port(bad).is_err(),
4301 "proxy.port {bad} was accepted"
4302 );
4303 }
4304 for good in [1, 80, 443, 8443, 65535] {
4305 assert!(
4306 validate_proxy_port(good).is_ok(),
4307 "proxy.port {good} was refused"
4308 );
4309 }
4310 }
4311
4312 #[test]
4313 fn granting_the_bind_capability_never_clears_others() {
4314 let bin = Path::new("/usr/local/bin/pitchfork");
4318 let caps = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
4319
4320 assert!(check_grant(bin, Some(&[])).is_ok());
4321 assert!(check_grant(bin, Some(&caps(&[BIND_CAPABILITY]))).is_ok());
4323 let err = check_grant(bin, Some(&caps(&["cap_net_raw", BIND_CAPABILITY])))
4324 .unwrap_err()
4325 .to_string();
4326 assert!(err.contains("cap_net_raw"), "{err}");
4327 assert!(check_grant(bin, None).is_err());
4329
4330 let mut c = ctx(Platform::Linux);
4332 c.proxy_port = 443;
4333 let step = plan(&c)
4334 .steps
4335 .into_iter()
4336 .find(|s| s.summary.contains("cap_net_bind_service"))
4337 .expect("linux plans a capability grant for a privileged port");
4338 assert!(matches!(step.action, Action::GrantBindCapability { .. }));
4339 }
4340
4341 #[test]
4342 fn undo_keeps_the_anchor_while_pf_conf_still_names_it() {
4343 let dir = tempfile::tempdir().unwrap();
4348 let conf = dir.path().join("pf.conf");
4349 let anchor = dir.path().join("pitchfork-anchor");
4350 std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
4351 std::fs::write(
4352 &conf,
4353 format!("{APPLE_PF_CONF}{MARKER_START}\nload anchor \"pitchfork\"\n{MARKER_END}\n"),
4354 )
4355 .unwrap();
4356
4357 let step = Step {
4358 summary: "remove the anchor".into(),
4359 action: Action::RemoveFile {
4360 path: anchor.clone(),
4361 sudo: false,
4362 still_referenced_by: Some(conf.clone()),
4363 },
4364 resource: None,
4365 };
4366
4367 let err = execute(&step).expect_err("the anchor was removed while still referenced");
4368 assert!(
4369 err.to_string().contains(&conf.display().to_string()),
4370 "the refusal did not name the file holding the reference: {err}"
4371 );
4372 assert!(anchor.exists(), "the anchor was deleted anyway");
4373
4374 std::fs::create_dir(dir.path().join("unreadable")).unwrap();
4378 let unreadable = Step {
4379 summary: "remove the anchor".into(),
4380 action: Action::RemoveFile {
4381 path: anchor.clone(),
4382 sudo: false,
4383 still_referenced_by: Some(dir.path().join("unreadable")),
4385 },
4386 resource: None,
4387 };
4388 let err = unreadable_err(&unreadable);
4389 assert!(
4390 err.contains("could not be read"),
4391 "an unreadable referrer did not stop the removal: {err}"
4392 );
4393 assert!(anchor.exists(), "the anchor was deleted on a failed read");
4394
4395 let gone = Step {
4397 summary: "remove the anchor".into(),
4398 action: Action::RemoveFile {
4399 path: anchor.clone(),
4400 sudo: false,
4401 still_referenced_by: Some(dir.path().join("no-such-file")),
4402 },
4403 resource: None,
4404 };
4405 execute(&gone).expect("a missing referrer blocked the removal");
4406 assert!(!anchor.exists());
4407
4408 std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
4410 std::fs::write(&conf, APPLE_PF_CONF).unwrap();
4411 execute(&step).expect("the anchor was kept with nothing referencing it");
4412 assert!(!anchor.exists());
4413 }
4414
4415 fn unreadable_err(step: &Step) -> String {
4417 execute(step)
4418 .expect_err("the step was allowed to run")
4419 .to_string()
4420 }
4421
4422 #[test]
4423 fn a_block_naming_a_file_pitchfork_does_not_own_is_not_written() {
4424 let dir = tempfile::tempdir().unwrap();
4431 let conf = dir.path().join("pf.conf");
4432 std::fs::write(&conf, APPLE_PF_CONF).unwrap();
4433 let anchor = dir.path().join("pitchfork-anchor");
4434
4435 let step = Step {
4436 summary: "load the pitchfork anchor".into(),
4437 action: Action::EnsureBlock {
4438 path: conf.clone(),
4439 content: format!("load anchor \"pitchfork\" from \"{}\"", anchor.display()),
4440 sudo: false,
4441 pf_order: true,
4442 requires: Some(anchor.clone()),
4443 },
4444 resource: None,
4445 };
4446
4447 let err = execute(&step).expect_err("the block was written anyway");
4448 assert!(
4449 err.to_string().contains(&anchor.display().to_string()),
4450 "the failure did not name the missing file: {err}"
4451 );
4452 assert_eq!(
4453 std::fs::read_to_string(&conf).unwrap(),
4454 APPLE_PF_CONF,
4455 "pf.conf was modified despite the missing anchor"
4456 );
4457
4458 std::fs::write(&anchor, "rdr pass on lo0\n").unwrap();
4462 let err = execute(&step).expect_err("pf.conf was pointed at a foreign anchor");
4463 assert!(
4464 err.to_string().contains("was not written by pitchfork"),
4465 "the refusal did not say why: {err}"
4466 );
4467 assert_eq!(std::fs::read_to_string(&conf).unwrap(), APPLE_PF_CONF);
4468
4469 std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
4471 execute(&step).expect("the block was refused with the anchor in place");
4472 assert!(
4473 std::fs::read_to_string(&conf)
4474 .unwrap()
4475 .contains(MARKER_START)
4476 );
4477 }
4478
4479 #[test]
4480 fn undo_leaves_a_resolver_file_it_did_not_write() {
4481 let dir = tempfile::tempdir().unwrap();
4482 let theirs = dir.path().join("test");
4483 std::fs::write(&theirs, "nameserver 10.0.0.1\n").unwrap();
4484
4485 let action = Action::RemoveFile {
4487 path: theirs.clone(),
4488 sudo: false,
4489 still_referenced_by: None,
4490 };
4491 assert!(already_done(&action));
4492 assert_eq!(skip_reason(&action), "not written by pitchfork, left alone");
4493 assert!(remove_file(&theirs, false).is_ok());
4494 assert!(theirs.exists(), "somebody else's resolver file was deleted");
4495
4496 let ours = dir.path().join("ours");
4498 std::fs::write(&ours, macos_resolver_file(15353)).unwrap();
4499 assert!(!already_done(&Action::RemoveFile {
4500 path: ours.clone(),
4501 sudo: false,
4502 still_referenced_by: None,
4503 }));
4504 remove_file(&ours, false).unwrap();
4505 assert!(!ours.exists());
4506 }
4507
4508 #[test]
4509 fn undo_sweeps_resolver_files_left_under_an_older_tld() {
4510 let dir = tempfile::tempdir().unwrap();
4511 let old = dir.path().join("oldtld");
4513 std::fs::write(&old, macos_resolver_file(15353)).unwrap();
4514 let theirs = dir.path().join("corp");
4516 std::fs::write(&theirs, "nameserver 10.0.0.1\n").unwrap();
4517
4518 let found = managed_resolver_files(dir.path(), "test", true);
4519 assert!(found.contains(&dir.path().join("test")), "the current TLD");
4520 assert!(found.contains(&old), "the orphaned file");
4521 assert!(!found.contains(&theirs), "somebody else's file");
4522 }
4523
4524 #[test]
4525 fn an_old_systemd_is_warned_about_rather_than_silently_ineffective() {
4526 let mut c = ctx(Platform::Linux);
4527 c.systemd_version = Some(245);
4528 let p = plan(&c);
4529 assert!(
4530 p.manual.iter().any(|m| m.contains("too old")),
4531 "expected a version warning: {:?}",
4532 p.manual
4533 );
4534 assert!(
4536 !plan(&ctx(Platform::Linux))
4537 .manual
4538 .iter()
4539 .any(|m| m.contains("too old"))
4540 );
4541 }
4542
4543 #[test]
4544 fn the_resolved_restart_is_unconditional() {
4545 let steps = plan(&ctx(Platform::Linux)).steps;
4549 let restart = steps
4550 .iter()
4551 .find(|s| s.summary.contains("restart systemd-resolved"))
4552 .expect("linux plans a restart");
4553 let Action::Run { skip_if, .. } = &restart.action else {
4554 panic!("expected a command");
4555 };
4556 assert!(skip_if.is_none(), "the restart must not be guarded");
4557 assert!(restart.summary.contains("interrupts DNS"));
4559 }
4560
4561 #[test]
4562 fn pf_keywords_are_whole_words_not_prefixes() {
4563 assert!(!is_pf_translation("nat_if = \"en0\""));
4565 assert!(!is_pf_translation("nat_if=\"en0\""));
4566 assert!(!is_pf_filter("pass_hosts = \"{ 10.0.0.1 }\""));
4567 assert!(!is_pf_filter("blocklist = \"{ 1.2.3.4 }\""));
4568 assert!(is_pf_translation("rdr-anchor \"com.apple/*\""));
4570 assert!(is_pf_translation(" nat on en0 from any to any"));
4571 assert!(is_pf_filter("anchor \"com.apple/*\""));
4572 assert!(is_pf_filter("pass out all"));
4573 assert!(is_pf_filter("block in all"));
4574 assert!(!is_pf_translation("set skip on lo0"));
4576 assert!(!is_pf_filter("set skip on lo0"));
4577 }
4578
4579 #[test]
4580 fn a_macro_named_like_a_rule_does_not_move_the_block() {
4581 let custom = "nat_if = \"en0\"\nset skip on lo0\nblock in all\n";
4584 let out = splice_pf_block(custom, "rdr-anchor \"pitchfork\"");
4585 let lines: Vec<&str> = out.lines().collect();
4586 let ours = lines
4587 .iter()
4588 .position(|l| l.contains("rdr-anchor \"pitchfork\""))
4589 .unwrap();
4590 let macro_line = lines.iter().position(|l| l.starts_with("nat_if")).unwrap();
4591 let filter = lines
4592 .iter()
4593 .position(|l| l.trim() == "block in all")
4594 .unwrap();
4595 assert!(
4596 ours > macro_line,
4597 "must not be treated as a translation rule"
4598 );
4599 assert!(ours < filter, "must still precede the filter");
4600 }
4601
4602 #[test]
4603 fn splice_block_inserts_replaces_and_removes() {
4604 let original = "scrub-anchor \"com.apple/*\"\n";
4605 let added = splice_block(original, "rdr-anchor \"pitchfork\"");
4606 assert_eq!(
4607 added,
4608 "scrub-anchor \"com.apple/*\"\n\n# pitchfork-start\nrdr-anchor \"pitchfork\"\n# pitchfork-end\n"
4609 );
4610
4611 let replaced = splice_block(&added, "rdr-anchor \"other\"");
4612 assert!(replaced.contains("rdr-anchor \"other\""));
4613 assert!(!replaced.contains("rdr-anchor \"pitchfork\""));
4614 assert_eq!(replaced.matches(MARKER_START).count(), 1);
4615
4616 assert_eq!(splice_block(&added, ""), original);
4617 }
4618
4619 #[test]
4620 fn generated_files_carry_the_expected_contents() {
4621 assert_eq!(
4622 macos_resolver_file(15353),
4623 "# Managed by pitchfork (pitchfork proxy setup)\nnameserver 127.0.0.1\nport 15353\n"
4624 );
4625 let dropin = resolved_dropin("test", 15353);
4626 assert!(dropin.contains("[Resolve]"));
4627 assert!(dropin.contains("DNS=127.0.0.1:15353"));
4628 assert!(dropin.contains("Domains=~test"));
4630 assert!(
4631 pf_anchor_rules(443, 8443, "127.0.0.1")
4632 .contains("lo0 inet proto tcp from any to any port 443 -> 127.0.0.1 port 8443")
4633 );
4634 }
4635
4636 #[test]
4637 fn only_a_sudo_elevated_run_counts_as_sudo() {
4638 assert_eq!(
4639 sudo_user(true, Some("alice".into())).as_deref(),
4640 Some("alice")
4641 );
4642 assert_eq!(sudo_user(true, None), None);
4644 assert_eq!(sudo_user(true, Some("root".into())), None);
4645 assert_eq!(sudo_user(false, Some("alice".into())), None);
4647 }
4648
4649 #[test]
4650 fn pac_leaves_the_local_namespace_alone_in_lan_mode() {
4651 for platform in [Platform::MacOs, Platform::Linux, Platform::Other] {
4654 let mut c = ctx(platform);
4655 c.network_services = vec!["Wi-Fi".into()];
4656 c.gnome = true;
4657 c.pac = true;
4658 c.lan = true;
4659 c.tld = "local".into();
4660 let p = plan(&c);
4661 assert!(
4662 !p.steps
4663 .iter()
4664 .any(|s| matches!(s.resource, Some(Resource::AutoProxy { .. }))),
4665 "{platform:?} configured an automatic proxy in LAN mode: {:?}",
4666 p.describe()
4667 );
4668 assert!(
4669 !p.manual.iter().any(|m| m.contains("proxy.pac")),
4670 "{platform:?} asked for a manual PAC URL in LAN mode"
4671 );
4672 }
4673 }
4674
4675 #[test]
4676 fn the_pf_reference_is_kept_and_given_back() {
4677 let stderr = "No ALTQ support in kernel\nALTQ related functions disabled\n\
4679 pf enabled\nToken : 11083498731209435137\n";
4680 assert_eq!(
4681 parse_pf_token(stderr).as_deref(),
4682 Some("11083498731209435137")
4683 );
4684 assert_eq!(parse_pf_token("pf already enabled\n"), None);
4685 assert_eq!(parse_pf_token("Token : ; rm -rf /\n"), None);
4686
4687 let mac = ctx(Platform::MacOs);
4691 let forward = plan(&mac);
4692 assert!(
4693 forward
4694 .steps
4695 .iter()
4696 .any(|s| matches!(s.action, Action::EnablePf { .. }))
4697 );
4698 assert!(
4699 plan_undo(&mac)
4700 .steps
4701 .iter()
4702 .any(|s| matches!(s.action, Action::ReleasePf { .. }))
4703 );
4704 assert!(
4705 !plan_with_reconcile(&mac, std::slice::from_ref(&mac))
4706 .steps
4707 .iter()
4708 .any(|s| matches!(s.action, Action::ReleasePf { .. }))
4709 );
4710
4711 let mut pac = mac.clone();
4713 pac.pac = true;
4714 assert!(
4715 plan_with_reconcile(&pac, std::slice::from_ref(&mac))
4716 .steps
4717 .iter()
4718 .any(|s| matches!(s.action, Action::ReleasePf { .. }))
4719 );
4720 }
4721
4722 #[test]
4723 fn the_redirect_follows_the_family_the_proxy_answers_on() {
4724 let mut linux = ctx(Platform::Linux);
4727 linux.contact_host = "[::1]".into();
4728 let step = plan(&linux)
4729 .steps
4730 .into_iter()
4731 .find(|s| matches!(s.resource, Some(Resource::Redirect { .. })))
4732 .expect("a redirect for an unprivileged port");
4733 let Action::Run { argv, skip_if, .. } = &step.action else {
4734 panic!("unexpected action {:?}", step.action);
4735 };
4736 assert_eq!(argv[0], "ip6tables");
4737 assert_eq!(skip_if.as_ref().unwrap().argv[0], "ip6tables");
4738 let undo = plan_undo(&linux);
4739 let undo = undo
4740 .steps
4741 .iter()
4742 .find(|s| s.resource == step.resource)
4743 .expect("undo removes the same redirect");
4744 let Action::RunIfPresent { argv, .. } = &undo.action else {
4745 panic!("unexpected action {:?}", undo.action);
4746 };
4747 assert_eq!(argv[0], "ip6tables");
4748
4749 let mut mac = ctx(Platform::MacOs);
4750 mac.contact_host = "[::1]".into();
4751 let anchor = plan(&mac)
4752 .steps
4753 .into_iter()
4754 .find_map(|s| match s.action {
4755 Action::WriteFile { path, content, .. } if path == mac.pf_anchor => Some(content),
4756 _ => None,
4757 })
4758 .expect("an anchor file");
4759 assert!(
4760 anchor.contains("lo0 inet6 proto tcp from any to any port 443 -> ::1 port 8443"),
4761 "{anchor}"
4762 );
4763
4764 let step = plan(&ctx(Platform::Linux))
4766 .steps
4767 .into_iter()
4768 .find(|s| matches!(s.resource, Some(Resource::Redirect { .. })))
4769 .unwrap();
4770 assert!(matches!(&step.action, Action::Run { argv, .. } if argv[0] == "iptables"));
4771 }
4772}