Skip to main content

pitchfork_cli/proxy/
setup.rs

1//! Host integration for the proxy: `pitchfork proxy setup`, `--undo`, `doctor`.
2//!
3//! The supervisor itself never needs privileges. Everything that does — writing
4//! a resolver file, installing the CA, letting an unprivileged process reach
5//! ports 80 and 443 — is collected here, printed as a plan before anything runs,
6//! and reversed by `--undo`.
7//!
8//! The plan is built from a [`SetupContext`] that carries every platform fact
9//! the steps depend on, so the same code can be exercised in tests for a
10//! platform the test is not running on.
11
12use std::path::{Path, PathBuf};
13
14use crate::Result;
15
16/// Marker delimiting a pitchfork-managed block inside a file we do not own.
17const MARKER_START: &str = "# pitchfork-start";
18const MARKER_END: &str = "# pitchfork-end";
19
20/// Header written at the top of files pitchfork owns outright.
21const OWNED_HEADER: &str = "# Managed by pitchfork (pitchfork proxy setup)";
22
23/// Host platform, as far as setup is concerned.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub enum Platform {
26    MacOs,
27    Linux,
28    Other,
29}
30
31impl Platform {
32    /// The platform this binary was built for.
33    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
44/// Where `proxy setup` generates its certificate authority.
45fn default_generated_ca() -> PathBuf {
46    crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
47}
48
49/// Everything the plan depends on, gathered up front so planning is pure.
50///
51/// Serialized after a run so `--undo` can reverse what setup actually did
52/// rather than what the settings say now. Between the two, `proxy.tld`,
53/// `proxy.port`, `proxy.host` or `proxy.tls_cert` may well have changed, and
54/// undo built from the new values would probe for a different iptables rule or
55/// PAC URL and leave the real ones in place.
56#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
57pub struct SetupContext {
58    pub platform: Platform,
59    /// TLD proxy URLs live under.
60    pub tld: String,
61    /// Port the loopback resolver listens on.
62    pub dns_port: u16,
63    /// Port the proxy listens on.
64    pub proxy_port: u16,
65    /// Whether the proxy serves HTTPS (decides CA steps and which standard port matters).
66    pub https: bool,
67    /// Whether the loopback resolver is enabled at all.
68    pub dns_enabled: bool,
69    /// Configure the system through a PAC file instead of the resolver.
70    pub pac: bool,
71    /// Whether systemd-resolved is the active stub resolver.
72    pub systemd_resolved: bool,
73    /// Major version of systemd, when it could be determined.
74    pub systemd_version: Option<u32>,
75    /// Whether LAN mode is on, in which case `.local` is mDNS territory.
76    pub lan: bool,
77    /// Address a local client uses to reach the proxy, as it appears in a URL.
78    ///
79    /// Normally `127.0.0.1`, but `proxy.host = "::1"` means nothing is
80    /// listening on IPv4 and the PAC file has to say so.
81    pub contact_host: String,
82    /// Path to the CA certificate.
83    pub ca_path: PathBuf,
84    /// Whether the CA is already in the system trust store.
85    pub ca_trusted: bool,
86    /// Whether a custom `tls_cert` is configured (pitchfork then owns no CA).
87    pub custom_cert: bool,
88    /// Where the CA pitchfork generates lives, whatever `proxy.tls_cert` says.
89    ///
90    /// Undo works from this rather than `ca_path`: configuring a custom
91    /// certificate later must not hide a CA an earlier setup installed.
92    ///
93    /// Records written before this field existed default to the current
94    /// generated path rather than to an empty one, which would have planned the
95    /// removal of nothing.
96    #[serde(default = "default_generated_ca")]
97    pub generated_ca: PathBuf,
98    /// Path to the running pitchfork binary, used for `setcap` and re-invocation.
99    pub binary: PathBuf,
100    /// Directory holding per-domain macOS resolver files (`/etc/resolver`).
101    pub resolver_dir: PathBuf,
102    /// Directory holding systemd-resolved drop-ins.
103    pub resolved_dropin_dir: PathBuf,
104    /// Path to `pf.conf` on macOS.
105    pub pf_conf: PathBuf,
106    /// Path to the pitchfork pf anchor file on macOS.
107    pub pf_anchor: PathBuf,
108    /// Active macOS network services to point at the PAC file.
109    pub network_services: Vec<String>,
110    /// Whether the GNOME proxy settings are available.
111    pub gnome: bool,
112    /// The automatic-proxy configuration found before `--pac` overwrote it.
113    ///
114    /// Without this, `--undo` can only switch the proxy off: it has nothing to
115    /// put back, so a machine that already had a corporate or hand-written PAC
116    /// URL would lose it the first time `setup --pac` ran. Entries pointing at
117    /// pitchfork's own PAC file are not recorded, so re-running setup cannot
118    /// overwrite a genuine earlier value with our own.
119    ///
120    /// Records written before this field existed simply have none, which
121    /// leaves undo behaving as it did before.
122    #[serde(default)]
123    pub prior_auto_proxy: Vec<PriorAutoProxy>,
124}
125
126/// An automatic-proxy setting as it stood before setup changed it.
127#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub struct PriorAutoProxy {
129    /// The macOS network service this belongs to, or `gnome`.
130    pub target: String,
131    /// The URL that was configured.
132    pub url: String,
133    /// The switch that went with it: `on`/`off` on macOS, the proxy mode on
134    /// GNOME. Recorded verbatim because it is passed back to the same tool.
135    pub state: String,
136}
137
138impl SetupContext {
139    /// The standard port proxy URLs would use with no port suffix.
140    fn standard_port(&self) -> u16 {
141        if self.https { 443 } else { 80 }
142    }
143
144    /// Whether traffic has to be redirected from the standard port.
145    ///
146    /// False when the proxy already listens there, and false when the proxy is
147    /// on a privileged port it cannot bind unprivileged — that case is reported
148    /// as a problem rather than papered over with a redirect.
149    fn needs_port_redirect(&self) -> bool {
150        self.proxy_port >= 1024 && self.proxy_port != self.standard_port()
151    }
152
153    /// macOS resolver file for the configured TLD.
154    fn resolver_file(&self) -> PathBuf {
155        self.resolver_dir.join(&self.tld)
156    }
157
158    /// systemd-resolved drop-in for the configured TLD.
159    fn resolved_dropin(&self) -> PathBuf {
160        self.resolved_dropin_dir.join("pitchfork.conf")
161    }
162
163    /// Address the port redirect points at: the one the proxy answers on,
164    /// without the brackets a URL needs.
165    fn redirect_target(&self) -> &str {
166        self.contact_host
167            .trim_start_matches('[')
168            .trim_end_matches(']')
169    }
170
171    /// Whether the proxy answers on IPv6, which decides the redirect's family.
172    fn ipv6(&self) -> bool {
173        self.redirect_target().contains(':')
174    }
175
176    /// Whether this setup routes through a PAC file.
177    ///
178    /// `--pac` does not apply in LAN mode. Its TLD is `local`, so a PAC file
179    /// would send every `*.local` request the browser makes — printers, other
180    /// machines — through pitchfork, which answers only for its own names. mDNS
181    /// already resolves what LAN mode publishes, so LAN mode is planned as it
182    /// is without `--pac`.
183    fn uses_pac(&self) -> bool {
184        self.pac && !self.lan
185    }
186
187    /// URL of the PAC script served by the proxy.
188    fn pac_url(&self) -> String {
189        super::pac::url(&self.contact_host, self.proxy_port)
190    }
191}
192
193/// A command run for its result rather than its effect.
194///
195/// Used to decide whether a step still needs doing, and whether something is
196/// ours to undo, by asking the system rather than assuming.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct Probe {
199    pub argv: Vec<String>,
200    /// Every one of these must hold for the probe to pass.
201    pub expect: Vec<ProbeExpect>,
202}
203
204/// A condition on a probe's output.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum ProbeExpect {
207    /// The output contains this text anywhere.
208    Contains(String),
209}
210
211impl Probe {
212    /// A probe that passes on exit status alone.
213    fn status(argv: Vec<String>) -> Self {
214        Probe {
215            argv,
216            expect: vec![],
217        }
218    }
219
220    /// A probe whose output must contain `expect`.
221    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/// One action in a plan.
230#[derive(Clone, Debug, PartialEq, Eq)]
231pub enum Action {
232    /// Replace a file pitchfork owns entirely.
233    WriteFile {
234        path: PathBuf,
235        content: String,
236        sudo: bool,
237    },
238    /// Delete a file pitchfork wrote.
239    ///
240    /// The file's managed header is checked first: `--undo` must never delete a
241    /// root-owned file that somebody else put there under a name we happen to
242    /// use.
243    RemoveFile {
244        path: PathBuf,
245        sudo: bool,
246        /// A file that must no longer reference this one before it is removed.
247        ///
248        /// The mirror of `EnsureBlock`'s `requires`. `apply` continues past a
249        /// failed step, so a `RemoveBlock` that did not go through would
250        /// otherwise be followed by the deletion of the file that block names,
251        /// leaving `/etc/pf.conf` pointing at a missing anchor — the same
252        /// broken reference, arrived at from the undo side.
253        still_referenced_by: Option<PathBuf>,
254    },
255    /// Insert or replace a marked block inside a file pitchfork shares.
256    EnsureBlock {
257        path: PathBuf,
258        content: String,
259        sudo: bool,
260        /// Place the block where pf's rule ordering requires, rather than at
261        /// the end of the file.
262        pf_order: bool,
263        /// A file the block's content refers to, which must exist before the
264        /// block is written.
265        ///
266        /// `apply` runs every step, continuing past one that failed, so that a
267        /// single refusal does not abandon the independent work either side of
268        /// it. That is wrong for a block that names another file: splicing
269        /// `load anchor ... from "<path>"` into `/etc/pf.conf` after the write
270        /// of `<path>` failed leaves a dangling reference in a shared system
271        /// file, which breaks every later `pfctl -f` including the one at
272        /// boot. Naming the prerequisite here turns that into a clean failure
273        /// of this step instead.
274        requires: Option<PathBuf>,
275    },
276    /// Remove pitchfork's marked block from a shared file.
277    RemoveBlock { path: PathBuf, sudo: bool },
278    /// Run a command.
279    Run {
280        argv: Vec<String>,
281        sudo: bool,
282        /// A probe that succeeds when this action has already taken effect.
283        /// Without one the action is assumed safe to repeat.
284        skip_if: Option<Probe>,
285    },
286    /// Run a command only when a probe shows the state is actually ours to
287    /// revert.
288    ///
289    /// `--undo` runs against whatever the machine looks like now, which is not
290    /// necessarily what setup left behind. Reverting a proxy URL somebody else
291    /// configured, or stripping capabilities pitchfork never granted, would
292    /// damage unrelated system state.
293    RunIfPresent {
294        /// Probe whose success means there is something of ours to revert.
295        probe: Probe,
296        argv: Vec<String>,
297        sudo: bool,
298    },
299    /// Drop `cap_net_bind_service` from a binary's file capabilities.
300    ///
301    /// Its own action because `setcap -r` clears the whole set: whether that is
302    /// safe depends on what else is on the file, which has to be read first.
303    RevokeBindCapability { binary: PathBuf },
304    /// Add `cap_net_bind_service` to a binary's file capabilities.
305    ///
306    /// Its own action for the same reason as the revoke: `setcap` writes the
307    /// whole set, so `cap_net_bind_service=+ep` alone silently drops anything
308    /// else the file carried. What is already there has to be read first.
309    GrantBindCapability { binary: PathBuf },
310    /// Enable pf with `pfctl -Ef`, loading `pf_conf`, and keep the reference
311    /// token it returns in `token`.
312    ///
313    /// pf is shared. Apple's stock `pf.conf` asks every component to enable it
314    /// with `-E` and release it with `-X <token>`, so that it goes off only
315    /// once nothing holds it. Without the token undo could only leave pf on
316    /// for good, or switch it off under whoever else needs it.
317    EnablePf { pf_conf: PathBuf, token: PathBuf },
318    /// Reload `pf_conf` and release the reference `EnablePf` kept in `token`.
319    ///
320    /// One step with the enable's resource, so a re-run that still uses pf
321    /// does not release it only to take it again.
322    ReleasePf { pf_conf: PathBuf, token: PathBuf },
323    /// Generate the local CA pair the supervisor would otherwise create on its
324    /// first HTTPS start, so it exists to be trusted. Skipped when it does.
325    GenerateCa { cert: PathBuf, key: PathBuf },
326    /// Install the CA into the system trust store, in this process.
327    TrustCa { path: PathBuf },
328    /// Remove the CA from the system trust store.
329    ///
330    /// Skipped when that certificate is not trusted, which is the probe that
331    /// lets undo queue it unconditionally: an earlier setup may have installed
332    /// the generated CA even though a custom certificate is configured now.
333    UntrustCa { path: PathBuf, sudo: bool },
334    /// Nothing to do; the line exists to explain why.
335    Note,
336}
337
338/// The system resource a step installs or removes.
339///
340/// Forward and undo steps for the same resource carry the same value, which is
341/// what lets a re-run tell "this configuration still wants that" from "that
342/// belonged to a configuration being replaced". Comparing descriptions cannot
343/// do it: installing and removing the same thing read quite differently.
344#[derive(Clone, Debug, PartialEq, Eq, Hash)]
345pub enum Resource {
346    /// A file pitchfork owns, by path.
347    File(PathBuf),
348    /// A marked block inside a file pitchfork shares, by path.
349    Block(PathBuf),
350    /// A redirect from one port to another.
351    Redirect { from: u16, to: u16, ipv6: bool },
352    /// The bind capability on a binary.
353    BindCapability(PathBuf),
354    /// The CA in the system trust store.
355    TrustedCa(PathBuf),
356    /// An automatic proxy URL on a named network service.
357    AutoProxy { service: String, url: String },
358    /// A service reload, which belongs to whatever configuration triggered it.
359    ServiceReload(String),
360}
361
362/// A single planned step: what it does, and how it is described.
363#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct Step {
365    /// One line, shown in the plan and echoed while running.
366    pub summary: String,
367    pub action: Action,
368    /// What this step acts on, when it acts on something durable.
369    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    /// Whether running this step needs elevated privileges.
382    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            // macOS installs into the login keychain and prompts on its own;
395            // on Linux the CA step is planned as a sudo'd re-invocation instead.
396            Action::UntrustCa { sudo, .. } => *sudo,
397            Action::GenerateCa { .. } | Action::TrustCa { .. } | Action::Note => false,
398        }
399    }
400}
401
402/// An ordered list of steps plus trailing advice.
403#[derive(Clone, Debug, Default, PartialEq, Eq)]
404pub struct Plan {
405    pub steps: Vec<Step>,
406    /// Things the user has to do by hand, printed after the plan.
407    pub manual: Vec<String>,
408}
409
410impl Plan {
411    /// One display line per step, `sudo` steps marked.
412    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    /// Whether any step needs elevated privileges.
426    pub fn needs_sudo(&self) -> bool {
427        self.steps.iter().any(Step::needs_sudo)
428    }
429
430    /// Whether the plan would change anything at all.
431    pub fn is_empty(&self) -> bool {
432        self.steps.iter().all(|s| s.action == Action::Note)
433    }
434}
435
436// ─── Plan construction ───────────────────────────────────────────────────────
437
438/// Contents of the macOS `/etc/resolver/<tld>` file.
439fn macos_resolver_file(dns_port: u16) -> String {
440    format!("{OWNED_HEADER}\nnameserver 127.0.0.1\nport {dns_port}\n")
441}
442
443/// Contents of the systemd-resolved drop-in routing the TLD at our resolver.
444///
445/// `Domains=~<tld>` marks the TLD a routing-only domain, so only names under it
446/// are sent to the listed server; everything else keeps using the link's own
447/// DNS servers.
448fn 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
452/// pf rules redirecting the standard port to the proxy's unprivileged port.
453///
454/// `target` is the address the proxy answers on. Its family decides the rule's:
455/// with `proxy.host = "::1"` the resolver answers AAAA only, so clients connect
456/// over IPv6 and an `inet` rule would never see them.
457fn 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
469/// Reject a `proxy.port` the proxy itself would refuse to listen on.
470///
471/// Every other reader of this setting — the listener, `proxy doctor`, the URL
472/// builder — rejects a value outside 1..=65535. Setup used to fall back to 443
473/// instead, which meant configuring the machine for a port nothing will ever
474/// bind: a redirect to the standard port, or a capability granted, for a proxy
475/// that cannot start. Worse, that port went into the record, so undo would act
476/// on it too.
477pub 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
487/// Reject a TLD that must not reach a privileged file path or a config file.
488///
489/// `proxy.tld` is an arbitrary user string that ends up joined onto
490/// `/etc/resolver` for a `sudo` write and delete, and interpolated into a
491/// systemd-resolved drop-in. A traversal component would aim those at a
492/// root-owned file elsewhere; a newline would inject directives into the
493/// drop-in. Only a real DNS suffix is allowed through.
494pub 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
508/// Build the plan for `pitchfork proxy setup`.
509pub 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
521/// Resolver steps: teach the system where `*.<tld>` is answered.
522fn 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        // LAN mode serves `.local`, which belongs to mDNS. Routing that suffix
531        // at a unicast resolver would take the whole Bonjour namespace with it,
532        // so other machines, printers and AirDrop names would stop resolving.
533        // mDNS already answers these names; nothing needs installing.
534        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                // A resolver file takes over DNS for the suffix it names, and
557                // the responder only runs inside the supervisor. Worth saying
558                // out loud for the default TLD, because it is the one people
559                // will not think to check.
560                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                    // Unconditional, deliberately.
602                    //
603                    // Four guards have been tried here: whether the file
604                    // changed, whether `resolvectl status` shows the server and
605                    // domain, whether those appear in the same scope, and
606                    // whether a name resolves. Each was wrong in a way that
607                    // skipped a restart that was needed, and the symptom every
608                    // time was setup reporting success while proxy names did
609                    // not resolve.
610                    //
611                    // What the guard bought was avoiding a brief interruption
612                    // to name resolution when setup is re-run with nothing to
613                    // change. That is not worth a silent failure, in a command
614                    // the user invoked on purpose and which already stops to
615                    // ask for a password.
616                    skip_if: None,
617                },
618                resource: Some(Resource::ServiceReload("systemd-resolved".into())),
619            });
620            // `DNS=<addr>:<port>` needs systemd 246, and routing a domain at it
621            // with `Domains=~<tld>` needs 247. On anything older the files are
622            // written and the service restarts, but the route never takes
623            // effect, so say so here rather than let `doctor` be the first hint.
624            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
663/// PAC steps: the no-sudo path.
664fn 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
742/// CA steps: make the leaf certificates the proxy mints verifiable.
743fn 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        // Claims the resource even though there is nothing to do: this
755        // configuration still wants the CA trusted, so a re-run must not let
756        // an earlier record's undo step remove it.
757        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    // The supervisor generates the CA on its first HTTPS start, but the
768    // documented order runs setup before that, and there is nothing to trust
769    // until the file exists. Generating it here is the same work the
770    // supervisor would do; it then finds the pair and uses it.
771    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    // On Linux the trust store is root-owned, so the step re-invokes pitchfork
784    // under sudo. On macOS the login keychain takes it unprivileged, with the
785    // OS prompting for confirmation.
786    let action = if ctx.platform == Platform::Linux {
787        Action::Run {
788            // `--cert` is passed explicitly: sudo resets the environment, so a
789            // child left to re-derive the path would read a different
790            // PITCHFORK_STATE_DIR and trust a certificate the proxy never
791            // serves, while reporting success.
792            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
814/// Port steps: let the proxy answer on 80/443 without running as root.
815fn plan_ports(ctx: &SetupContext, plan: &mut Plan) {
816    let standard = ctx.standard_port();
817    if ctx.proxy_port < 1024 {
818        // The supervisor is configured to bind a privileged port itself, which
819        // no redirect can help with — something has to grant it that right.
820        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        // The PAC file sends the browser straight at the proxy port, so the
852        // standard port never enters the picture.
853        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                    // The block names the anchor file, so it must not be
885                    // written before that file exists.
886                    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                    // `-C` checks for the identical rule, so re-running setup
910                    // does not stack duplicate NAT entries.
911                    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
932/// The iptables binary for the address family the proxy answers on.
933fn iptables_program(ipv6: bool) -> &'static str {
934    if ipv6 { "ip6tables" } else { "iptables" }
935}
936
937/// iptables arguments for the loopback redirect, parameterised by `-A`/`-D`.
938///
939/// IPv6 traffic never passes through the `iptables` tables, so a proxy on
940/// `::1` needs its rule in `ip6tables`.
941fn 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
964/// Resolver files in `dir` that pitchfork wrote, plus the one for `tld`.
965///
966/// The current TLD's path is always included even when the file is absent, so
967/// the plan still lists it and reports "nothing there" rather than staying
968/// silent. Scanning picks up files left by a setup run under a different TLD.
969fn 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 != &current && p.is_file() && is_managed_file(p, false))
983        .collect();
984    others.sort();
985    found.extend(others);
986    found
987}
988
989/// Where the record of the last successful setup lives.
990/// Where the pf reference token taken by setup is kept, for undo to release.
991fn 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/// Every setup whose resources may still be installed.
1004#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
1005struct SetupRecords {
1006    #[serde(default)]
1007    setups: Vec<SetupContext>,
1008}
1009
1010/// How many past setups are remembered.
1011///
1012/// Each entry is one configuration whose resources might still be installed.
1013/// A machine reaches a handful; the cap only stops a pathological loop from
1014/// growing the file without limit.
1015const MAX_RECORDS: usize = 32;
1016
1017/// What two contexts have to agree on to count as the same installation.
1018///
1019/// Compared by the undo plan rather than field by field, because that is
1020/// exactly the set of resources at stake.
1021fn undo_key(ctx: &SetupContext) -> String {
1022    plan_undo(ctx).describe().join("\n")
1023}
1024
1025/// Record a setup alongside the ones before it, for a later `--undo`.
1026///
1027/// Written before the steps run, so a run that fails halfway still leaves
1028/// something to reverse. Best-effort: failing to record must not stop setup,
1029/// because undo falls back to the current settings.
1030///
1031/// Earlier setups are kept, not replaced. Running setup again after changing
1032/// `proxy.port` installs a second redirect, and only the earlier record names
1033/// the first one.
1034/// Holds the setup lock for as long as it is alive.
1035pub struct SetupLock(#[allow(dead_code)] Option<xx::fslock::LockFile>);
1036
1037/// Take the lock that serialises a whole `proxy setup` run.
1038///
1039/// It has to span the entire transaction, not just one step. The CLI reads the
1040/// records, builds a plan from them, writes the new record, applies the plan
1041/// and, on undo, clears the record. Locking only the apply would still let a
1042/// second run read the same records, build a plan against a state the first run
1043/// is about to change, and then apply it — including splicing a block into
1044/// `/etc/pf.conf` or a resolver drop-in from a stale pre-image, so one run's
1045/// edit is silently dropped while both report success.
1046///
1047/// A lock that cannot be taken is an error rather than a warning to run on
1048/// through. Every step past this point edits shared system state — a resolver
1049/// file, a firewall rule, the trust store — and this repository's rule is that
1050/// the state file is always locked. Applying those edits unserialised because
1051/// the lock was unavailable is the one outcome worse than not applying them.
1052/// The record file is the lock's *name*, not the file that gets locked:
1053/// `xx::fslock` flocks a file of its own under the temporary directory, keyed
1054/// on a hash of this path. So `clear_record` unlinking the record after a
1055/// successful undo does not drop or orphan the lock, and a run waiting on it
1056/// still holds the same one.
1057pub 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    // Blocks while another run holds it, so a concurrent `proxy setup` waits
1064    // its turn rather than failing.
1065    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
1076/// Record what this configuration set up, for a later `--undo`.
1077///
1078/// The caller holds the lock from [`lock_setup`]; this does not take it again,
1079/// because the file lock is not re-entrant.
1080pub 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    // Through `write_file`, so the journal lands whole or not at all. This is
1095    // the only thing that knows which resources were really installed, as
1096    // against what the settings say now; a truncated file reads as "nothing
1097    // recorded" or parses as a shorter list, and either way `--undo` loses
1098    // track of a resolver file, a redirect, a capability grant or a trusted CA
1099    // that nothing else can find again.
1100    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
1108/// `existing` with `ctx` added, oldest first.
1109///
1110/// An identical configuration replaces its earlier entry; a different one is
1111/// appended. Nothing is dropped for failing, because a setup that fails
1112/// part-way may still have installed something, and the entries it would have
1113/// discarded name resources an earlier run really did install.
1114fn 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
1126/// A record as read from disk, with everything privileged rebuilt from source.
1127///
1128/// The record decides which resources undo removes, and its fields reach `sudo`
1129/// as file paths. It is an ordinary file in the state directory, so it is
1130/// treated as input rather than as truth: the TLD is validated exactly as a
1131/// configured one is, and the fixed system paths are restored from the values
1132/// this build uses. A record naming something else cannot then steer a
1133/// privileged write or delete to it.
1134///
1135/// `binary` is rebuilt too. Keeping it looked reasonable — revoking from a
1136/// binary that has since moved is the kind of thing a record is for — but the
1137/// only guard on that step reads the target's current capabilities, which says
1138/// what a file has and not who granted it. A record naming an unrelated
1139/// executable that happens to hold `cap_net_bind_service` would have had undo
1140/// strip it under `sudo`. Undo now revokes from the running pitchfork only, so
1141/// a capability granted to a copy that has since been replaced is left behind
1142/// rather than guessed at.
1143fn 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    // Every one of these lands in an argv on undo. A value beginning with `-`
1156    // would be read as a flag by `networksetup` or `gsettings` rather than as
1157    // data, and a state outside the vocabulary of the tool it is handed to is
1158    // not something this file wrote. Drop the entry rather than the record: an
1159    // unusable restore should not cost the rest of the undo.
1160    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
1178/// The running pitchfork executable, which is the only binary undo will touch.
1179fn current_binary() -> PathBuf {
1180    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pitchfork"))
1181}
1182
1183/// The system paths setup writes to, which are fixed rather than configured.
1184struct 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
1200/// Every recorded setup, oldest first.
1201pub fn load_records() -> Vec<SetupContext> {
1202    let Ok(text) = std::fs::read_to_string(record_path()) else {
1203        return vec![];
1204    };
1205    // A file in the previous single-context format parses as `SetupRecords`
1206    // with no entries, because TOML ignores keys the struct does not name and
1207    // `setups` defaults to empty. An empty list therefore means "try the older
1208    // format" rather than "nothing was recorded".
1209    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        // Genuinely empty, or unreadable; either way there is nothing to undo
1221        // from it.
1222        Err(e) => {
1223            log::debug!("No usable setup record: {e}");
1224            vec![]
1225        }
1226    }
1227}
1228
1229/// Forget the recorded setup, once it has been undone.
1230pub 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
1239/// The undo plan, reversing what setup recorded and what the settings imply.
1240///
1241/// The record is authoritative: it names the resources actually installed. The
1242/// current context is included as well, so a setup performed before records
1243/// existed, or changed by hand since, is still cleaned up. Steps are
1244/// deduplicated by their description, which names the path or rule each one
1245/// acts on.
1246/// `plan_undo_all`, with the current settings optional.
1247///
1248/// They are left out when `proxy.tld` no longer validates: paths built from it
1249/// are exactly what `validate_tld` refuses to let near a privileged write, and
1250/// the records were validated when they were written, so undo still works from
1251/// those alone.
1252pub 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    // Newest first, so the most recent installation is cleaned up before the
1256    // ones it superseded.
1257    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(&note) {
1266                plan.manual.push(note);
1267            }
1268        }
1269    }
1270    plan
1271}
1272
1273/// The setup plan, preceded by removal of anything an earlier setup installed
1274/// that this one supersedes.
1275///
1276/// Changing `proxy.port` and running setup again would otherwise leave the old
1277/// redirect in place beside the new one. That is worse than a leak: with two
1278/// iptables rules for the same port the first one wins, so traffic keeps going
1279/// to the port that is no longer in use.
1280pub fn plan_with_reconcile(ctx: &SetupContext, recorded: &[SetupContext]) -> Plan {
1281    let forward = plan(ctx);
1282    // What this configuration wants in place afterwards, including anything it
1283    // finds already done. Matched by resource rather than by description,
1284    // because installing and removing the same thing read quite differently:
1285    // comparing descriptions once let a re-run untrust the CA it still needed.
1286    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                // Acts on nothing durable, so there is nothing to reconcile and
1298                // running it would only be churn.
1299                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
1312/// Build the plan for `pitchfork proxy setup --undo`.
1313///
1314/// Every step `plan` can take has a counterpart here; steps whose forward
1315/// version did nothing are simply absent.
1316pub fn plan_undo(ctx: &SetupContext) -> Plan {
1317    let mut plan = Plan::default();
1318
1319    // Mirrors `plan_resolver`, so the undo plan claims a resolver file only
1320    // where that configuration would have written one. Claiming it otherwise
1321    // both lists a privileged removal that never applied and, on a re-run,
1322    // offers the file up as something the new configuration has superseded.
1323    // `pac` is part of that test because `plan` runs `plan_pac` *instead of*
1324    // `plan_resolver`: a PAC setup points the system at a proxy URL and never
1325    // touches the resolver, so it has no resolver file to take back.
1326    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            // Every resolver file we wrote, not just the one for the TLD
1333            // configured right now: changing `proxy.tld` after a setup would
1334            // otherwise orphan the earlier file for good. The managed-header
1335            // check on removal keeps this off anyone else's files.
1336            // The scan finds files carrying our header, which is evidence they
1337            // were written; the current TLD's path is added only when this
1338            // configuration would have written it.
1339            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    // Ports. Mirrors what `plan_ports` would have installed for this context,
1381    // so the undo plan is a faithful inverse rather than a superset: a
1382    // reconciling re-run reads it as "what that configuration installed", and
1383    // a step for something never installed would be listed and, worse, treated
1384    // as a resource the new run has to reverse.
1385    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                    // Only once `pf.conf` no longer names it.
1403                    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                    // Names both ports: the plan should say which rule it will
1423                    // remove, and two setups that differ only in `proxy.port` must
1424                    // not look like the same step.
1425                    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                        // `-C` succeeds only when that exact rule exists, so undo
1433                        // neither fails nor deletes a rule shaped like ours but
1434                        // added by someone else.
1435                        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    // PAC.
1470    match ctx.platform {
1471        Platform::MacOs => {
1472            for service in &ctx.network_services {
1473                plan.steps.push(Step {
1474                    // Names the URL for the same reason the iptables step
1475                    // names both ports.
1476                    summary: format!(
1477                        "turn off the automatic proxy URL {} for \"{service}\"",
1478                        ctx.pac_url()
1479                    ),
1480                    action: Action::RunIfPresent {
1481                        // Only if the service still points at pitchfork's PAC
1482                        // file. A corporate or hand-configured proxy URL is
1483                        // left exactly as it is.
1484                        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                // Put back what was there before, when setup recorded it.
1506                //
1507                // The URL goes first and the switch last, because
1508                // `-setautoproxyurl` turns the switch on as a side effect:
1509                // writing the URL after the switch would re-enable a proxy the
1510                // user had deliberately left off. Setting the state last makes
1511                // the recorded value the one that survives either way.
1512                //
1513                // Each step is guarded by what the one before it leaves
1514                // behind: the URL step runs only while the service still points
1515                // at pitchfork's PAC file, and the switch step only once the
1516                // URL is the recorded one, which is the proof that the URL step
1517                // ran.
1518                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                    // Same care as on macOS: leave a proxy URL we did not set.
1570                    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            // Same ordering and the same guard chain as macOS: the URL while
1594            // it is still ours, then the mode once the URL proves that ran.
1595            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    // CA, last: the resolver is useless without it but harmless with it.
1638    //
1639    // Gated on HTTPS, matching `plan_ca`: a configuration serving plain HTTP
1640    // installed no CA, so claiming the resource would let a re-run remove one
1641    // that something else — an earlier HTTPS setup, or the user by hand — put
1642    // there.
1643    //
1644    // Not gated on `proxy.tls_cert`, which is a different question. Setting a
1645    // custom certificate after a setup does not untrust the CA that setup
1646    // installed, and gating on it left the root CA and its private key trusted
1647    // with no command able to remove them. The step probes the trust store
1648    // instead and skips when there is nothing there.
1649    // An empty path would name no certificate at all, so there is nothing to
1650    // plan; records always carry one, defaulted on read when absent.
1651    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
1668// ─── Execution ───────────────────────────────────────────────────────────────
1669
1670/// Whether the current process is already root, in which case `sudo` is
1671/// unnecessary (and may not even be installed).
1672/// The user who ran this process through sudo, when it is running as root
1673/// on their behalf rather than as a genuine root login.
1674pub 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        // SAFETY: geteuid is always safe to call and cannot fail.
1686        unsafe { libc::geteuid() == 0 }
1687    }
1688    #[cfg(not(unix))]
1689    {
1690        false
1691    }
1692}
1693
1694/// Run a command, prefixing `sudo` when the step needs privileges we lack.
1695fn 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
1715/// Write `content` to `path`, elevating if needed.
1716///
1717/// The privileged path pipes through `tee` rather than writing directly so that
1718/// only the write itself runs as root.
1719/// Write `path` so that it is either wholly the old content or wholly the new.
1720///
1721/// Every file this touches is shared system configuration: `/etc/pf.conf`, a
1722/// `/etc/resolver` entry, a systemd-resolved drop-in. Writing in place would
1723/// truncate the destination the moment it opened — `tee` does this too — so a
1724/// declined sudo prompt, a hangup or a full disk part-way through would leave
1725/// a half-written file that the system still reads. Writing a sibling
1726/// temporary file and renaming it over the destination makes the swap atomic,
1727/// because a rename within a directory either happens or does not.
1728fn 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    // A sibling, so the rename stays within one filesystem. The pid keeps two
1733    // processes from sharing one temporary file.
1734    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            // A partial write leaves the temporary behind, and these land in
1747            // system config directories where a stray file is not inert:
1748            // anything in `/etc/resolver` is read as a resolver file.
1749            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            // The destination still holds whatever it held before.
1754            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        // The write's result is held until the child has been waited for: a
1778        // `tee` that died early (a declined password, say) breaks the pipe,
1779        // and returning then would leave it unreaped.
1780        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        // `tee` creates the temporary file under root's umask, and the rename
1800        // carries that mode to the destination. These are files the whole
1801        // system reads, so say so rather than inherit whatever it happened to
1802        // be.
1803        run(
1804            &[
1805                "chmod".into(),
1806                "644".into(),
1807                tmp.to_string_lossy().into_owned(),
1808            ],
1809            true,
1810        )?;
1811        // `-f` because the destination exists in the ordinary case; this is
1812        // the atomic swap.
1813        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        // Leave no half-written temporary behind. The destination is untouched
1826        // either way, which is the point of writing beside it.
1827        let _ = run(
1828            &["rm".into(), "-f".into(), tmp.to_string_lossy().into_owned()],
1829            true,
1830        );
1831    })
1832}
1833
1834/// Whether `path` is a file pitchfork wrote, identified by its managed header.
1835///
1836/// A file we cannot read counts as not ours: `--undo` declines to delete
1837/// anything it cannot positively identify.
1838fn 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
1844/// Run a probe: succeed, and optionally match `expect` in its output.
1845fn 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
1869/// Delete `path`, elevating if needed. A missing file is not an error.
1870///
1871/// Refuses to delete a file that does not carry pitchfork's managed header, so
1872/// an `/etc/resolver/<tld>` an administrator wrote by hand survives `--undo`.
1873fn remove_file(path: &Path, sudo: bool) -> Result<()> {
1874    if !path.exists() {
1875        return Ok(());
1876    }
1877    if !is_managed_file(path, sudo) {
1878        // Declining to delete somebody else's file is the right outcome, not an
1879        // error: `apply` reports it as skipped so an otherwise clean undo still
1880        // succeeds.
1881        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
1897/// The leading keyword of a `pf.conf` line, if it has one.
1898///
1899/// Compared whole rather than by prefix: custom rulesets routinely define
1900/// macros such as `nat_if = "en0"` or `pass_hosts = "{ ... }"`, and treating
1901/// those as rules would put the pitchfork block on the wrong side of the
1902/// ordering boundary and make `pfctl -f` reject the file.
1903fn pf_keyword(line: &str) -> Option<&str> {
1904    let first = line.split_whitespace().next()?;
1905    // A macro assignment written without spaces, `nat_if="en0"`.
1906    match first.split_once('=') {
1907        Some(_) => None,
1908        None => Some(first),
1909    }
1910}
1911
1912/// Whether a `pf.conf` line is a translation rule, which must precede filters.
1913fn 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
1922/// Whether a `pf.conf` line is a filter rule, which must follow translation.
1923///
1924/// `anchor` is a filter anchor; the translation anchors have their own
1925/// keywords and are matched by [`is_pf_translation`] first.
1926fn is_pf_filter(line: &str) -> bool {
1927    pf_keyword(line)
1928        .is_some_and(|kw| matches!(kw, "anchor" | "pass" | "block" | "match" | "antispoof"))
1929}
1930
1931/// Insert or replace the pitchfork block in a `pf.conf`, respecting rule order.
1932///
1933/// pf requires translation rules (`rdr`) before filter rules, and Apple's stock
1934/// `/etc/pf.conf` ends with `anchor "com.apple/*"`, a filter anchor. Appending
1935/// there makes `pfctl -f` fail with "Rules must be in order", so the block goes
1936/// immediately after the last existing `rdr-anchor` line instead. With no such
1937/// line to order against, it falls back to appending.
1938pub(crate) fn splice_pf_block(text: &str, block: &str) -> String {
1939    // Strip any block we already placed first, so a re-run replaces it in the
1940    // right position instead of leaving the old one and appending a new one.
1941    let stripped = splice_block(text, "");
1942    if block.is_empty() {
1943        return stripped;
1944    }
1945    let mut out: Vec<&str> = stripped.lines().collect();
1946    // After the last translation rule if there is one. Otherwise before the
1947    // first filter rule, because appending past it would break the same
1948    // ordering requirement on a customised file that has filters but no
1949    // `rdr-anchor`. With neither, the end of the file is fine.
1950    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
1965/// Replace the pitchfork-managed block in `text` with `block`, or append it.
1966///
1967/// Passing an empty `block` removes the block. Exposed for tests.
1968pub(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
1995/// Read a file that may need privileges to read. Missing files read as empty.
1996fn 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
2015/// Whether a step's effect is already in place, so running it can be skipped.
2016fn 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        // Either it is gone, or it is not ours to remove.
2022        Action::RemoveFile { path, sudo, .. } => !path.exists() || !is_managed_file(path, *sudo),
2023        // For a pf.conf the block's position matters as much as its presence:
2024        // one left sitting after the filter anchor by an older version keeps
2025        // `pfctl -f` failing, so compare against what the splice would produce.
2026        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        // A command with no guard is either idempotent by construction or
2039        // cheap to repeat; one with a guard is skipped when the guard succeeds.
2040        Action::Run { skip_if, sudo, .. } => {
2041            skip_if.as_ref().is_some_and(|p| probe_matches(p, *sudo))
2042        }
2043        // Nothing of ours to revert means there is nothing to do.
2044        Action::RunIfPresent { probe, sudo, .. } => !probe_matches(probe, *sudo),
2045        Action::RevokeBindCapability { binary } => {
2046            revoke_already_done(file_capabilities(binary).as_deref())
2047        }
2048        // Already carrying it. An unreadable answer is not that, so the step
2049        // runs and reports what it found rather than assuming.
2050        Action::GrantBindCapability { binary } => {
2051            file_capabilities(binary).is_some_and(|caps| caps.iter().any(|c| c == BIND_CAPABILITY))
2052        }
2053        // Both reload rules the other steps may just have changed.
2054        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        // Skipped only when the certificate is present and demonstrably not
2058        // trusted. Two absences are deliberately not enough. A missing PEM is
2059        // precisely the case `uninstall_cert` exists for: the certificate can
2060        // still be installed under its own name in the trust store. And a
2061        // trust store that would not answer says nothing either way, so
2062        // `ca_trust_state` is used rather than `is_ca_trusted`, which folds
2063        // that into `false`. Skipping on either would leave a trusted root
2064        // behind with no command left to remove it.
2065        Action::UntrustCa { path, .. } => {
2066            untrust_already_done(path.exists(), crate::proxy::trust::ca_trust_state(path))
2067        }
2068    }
2069}
2070
2071/// Whether removing the CA from the trust store has nothing left to do.
2072///
2073/// Split out so every combination can be checked: the unknown only arises on
2074/// macOS, where `security verify-cert` can be killed for running too long, and
2075/// there is no way to provoke it from a test on another platform.
2076fn untrust_already_done(pem_exists: bool, trusted: Option<bool>) -> bool {
2077    // Only a positive "not trusted" counts. A missing PEM does not, because
2078    // that is exactly the case removal exists for: the certificate can still
2079    // be installed under its own name in the store. Nor does a store that
2080    // would not answer, which says nothing either way. Skipping on either
2081    // leaves a trusted root behind with no command left to remove it.
2082    pem_exists && trusted == Some(false)
2083}
2084
2085/// Whether revoking the bind capability has nothing left to do.
2086///
2087/// Split out so both mistakes can be checked without depending on what a
2088/// particular `getcap` build prints. `None` is "could not ask", which settles
2089/// nothing and must not skip the step; `Some(&[])` is a file that definitely
2090/// carries no capabilities, which is the ordinary state after an upgrade
2091/// replaces the binary and must not fail the undo.
2092fn revoke_already_done(caps: Option<&[String]>) -> bool {
2093    matches!(caps, Some(caps) if !caps.iter().any(|c| c == BIND_CAPABILITY))
2094}
2095
2096/// Why a step was skipped, for the line `apply` prints.
2097fn 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
2108/// Execute one step.
2109fn execute(step: &Step) -> Result<()> {
2110    match &step.action {
2111        Action::Note => Ok(()),
2112        Action::WriteFile {
2113            path,
2114            content,
2115            sudo,
2116        } => {
2117            // A file carrying our header is one pitchfork claims outright, so
2118            // writing it means taking the path over. `--undo` refuses to
2119            // delete anything without that header; setting up had no matching
2120            // check, so it would silently replace somebody else's file and
2121            // then decline to clean up after itself.
2122            //
2123            // `/etc/resolver/<tld>` is where this bites: the TLD is chosen by
2124            // the user, and `test` or `dev` is exactly what other local proxies
2125            // put there. Overwriting one of those with no copy kept is not
2126            // pitchfork's call to make.
2127            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                // Fails closed. A referrer we cannot read — a declined sudo
2144                // prompt is the ordinary way that happens — is not evidence
2145                // that the reference is gone, and guessing wrong is exactly
2146                // what this guard exists to prevent: the anchor is deleted
2147                // while `/etc/pf.conf` still loads it, and every later
2148                // `pfctl -f` breaks, including the one at boot.
2149                //
2150                // A referrer that is not there at all is different: a file
2151                // that does not exist cannot reference anything.
2152                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            // Present *and* ours. Existence alone is not enough: the write
2180            // of the anchor refuses when something else already holds that
2181            // path, and the file it declined to replace would otherwise
2182            // satisfy this check. `/etc/pf.conf` would then carry
2183            // `load anchor "pitchfork" from "<somebody else's rules>"`, and
2184            // `pfctl` would load them — a worse outcome than the dangling
2185            // reference this guard was added for.
2186            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(&current, content)
2203            } else {
2204                splice_block(&current, 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(&current, ""), *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                // The Linux trust store is root-owned, so this re-invokes
2237                // pitchfork rather than failing on permissions.
2238                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
2258/// The capability `proxy setup` grants so an unprivileged proxy can bind 443.
2259const BIND_CAPABILITY: &str = "cap_net_bind_service";
2260
2261/// Capability names currently set on `path`, or `None` if it has none or
2262/// `getcap` could not be run.
2263///
2264/// `getcap` prints `<path> cap_net_bind_service=ep`, or a comma-separated list
2265/// when a file carries several.
2266///
2267/// `Some` is a definite answer, including `Some(vec![])` for a file carrying
2268/// none. `None` means the question could not be asked: no `getcap` anywhere it
2269/// was looked for, or one that failed. Callers must not read that as "no
2270/// capability", because the two call for opposite actions.
2271fn file_capabilities(path: &Path) -> Option<Vec<String>> {
2272    // `getcap` lives in `/usr/sbin`, which is not on an ordinary user's PATH on
2273    // Debian and its derivatives. Granting goes through `sudo setcap`, whose
2274    // secure_path does include it, so without these fallbacks setup can grant a
2275    // capability that undo then cannot even see.
2276    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
2294/// Read `getcap`'s output for `path`.
2295///
2296/// `getcap` prints `<path> <capabilities>`. The path is known, so it is
2297/// stripped rather than guessed at by splitting, which keeps working when the
2298/// path itself contains spaces.
2299///
2300/// Separated from running the command so both readings of "no matching line"
2301/// can be tested, which is the distinction that matters here and the one a
2302/// live `getcap` cannot be made to demonstrate.
2303fn 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    // Two different things end up here.
2308    //
2309    // Nothing at all is how `getcap` reports a file that carries no
2310    // capabilities — the ordinary state after an upgrade replaces the binary,
2311    // or after someone cleared them by hand. That is a definite empty answer,
2312    // and reading it as unknown makes `--undo` refuse to finish where there is
2313    // nothing left to revoke.
2314    //
2315    // Output that does not name the path is not that. The path is matched
2316    // through `to_string_lossy`, so a binary whose path is not valid UTF-8
2317    // never matches what `getcap` printed back, and a future release could
2318    // word its output differently. Calling that "no capabilities" would have
2319    // undo skip the revocation and leave the binary able to bind privileged
2320    // ports, so it is reported as no answer instead.
2321    stdout.trim().is_empty().then(Vec::new)
2322}
2323
2324/// Capability names in a `getcap` capability string.
2325///
2326/// The string is one or more whitespace-separated clauses, because capabilities
2327/// with different flag sets print separately: `cap_chown=ei
2328/// cap_net_bind_service=ep`. Reading only one clause would miss the rest, and
2329/// the rest is exactly what decides whether removing ours takes somebody
2330/// else's with it.
2331fn parse_capabilities(spec: &str) -> Vec<String> {
2332    let mut names: Vec<String> = Vec::new();
2333    for clause in spec.split_whitespace() {
2334        // A clause is `names=flags`, `names+flags` or `names-flags`; a bare
2335        // `=` or a flags-only fragment carries no names.
2336        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
2348/// Add `cap_net_bind_service` to `path`, refusing to discard any others.
2349///
2350/// `setcap` writes the whole set, so granting ours on its own would silently
2351/// drop any other capability the file carried — the mirror of the hazard the
2352/// revoke guards against.
2353fn 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
2365/// Refuse a grant that would clear capabilities pitchfork did not set.
2366///
2367/// Carrying the others across is not attempted: `getcap` is read here only
2368/// for names, and rewriting them with our flags could widen a capability
2369/// that was, say, inheritable only. Separated from running `getcap` so the
2370/// decision can be tested.
2371fn check_grant(path: &Path, caps: Option<&[String]>) -> Result<()> {
2372    // An unreadable answer is not "nothing there". Writing only our capability
2373    // on that basis could clear one we never saw, so say so instead.
2374    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
2404/// Why the CA pair cannot be used, or `None` when it is ready to trust.
2405fn 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
2422/// Generate the local CA pair, as the supervisor does on its first HTTPS start.
2423///
2424/// A pair that exists but cannot sign — truncated, or a key from another CA —
2425/// is replaced rather than trusted: the proxy cannot start with it, so there
2426/// is nothing in it worth keeping. The check is repeated under the CA lock, so
2427/// a supervisor generating the pair at the same moment is waited for, not
2428/// raced.
2429fn 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                // Either file on its own is a pair being replaced, not a
2436                // first generation.
2437                if cert.exists() || key.exists() {
2438                    println!("  the existing CA cannot be used ({problem}); generating a new one");
2439                    // A supervisor started before the files went bad still
2440                    // holds the old CA in memory and signs with it until it
2441                    // restarts, so the certificates it serves would not chain
2442                    // to the one about to be trusted.
2443                    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
2460/// Enable pf, loading `pf_conf`, and record the reference that holds it on.
2461///
2462/// The new reference is taken before an earlier run's is let go, so pf never
2463/// drops out in between; releasing the old one keeps re-runs from stacking
2464/// references that undo would never give back.
2465fn 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    // pfctl reports the token on stderr, alongside anything else it has to
2477    // say, so the output is captured and passed on rather than swallowed.
2478    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        // Nothing to keep, so undo will leave pf as it is, as it always did.
2503        _ => {
2504            let _ = std::fs::remove_file(token);
2505        }
2506    }
2507    if let Some(old) = previous {
2508        // Best effort: the rules are loaded either way.
2509        let _ = run(&["pfctl".into(), "-X".into(), old], true);
2510    }
2511    Ok(())
2512}
2513
2514/// Release the pf reference `enable_pf` recorded, if it still means anything.
2515fn release_pf(token: &Path) -> Result<()> {
2516    let Some(held) = read_pf_token(token) else {
2517        // None recorded, or one from before a reboot, which pf has forgotten.
2518        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
2526/// The token in `path`, when it was taken during the current boot.
2527///
2528/// References do not survive a reboot, and pf hands out tokens afresh, so a
2529/// stale one could release a reference some other component holds.
2530fn 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
2541/// The reference token in `pfctl -E` output: `Token : 1234567890`.
2542fn 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
2551/// When the machine booted, as the kernel reports it; stable for one boot.
2552fn 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
2562/// Remove `cap_net_bind_service` from `path`, leaving any others alone.
2563///
2564/// `setcap -r` clears the whole set, so it is used only when ours is the only
2565/// capability on the file. A binary carrying others was configured by
2566/// something else and pitchfork declines to guess at what it may discard.
2567fn revoke_bind_capability(path: &Path) -> Result<()> {
2568    // Not `Ok(())`. Reporting a revocation that never happened would leave the
2569    // binary able to bind privileged ports with `--undo` claiming otherwise.
2570    // Running `setcap -r` blind is not the answer either: it would clear
2571    // capabilities pitchfork did not grant, which is what the check below
2572    // exists to prevent.
2573    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/// Outcome of running a plan, for reporting.
2618#[derive(Debug, Default)]
2619pub struct RunReport {
2620    pub applied: usize,
2621    pub skipped: usize,
2622    pub failed: Vec<(String, String)>,
2623}
2624
2625/// Run every step, skipping those already in effect.
2626///
2627/// A failing step is recorded and the rest still run: a resolver file that
2628/// cannot be written should not stop the CA from being installed.
2629pub 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
2657// ─── Environment detection ───────────────────────────────────────────────────
2658
2659/// Whether systemd-resolved is the active stub resolver.
2660pub 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
2673/// Major version of systemd, parsed from `systemctl --version`.
2674///
2675/// `None` when systemd is absent or the output cannot be read, in which case no
2676/// version-specific warning is given rather than a wrong one.
2677pub 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    // First line looks like `systemd 255 (255.4-1)`.
2687    String::from_utf8_lossy(&out.stdout)
2688        .lines()
2689        .next()?
2690        .split_whitespace()
2691        .nth(1)?
2692        .parse()
2693        .ok()
2694}
2695
2696/// Whether the GNOME proxy schema is present.
2697pub 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
2707/// Active macOS network services, as `networksetup` names them.
2708/// Read the automatic-proxy configuration that is in place right now.
2709///
2710/// Called before `setup --pac` overwrites it, so `--undo` has something to put
2711/// back. A target already pointing at `our_url` is skipped: re-running setup
2712/// must not record pitchfork's own PAC file as the value to restore.
2713///
2714/// Anything unreadable is skipped rather than guessed at. A missing entry
2715/// means undo switches the proxy off, which is what it did before this was
2716/// recorded at all.
2717pub 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                // `(null)` is what `networksetup` prints for an unset URL.
2748                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) // header line explaining the asterisk
2798        // A leading asterisk marks a disabled service.
2799        .filter(|l| !l.trim().is_empty() && !l.starts_with('*'))
2800        .map(|l| l.trim().to_string())
2801        .collect()
2802}
2803
2804/// The address a local client uses to reach a proxy bound to `proxy_host`,
2805/// formatted for a URL.
2806///
2807/// A wildcard bind is reachable on the loopback address of its own family. An
2808/// IPv6 literal is bracketed, as a URL requires.
2809fn 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        // Not an address at all; the proxy falls back to IPv4 loopback too.
2822        Err(_) => "127.0.0.1".to_string(),
2823    }
2824}
2825
2826/// Build the context for the current machine and settings.
2827pub fn context_from_settings(s: &crate::settings::Settings, pac: bool) -> SetupContext {
2828    // The same source the records are rebuilt from, so a plan and an undo
2829    // of that plan always name the same files.
2830    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        // Falls back to the standard port only so the struct can be built;
2845        // `validate_proxy_port` refuses the run before any plan is made from
2846        // it, and undo works from the recorded port rather than this one.
2847        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        // Probed regardless of `pac`, because `--undo` has to be able to turn
2868        // off an automatic proxy URL that an earlier `--pac` run switched on.
2869        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    // Only on the way in. `--undo` takes no `--pac`, and reads the value to
2878    // restore from the record rather than from the system it is about to
2879    // change.
2880    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        // These reach `/etc/resolver/<tld>` for a privileged write and delete,
2922        // and the systemd-resolved drop-in, so none may be planned at all.
2923        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        // Routing `.local` at a unicast resolver would swallow every Bonjour
2945        // name on the network, not just pitchfork's.
2946        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        // sudo resets the environment, so a child that re-derived the path
2963        // could trust a different certificate than the plan promised.
2964        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        // Guarded on our own URL, so a corporate PAC survives undo.
2991        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        // `setcap -r` clears every capability on the file, so the decision
3002        // needs the current set, not a yes/no probe.
3003        // Only a setup that granted it plans to revoke it: the undo plan is a
3004        // faithful inverse, not a list of everything that might be present.
3005        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        // An unprivileged port never granted it, so undo leaves it alone.
3015        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        // Capabilities with different flag sets print as separate clauses.
3026        // Reading one clause would either miss another capability and let
3027        // `setcap -r` clear it, or miss ours and leave it installed.
3028        assert_eq!(
3029            parse_capabilities("cap_net_bind_service=ep"),
3030            vec!["cap_net_bind_service"]
3031        );
3032        // Ours last: the earlier capability must still be seen.
3033        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        // Ours first: it must still be found.
3038        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        // Comma-separated within one clause, and the older `+ep` spelling.
3043        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        // A leading empty base set, as `cap_to_text` can emit.
3052        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        // Only ours: safe to clear the whole set. Shared: undo must refuse.
3059        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        // Rendered with the host's path separator, so the expectation is built
3073        // the same way rather than spelled with a forward slash.
3074        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        // Rendered with the host's path separator, so the expectation is built
3093        // the same way rather than spelled with a forward slash.
3094        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        // A TLD that is not the machine's own keeps quiet.
3122        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        // No port redirect: the PAC file names the proxy port directly.
3160        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        // The browser connects to `proxy.port` directly under PAC, so no
3170        // redirect is installed — but binding that port is a separate problem,
3171        // and the default is 443. Claiming `--pac` removes port-related sudo
3172        // would be wrong for the configuration most people start from.
3173        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        // macOS cannot grant it at all, so setup says so rather than planning.
3186        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        // Being straight about the one exception: the Linux trust store is
3202        // root-owned, so HTTPS through the PAC file needs that one sudo step.
3203        // Nothing about resolution or ports does.
3204        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        // With the CA already trusted, or without HTTPS, nothing needs sudo.
3216        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        // With `proxy.host = "::1"` nothing listens on IPv4, so a PAC file
3227        // naming 127.0.0.1 would hand the browser a dead address.
3228        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        // Plain HTTP on 443: privileged, and not the standard port either. A
3304        // redirect cannot help — the supervisor still has to bind it.
3305        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        // The guard has to describe the same rule, or it would not match.
3346        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        // Settings can change between setup and undo. Rebuilding the plan from
3353        // the new values would probe for an iptables rule and a PAC URL that
3354        // were never installed, and leave the real ones running.
3355        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(&current), 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        // The current settings are cleaned up too, in case they were applied
3369        // by a setup that predates records or was changed by hand.
3370        assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
3371        // And nothing is planned twice.
3372        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        // The record is written before the steps run, so a setup that fails
3381        // part-way still leaves a trace. It must not take the previous
3382        // successful run's record with it: those resources are still there.
3383        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        // And undo cleans up both, including the port the failed run replaced.
3395        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        // The oldest go first, so the newest configuration is always kept.
3419        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        // Changing `proxy.port` and re-running setup would otherwise leave two
3428        // iptables rules for port 443. The first one wins, so traffic would
3429        // keep going to the port that is no longer in use.
3430        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        // Once the CA is trusted the forward plan only notes it, so matching
3450        // the earlier record's removal against descriptions let the removal
3451        // through and broke HTTPS on every re-run.
3452        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        // The superseded redirect is still removed.
3467        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        // Two configurations can have identical undo plans while installing
3473        // different things. PAC does not install the resolver drop-in, so the
3474        // earlier one has to be removed or the stale DNS routing stays live.
3475        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        // Identical configuration: nothing is superseded, so the plan must not
3492        // churn through removing and reinstalling the same resources.
3493        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        // Two setups at different ports leave two redirects installed. Undo has
3503        // to know about both.
3504        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(&current), &[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        // Changing `proxy.tld` to something invalid after a setup must not
3522        // strand what that setup installed. The records were validated when
3523        // written, so undo runs from those with the current settings dropped.
3524        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        // And with nothing recorded there is simply nothing to do.
3530        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(&current), &[]).describe(),
3538            plan_undo(&current).describe()
3539        );
3540    }
3541
3542    #[test]
3543    fn a_tampered_record_cannot_steer_a_privileged_write() {
3544        // The record lives in the state directory and its fields reach `sudo`
3545        // as paths, so it is input, not truth. A TLD that would escape the
3546        // resolver directory is refused outright, and the fixed system paths
3547        // are rebuilt rather than believed.
3548        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        // The binary is rebuilt too: the capability check reads what a file
3566        // has, not who granted it, so a record naming an unrelated executable
3567        // that happens to hold `cap_net_bind_service` would have had undo
3568        // strip it.
3569        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        // Nothing in the resulting plan names the attacker's paths.
3579        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        // TOML ignores keys a struct does not name, so a pre-list file parses
3589        // as `SetupRecords` with an empty list. Treating that as "nothing
3590        // recorded" would forget the installation it describes.
3591        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        // And the list format round-trips as itself.
3604        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        // macOS records `systemd_version: None`, which TOML omits entirely.
3616        // If that failed to read back, `--undo` would silently fall back to
3617        // current settings on the platform where the pf anchor lives.
3618        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        // The field defaults to the current generated path, not to an empty
3642        // one, or undo would plan the removal of nothing and leave the CA that
3643        // setup installed trusted.
3644        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        // `is_ca_trusted` answers false for a missing PEM, which is exactly
3665        // when `uninstall_cert` is needed: the certificate can still be
3666        // installed under its own name. Skipping there leaves a trusted root.
3667        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        // Found by running setup twice for real on a machine without
3678        // systemd-resolved: the second run planned a privileged removal of a
3679        // drop-in the first had never written.
3680        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        // The same for the other configurations that install no resolver file.
3691        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        // And a configuration that does write one still reverses it.
3717        assert!(
3718            plan_undo(&ctx(Platform::Linux))
3719                .describe()
3720                .iter()
3721                .any(|l| l.contains("resolved.conf.d"))
3722        );
3723
3724        // Re-running on a machine without systemd-resolved removes nothing.
3725        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        // The superseded redirect is still reversed.
3730        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        // Plain HTTP installs no CA, so its undo must not claim one. Otherwise
3736        // a later HTTP setup reconciles against it and removes a CA that an
3737        // earlier HTTPS run, or the user, put in the trust store.
3738        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        // And reconciling two HTTP setups leaves the trust store alone.
3749        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        // The superseded redirect is still reversed.
3754        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        // Setting `proxy.tls_cert` after a setup does not untrust the CA that
3760        // setup installed. Gating the removal on it left the root CA and its
3761        // private key trusted with no command able to remove them, so the step
3762        // is queued regardless and skipped by probing the trust store.
3763        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        // Always the generated CA, never the configured certificate: pitchfork
3776        // installed the former and has no business removing the latter.
3777        assert_eq!(path, &custom.generated_ca);
3778        assert_ne!(path, &custom.ca_path);
3779
3780        // Linux needs elevation for its trust store; macOS does not.
3781        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        // 8443 installs a redirect and grants no capability, so undo reverses
3816        // exactly that.
3817        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        // `--undo` takes no `--pac`, so it has to reverse the PAC steps anyway.
3832        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    /// Apple's stock `/etc/pf.conf`, verbatim.
3844    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        // pf requires translation rules before filter rules, and Apple's stock
3867        // file ends with a filter anchor. Appending there makes `pfctl -f` fail
3868        // with "Rules must be in order", so the block goes after the last
3869        // `rdr-anchor` instead.
3870        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        // Re-running replaces in place rather than adding a second block.
3892        let again = splice_pf_block(&out, block);
3893        assert_eq!(again, out);
3894
3895        // And undo restores Apple's file byte for byte.
3896        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        // A customised file with filter rules but no translation anchor to
3902        // follow: appending would put our rdr after them and `pfctl -f` would
3903        // reject the file, so the block goes in front of the first filter.
3904        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        // With neither translation nor filter rules, the end is fine.
3919        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        // What an older pitchfork left behind: the block appended after the
3927        // filter anchor, where pf rejects it.
3928        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        // Not "already done" just because the markers are present somewhere.
3940        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        // `plan` runs `plan_pac` instead of `plan_resolver`, so a PAC setup
3960        // never writes `/etc/resolver/<tld>` or a systemd-resolved drop-in.
3961        // Undo must not offer them up: listing them plans a privileged removal
3962        // that never applied, and a reconciling re-run reads the undo plan as
3963        // "what this configuration installed" and acts on it.
3964        for platform in [Platform::MacOs, Platform::Linux] {
3965            // An empty directory, so the macOS sweep for files carrying our
3966            // header cannot pick up whatever a developer's `/etc/resolver`
3967            // happens to hold.
3968            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        // Without the recorded value undo can only switch the proxy off, so a
3998        // machine with a corporate PAC URL would lose it permanently.
3999        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        // The URL is restored before the switch. `-setautoproxyurl` turns the
4019        // switch on as a side effect, so writing it last would re-enable a
4020        // proxy the recorded state says was off.
4021        assert!(off < url && url < state, "restore steps are out of order");
4022
4023        // And a recorded `off` really does end up off: the last word on the
4024        // switch is the recorded value, not the side effect of the URL write.
4025        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        // GNOME records the mode rather than on/off, and restores it the same
4039        // way round.
4040        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        // With nothing recorded, undo is exactly what it was before.
4060        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        // These are handed back to `networksetup` and `gsettings` as argv. A
4075        // value starting with `-` would be read as a flag, and a state outside
4076        // the tool's vocabulary is not something this file wrote.
4077        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        // These are shared system files, so a partial write is worse than no
4122        // write: the system goes on reading whatever is there.
4123        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        // Nothing is left beside it. A stray temporary in `/etc/resolver`
4131        // would be read as another resolver file.
4132        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        // A directory that does not exist yet is created, not an error.
4141        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        // `--undo` already refuses to delete a file without our header. Until
4152        // now setting up had no matching check, so it would take over another
4153        // tool's `/etc/resolver/test` and then decline to clean up after
4154        // itself — protection in the removal direction only.
4155        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        // One pitchfork wrote is replaced as usual, so a re-run still works.
4182        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        // And a path with nothing at it is written without argument.
4190        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        // `is_ca_trusted` reports an unreadable store as "not trusted", which
4207        // is the safe reading when deciding whether to *add* trust but the
4208        // wrong one here: it would mark the removal already done and leave the
4209        // CA trusted with nothing left to take it out.
4210        assert!(
4211            !untrust_already_done(true, None),
4212            "an unreadable trust store cancelled the removal"
4213        );
4214        // A missing PEM does not cancel it either: the certificate can still be
4215        // installed in the store under its own name.
4216        assert!(!untrust_already_done(false, Some(false)));
4217        assert!(!untrust_already_done(false, None));
4218        assert!(!untrust_already_done(false, Some(true)));
4219        // Still trusted, so there is work to do.
4220        assert!(!untrust_already_done(true, Some(true)));
4221        // The one case that is genuinely done.
4222        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        // Two opposite mistakes, one decision.
4228        //
4229        // Granting goes through `sudo setcap`, whose secure_path includes
4230        // `/usr/sbin`; an ordinary user's PATH on Debian does not. So the
4231        // probe failing outright is likely on exactly the machines where the
4232        // capability was granted, and reading that as "nothing there" would
4233        // have `--undo` report success while the binary keeps the right to
4234        // bind privileged ports.
4235        //
4236        // And `getcap` exits 0 printing nothing for a file that carries no
4237        // capabilities, which is the ordinary state after an upgrade replaces
4238        // the binary. Reading *that* as "could not ask" makes `--undo` refuse
4239        // to finish when there is nothing left to do.
4240        let bind = BIND_CAPABILITY.to_string();
4241        let other = "cap_net_raw".to_string();
4242
4243        // Could not ask: not done, whatever else is true.
4244        assert!(
4245            !revoke_already_done(None),
4246            "an unreadable probe was taken for absent"
4247        );
4248
4249        // Definitely nothing of ours there: done.
4250        assert!(revoke_already_done(Some(&[])));
4251        assert!(revoke_already_done(Some(std::slice::from_ref(&other))));
4252
4253        // Definitely there: not done.
4254        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        // A granted capability, and the empty output that means the file
4261        // carries none.
4262        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        // Only genuinely empty output means "no capabilities". Output that
4272        // says something we cannot attribute to this path — a binary whose
4273        // path is not valid UTF-8, or a `getcap` that words things
4274        // differently — must not be read as an empty list, because that would
4275        // have `--undo` skip the revocation and leave the capability in place.
4276        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        // The listener, `proxy doctor` and the URL builder all reject a port
4295        // outside 1..=65535. Setup used to fall back to 443, which configures
4296        // the machine for a port nothing will ever bind — and records it, so
4297        // undo acts on it too.
4298        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        // `setcap` writes the whole set, so granting ours on its own drops
4315        // anything else the binary carried, silently. The revoke side already
4316        // guards against that hazard; the grant has to as well.
4317        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        // Ours already there is a re-grant of the same set.
4322        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        // Unknown is not empty.
4328        assert!(check_grant(bin, None).is_err());
4329
4330        // And the plan uses the action that checks, not a bare `setcap`.
4331        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        // The mirror of the forward guard. `apply` continues past a failed
4344        // step, so a `RemoveBlock` that did not go through would be followed
4345        // by the deletion of the file that block names, leaving `/etc/pf.conf`
4346        // pointing at a missing anchor and breaking every later `pfctl -f`.
4347        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        // A referrer that cannot be read is not evidence that the reference is
4375        // gone. Guessing wrong here is the whole failure this guard prevents,
4376        // so an unreadable file keeps the anchor.
4377        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                // A directory, so reading it as a file fails.
4384                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        // A referrer that is not there at all cannot reference anything.
4396        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        // And with the block gone the ordinary removal goes through.
4409        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    /// `execute`'s error as a string, for asserting on the reason.
4416    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        // `apply` continues past a failed step, so a refused or failed write of
4425        // the pf anchor would otherwise be followed by a splice that points
4426        // `/etc/pf.conf` at a file pitchfork does not control — either missing,
4427        // which breaks every later `pfctl -f` including at boot, or somebody
4428        // else's, which makes pf load their rules under our name. Both survive
4429        // the run that caused them.
4430        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        // A file at that path that pitchfork did not write is not good enough
4459        // either: the write of the anchor refuses to replace it, so splicing a
4460        // reference would point pf at somebody else's rules.
4461        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        // Once our own anchor is in place the same step goes through.
4470        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        // Removing it is reported as already done, and the file survives.
4486        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        // One we wrote is removed.
4497        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        // Written by an earlier setup, before `proxy.tld` changed.
4512        let old = dir.path().join("oldtld");
4513        std::fs::write(&old, macos_resolver_file(15353)).unwrap();
4514        // Not ours.
4515        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        // A current systemd says nothing.
4535        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        // Every guard tried here has at some point skipped a restart that was
4546        // needed, and the symptom is setup reporting success while names do not
4547        // resolve. Restarting always is the behaviour worth pinning.
4548        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        // And the cost is stated in the plan the user approves.
4558        assert!(restart.summary.contains("interrupts DNS"));
4559    }
4560
4561    #[test]
4562    fn pf_keywords_are_whole_words_not_prefixes() {
4563        // Macros are common in custom rulesets and are not rules.
4564        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        // Real rules still classify.
4569        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        // Options are neither, so the block is never placed before them.
4575        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        // `nat_if` must not count as a translation rule, or the block would
4582        // land before the `set` options and after nothing useful.
4583        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        // A routing-only domain, so other lookups keep using the link's servers.
4629        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        // A genuine root login (a container, say) runs the supervisor as root too.
4643        assert_eq!(sudo_user(true, None), None);
4644        assert_eq!(sudo_user(true, Some("root".into())), None);
4645        // SUDO_USER left in the environment of an unprivileged process.
4646        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        // LAN mode's TLD is `local`; a PAC file for it would capture every
4652        // `*.local` URL in the browser, not only pitchfork's.
4653        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        // pfctl -E prints the token among its other chatter.
4678        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        // Setup takes the reference, undo gives it back, and a re-run that
4688        // still redirects through pf does not release it in between: the two
4689        // steps share a resource, so reconcile leaves the release out.
4690        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        // Switching to PAC drops the redirect, so the reference goes too.
4712        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        // With `proxy.host = "::1"` the resolver answers AAAA only, so clients
4725        // arrive over IPv6 and an IPv4-only redirect never sees them.
4726        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        // The IPv4 default is unchanged.
4765        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}