use std::path::{Path, PathBuf};
use crate::Result;
const MARKER_START: &str = "# pitchfork-start";
const MARKER_END: &str = "# pitchfork-end";
const OWNED_HEADER: &str = "# Managed by pitchfork (pitchfork proxy setup)";
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Platform {
MacOs,
Linux,
Other,
}
impl Platform {
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Platform::MacOs
} else if cfg!(target_os = "linux") {
Platform::Linux
} else {
Platform::Other
}
}
}
fn default_generated_ca() -> PathBuf {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SetupContext {
pub platform: Platform,
pub tld: String,
pub dns_port: u16,
pub proxy_port: u16,
pub https: bool,
pub dns_enabled: bool,
pub pac: bool,
pub systemd_resolved: bool,
pub systemd_version: Option<u32>,
pub lan: bool,
pub contact_host: String,
pub ca_path: PathBuf,
pub ca_trusted: bool,
pub custom_cert: bool,
#[serde(default = "default_generated_ca")]
pub generated_ca: PathBuf,
pub binary: PathBuf,
pub resolver_dir: PathBuf,
pub resolved_dropin_dir: PathBuf,
pub pf_conf: PathBuf,
pub pf_anchor: PathBuf,
pub network_services: Vec<String>,
pub gnome: bool,
#[serde(default)]
pub prior_auto_proxy: Vec<PriorAutoProxy>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PriorAutoProxy {
pub target: String,
pub url: String,
pub state: String,
}
impl SetupContext {
fn standard_port(&self) -> u16 {
if self.https { 443 } else { 80 }
}
fn needs_port_redirect(&self) -> bool {
self.proxy_port >= 1024 && self.proxy_port != self.standard_port()
}
fn resolver_file(&self) -> PathBuf {
self.resolver_dir.join(&self.tld)
}
fn resolved_dropin(&self) -> PathBuf {
self.resolved_dropin_dir.join("pitchfork.conf")
}
fn redirect_target(&self) -> &str {
self.contact_host
.trim_start_matches('[')
.trim_end_matches(']')
}
fn ipv6(&self) -> bool {
self.redirect_target().contains(':')
}
fn uses_pac(&self) -> bool {
self.pac && !self.lan
}
fn pac_url(&self) -> String {
super::pac::url(&self.contact_host, self.proxy_port)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Probe {
pub argv: Vec<String>,
pub expect: Vec<ProbeExpect>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProbeExpect {
Contains(String),
}
impl Probe {
fn status(argv: Vec<String>) -> Self {
Probe {
argv,
expect: vec![],
}
}
fn output(argv: Vec<String>, expect: impl Into<String>) -> Self {
Probe {
argv,
expect: vec![ProbeExpect::Contains(expect.into())],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
WriteFile {
path: PathBuf,
content: String,
sudo: bool,
},
RemoveFile {
path: PathBuf,
sudo: bool,
still_referenced_by: Option<PathBuf>,
},
EnsureBlock {
path: PathBuf,
content: String,
sudo: bool,
pf_order: bool,
requires: Option<PathBuf>,
},
RemoveBlock { path: PathBuf, sudo: bool },
Run {
argv: Vec<String>,
sudo: bool,
skip_if: Option<Probe>,
},
RunIfPresent {
probe: Probe,
argv: Vec<String>,
sudo: bool,
},
RevokeBindCapability { binary: PathBuf },
GrantBindCapability { binary: PathBuf },
EnablePf { pf_conf: PathBuf, token: PathBuf },
ReleasePf { pf_conf: PathBuf, token: PathBuf },
GenerateCa { cert: PathBuf, key: PathBuf },
TrustCa { path: PathBuf },
UntrustCa { path: PathBuf, sudo: bool },
Note,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Resource {
File(PathBuf),
Block(PathBuf),
Redirect { from: u16, to: u16, ipv6: bool },
BindCapability(PathBuf),
TrustedCa(PathBuf),
AutoProxy { service: String, url: String },
ServiceReload(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Step {
pub summary: String,
pub action: Action,
pub resource: Option<Resource>,
}
impl Step {
fn note(summary: impl Into<String>) -> Self {
Step {
summary: summary.into(),
action: Action::Note,
resource: None,
}
}
pub fn needs_sudo(&self) -> bool {
match &self.action {
Action::WriteFile { sudo, .. }
| Action::RemoveFile { sudo, .. }
| Action::EnsureBlock { sudo, .. }
| Action::RemoveBlock { sudo, .. }
| Action::Run { sudo, .. }
| Action::RunIfPresent { sudo, .. } => *sudo,
Action::RevokeBindCapability { .. }
| Action::GrantBindCapability { .. }
| Action::EnablePf { .. }
| Action::ReleasePf { .. } => true,
Action::UntrustCa { sudo, .. } => *sudo,
Action::GenerateCa { .. } | Action::TrustCa { .. } | Action::Note => false,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
pub steps: Vec<Step>,
pub manual: Vec<String>,
}
impl Plan {
pub fn describe(&self) -> Vec<String> {
self.steps
.iter()
.map(|s| {
if s.needs_sudo() {
format!("[sudo] {}", s.summary)
} else {
s.summary.clone()
}
})
.collect()
}
pub fn needs_sudo(&self) -> bool {
self.steps.iter().any(Step::needs_sudo)
}
pub fn is_empty(&self) -> bool {
self.steps.iter().all(|s| s.action == Action::Note)
}
}
fn macos_resolver_file(dns_port: u16) -> String {
format!("{OWNED_HEADER}\nnameserver 127.0.0.1\nport {dns_port}\n")
}
fn resolved_dropin(tld: &str, dns_port: u16) -> String {
format!("{OWNED_HEADER}\n[Resolve]\nDNS=127.0.0.1:{dns_port}\nDomains=~{tld}\n")
}
fn pf_anchor_rules(standard_port: u16, proxy_port: u16, target: &str) -> String {
let family = if target.contains(':') {
"inet6"
} else {
"inet"
};
format!(
"{OWNED_HEADER}\n\
rdr pass on lo0 {family} proto tcp from any to any port {standard_port} -> {target} port {proxy_port}\n"
)
}
pub fn validate_proxy_port(port: i64) -> Result<()> {
if u16::try_from(port).ok().filter(|&p| p > 0).is_some() {
return Ok(());
}
miette::bail!(
"proxy.port is {port}, which is not a usable port. \
Set it between 1 and 65535 before running setup."
)
}
pub fn validate_tld(tld: &str) -> Result<()> {
if !super::pac::is_valid_tld(tld) {
miette::bail!(
"proxy.tld {tld:?} is not a valid host name suffix.\n\
It must be a DNS suffix such as `localhost` or `test`: \
dot-separated labels of ASCII letters, digits and `-`, each at \
most 63 bytes and not starting or ending with `-`.\n\
`proxy setup` writes it into privileged system files, so it is \
refused rather than escaped."
);
}
Ok(())
}
pub fn plan(ctx: &SetupContext) -> Plan {
let mut plan = Plan::default();
if ctx.uses_pac() {
plan_pac(ctx, &mut plan);
} else {
plan_resolver(ctx, &mut plan);
}
plan_ca(ctx, &mut plan);
plan_ports(ctx, &mut plan);
plan
}
fn plan_resolver(ctx: &SetupContext, plan: &mut Plan) {
if !ctx.dns_enabled {
plan.steps.push(Step::note(
"proxy.dns is false, so no resolver is running — skipping resolver setup",
));
return;
}
if ctx.lan {
plan.steps.push(Step::note(
"LAN mode resolves *.local over mDNS — leaving the .local namespace alone",
));
return;
}
match ctx.platform {
Platform::MacOs => {
plan.steps.push(Step {
summary: format!(
"write {} pointing *.{} at 127.0.0.1:{}",
ctx.resolver_file().display(),
ctx.tld,
ctx.dns_port
),
action: Action::WriteFile {
path: ctx.resolver_file(),
content: macos_resolver_file(ctx.dns_port),
sudo: true,
},
resource: Some(Resource::File(ctx.resolver_file())),
});
if ctx.tld.eq_ignore_ascii_case("localhost") {
plan.manual.push(
concat!(
"Note: /etc/resolver/localhost hands *.localhost lookups to ",
"pitchfork, so subdomains such as api.localhost resolve only while ",
"the supervisor is running. Plain `localhost` keeps resolving from ",
"/etc/hosts either way. Undo this with `pitchfork proxy setup --undo`.",
)
.to_string(),
);
}
}
Platform::Linux if ctx.systemd_resolved && ctx.tld.eq_ignore_ascii_case("localhost") => {
plan.steps.push(Step::note(
"systemd-resolved already answers *.localhost with 127.0.0.1 — no resolver change needed",
));
}
Platform::Linux if ctx.systemd_resolved => {
plan.steps.push(Step {
summary: format!(
"write {} routing *.{} to 127.0.0.1:{}",
ctx.resolved_dropin().display(),
ctx.tld,
ctx.dns_port
),
action: Action::WriteFile {
path: ctx.resolved_dropin(),
content: resolved_dropin(&ctx.tld, ctx.dns_port),
sudo: true,
},
resource: Some(Resource::File(ctx.resolved_dropin())),
});
plan.steps.push(Step {
summary: "restart systemd-resolved to pick up the route (interrupts DNS briefly)"
.to_string(),
action: Action::Run {
argv: vec![
"systemctl".into(),
"restart".into(),
"systemd-resolved".into(),
],
sudo: true,
skip_if: None,
},
resource: Some(Resource::ServiceReload("systemd-resolved".into())),
});
if let Some(v) = ctx.systemd_version
&& v < 247
{
plan.manual.push(format!(
"Warning: systemd {v} is too old to route a domain at a resolver on a \
non-standard port. That needs 246 for `DNS=127.0.0.1:{port}` and 247 \
for `Domains=~{tld}`.\n\
The drop-in will be written but will not take effect. Use \
`pitchfork proxy setup --pac`, or point dnsmasq at \
127.0.0.1#{port} and make it your system resolver.",
port = ctx.dns_port,
tld = ctx.tld
));
}
}
Platform::Linux => {
plan.manual.push(format!(
"systemd-resolved is not active, so pitchfork cannot route *.{tld} for you.\n\
Point a local resolver at pitchfork instead, for example with dnsmasq:\n\
\x20 # /etc/dnsmasq.d/pitchfork\n\
\x20 server=/{tld}/127.0.0.1#{port}\n\
then make dnsmasq your system resolver and restart it.\n\
Alternatively run `pitchfork proxy setup --pac`, which needs no root access.",
tld = ctx.tld,
port = ctx.dns_port
));
}
Platform::Other => {
plan.manual.push(format!(
"pitchfork cannot configure this platform's resolver automatically.\n\
Point your system resolver at 127.0.0.1:{port} for *.{tld}, \
or run `pitchfork proxy setup --pac`.",
port = ctx.dns_port,
tld = ctx.tld
));
}
}
}
fn plan_pac(ctx: &SetupContext, plan: &mut Plan) {
let url = ctx.pac_url();
plan.steps.push(Step::note(format!(
"the supervisor serves the PAC file at {url} while the proxy is running"
)));
match ctx.platform {
Platform::MacOs if !ctx.network_services.is_empty() => {
for service in &ctx.network_services {
plan.steps.push(Step {
summary: format!("set the automatic proxy URL for \"{service}\" to {url}"),
action: Action::Run {
argv: vec![
"networksetup".into(),
"-setautoproxyurl".into(),
service.clone(),
url.clone(),
],
sudo: false,
skip_if: None,
},
resource: Some(Resource::AutoProxy {
service: service.clone(),
url: url.clone(),
}),
});
}
}
Platform::MacOs => {
plan.manual.push(format!(
"No active network services were found, so the automatic proxy URL was not set.\n\
Set it by hand in System Settings → Network → Details → Proxies → \
Automatic proxy configuration, using {url}."
));
}
Platform::Linux if ctx.gnome => {
plan.steps.push(Step {
summary: format!("set the GNOME automatic proxy URL to {url}"),
action: Action::Run {
argv: vec![
"gsettings".into(),
"set".into(),
"org.gnome.system.proxy".into(),
"autoconfig-url".into(),
url.clone(),
],
sudo: false,
skip_if: None,
},
resource: Some(Resource::AutoProxy {
service: "gnome".into(),
url: url.clone(),
}),
});
plan.steps.push(Step {
summary: "switch the GNOME proxy mode to automatic".to_string(),
action: Action::Run {
argv: vec![
"gsettings".into(),
"set".into(),
"org.gnome.system.proxy".into(),
"mode".into(),
"auto".into(),
],
sudo: false,
skip_if: None,
},
resource: None,
});
}
_ => {
plan.manual.push(format!(
"pitchfork cannot set this system's automatic proxy URL for you.\n\
Point your browser or desktop proxy settings at {url}."
));
}
}
}
fn plan_ca(ctx: &SetupContext, plan: &mut Plan) {
if !ctx.https {
return;
}
if ctx.custom_cert {
plan.steps.push(Step::note(
"proxy.tls_cert is set, so pitchfork serves your certificate and installs no CA",
));
return;
}
if ctx.ca_trusted {
plan.steps.push(Step {
summary: format!(
"the pitchfork CA at {} is already trusted",
ctx.ca_path.display()
),
action: Action::Note,
resource: Some(Resource::TrustedCa(ctx.ca_path.clone())),
});
return;
}
plan.steps.push(Step {
summary: format!("generate the pitchfork CA at {}", ctx.ca_path.display()),
action: Action::GenerateCa {
cert: ctx.ca_path.clone(),
key: ctx.ca_path.with_file_name("ca-key.pem"),
},
resource: None,
});
let summary = format!(
"install the pitchfork CA at {} into the system trust store",
ctx.ca_path.display()
);
let action = if ctx.platform == Platform::Linux {
Action::Run {
argv: vec![
ctx.binary.to_string_lossy().into_owned(),
"proxy".into(),
"trust".into(),
"--cert".into(),
ctx.ca_path.to_string_lossy().into_owned(),
],
sudo: true,
skip_if: None,
}
} else {
Action::TrustCa {
path: ctx.ca_path.clone(),
}
};
plan.steps.push(Step {
summary,
action,
resource: Some(Resource::TrustedCa(ctx.ca_path.clone())),
});
}
fn plan_ports(ctx: &SetupContext, plan: &mut Plan) {
let standard = ctx.standard_port();
if ctx.proxy_port < 1024 {
match ctx.platform {
Platform::Linux => plan.steps.push(Step {
summary: format!(
"grant {} permission to bind ports below 1024 (cap_net_bind_service)",
ctx.binary.display()
),
action: Action::GrantBindCapability {
binary: ctx.binary.clone(),
},
resource: Some(Resource::BindCapability(ctx.binary.clone())),
}),
_ => plan.manual.push(format!(
"proxy.port is {port}, which an unprivileged process cannot bind on macOS, \
and pitchfork will not run the supervisor as root.\n\
Set an unprivileged port and re-run setup, which then redirects \
{port} to it through pf:\n\
\x20 pitchfork settings set proxy.port {suggested}",
port = ctx.proxy_port,
suggested = if ctx.https { 8443 } else { 8080 }
)),
}
return;
}
if !ctx.needs_port_redirect() {
plan.steps.push(Step::note(format!(
"the proxy listens on port {}, which needs no redirect",
ctx.proxy_port
)));
return;
}
if ctx.uses_pac() {
plan.steps.push(Step::note(format!(
"the PAC file sends requests directly to port {}, so no port redirect is needed",
ctx.proxy_port
)));
return;
}
match ctx.platform {
Platform::MacOs => {
plan.steps.push(Step {
summary: format!(
"write {} redirecting port {standard} to {}",
ctx.pf_anchor.display(),
ctx.proxy_port
),
action: Action::WriteFile {
path: ctx.pf_anchor.clone(),
content: pf_anchor_rules(standard, ctx.proxy_port, ctx.redirect_target()),
sudo: true,
},
resource: Some(Resource::File(ctx.pf_anchor.clone())),
});
plan.steps.push(Step {
summary: format!("load the pitchfork anchor into {}", ctx.pf_conf.display()),
action: Action::EnsureBlock {
path: ctx.pf_conf.clone(),
content: format!(
"rdr-anchor \"pitchfork\"\nload anchor \"pitchfork\" from \"{}\"",
ctx.pf_anchor.display()
),
sudo: true,
pf_order: true,
requires: Some(ctx.pf_anchor.clone()),
},
resource: Some(Resource::Block(ctx.pf_conf.clone())),
});
plan.steps.push(Step {
summary: "enable pf and load the new rules".to_string(),
action: Action::EnablePf {
pf_conf: ctx.pf_conf.clone(),
token: pf_token_path(),
},
resource: Some(Resource::ServiceReload("pf".into())),
});
}
Platform::Linux => {
plan.steps.push(Step {
summary: format!(
"redirect loopback traffic for port {standard} to {} ({})",
ctx.proxy_port,
iptables_program(ctx.ipv6())
),
action: Action::Run {
argv: iptables_redirect_argv("-A", standard, ctx.proxy_port, ctx.ipv6()),
sudo: true,
skip_if: Some(Probe::status(iptables_redirect_argv(
"-C",
standard,
ctx.proxy_port,
ctx.ipv6(),
))),
},
resource: Some(Resource::Redirect {
from: standard,
to: ctx.proxy_port,
ipv6: ctx.ipv6(),
}),
});
}
Platform::Other => plan.manual.push(format!(
"Redirect port {standard} to {} yourself, or use the port in the URL.",
ctx.proxy_port
)),
}
}
fn iptables_program(ipv6: bool) -> &'static str {
if ipv6 { "ip6tables" } else { "iptables" }
}
fn iptables_redirect_argv(op: &str, from: u16, to: u16, ipv6: bool) -> Vec<String> {
[
iptables_program(ipv6),
"-t",
"nat",
op,
"OUTPUT",
"-p",
"tcp",
"-o",
"lo",
"--dport",
&from.to_string(),
"-j",
"REDIRECT",
"--to-ports",
&to.to_string(),
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn managed_resolver_files(dir: &Path, tld: &str, include_current: bool) -> Vec<PathBuf> {
let current = dir.join(tld);
let mut found = if include_current {
vec![current.clone()]
} else {
vec![]
};
let Ok(entries) = std::fs::read_dir(dir) else {
return found;
};
let mut others: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p != ¤t && p.is_file() && is_managed_file(p, false))
.collect();
others.sort();
found.extend(others);
found
}
fn pf_token_path() -> PathBuf {
crate::env::PITCHFORK_STATE_DIR
.join("proxy")
.join("pf-token")
}
fn record_path() -> PathBuf {
crate::env::PITCHFORK_STATE_DIR
.join("proxy")
.join("setup.toml")
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct SetupRecords {
#[serde(default)]
setups: Vec<SetupContext>,
}
const MAX_RECORDS: usize = 32;
fn undo_key(ctx: &SetupContext) -> String {
plan_undo(ctx).describe().join("\n")
}
pub struct SetupLock(#[allow(dead_code)] Option<xx::fslock::LockFile>);
pub fn lock_setup() -> miette::Result<SetupLock> {
let path = record_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| miette::miette!("Could not create {}: {e}", parent.display()))?;
}
let lock = xx::fslock::get(&path, false).map_err(|e| {
miette::miette!(
"Could not lock {}: {e}\n\
`pitchfork proxy setup` changes system configuration and will not \
run without the lock.",
path.display()
)
})?;
Ok(SetupLock(lock))
}
pub fn save_record(ctx: &SetupContext) {
let path = record_path();
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
let records = SetupRecords {
setups: merged_records(load_records(), ctx),
};
let Ok(text) = toml::to_string_pretty(&records) else {
return;
};
if let Err(e) = write_file(&path, &text, false) {
log::debug!(
"Could not record the setup state at {}: {e}",
path.display()
);
}
}
fn merged_records(existing: Vec<SetupContext>, ctx: &SetupContext) -> Vec<SetupContext> {
let key = undo_key(ctx);
let mut merged = existing;
merged.retain(|r| undo_key(r) != key);
merged.push(ctx.clone());
if merged.len() > MAX_RECORDS {
let excess = merged.len() - MAX_RECORDS;
merged.drain(..excess);
}
merged
}
fn sanitize_record(mut ctx: SetupContext) -> Option<SetupContext> {
if let Err(e) = validate_tld(&ctx.tld) {
log::warn!("Ignoring a setup record with an unusable proxy.tld: {e}");
return None;
}
let fixed = fixed_paths();
ctx.resolver_dir = fixed.resolver_dir;
ctx.resolved_dropin_dir = fixed.resolved_dropin_dir;
ctx.pf_conf = fixed.pf_conf;
ctx.pf_anchor = fixed.pf_anchor;
ctx.generated_ca = default_generated_ca();
ctx.binary = current_binary();
ctx.prior_auto_proxy.retain(|p| {
let plain = |v: &str| !v.is_empty() && !v.starts_with('-');
let known_state = matches!(p.state.as_str(), "on" | "off" | "none" | "auto" | "manual");
let usable_url = p.url.starts_with("http://")
|| p.url.starts_with("https://")
|| p.url.starts_with("file://");
let ok = plain(&p.target) && plain(&p.url) && known_state && usable_url;
if !ok {
log::warn!(
"Ignoring an unusable recorded automatic-proxy value for {:?}",
p.target
);
}
ok
});
Some(ctx)
}
fn current_binary() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("pitchfork"))
}
struct FixedPaths {
resolver_dir: PathBuf,
resolved_dropin_dir: PathBuf,
pf_conf: PathBuf,
pf_anchor: PathBuf,
}
fn fixed_paths() -> FixedPaths {
FixedPaths {
resolver_dir: PathBuf::from("/etc/resolver"),
resolved_dropin_dir: PathBuf::from("/etc/systemd/resolved.conf.d"),
pf_conf: PathBuf::from("/etc/pf.conf"),
pf_anchor: PathBuf::from("/etc/pf.anchors/pitchfork"),
}
}
pub fn load_records() -> Vec<SetupContext> {
let Ok(text) = std::fs::read_to_string(record_path()) else {
return vec![];
};
if let Ok(records) = toml::from_str::<SetupRecords>(&text)
&& !records.setups.is_empty()
{
return records
.setups
.into_iter()
.filter_map(sanitize_record)
.collect();
}
match toml::from_str::<SetupContext>(&text) {
Ok(ctx) => sanitize_record(ctx).into_iter().collect(),
Err(e) => {
log::debug!("No usable setup record: {e}");
vec![]
}
}
}
pub fn clear_record() {
let path = record_path();
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::debug!("Could not clear {}: {e}", path.display());
}
}
pub fn plan_undo_from(current: Option<&SetupContext>, recorded: &[SetupContext]) -> Plan {
let mut plan = Plan::default();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for ctx in recorded.iter().rev().chain(current) {
let part = plan_undo(ctx);
for step in part.steps {
if seen.insert(step.summary.clone()) {
plan.steps.push(step);
}
}
for note in part.manual {
if !plan.manual.contains(¬e) {
plan.manual.push(note);
}
}
}
plan
}
pub fn plan_with_reconcile(ctx: &SetupContext, recorded: &[SetupContext]) -> Plan {
let forward = plan(ctx);
let wanted: std::collections::HashSet<&Resource> = forward
.steps
.iter()
.filter_map(|s| s.resource.as_ref())
.collect();
let mut reconciled = Plan::default();
let mut seen: std::collections::HashSet<Resource> = std::collections::HashSet::new();
for old in recorded {
for step in plan_undo(old).steps {
let Some(resource) = step.resource.clone() else {
continue;
};
if wanted.contains(&resource) || !seen.insert(resource) {
continue;
}
reconciled.steps.push(step);
}
}
reconciled.steps.extend(forward.steps);
reconciled.manual = forward.manual;
reconciled
}
pub fn plan_undo(ctx: &SetupContext) -> Plan {
let mut plan = Plan::default();
let wrote_resolver = ctx.dns_enabled && !ctx.lan && !ctx.uses_pac();
let wrote_dropin =
wrote_resolver && ctx.systemd_resolved && !ctx.tld.eq_ignore_ascii_case("localhost");
match ctx.platform {
Platform::MacOs => {
for path in managed_resolver_files(&ctx.resolver_dir, &ctx.tld, wrote_resolver) {
plan.steps.push(Step {
summary: format!("remove {}", path.display()),
resource: Some(Resource::File(path.clone())),
action: Action::RemoveFile {
path,
sudo: true,
still_referenced_by: None,
},
});
}
}
Platform::Linux if wrote_dropin => {
plan.steps.push(Step {
summary: format!("remove {}", ctx.resolved_dropin().display()),
action: Action::RemoveFile {
path: ctx.resolved_dropin(),
sudo: true,
still_referenced_by: None,
},
resource: Some(Resource::File(ctx.resolved_dropin())),
});
if ctx.systemd_resolved {
plan.steps.push(Step {
summary: "restart systemd-resolved to drop the route".to_string(),
action: Action::Run {
argv: vec![
"systemctl".into(),
"restart".into(),
"systemd-resolved".into(),
],
sudo: true,
skip_if: None,
},
resource: Some(Resource::ServiceReload("systemd-resolved".into())),
});
}
}
Platform::Linux | Platform::Other => {}
}
let granted_capability = ctx.proxy_port < 1024;
let installed_redirect = !granted_capability && ctx.needs_port_redirect() && !ctx.uses_pac();
match ctx.platform {
Platform::MacOs if installed_redirect => {
plan.steps.push(Step {
summary: format!("remove the pitchfork anchor from {}", ctx.pf_conf.display()),
action: Action::RemoveBlock {
path: ctx.pf_conf.clone(),
sudo: true,
},
resource: Some(Resource::Block(ctx.pf_conf.clone())),
});
plan.steps.push(Step {
summary: format!("remove {}", ctx.pf_anchor.display()),
action: Action::RemoveFile {
path: ctx.pf_anchor.clone(),
sudo: true,
still_referenced_by: Some(ctx.pf_conf.clone()),
},
resource: Some(Resource::File(ctx.pf_anchor.clone())),
});
plan.steps.push(Step {
summary: format!(
"reload pf rules from {} and release pitchfork's hold on pf",
ctx.pf_conf.display()
),
action: Action::ReleasePf {
pf_conf: ctx.pf_conf.clone(),
token: pf_token_path(),
},
resource: Some(Resource::ServiceReload("pf".into())),
});
}
Platform::Linux => {
if installed_redirect {
plan.steps.push(Step {
summary: format!(
"drop the {} redirect from port {} to {}",
iptables_program(ctx.ipv6()),
ctx.standard_port(),
ctx.proxy_port
),
action: Action::RunIfPresent {
probe: Probe::status(iptables_redirect_argv(
"-C",
ctx.standard_port(),
ctx.proxy_port,
ctx.ipv6(),
)),
argv: iptables_redirect_argv(
"-D",
ctx.standard_port(),
ctx.proxy_port,
ctx.ipv6(),
),
sudo: true,
},
resource: Some(Resource::Redirect {
from: ctx.standard_port(),
to: ctx.proxy_port,
ipv6: ctx.ipv6(),
}),
});
}
if granted_capability {
plan.steps.push(Step {
summary: format!("revoke cap_net_bind_service from {}", ctx.binary.display()),
action: Action::RevokeBindCapability {
binary: ctx.binary.clone(),
},
resource: Some(Resource::BindCapability(ctx.binary.clone())),
});
}
}
Platform::MacOs | Platform::Other => {}
}
match ctx.platform {
Platform::MacOs => {
for service in &ctx.network_services {
plan.steps.push(Step {
summary: format!(
"turn off the automatic proxy URL {} for \"{service}\"",
ctx.pac_url()
),
action: Action::RunIfPresent {
probe: Probe::output(
vec![
"networksetup".into(),
"-getautoproxyurl".into(),
service.clone(),
],
ctx.pac_url(),
),
argv: vec![
"networksetup".into(),
"-setautoproxystate".into(),
service.clone(),
"off".into(),
],
sudo: false,
},
resource: Some(Resource::AutoProxy {
service: service.clone(),
url: ctx.pac_url(),
}),
});
for prior in ctx.prior_auto_proxy.iter().filter(|p| &p.target == service) {
let geturl = vec![
"networksetup".into(),
"-getautoproxyurl".into(),
service.clone(),
];
for (summary, expect, argv) in [
(
format!(
"restore the automatic proxy URL for \"{service}\" to {}",
prior.url
),
ctx.pac_url(),
vec![
"networksetup".into(),
"-setautoproxyurl".into(),
service.clone(),
prior.url.clone(),
],
),
(
format!(
"restore the automatic proxy switch for \"{service}\" to {}",
prior.state
),
prior.url.clone(),
vec![
"networksetup".into(),
"-setautoproxystate".into(),
service.clone(),
prior.state.clone(),
],
),
] {
plan.steps.push(Step {
summary,
action: Action::RunIfPresent {
probe: Probe::output(geturl.clone(), expect),
argv,
sudo: false,
},
resource: None,
});
}
}
}
}
Platform::Linux if ctx.gnome => {
plan.steps.push(Step {
summary: "switch the GNOME proxy mode back to none".to_string(),
action: Action::RunIfPresent {
probe: Probe::output(
vec![
"gsettings".into(),
"get".into(),
"org.gnome.system.proxy".into(),
"autoconfig-url".into(),
],
ctx.pac_url(),
),
argv: vec![
"gsettings".into(),
"set".into(),
"org.gnome.system.proxy".into(),
"mode".into(),
"none".into(),
],
sudo: false,
},
resource: Some(Resource::AutoProxy {
service: "gnome".into(),
url: ctx.pac_url(),
}),
});
for prior in ctx.prior_auto_proxy.iter().filter(|p| p.target == "gnome") {
let geturl = vec![
"gsettings".into(),
"get".into(),
"org.gnome.system.proxy".into(),
"autoconfig-url".into(),
];
for (summary, expect, key, value) in [
(
format!("restore the GNOME automatic proxy URL to {}", prior.url),
ctx.pac_url(),
"autoconfig-url",
prior.url.clone(),
),
(
format!("restore the GNOME proxy mode to {}", prior.state),
prior.url.clone(),
"mode",
prior.state.clone(),
),
] {
plan.steps.push(Step {
summary,
action: Action::RunIfPresent {
probe: Probe::output(geturl.clone(), expect),
argv: vec![
"gsettings".into(),
"set".into(),
"org.gnome.system.proxy".into(),
key.into(),
value,
],
sudo: false,
},
resource: None,
});
}
}
}
_ => {}
}
if ctx.https && !ctx.generated_ca.as_os_str().is_empty() {
plan.steps.push(Step {
summary: format!(
"remove the pitchfork CA at {} from the system trust store",
ctx.generated_ca.display()
),
action: Action::UntrustCa {
path: ctx.generated_ca.clone(),
sudo: ctx.platform == Platform::Linux,
},
resource: Some(Resource::TrustedCa(ctx.generated_ca.clone())),
});
}
plan
}
pub fn invoked_through_sudo() -> Option<String> {
sudo_user(is_root(), std::env::var("SUDO_USER").ok())
}
fn sudo_user(root: bool, sudo_user: Option<String>) -> Option<String> {
sudo_user.filter(|u| root && !u.is_empty() && u != "root")
}
fn is_root() -> bool {
#[cfg(unix)]
{
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}
fn run(argv: &[String], sudo: bool) -> Result<()> {
let (program, args): (&str, &[String]) = if sudo && !is_root() {
("sudo", argv)
} else {
(argv[0].as_str(), &argv[1..])
};
let status = std::process::Command::new(program)
.args(args)
.status()
.map_err(|e| miette::miette!("Failed to run `{}`: {e}", argv.join(" ")))?;
if !status.success() {
miette::bail!(
"`{}` failed with exit code {}",
argv.join(" "),
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn write_file(path: &Path, content: &str, sudo: bool) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| miette::miette!("{} has no parent directory", path.display()))?;
let tmp = parent.join(format!(
".{}.pitchfork-{}.tmp",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string()),
std::process::id()
));
if !sudo || is_root() {
std::fs::create_dir_all(parent)
.map_err(|e| miette::miette!("Failed to create {}: {e}", parent.display()))?;
std::fs::write(&tmp, content).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
miette::miette!("Failed to write {}: {e}", tmp.display())
})?;
return std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
miette::miette!("Failed to replace {}: {e}", path.display())
});
}
run(
&[
"mkdir".into(),
"-p".into(),
parent.to_string_lossy().into_owned(),
],
true,
)?;
let write_tmp = || -> Result<()> {
use std::io::Write;
let mut child = std::process::Command::new("sudo")
.arg("tee")
.arg(&tmp)
.stdout(std::process::Stdio::null())
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| miette::miette!("Failed to write {} via sudo: {e}", path.display()))?;
let written = child
.stdin
.take()
.ok_or_else(|| miette::miette!("Failed to open stdin for sudo tee"))
.and_then(|mut stdin| {
stdin
.write_all(content.as_bytes())
.map_err(|e| miette::miette!("Failed to write {}: {e}", path.display()))
});
let status = child
.wait()
.map_err(|e| miette::miette!("Failed to write {}: {e}", path.display()))?;
written?;
if !status.success() {
miette::bail!(
"Failed to write {}: sudo tee exited nonzero",
path.display()
);
}
run(
&[
"chmod".into(),
"644".into(),
tmp.to_string_lossy().into_owned(),
],
true,
)?;
run(
&[
"mv".into(),
"-f".into(),
tmp.to_string_lossy().into_owned(),
path.to_string_lossy().into_owned(),
],
true,
)
};
write_tmp().inspect_err(|_| {
let _ = run(
&["rm".into(), "-f".into(), tmp.to_string_lossy().into_owned()],
true,
);
})
}
fn is_managed_file(path: &Path, sudo: bool) -> bool {
read_maybe_privileged(path, sudo)
.map(|c| c.contains(OWNED_HEADER))
.unwrap_or(false)
}
fn probe_matches(probe: &Probe, sudo: bool) -> bool {
let argv = &probe.argv;
let expect = &probe.expect;
let (program, args): (&str, &[String]) = if sudo && !is_root() {
("sudo", argv)
} else {
(argv[0].as_str(), &argv[1..])
};
let Ok(out) = std::process::Command::new(program)
.args(args)
.stderr(std::process::Stdio::null())
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let stdout = String::from_utf8_lossy(&out.stdout);
expect.iter().all(|e| match e {
ProbeExpect::Contains(text) => stdout.contains(text.as_str()),
})
}
fn remove_file(path: &Path, sudo: bool) -> Result<()> {
if !path.exists() {
return Ok(());
}
if !is_managed_file(path, sudo) {
return Ok(());
}
if !sudo || is_root() {
return std::fs::remove_file(path)
.map_err(|e| miette::miette!("Failed to remove {}: {e}", path.display()));
}
run(
&[
"rm".into(),
"-f".into(),
path.to_string_lossy().into_owned(),
],
true,
)
}
fn pf_keyword(line: &str) -> Option<&str> {
let first = line.split_whitespace().next()?;
match first.split_once('=') {
Some(_) => None,
None => Some(first),
}
}
fn is_pf_translation(line: &str) -> bool {
pf_keyword(line).is_some_and(|kw| {
matches!(
kw,
"rdr-anchor" | "nat-anchor" | "binat-anchor" | "rdr" | "nat" | "binat"
)
})
}
fn is_pf_filter(line: &str) -> bool {
pf_keyword(line)
.is_some_and(|kw| matches!(kw, "anchor" | "pass" | "block" | "match" | "antispoof"))
}
pub(crate) fn splice_pf_block(text: &str, block: &str) -> String {
let stripped = splice_block(text, "");
if block.is_empty() {
return stripped;
}
let mut out: Vec<&str> = stripped.lines().collect();
let at = out
.iter()
.rposition(|l| is_pf_translation(l))
.map(|i| i + 1)
.or_else(|| out.iter().position(|l| is_pf_filter(l)))
.unwrap_or(out.len());
let managed = format!("{MARKER_START}\n{block}\n{MARKER_END}");
out.insert(at, &managed);
let mut joined = out.join("\n");
if stripped.ends_with('\n') {
joined.push('\n');
}
joined
}
pub(crate) fn splice_block(text: &str, block: &str) -> String {
let stripped = match (text.find(MARKER_START), text.find(MARKER_END)) {
(Some(start), Some(end)) if end > start => {
let end = end + MARKER_END.len();
let mut out = String::with_capacity(text.len());
out.push_str(&text[..start]);
out.push_str(text[end..].trim_start_matches('\n'));
out
}
_ => text.to_string(),
};
if block.is_empty() {
let trimmed = stripped.trim_end();
if trimmed.is_empty() {
return String::new();
}
return format!("{trimmed}\n");
}
let body = stripped.trim_end();
let prefix = if body.is_empty() {
String::new()
} else {
format!("{body}\n\n")
};
format!("{prefix}{MARKER_START}\n{block}\n{MARKER_END}\n")
}
fn read_maybe_privileged(path: &Path, sudo: bool) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(c) => Ok(c),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(_) if sudo && !is_root() => {
let out = std::process::Command::new("sudo")
.arg("cat")
.arg(path)
.output()
.map_err(|e| miette::miette!("Failed to read {}: {e}", path.display()))?;
if !out.status.success() {
miette::bail!("Failed to read {}", path.display());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
Err(e) => Err(miette::miette!("Failed to read {}: {e}", path.display())),
}
}
fn already_done(action: &Action) -> bool {
match action {
Action::WriteFile { path, content, .. } => {
std::fs::read_to_string(path).is_ok_and(|c| &c == content)
}
Action::RemoveFile { path, sudo, .. } => !path.exists() || !is_managed_file(path, *sudo),
Action::EnsureBlock {
path,
content,
pf_order: true,
..
} => std::fs::read_to_string(path).is_ok_and(|c| splice_pf_block(&c, content) == c),
Action::EnsureBlock { path, content, .. } => std::fs::read_to_string(path)
.is_ok_and(|c| c.contains(MARKER_START) && c.contains(content.as_str())),
Action::RemoveBlock { path, .. } => {
std::fs::read_to_string(path).is_ok_and(|c| !c.contains(MARKER_START))
}
Action::Note => true,
Action::Run { skip_if, sudo, .. } => {
skip_if.as_ref().is_some_and(|p| probe_matches(p, *sudo))
}
Action::RunIfPresent { probe, sudo, .. } => !probe_matches(probe, *sudo),
Action::RevokeBindCapability { binary } => {
revoke_already_done(file_capabilities(binary).as_deref())
}
Action::GrantBindCapability { binary } => {
file_capabilities(binary).is_some_and(|caps| caps.iter().any(|c| c == BIND_CAPABILITY))
}
Action::EnablePf { .. } | Action::ReleasePf { .. } => false,
Action::GenerateCa { cert, key } => ca_pair_problem(cert, key).is_none(),
Action::TrustCa { path } => crate::proxy::trust::is_ca_trusted(path),
Action::UntrustCa { path, .. } => {
untrust_already_done(path.exists(), crate::proxy::trust::ca_trust_state(path))
}
}
}
fn untrust_already_done(pem_exists: bool, trusted: Option<bool>) -> bool {
pem_exists && trusted == Some(false)
}
fn revoke_already_done(caps: Option<&[String]>) -> bool {
matches!(caps, Some(caps) if !caps.iter().any(|c| c == BIND_CAPABILITY))
}
fn skip_reason(action: &Action) -> &'static str {
match action {
Action::RemoveFile { path, sudo, .. } if path.exists() && !is_managed_file(path, *sudo) => {
"not written by pitchfork, left alone"
}
Action::RemoveFile { .. } => "nothing there",
Action::RunIfPresent { .. } => "not configured by pitchfork",
_ => "already done",
}
}
fn execute(step: &Step) -> Result<()> {
match &step.action {
Action::Note => Ok(()),
Action::WriteFile {
path,
content,
sudo,
} => {
if content.contains(OWNED_HEADER) && path.exists() && !is_managed_file(path, *sudo) {
return Err(miette::miette!(
"{} already exists and was not written by pitchfork, so it \
was left alone. Move it aside if it is no longer needed, \
or choose a different proxy.tld.",
path.display()
));
}
write_file(path, content, *sudo)
}
Action::RemoveFile {
path,
sudo,
still_referenced_by,
} => {
if let Some(referrer) = still_referenced_by {
let blocked = if referrer.exists() {
match read_maybe_privileged(referrer, *sudo) {
Ok(contents) => contents
.contains(MARKER_START)
.then(|| format!("still names {}", path.display())),
Err(e) => Some(format!("could not be read ({e})")),
}
} else {
None
};
if let Some(why) = blocked {
return Err(miette::miette!(
"{} {why}, so {} was left in place",
referrer.display(),
path.display()
));
}
}
remove_file(path, *sudo)
}
Action::EnsureBlock {
path,
content,
sudo,
pf_order,
requires,
} => {
if let Some(required) = requires
&& !is_managed_file(required, *sudo)
{
let why = if required.exists() {
"was not written by pitchfork"
} else {
"does not exist"
};
return Err(miette::miette!(
"{} {why}, so the block naming it was not written to {}",
required.display(),
path.display()
));
}
let current = read_maybe_privileged(path, *sudo)?;
let spliced = if *pf_order {
splice_pf_block(¤t, content)
} else {
splice_block(¤t, content)
};
write_file(path, &spliced, *sudo)
}
Action::RemoveBlock { path, sudo } => {
if !path.exists() {
return Ok(());
}
let current = read_maybe_privileged(path, *sudo)?;
write_file(path, &splice_block(¤t, ""), *sudo)
}
Action::Run { argv, sudo, .. } | Action::RunIfPresent { argv, sudo, .. } => {
run(argv, *sudo)
}
Action::RevokeBindCapability { binary } => revoke_bind_capability(binary),
Action::EnablePf { pf_conf, token } => enable_pf(pf_conf, token),
Action::ReleasePf { pf_conf, token } => {
run(
&[
"pfctl".into(),
"-f".into(),
pf_conf.to_string_lossy().into_owned(),
],
true,
)?;
release_pf(token)
}
Action::GrantBindCapability { binary } => grant_bind_capability(binary),
Action::GenerateCa { cert, key } => generate_ca(cert, key),
Action::TrustCa { path } => crate::proxy::trust::install_cert(path),
Action::UntrustCa { path, sudo } => {
if *sudo && !is_root() {
run(
&[
std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("pitchfork"))
.to_string_lossy()
.into_owned(),
"proxy".into(),
"untrust".into(),
"--cert".into(),
path.to_string_lossy().into_owned(),
],
true,
)
} else {
crate::proxy::trust::uninstall_cert(path)
}
}
}
}
const BIND_CAPABILITY: &str = "cap_net_bind_service";
fn file_capabilities(path: &Path) -> Option<Vec<String>> {
let out = ["getcap", "/usr/sbin/getcap", "/sbin/getcap"]
.into_iter()
.find_map(|prog| {
std::process::Command::new(prog)
.arg(path)
.stderr(std::process::Stdio::null())
.output()
.ok()
})?;
if !out.status.success() {
return None;
}
capabilities_from_getcap(
path.to_string_lossy().as_ref(),
&String::from_utf8_lossy(&out.stdout),
)
}
fn capabilities_from_getcap(path: &str, stdout: &str) -> Option<Vec<String>> {
if let Some(line) = stdout.lines().find_map(|l| l.trim_end().strip_prefix(path)) {
return Some(parse_capabilities(line));
}
stdout.trim().is_empty().then(Vec::new)
}
fn parse_capabilities(spec: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for clause in spec.split_whitespace() {
let Some(list) = clause.split(['=', '+', '-']).next() else {
continue;
};
for name in list.split(',').map(str::trim) {
if name.starts_with("cap_") && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
}
names
}
fn grant_bind_capability(path: &Path) -> Result<()> {
check_grant(path, file_capabilities(path).as_deref())?;
run(
&[
"setcap".into(),
format!("{BIND_CAPABILITY}=+ep"),
path.to_string_lossy().into_owned(),
],
true,
)
}
fn check_grant(path: &Path, caps: Option<&[String]>) -> Result<()> {
let Some(caps) = caps else {
miette::bail!(
"Could not read the capabilities on {} — `getcap` was not found or failed, \
so {BIND_CAPABILITY} was not granted.\n\
Check with: sudo getcap {}\n\
Grant with: sudo setcap {BIND_CAPABILITY}=+ep {}",
path.display(),
path.display(),
path.display()
);
};
let others: Vec<&str> = caps
.iter()
.map(String::as_str)
.filter(|c| *c != BIND_CAPABILITY)
.collect();
if !others.is_empty() {
miette::bail!(
"{} already carries {}, which pitchfork did not grant. \
`setcap` replaces the whole set, so granting {BIND_CAPABILITY} here \
would clear those; it has not been granted.\n\
Add it alongside them by hand, keeping their flags as `sudo getcap {}` prints them.",
path.display(),
others.join(", "),
path.display()
);
}
Ok(())
}
fn ca_pair_problem(cert: &Path, key: &Path) -> Option<String> {
match (cert.exists(), key.exists()) {
(false, false) => return Some("not generated yet".to_string()),
(true, false) => return Some(format!("{} is missing", key.display())),
(false, true) => return Some(format!("{} is missing", cert.display())),
(true, true) => {}
}
#[cfg(feature = "proxy-tls")]
{
crate::proxy::server::ca_pair_problem(cert, key)
}
#[cfg(not(feature = "proxy-tls"))]
{
None
}
}
fn generate_ca(cert: &Path, key: &Path) -> Result<()> {
#[cfg(feature = "proxy-tls")]
{
crate::proxy::server::ensure_ca(cert, key, || match ca_pair_problem(cert, key) {
None => true,
Some(problem) => {
if cert.exists() || key.exists() {
println!(" the existing CA cannot be used ({problem}); generating a new one");
println!(
" restart the supervisor so it signs with the new CA: \
pitchfork supervisor start --force"
);
}
false
}
})
.map(|_| ())
}
#[cfg(not(feature = "proxy-tls"))]
{
let _ = (cert, key);
miette::bail!("HTTPS proxy support requires the `proxy-tls` feature")
}
}
fn enable_pf(pf_conf: &Path, token: &Path) -> Result<()> {
let argv: Vec<String> = vec![
"pfctl".into(),
"-Ef".into(),
pf_conf.to_string_lossy().into_owned(),
];
let (program, args): (&str, &[String]) = if is_root() {
(argv[0].as_str(), &argv[1..])
} else {
("sudo", &argv[..])
};
let out = std::process::Command::new(program)
.args(args)
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| miette::miette!("Failed to run `{}`: {e}", argv.join(" ")))?;
let stderr = String::from_utf8_lossy(&out.stderr);
eprint!("{stderr}");
if !out.status.success() {
miette::bail!(
"`{}` failed with exit code {}",
argv.join(" "),
out.status.code().unwrap_or(-1)
);
}
let previous = read_pf_token(token);
match (parse_pf_token(&stderr), boot_time()) {
(Some(new), Some(boot)) => {
if let Some(parent) = token.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(token, format!("{new}\n{boot}\n"))
.map_err(|e| miette::miette!("Failed to write {}: {e}", token.display()))?;
}
_ => {
let _ = std::fs::remove_file(token);
}
}
if let Some(old) = previous {
let _ = run(&["pfctl".into(), "-X".into(), old], true);
}
Ok(())
}
fn release_pf(token: &Path) -> Result<()> {
let Some(held) = read_pf_token(token) else {
let _ = std::fs::remove_file(token);
return Ok(());
};
run(&["pfctl".into(), "-X".into(), held], true)?;
let _ = std::fs::remove_file(token);
Ok(())
}
fn read_pf_token(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let mut lines = content.lines();
let token = lines.next()?.trim();
let boot = lines.next()?.trim();
(token.bytes().all(|b| b.is_ascii_digit())
&& !token.is_empty()
&& Some(boot) == boot_time().as_deref())
.then(|| token.to_string())
}
fn parse_pf_token(stderr: &str) -> Option<String> {
stderr.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
let value = value.trim();
(key.trim() == "Token" && !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
.then(|| value.to_string())
})
}
fn boot_time() -> Option<String> {
let out = std::process::Command::new("sysctl")
.args(["-n", "kern.boottime"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
(out.status.success() && !text.is_empty()).then_some(text)
}
fn revoke_bind_capability(path: &Path) -> Result<()> {
let Some(caps) = file_capabilities(path) else {
miette::bail!(
"Could not read the capabilities on {} — `getcap` was not found or failed, \
so {BIND_CAPABILITY} was left in place.\n\
Check with: sudo getcap {}\n\
Remove with: sudo setcap -r {}",
path.display(),
path.display(),
path.display()
);
};
if !caps.iter().any(|c| c == BIND_CAPABILITY) {
return Ok(());
}
let others: Vec<&String> = caps.iter().filter(|c| *c != BIND_CAPABILITY).collect();
if !others.is_empty() {
miette::bail!(
"{} also carries {}, which pitchfork did not grant. \
Removing {BIND_CAPABILITY} here would clear those too, so it has been left alone.\n\
Remove it by hand with: sudo setcap {}=ep {}",
path.display(),
others
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", "),
others
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(","),
path.display()
);
}
run(
&[
"setcap".into(),
"-r".into(),
path.to_string_lossy().into_owned(),
],
true,
)
}
#[derive(Debug, Default)]
pub struct RunReport {
pub applied: usize,
pub skipped: usize,
pub failed: Vec<(String, String)>,
}
pub fn apply(plan: &Plan) -> RunReport {
let mut report = RunReport::default();
for step in &plan.steps {
if step.action == Action::Note {
continue;
}
if already_done(&step.action) {
println!(" · {} ({})", step.summary, skip_reason(&step.action));
report.skipped += 1;
continue;
}
print!(" → {} ... ", step.summary);
use std::io::Write;
let _ = std::io::stdout().flush();
match execute(step) {
Ok(()) => {
println!("ok");
report.applied += 1;
}
Err(e) => {
println!("failed");
report.failed.push((step.summary.clone(), e.to_string()));
}
}
}
report
}
pub fn systemd_resolved_active() -> bool {
if !cfg!(target_os = "linux") {
return false;
}
std::process::Command::new("systemctl")
.args(["is-active", "--quiet", "systemd-resolved"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn systemd_version() -> Option<u32> {
if !cfg!(target_os = "linux") {
return None;
}
let out = std::process::Command::new("systemctl")
.arg("--version")
.stderr(std::process::Stdio::null())
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.next()?
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
pub fn gnome_proxy_available() -> bool {
std::process::Command::new("gsettings")
.args(["get", "org.gnome.system.proxy", "mode"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn read_prior_auto_proxy(
platform: Platform,
services: &[String],
gnome: bool,
our_url: &str,
) -> Vec<PriorAutoProxy> {
let run = |argv: &[&str]| -> Option<String> {
let out = std::process::Command::new(argv[0])
.args(&argv[1..])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
};
let mut prior = vec![];
match platform {
Platform::MacOs => {
for service in services {
let Some(out) = run(&["networksetup", "-getautoproxyurl", service]) else {
continue;
};
let field = |name: &str| {
out.lines()
.filter_map(|l| l.split_once(':'))
.find(|(k, _)| k.trim().eq_ignore_ascii_case(name))
.map(|(_, v)| v.trim().to_string())
};
let url = field("URL").unwrap_or_default();
if url.is_empty() || url == "(null)" || url == our_url {
continue;
}
let enabled = field("Enabled").is_some_and(|v| v.eq_ignore_ascii_case("yes"));
prior.push(PriorAutoProxy {
target: service.clone(),
url,
state: if enabled { "on" } else { "off" }.to_string(),
});
}
}
Platform::Linux if gnome => {
let unquote = |v: String| v.trim().trim_matches('\'').to_string();
let url = run(&[
"gsettings",
"get",
"org.gnome.system.proxy",
"autoconfig-url",
])
.map(unquote)
.unwrap_or_default();
if !url.is_empty() && url != our_url {
let mode = run(&["gsettings", "get", "org.gnome.system.proxy", "mode"])
.map(unquote)
.unwrap_or_else(|| "none".to_string());
prior.push(PriorAutoProxy {
target: "gnome".to_string(),
url,
state: mode,
});
}
}
Platform::Linux | Platform::Other => {}
}
prior
}
pub fn macos_network_services() -> Vec<String> {
if !cfg!(target_os = "macos") {
return vec![];
}
let Ok(out) = std::process::Command::new("networksetup")
.arg("-listallnetworkservices")
.output()
else {
return vec![];
};
String::from_utf8_lossy(&out.stdout)
.lines()
.skip(1) .filter(|l| !l.trim().is_empty() && !l.starts_with('*'))
.map(|l| l.trim().to_string())
.collect()
}
fn contact_host(proxy_host: &str) -> String {
match proxy_host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V6(ip)) => {
let ip = if ip.is_unspecified() {
std::net::Ipv6Addr::LOCALHOST
} else {
ip
};
format!("[{ip}]")
}
Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => "127.0.0.1".to_string(),
Ok(std::net::IpAddr::V4(ip)) => ip.to_string(),
Err(_) => "127.0.0.1".to_string(),
}
}
pub fn context_from_settings(s: &crate::settings::Settings, pac: bool) -> SetupContext {
let fixed = fixed_paths();
let platform = Platform::current();
let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
let tld = crate::proxy::effective_tld(s).to_string();
let custom_cert = !s.proxy.tls_cert.is_empty();
let ca_path = if custom_cert {
PathBuf::from(&s.proxy.tls_cert)
} else {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
};
let mut ctx = SetupContext {
platform,
tld,
dns_port: super::dns::dns_port(s),
proxy_port: u16::try_from(s.proxy.port)
.ok()
.filter(|&p| p > 0)
.unwrap_or(443),
https: s.proxy.https,
dns_enabled: s.proxy.dns,
pac,
systemd_resolved: systemd_resolved_active(),
systemd_version: systemd_version(),
lan: lan_enabled,
contact_host: contact_host(&s.proxy.host),
ca_trusted: crate::proxy::trust::is_ca_trusted(&ca_path),
ca_path,
generated_ca: default_generated_ca(),
custom_cert,
binary: current_binary(),
resolver_dir: fixed.resolver_dir,
resolved_dropin_dir: fixed.resolved_dropin_dir,
pf_conf: fixed.pf_conf,
pf_anchor: fixed.pf_anchor,
network_services: if platform == Platform::MacOs {
macos_network_services()
} else {
vec![]
},
gnome: platform == Platform::Linux && gnome_proxy_available(),
prior_auto_proxy: vec![],
};
if pac {
ctx.prior_auto_proxy =
read_prior_auto_proxy(platform, &ctx.network_services, ctx.gnome, &ctx.pac_url());
}
ctx
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(platform: Platform) -> SetupContext {
SetupContext {
platform,
tld: "test".into(),
dns_port: 15353,
proxy_port: 8443,
https: true,
dns_enabled: true,
pac: false,
systemd_resolved: true,
systemd_version: Some(255),
lan: false,
contact_host: "127.0.0.1".to_string(),
ca_path: PathBuf::from("/state/proxy/ca.pem"),
generated_ca: PathBuf::from("/state/proxy/ca.pem"),
ca_trusted: false,
custom_cert: false,
binary: PathBuf::from("/usr/local/bin/pitchfork"),
resolver_dir: PathBuf::from("/etc/resolver"),
resolved_dropin_dir: PathBuf::from("/etc/systemd/resolved.conf.d"),
pf_conf: PathBuf::from("/etc/pf.conf"),
pf_anchor: PathBuf::from("/etc/pf.anchors/pitchfork"),
network_services: vec![],
gnome: false,
prior_auto_proxy: vec![],
}
}
#[test]
fn a_tld_that_could_escape_the_resolver_directory_is_refused() {
for bad in [
"../../etc/passwd",
"..",
"a/b",
"test\nDNS=8.8.8.8",
"",
".test",
"te st",
] {
assert!(validate_tld(bad).is_err(), "expected {bad:?} to be refused");
}
for good in ["localhost", "test", "dev.internal", "my-tld"] {
assert!(
validate_tld(good).is_ok(),
"expected {good:?} to be allowed"
);
}
}
#[test]
fn lan_mode_leaves_the_mdns_local_namespace_alone() {
let mut c = ctx(Platform::MacOs);
c.lan = true;
c.tld = "local".into();
let lines = plan(&c).describe();
assert!(lines.iter().any(|l| l.contains("mDNS")));
assert!(!lines.iter().any(|l| l.contains("/etc/resolver")));
let mut c = ctx(Platform::Linux);
c.lan = true;
c.tld = "local".into();
let lines = plan(&c).describe();
assert!(!lines.iter().any(|l| l.contains("resolved.conf.d")));
}
#[test]
fn the_sudo_ca_step_names_the_certificate_it_should_trust() {
let c = ctx(Platform::Linux);
let step = plan(&c)
.steps
.into_iter()
.find(|s| s.summary.contains("trust store"))
.expect("linux plans a CA install");
let Action::Run { argv, .. } = step.action else {
panic!("expected a command");
};
assert!(argv.contains(&"--cert".to_string()));
assert!(argv.contains(&"/state/proxy/ca.pem".to_string()));
}
#[test]
fn undo_only_reverts_proxy_settings_that_point_at_pitchfork() {
let mut c = ctx(Platform::MacOs);
c.network_services = vec!["Wi-Fi".into()];
let step = plan_undo(&c)
.steps
.into_iter()
.find(|s| s.summary.contains("automatic proxy URL"))
.expect("undo turns the PAC URL off");
let Action::RunIfPresent { probe, .. } = step.action else {
panic!("expected a guarded command");
};
assert!(probe.argv.contains(&"-getautoproxyurl".to_string()));
assert_eq!(
probe.expect,
vec![ProbeExpect::Contains(
"http://127.0.0.1:8443/proxy.pac".to_string()
)]
);
}
#[test]
fn undo_revokes_the_capability_through_a_checked_action() {
let mut privileged = ctx(Platform::Linux);
privileged.proxy_port = 443;
let step = plan_undo(&privileged)
.steps
.into_iter()
.find(|s| s.summary.contains("cap_net_bind_service"))
.expect("undo revokes the capability it granted");
assert!(matches!(step.action, Action::RevokeBindCapability { .. }));
assert!(
!plan_undo(&ctx(Platform::Linux))
.describe()
.iter()
.any(|l| l.contains("cap_net_bind_service"))
);
}
#[test]
fn every_capability_clause_is_read_not_just_the_last() {
assert_eq!(
parse_capabilities("cap_net_bind_service=ep"),
vec!["cap_net_bind_service"]
);
assert_eq!(
parse_capabilities("cap_sys_admin=ei cap_net_bind_service=ep"),
vec!["cap_sys_admin", "cap_net_bind_service"]
);
assert_eq!(
parse_capabilities("cap_net_bind_service=ep cap_sys_admin=ei"),
vec!["cap_net_bind_service", "cap_sys_admin"]
);
assert_eq!(
parse_capabilities("cap_net_bind_service,cap_sys_admin=ep"),
vec!["cap_net_bind_service", "cap_sys_admin"]
);
assert_eq!(
parse_capabilities("cap_net_bind_service+ep"),
vec!["cap_net_bind_service"]
);
assert_eq!(
parse_capabilities("= cap_net_bind_service+ep"),
vec!["cap_net_bind_service"]
);
assert!(parse_capabilities("").is_empty());
assert!(
parse_capabilities("cap_net_bind_service=ep")
.iter()
.all(|c| c == BIND_CAPABILITY)
);
let shared = parse_capabilities("cap_sys_admin=ei cap_net_bind_service=ep");
assert!(shared.iter().any(|c| c == BIND_CAPABILITY));
assert!(shared.iter().any(|c| c != BIND_CAPABILITY));
}
#[test]
fn macos_plan_covers_resolver_ca_and_port_redirect() {
let c = ctx(Platform::MacOs);
let resolver = c.resolver_file().display().to_string();
assert_eq!(
plan(&c).describe(),
vec![
format!("[sudo] write {resolver} pointing *.test at 127.0.0.1:15353"),
"generate the pitchfork CA at /state/proxy/ca.pem".to_string(),
"install the pitchfork CA at /state/proxy/ca.pem into the system trust store"
.to_string(),
"[sudo] write /etc/pf.anchors/pitchfork redirecting port 443 to 8443".to_string(),
"[sudo] load the pitchfork anchor into /etc/pf.conf".to_string(),
"[sudo] enable pf and load the new rules".to_string(),
]
);
}
#[test]
fn linux_plan_writes_a_resolved_dropin_and_sudoes_the_ca() {
let c = ctx(Platform::Linux);
let dropin = c.resolved_dropin().display().to_string();
let ca = c.ca_path.display().to_string();
assert_eq!(
plan(&c).describe(),
vec![
format!("[sudo] write {dropin} routing *.test to 127.0.0.1:15353"),
"[sudo] restart systemd-resolved to pick up the route (interrupts DNS briefly)"
.to_string(),
format!("generate the pitchfork CA at {ca}"),
format!("[sudo] install the pitchfork CA at {ca} into the system trust store"),
"[sudo] redirect loopback traffic for port 443 to 8443 (iptables)".to_string(),
]
);
}
#[test]
fn macos_says_what_taking_over_localhost_costs() {
let mut c = ctx(Platform::MacOs);
c.tld = "localhost".into();
let p = plan(&c);
assert!(
p.manual
.iter()
.any(|m| m.contains("only while the supervisor is running")),
"expected a note about the dependency: {:?}",
p.manual
);
assert!(plan(&ctx(Platform::MacOs)).manual.is_empty());
}
#[test]
fn linux_with_localhost_tld_needs_no_resolver_change() {
let mut c = ctx(Platform::Linux);
c.tld = "localhost".into();
let lines = plan(&c).describe();
assert!(lines[0].contains("systemd-resolved already answers *.localhost"));
assert!(!lines[0].starts_with("[sudo]"));
}
#[test]
fn linux_without_systemd_resolved_falls_back_to_dnsmasq_advice() {
let mut c = ctx(Platform::Linux);
c.systemd_resolved = false;
let p = plan(&c);
assert!(!p.describe().iter().any(|l| l.contains("resolved.conf.d")));
assert!(p.manual[0].contains("dnsmasq"));
assert!(p.manual[0].contains("server=/test/127.0.0.1#15353"));
}
#[test]
fn pac_plan_needs_no_sudo_for_resolution_or_ports() {
let mut c = ctx(Platform::MacOs);
c.pac = true;
c.ca_trusted = true;
c.network_services = vec!["Wi-Fi".into()];
let p = plan(&c);
assert!(
!p.needs_sudo(),
"PAC setup must not require sudo: {:?}",
p.describe()
);
assert!(p.describe().iter().any(|l| l.contains(
"set the automatic proxy URL for \"Wi-Fi\" to http://127.0.0.1:8443/proxy.pac"
)));
assert!(
p.describe()
.iter()
.any(|l| l.contains("no port redirect is needed"))
);
}
#[test]
fn pac_does_not_excuse_a_privileged_port() {
let mut c = ctx(Platform::Linux);
c.pac = true;
c.proxy_port = 443;
c.ca_trusted = true;
let lines = plan(&c).describe();
assert!(
lines
.iter()
.any(|l| l.starts_with("[sudo]") && l.contains("cap_net_bind_service")),
"expected the capability grant: {lines:?}"
);
let mut c = ctx(Platform::MacOs);
c.pac = true;
c.proxy_port = 443;
c.ca_trusted = true;
let p = plan(&c);
assert!(!p.needs_sudo());
assert!(
p.manual
.iter()
.any(|m| m.contains("will not run the supervisor as root"))
);
}
#[test]
fn pac_on_linux_still_needs_sudo_only_for_the_ca() {
let mut c = ctx(Platform::Linux);
c.pac = true;
c.gnome = true;
let sudo_steps: Vec<String> = plan(&c)
.describe()
.into_iter()
.filter(|l| l.starts_with("[sudo]"))
.collect();
assert_eq!(sudo_steps.len(), 1, "unexpected sudo steps: {sudo_steps:?}");
assert!(sudo_steps[0].contains("trust store"));
let mut trusted = c.clone();
trusted.ca_trusted = true;
assert!(!plan(&trusted).needs_sudo());
let mut plain = c.clone();
plain.https = false;
assert!(!plan(&plain).needs_sudo());
}
#[test]
fn an_ipv6_proxy_host_is_advertised_as_an_ipv6_url() {
assert_eq!(contact_host("::1"), "[::1]");
assert_eq!(contact_host("::"), "[::1]");
assert_eq!(contact_host("0.0.0.0"), "127.0.0.1");
assert_eq!(contact_host("127.0.0.1"), "127.0.0.1");
assert_eq!(contact_host("192.168.1.5"), "192.168.1.5");
assert_eq!(contact_host("not-an-address"), "127.0.0.1");
let mut c = ctx(Platform::MacOs);
c.pac = true;
c.contact_host = "[::1]".to_string();
c.network_services = vec!["Wi-Fi".into()];
assert!(
plan(&c)
.describe()
.iter()
.any(|l| l.contains("http://[::1]:8443/proxy.pac"))
);
}
#[test]
fn gnome_pac_plan_sets_the_autoconfig_url() {
let mut c = ctx(Platform::Linux);
c.pac = true;
c.gnome = true;
c.https = false;
let lines = plan(&c).describe();
assert!(
lines
.iter()
.any(|l| l.contains("GNOME automatic proxy URL"))
);
assert!(lines.iter().any(|l| l.contains("proxy mode to automatic")));
}
#[test]
fn privileged_proxy_port_uses_setcap_on_linux_and_advice_on_macos() {
let mut c = ctx(Platform::Linux);
c.proxy_port = 443;
assert!(
plan(&c)
.describe()
.iter()
.any(|l| l.contains("cap_net_bind_service"))
);
let mut c = ctx(Platform::MacOs);
c.proxy_port = 443;
let p = plan(&c);
assert!(!p.describe().iter().any(|l| l.contains("pf.anchors")));
assert!(p.manual[0].contains("will not run the supervisor as root"));
}
#[test]
fn a_trusted_ca_and_a_custom_cert_both_skip_the_trust_step() {
let mut c = ctx(Platform::MacOs);
c.ca_trusted = true;
assert!(
plan(&c)
.describe()
.iter()
.any(|l| l.contains("is already trusted"))
);
let mut c = ctx(Platform::MacOs);
c.custom_cert = true;
assert!(
plan(&c)
.describe()
.iter()
.any(|l| l.contains("installs no CA"))
);
}
#[test]
fn a_privileged_port_is_never_papered_over_with_a_redirect() {
let mut c = ctx(Platform::Linux);
c.https = false;
c.proxy_port = 443;
let lines = plan(&c).describe();
assert!(lines.iter().any(|l| l.contains("cap_net_bind_service")));
assert!(!lines.iter().any(|l| l.contains("iptables")));
}
#[test]
fn plain_http_plans_no_ca_step_and_redirects_port_80() {
let mut c = ctx(Platform::Linux);
c.https = false;
let lines = plan(&c).describe();
assert!(!lines.iter().any(|l| l.contains("trust store")));
assert!(lines.iter().any(|l| l.contains("port 80 to 8443")));
}
#[test]
fn disabled_resolver_skips_resolver_steps() {
let mut c = ctx(Platform::MacOs);
c.dns_enabled = false;
let lines = plan(&c).describe();
assert!(lines[0].contains("proxy.dns is false"));
assert!(!lines.iter().any(|l| l.contains("/etc/resolver")));
}
#[test]
fn the_iptables_redirect_is_guarded_against_stacking_duplicates() {
let c = ctx(Platform::Linux);
let step = plan(&c)
.steps
.into_iter()
.find(|s| s.summary.contains("iptables"))
.expect("linux plans an iptables redirect");
let Action::Run { skip_if, argv, .. } = step.action else {
panic!("expected a command");
};
assert!(argv.contains(&"-A".to_string()));
let guard = skip_if.expect("the append must carry a guard").argv;
assert!(guard.contains(&"-C".to_string()));
assert_eq!(guard.len(), argv.len());
assert_eq!(guard[guard.len() - 1], argv[argv.len() - 1]);
}
#[test]
fn undo_reverses_what_setup_recorded_not_what_settings_now_say() {
let mut recorded = ctx(Platform::Linux);
recorded.proxy_port = 8443;
recorded.contact_host = "127.0.0.1".into();
let mut current = ctx(Platform::Linux);
current.proxy_port = 9999;
current.contact_host = "127.0.0.1".into();
let lines = plan_undo_from(Some(¤t), std::slice::from_ref(&recorded)).describe();
assert!(
lines.iter().any(|l| l.contains("port 443 to 8443")),
"the installed redirect must be reversed: {lines:?}"
);
assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
let mut sorted = lines.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), lines.len(), "duplicate steps in {lines:?}");
}
#[test]
fn a_failed_setup_does_not_erase_what_an_earlier_one_installed() {
let mut first = ctx(Platform::Linux);
first.proxy_port = 8443;
let mut second = ctx(Platform::Linux);
second.proxy_port = 9999;
let after_first = merged_records(vec![], &first);
let after_failed_second = merged_records(after_first, &second);
assert_eq!(after_failed_second.len(), 2);
assert_eq!(after_failed_second[0].proxy_port, 8443);
assert_eq!(after_failed_second[1].proxy_port, 9999);
let lines = plan_undo_from(Some(&second), &after_failed_second).describe();
assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
}
#[test]
fn re_recording_the_same_setup_does_not_grow_the_list() {
let c = ctx(Platform::Linux);
let once = merged_records(vec![], &c);
let twice = merged_records(once.clone(), &c);
assert_eq!(twice.len(), 1, "an identical setup replaces its entry");
assert_eq!(undo_key(&twice[0]), undo_key(&c));
}
#[test]
fn the_record_list_is_capped() {
let mut records = vec![];
for port in 0..(MAX_RECORDS as u16 + 5) {
let mut c = ctx(Platform::Linux);
c.proxy_port = 2000 + port;
records = merged_records(records, &c);
}
assert_eq!(records.len(), MAX_RECORDS);
assert_eq!(
records.last().unwrap().proxy_port,
2000 + MAX_RECORDS as u16 + 4
);
}
#[test]
fn a_second_setup_removes_the_redirect_it_supersedes() {
let mut first = ctx(Platform::Linux);
first.proxy_port = 8443;
let mut second = ctx(Platform::Linux);
second.proxy_port = 9999;
let lines = plan_with_reconcile(&second, std::slice::from_ref(&first)).describe();
let drop_old = lines
.iter()
.position(|l| l.contains("drop the iptables redirect from port 443 to 8443"))
.expect("the superseded redirect is removed");
let add_new = lines
.iter()
.position(|l| l.contains("redirect loopback traffic for port 443 to 9999"))
.expect("the new redirect is installed");
assert!(drop_old < add_new, "remove before install: {lines:?}");
}
#[test]
fn re_running_setup_never_untrusts_the_ca_it_still_needs() {
let mut first = ctx(Platform::Linux);
first.proxy_port = 8443;
first.ca_trusted = true;
let mut second = ctx(Platform::Linux);
second.proxy_port = 9999;
second.ca_trusted = true;
let lines = plan_with_reconcile(&second, std::slice::from_ref(&first)).describe();
assert!(
!lines
.iter()
.any(|l| l.contains("from the system trust store")),
"the CA must survive a re-run: {lines:?}"
);
assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
}
#[test]
fn switching_to_pac_removes_the_resolver_it_no_longer_uses() {
let dns = ctx(Platform::Linux);
let mut pac = ctx(Platform::Linux);
pac.pac = true;
pac.ca_trusted = true;
let lines = plan_with_reconcile(&pac, std::slice::from_ref(&dns)).describe();
assert!(
lines
.iter()
.any(|l| l.contains("remove") && l.contains("resolved.conf.d")),
"the superseded resolver drop-in must be removed: {lines:?}"
);
}
#[test]
fn re_running_the_same_setup_removes_nothing() {
let c = ctx(Platform::Linux);
assert_eq!(
plan_with_reconcile(&c, std::slice::from_ref(&c)).describe(),
plan(&c).describe()
);
}
#[test]
fn undo_reverses_every_recorded_setup_not_just_the_last() {
let mut first = ctx(Platform::Linux);
first.proxy_port = 8443;
let mut second = ctx(Platform::Linux);
second.proxy_port = 9999;
let current = second.clone();
let lines = plan_undo_from(Some(¤t), &[first, second]).describe();
assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
assert!(lines.iter().any(|l| l.contains("port 443 to 9999")));
let mut sorted = lines.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), lines.len(), "duplicate steps in {lines:?}");
}
#[test]
fn undo_works_from_records_alone_when_the_tld_no_longer_validates() {
let recorded = ctx(Platform::Linux);
let lines = plan_undo_from(None, std::slice::from_ref(&recorded)).describe();
assert_eq!(lines, plan_undo(&recorded).describe());
assert!(lines.iter().any(|l| l.contains("resolved.conf.d")));
assert!(plan_undo_from(None, &[]).steps.is_empty());
}
#[test]
fn undo_without_a_record_still_uses_the_current_settings() {
let current = ctx(Platform::Linux);
assert_eq!(
plan_undo_from(Some(¤t), &[]).describe(),
plan_undo(¤t).describe()
);
}
#[test]
fn a_tampered_record_cannot_steer_a_privileged_write() {
let mut evil = ctx(Platform::MacOs);
evil.tld = "../../etc/passwd".into();
assert!(
sanitize_record(evil).is_none(),
"an unusable TLD is dropped"
);
let mut redirected = ctx(Platform::MacOs);
redirected.resolver_dir = PathBuf::from("/tmp/attacker");
redirected.pf_conf = PathBuf::from("/etc/shadow");
redirected.pf_anchor = PathBuf::from("/tmp/anchor");
redirected.resolved_dropin_dir = PathBuf::from("/tmp/dropins");
redirected.generated_ca = PathBuf::from("/tmp/ca.pem");
redirected.binary = PathBuf::from("/usr/bin/some-other-daemon");
let clean = sanitize_record(redirected).expect("a valid TLD is kept");
assert_eq!(clean.binary, current_binary());
assert_ne!(clean.binary, PathBuf::from("/usr/bin/some-other-daemon"));
let fixed = fixed_paths();
assert_eq!(clean.resolver_dir, fixed.resolver_dir);
assert_eq!(clean.pf_conf, fixed.pf_conf);
assert_eq!(clean.pf_anchor, fixed.pf_anchor);
assert_eq!(clean.resolved_dropin_dir, fixed.resolved_dropin_dir);
assert_eq!(clean.generated_ca, default_generated_ca());
let lines = plan_undo(&clean).describe().join("\n");
assert!(!lines.contains("/usr/bin/some-other-daemon"));
assert!(!lines.contains("/tmp/attacker"));
assert!(!lines.contains("/etc/shadow"));
assert!(!lines.contains("/tmp/ca.pem"));
}
#[test]
fn a_record_in_the_previous_single_context_format_still_loads() {
let ctx = ctx(Platform::Linux);
let legacy = toml::to_string_pretty(&ctx).expect("serializes");
assert!(
toml::from_str::<SetupRecords>(&legacy)
.expect("parses as records")
.setups
.is_empty(),
"the empty parse is what makes the fallback necessary"
);
let back: SetupContext = toml::from_str(&legacy).expect("parses as one context");
assert_eq!(undo_key(&back), undo_key(&ctx));
let records = SetupRecords {
setups: vec![ctx.clone()],
};
let text = toml::to_string_pretty(&records).expect("serializes");
let back = toml::from_str::<SetupRecords>(&text).expect("parses");
assert_eq!(back.setups.len(), 1);
assert_eq!(undo_key(&back.setups[0]), undo_key(&ctx));
}
#[test]
fn a_record_with_no_systemd_version_survives_a_round_trip() {
let mut ctx = ctx(Platform::MacOs);
ctx.systemd_version = None;
ctx.network_services = vec![];
let text = toml::to_string_pretty(&ctx).expect("serializes");
let back: SetupContext = toml::from_str(&text).expect("deserializes without the field");
assert_eq!(back.systemd_version, None);
assert_eq!(plan_undo(&back).describe(), plan_undo(&ctx).describe());
}
#[test]
fn a_setup_record_survives_a_round_trip() {
let ctx = ctx(Platform::MacOs);
let text = toml::to_string_pretty(&ctx).expect("serializes");
let back: SetupContext = toml::from_str(&text).expect("deserializes");
assert_eq!(back.tld, ctx.tld);
assert_eq!(back.proxy_port, ctx.proxy_port);
assert_eq!(back.ca_path, ctx.ca_path);
assert_eq!(back.platform, ctx.platform);
assert_eq!(plan_undo(&back).describe(), plan_undo(&ctx).describe());
}
#[test]
fn a_record_from_before_the_generated_ca_field_still_names_one() {
let ctx = ctx(Platform::Linux);
let mut table = toml::Table::try_from(&ctx).expect("serializes");
table.remove("generated_ca");
let legacy = toml::to_string_pretty(&table).expect("re-serializes");
assert!(!legacy.contains("generated_ca"));
let back: SetupContext = toml::from_str(&legacy).expect("parses without the field");
assert_eq!(back.generated_ca, default_generated_ca());
assert!(!back.generated_ca.as_os_str().is_empty());
assert!(
plan_undo(&back)
.describe()
.iter()
.any(|l| l.contains("trust store")),
"a legacy record still plans the CA removal"
);
}
#[test]
fn an_untrust_step_runs_when_the_certificate_file_is_gone() {
let missing = PathBuf::from("/nonexistent/pitchfork-ca.pem");
assert!(!missing.exists());
assert!(!already_done(&Action::UntrustCa {
path: missing,
sudo: false,
}));
}
#[test]
fn a_setup_that_wrote_no_resolver_file_never_claims_one() {
let mut no_resolved = ctx(Platform::Linux);
no_resolved.systemd_resolved = false;
assert!(
!plan_undo(&no_resolved)
.describe()
.iter()
.any(|l| l.contains("resolved.conf.d")),
"nothing was installed, so nothing is claimed"
);
let mut dns_off = ctx(Platform::Linux);
dns_off.dns_enabled = false;
assert!(
!plan_undo(&dns_off)
.describe()
.iter()
.any(|l| l.contains("resolved.conf.d"))
);
let mut localhost = ctx(Platform::Linux);
localhost.tld = "localhost".into();
assert!(
!plan_undo(&localhost)
.describe()
.iter()
.any(|l| l.contains("resolved.conf.d"))
);
let mut lan = ctx(Platform::Linux);
lan.lan = true;
assert!(
!plan_undo(&lan)
.describe()
.iter()
.any(|l| l.contains("resolved.conf.d"))
);
assert!(
plan_undo(&ctx(Platform::Linux))
.describe()
.iter()
.any(|l| l.contains("resolved.conf.d"))
);
let mut second = no_resolved.clone();
second.proxy_port = 9999;
let lines = plan_with_reconcile(&second, std::slice::from_ref(&no_resolved)).describe();
assert!(!lines.iter().any(|l| l.contains("resolved.conf.d")));
assert!(lines.iter().any(|l| l.contains("port 443 to 8443")));
}
#[test]
fn an_http_only_setup_never_claims_the_ca() {
let mut http = ctx(Platform::Linux);
http.https = false;
assert!(
!plan_undo(&http)
.describe()
.iter()
.any(|l| l.contains("trust store")),
"an HTTP-only setup has no CA to remove"
);
let mut second = http.clone();
second.proxy_port = 9999;
let lines = plan_with_reconcile(&second, std::slice::from_ref(&http)).describe();
assert!(!lines.iter().any(|l| l.contains("trust store")));
assert!(lines.iter().any(|l| l.contains("port 80 to 8443")));
}
#[test]
fn undo_always_queues_the_ca_removal_and_probes_for_it() {
let mut custom = ctx(Platform::MacOs);
custom.custom_cert = true;
custom.ca_path = PathBuf::from("/etc/mycert.pem");
let step = plan_undo(&custom)
.steps
.into_iter()
.find(|s| s.summary.contains("trust store"))
.expect("the CA removal is queued even with a custom certificate");
let Action::UntrustCa { path, .. } = &step.action else {
panic!("expected the probing action");
};
assert_eq!(path, &custom.generated_ca);
assert_ne!(path, &custom.ca_path);
assert!(!step.needs_sudo());
let mut linux = ctx(Platform::Linux);
linux.custom_cert = true;
assert!(
plan_undo(&linux)
.steps
.iter()
.find(|s| s.summary.contains("trust store"))
.expect("queued on linux too")
.needs_sudo()
);
}
#[test]
fn undo_reverses_each_platform_step() {
let mac = ctx(Platform::MacOs);
let resolver = mac.resolver_file().display().to_string();
assert_eq!(
plan_undo(&mac).describe(),
vec![
format!("[sudo] remove {resolver}"),
"[sudo] remove the pitchfork anchor from /etc/pf.conf".to_string(),
"[sudo] remove /etc/pf.anchors/pitchfork".to_string(),
"[sudo] reload pf rules from /etc/pf.conf and release pitchfork's hold on pf"
.to_string(),
"remove the pitchfork CA at /state/proxy/ca.pem from the system trust store"
.to_string(),
]
);
let linux = ctx(Platform::Linux);
let dropin = linux.resolved_dropin().display().to_string();
let lines = plan_undo(&linux).describe();
assert!(lines.contains(&format!("[sudo] remove {dropin}")));
assert!(
lines
.iter()
.any(|l| l.contains("drop the iptables redirect"))
);
assert!(
!lines
.iter()
.any(|l| l.contains("revoke cap_net_bind_service"))
);
}
#[test]
fn undo_turns_off_a_pac_url_even_without_the_pac_flag() {
let mut c = ctx(Platform::MacOs);
c.pac = false;
c.network_services = vec!["Wi-Fi".into()];
assert!(
plan_undo(&c)
.describe()
.iter()
.any(|l| l.contains("turn off the automatic proxy URL") && l.contains("Wi-Fi"))
);
}
const APPLE_PF_CONF: &str = r#"#
# Default PF configuration file.
#
# This file contains the main ruleset, which gets automatically loaded
# at startup. PF will not be automatically enabled, however. Instead,
# each component which utilizes PF is responsible for enabling and disabling
# PF via -E and -X as documented in pfctl(8).
#
#
# com.apple anchor point
#
scrub-anchor "com.apple/*"
nat-anchor "com.apple/*"
rdr-anchor "com.apple/*"
dummynet-anchor "com.apple/*"
anchor "com.apple/*"
load anchor "com.apple" from "/etc/pf.anchors/com.apple"
"#;
#[test]
fn the_pf_block_lands_before_the_filter_anchor() {
let block = "rdr-anchor \"pitchfork\"\nload anchor \"pitchfork\" from \"/etc/pf.anchors/pitchfork\"";
let out = splice_pf_block(APPLE_PF_CONF, block);
let lines: Vec<&str> = out.lines().collect();
let ours = lines
.iter()
.position(|l| l.contains("rdr-anchor \"pitchfork\""))
.expect("our rdr-anchor is present");
let apple_rdr = lines
.iter()
.position(|l| l.contains("rdr-anchor \"com.apple/*\""))
.unwrap();
let apple_filter = lines
.iter()
.position(|l| l.trim() == "anchor \"com.apple/*\"")
.unwrap();
assert!(apple_rdr < ours, "must follow the existing rdr-anchor");
assert!(ours < apple_filter, "must precede the filter anchor");
assert_eq!(out.matches(MARKER_START).count(), 1);
assert!(out.ends_with('\n'));
let again = splice_pf_block(&out, block);
assert_eq!(again, out);
assert_eq!(splice_pf_block(&again, ""), APPLE_PF_CONF);
}
#[test]
fn a_pf_conf_with_no_rdr_anchor_still_lands_before_the_filters() {
let custom = "set skip on lo0\nblock in all\npass out all\n";
let out = splice_pf_block(custom, "rdr-anchor \"pitchfork\"");
let lines: Vec<&str> = out.lines().collect();
let ours = lines
.iter()
.position(|l| l.contains("rdr-anchor \"pitchfork\""))
.unwrap();
let first_filter = lines
.iter()
.position(|l| l.trim() == "block in all")
.unwrap();
assert!(ours < first_filter, "translation must precede filtering");
assert_eq!(splice_pf_block(&out, ""), custom);
let out = splice_pf_block("# empty ruleset\n", "rdr-anchor \"pitchfork\"");
assert!(out.contains("rdr-anchor \"pitchfork\""));
assert_eq!(out.matches(MARKER_START).count(), 1);
}
#[test]
fn a_pf_block_in_the_wrong_place_is_repositioned_not_left_alone() {
let stale = format!(
"{}{MARKER_START}\nrdr-anchor \"pitchfork\"\n{MARKER_END}\n",
APPLE_PF_CONF
);
let action = Action::EnsureBlock {
path: PathBuf::from("/nonexistent"),
content: "rdr-anchor \"pitchfork\"".to_string(),
sudo: false,
pf_order: true,
requires: None,
};
assert_ne!(splice_pf_block(&stale, "rdr-anchor \"pitchfork\""), stale);
assert!(!already_done(&action), "a missing file is not already done");
let fixed = splice_pf_block(&stale, "rdr-anchor \"pitchfork\"");
let lines: Vec<&str> = fixed.lines().collect();
let ours = lines
.iter()
.position(|l| l.contains("rdr-anchor \"pitchfork\""))
.unwrap();
let filter = lines
.iter()
.position(|l| l.trim() == "anchor \"com.apple/*\"")
.unwrap();
assert!(ours < filter, "the stale block should have moved up");
assert_eq!(fixed.matches(MARKER_START).count(), 1);
}
#[test]
fn a_pac_setup_claims_no_resolver_file_on_undo() {
for platform in [Platform::MacOs, Platform::Linux] {
let dir = tempfile::tempdir().unwrap();
let mut c = ctx(platform);
c.pac = true;
c.resolver_dir = dir.path().to_path_buf();
let resolver = c.resolver_file().display().to_string();
let dropin = c.resolved_dropin().display().to_string();
let names_resolver = |l: &String| l.contains(&resolver) || l.contains(&dropin);
let forward = plan(&c).describe();
assert!(
!forward.iter().any(names_resolver),
"{platform:?}: pac setup wrote resolver configuration: {forward:?}"
);
let undo = plan_undo(&c).describe();
assert!(
!undo.iter().any(names_resolver),
"{platform:?}: pac undo claimed a resolver file it never wrote: {undo:?}"
);
assert!(
!undo.iter().any(|l| l.contains("systemd-resolved")),
"{platform:?}: pac undo restarted the resolver for nothing: {undo:?}"
);
}
}
#[test]
fn undo_restores_an_automatic_proxy_url_that_setup_replaced() {
let mut mac = ctx(Platform::MacOs);
mac.pac = true;
mac.network_services = vec!["Wi-Fi".into()];
mac.prior_auto_proxy = vec![PriorAutoProxy {
target: "Wi-Fi".into(),
url: "https://corp.example/proxy.pac".into(),
state: "on".into(),
}];
let lines = plan_undo(&mac).describe();
let at = |needle: &str| lines.iter().position(|l| l.contains(needle));
let off = at("turn off the automatic proxy URL").expect("no disable step");
let url = at("restore the automatic proxy URL").expect("no url restore");
let state = at("restore the automatic proxy switch").expect("no state restore");
assert!(
lines[url].contains("https://corp.example/proxy.pac"),
"the restore step did not name the recorded URL: {:?}",
lines[url]
);
assert!(off < url && url < state, "restore steps are out of order");
let mut disabled = mac.clone();
disabled.prior_auto_proxy[0].state = "off".into();
let lines = plan_undo(&disabled).describe();
let last_switch = lines
.iter()
.rposition(|l| l.contains("automatic proxy switch"))
.expect("no state restore");
assert!(
lines[last_switch].ends_with("off"),
"the last word on the switch was not the recorded state: {:?}",
lines[last_switch]
);
let mut linux = ctx(Platform::Linux);
linux.pac = true;
linux.gnome = true;
linux.prior_auto_proxy = vec![PriorAutoProxy {
target: "gnome".into(),
url: "http://wpad.corp/proxy.pac".into(),
state: "manual".into(),
}];
let lines = plan_undo(&linux).describe();
let mode = lines
.iter()
.position(|l| l.contains("restore the GNOME proxy mode to manual"))
.expect("no mode restore");
let url = lines
.iter()
.position(|l| l.contains("http://wpad.corp/proxy.pac"))
.expect("no url restore");
assert!(url < mode, "the GNOME restore steps are out of order");
let mut bare = ctx(Platform::MacOs);
bare.pac = true;
bare.network_services = vec!["Wi-Fi".into()];
assert!(
!plan_undo(&bare)
.describe()
.iter()
.any(|l| l.contains("restore the automatic proxy")),
"a restore was planned with nothing recorded to restore"
);
}
#[test]
fn a_recorded_automatic_proxy_value_that_could_steer_a_command_is_dropped() {
let mut c = ctx(Platform::MacOs);
c.prior_auto_proxy = vec![
PriorAutoProxy {
target: "-setairportpower".into(),
url: "https://corp.example/proxy.pac".into(),
state: "on".into(),
},
PriorAutoProxy {
target: "Wi-Fi".into(),
url: "-setautoproxystate".into(),
state: "on".into(),
},
PriorAutoProxy {
target: "Wi-Fi".into(),
url: "https://corp.example/proxy.pac".into(),
state: "; rm -rf /".into(),
},
PriorAutoProxy {
target: "Wi-Fi".into(),
url: "/etc/passwd".into(),
state: "on".into(),
},
PriorAutoProxy {
target: "Wi-Fi".into(),
url: "https://corp.example/proxy.pac".into(),
state: "on".into(),
},
];
let kept = sanitize_record(c)
.expect("the record itself is usable")
.prior_auto_proxy;
assert_eq!(
kept,
vec![PriorAutoProxy {
target: "Wi-Fi".into(),
url: "https://corp.example/proxy.pac".into(),
state: "on".into(),
}],
"an unusable recorded value survived"
);
}
#[test]
fn a_write_replaces_a_file_whole_and_leaves_no_temporary_behind() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("pf.conf");
std::fs::write(&target, "original\n").unwrap();
write_file(&target, "replacement\n", false).unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "replacement\n");
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "pf.conf")
.collect();
assert!(strays.is_empty(), "left behind: {strays:?}");
let nested = dir.path().join("resolver").join("test");
write_file(&nested, "nameserver 127.0.0.1\n", false).unwrap();
assert_eq!(
std::fs::read_to_string(&nested).unwrap(),
"nameserver 127.0.0.1\n"
);
}
#[test]
fn setting_up_does_not_replace_a_resolver_file_somebody_else_wrote() {
let dir = tempfile::tempdir().unwrap();
let theirs = dir.path().join("test");
let original = "nameserver 127.0.0.1\nport 20560\n";
std::fs::write(&theirs, original).unwrap();
let step = Step {
summary: "write the resolver file".into(),
action: Action::WriteFile {
path: theirs.clone(),
content: macos_resolver_file(15353),
sudo: false,
},
resource: None,
};
let err = execute(&step).expect_err("somebody else's resolver file was replaced");
assert!(
err.to_string().contains(&theirs.display().to_string()),
"the refusal did not name the file: {err}"
);
assert_eq!(
std::fs::read_to_string(&theirs).unwrap(),
original,
"the file was modified despite the refusal"
);
std::fs::write(&theirs, macos_resolver_file(20000)).unwrap();
execute(&step).expect("pitchfork refused to update its own file");
assert_eq!(
std::fs::read_to_string(&theirs).unwrap(),
macos_resolver_file(15353)
);
let fresh = dir.path().join("fresh");
let step = Step {
summary: "write a new resolver file".into(),
action: Action::WriteFile {
path: fresh.clone(),
content: macos_resolver_file(15353),
sudo: false,
},
resource: None,
};
execute(&step).expect("a new file was refused");
assert!(fresh.exists());
}
#[test]
fn a_trust_store_that_will_not_answer_does_not_cancel_the_ca_removal() {
assert!(
!untrust_already_done(true, None),
"an unreadable trust store cancelled the removal"
);
assert!(!untrust_already_done(false, Some(false)));
assert!(!untrust_already_done(false, None));
assert!(!untrust_already_done(false, Some(true)));
assert!(!untrust_already_done(true, Some(true)));
assert!(untrust_already_done(true, Some(false)));
}
#[test]
fn a_capability_that_cannot_be_read_is_not_taken_for_absent() {
let bind = BIND_CAPABILITY.to_string();
let other = "cap_net_raw".to_string();
assert!(
!revoke_already_done(None),
"an unreadable probe was taken for absent"
);
assert!(revoke_already_done(Some(&[])));
assert!(revoke_already_done(Some(std::slice::from_ref(&other))));
assert!(!revoke_already_done(Some(std::slice::from_ref(&bind))));
assert!(!revoke_already_done(Some(&[bind, other])));
}
#[test]
fn getcap_output_is_read_the_way_getcap_writes_it() {
assert_eq!(
parse_capabilities(&format!(" {BIND_CAPABILITY}=ep")),
vec![BIND_CAPABILITY.to_string()]
);
assert!(parse_capabilities("").is_empty());
}
#[test]
fn output_that_does_not_name_the_binary_is_no_answer() {
let matched = |stdout: &str| capabilities_from_getcap("/usr/local/bin/pitchfork", stdout);
assert_eq!(matched(""), Some(vec![]), "empty output is no capabilities");
assert_eq!(matched(" \n"), Some(vec![]));
assert_eq!(
matched(&format!("/usr/local/bin/pitchfork {BIND_CAPABILITY}=ep\n")),
Some(vec![BIND_CAPABILITY.to_string()])
);
assert_eq!(
matched("/usr/local/bin/pitchfor\u{fffd}k cap_net_bind_service=ep\n"),
None,
"output naming a different path was read as an empty list"
);
assert_eq!(matched("something unexpected\n"), None);
}
#[test]
fn an_unusable_proxy_port_is_refused_rather_than_coerced() {
for bad in [0, -1, 65536, i64::MAX, i64::MIN] {
assert!(
validate_proxy_port(bad).is_err(),
"proxy.port {bad} was accepted"
);
}
for good in [1, 80, 443, 8443, 65535] {
assert!(
validate_proxy_port(good).is_ok(),
"proxy.port {good} was refused"
);
}
}
#[test]
fn granting_the_bind_capability_never_clears_others() {
let bin = Path::new("/usr/local/bin/pitchfork");
let caps = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
assert!(check_grant(bin, Some(&[])).is_ok());
assert!(check_grant(bin, Some(&caps(&[BIND_CAPABILITY]))).is_ok());
let err = check_grant(bin, Some(&caps(&["cap_net_raw", BIND_CAPABILITY])))
.unwrap_err()
.to_string();
assert!(err.contains("cap_net_raw"), "{err}");
assert!(check_grant(bin, None).is_err());
let mut c = ctx(Platform::Linux);
c.proxy_port = 443;
let step = plan(&c)
.steps
.into_iter()
.find(|s| s.summary.contains("cap_net_bind_service"))
.expect("linux plans a capability grant for a privileged port");
assert!(matches!(step.action, Action::GrantBindCapability { .. }));
}
#[test]
fn undo_keeps_the_anchor_while_pf_conf_still_names_it() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("pf.conf");
let anchor = dir.path().join("pitchfork-anchor");
std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
std::fs::write(
&conf,
format!("{APPLE_PF_CONF}{MARKER_START}\nload anchor \"pitchfork\"\n{MARKER_END}\n"),
)
.unwrap();
let step = Step {
summary: "remove the anchor".into(),
action: Action::RemoveFile {
path: anchor.clone(),
sudo: false,
still_referenced_by: Some(conf.clone()),
},
resource: None,
};
let err = execute(&step).expect_err("the anchor was removed while still referenced");
assert!(
err.to_string().contains(&conf.display().to_string()),
"the refusal did not name the file holding the reference: {err}"
);
assert!(anchor.exists(), "the anchor was deleted anyway");
std::fs::create_dir(dir.path().join("unreadable")).unwrap();
let unreadable = Step {
summary: "remove the anchor".into(),
action: Action::RemoveFile {
path: anchor.clone(),
sudo: false,
still_referenced_by: Some(dir.path().join("unreadable")),
},
resource: None,
};
let err = unreadable_err(&unreadable);
assert!(
err.contains("could not be read"),
"an unreadable referrer did not stop the removal: {err}"
);
assert!(anchor.exists(), "the anchor was deleted on a failed read");
let gone = Step {
summary: "remove the anchor".into(),
action: Action::RemoveFile {
path: anchor.clone(),
sudo: false,
still_referenced_by: Some(dir.path().join("no-such-file")),
},
resource: None,
};
execute(&gone).expect("a missing referrer blocked the removal");
assert!(!anchor.exists());
std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
std::fs::write(&conf, APPLE_PF_CONF).unwrap();
execute(&step).expect("the anchor was kept with nothing referencing it");
assert!(!anchor.exists());
}
fn unreadable_err(step: &Step) -> String {
execute(step)
.expect_err("the step was allowed to run")
.to_string()
}
#[test]
fn a_block_naming_a_file_pitchfork_does_not_own_is_not_written() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("pf.conf");
std::fs::write(&conf, APPLE_PF_CONF).unwrap();
let anchor = dir.path().join("pitchfork-anchor");
let step = Step {
summary: "load the pitchfork anchor".into(),
action: Action::EnsureBlock {
path: conf.clone(),
content: format!("load anchor \"pitchfork\" from \"{}\"", anchor.display()),
sudo: false,
pf_order: true,
requires: Some(anchor.clone()),
},
resource: None,
};
let err = execute(&step).expect_err("the block was written anyway");
assert!(
err.to_string().contains(&anchor.display().to_string()),
"the failure did not name the missing file: {err}"
);
assert_eq!(
std::fs::read_to_string(&conf).unwrap(),
APPLE_PF_CONF,
"pf.conf was modified despite the missing anchor"
);
std::fs::write(&anchor, "rdr pass on lo0\n").unwrap();
let err = execute(&step).expect_err("pf.conf was pointed at a foreign anchor");
assert!(
err.to_string().contains("was not written by pitchfork"),
"the refusal did not say why: {err}"
);
assert_eq!(std::fs::read_to_string(&conf).unwrap(), APPLE_PF_CONF);
std::fs::write(&anchor, pf_anchor_rules(443, 8443, "127.0.0.1")).unwrap();
execute(&step).expect("the block was refused with the anchor in place");
assert!(
std::fs::read_to_string(&conf)
.unwrap()
.contains(MARKER_START)
);
}
#[test]
fn undo_leaves_a_resolver_file_it_did_not_write() {
let dir = tempfile::tempdir().unwrap();
let theirs = dir.path().join("test");
std::fs::write(&theirs, "nameserver 10.0.0.1\n").unwrap();
let action = Action::RemoveFile {
path: theirs.clone(),
sudo: false,
still_referenced_by: None,
};
assert!(already_done(&action));
assert_eq!(skip_reason(&action), "not written by pitchfork, left alone");
assert!(remove_file(&theirs, false).is_ok());
assert!(theirs.exists(), "somebody else's resolver file was deleted");
let ours = dir.path().join("ours");
std::fs::write(&ours, macos_resolver_file(15353)).unwrap();
assert!(!already_done(&Action::RemoveFile {
path: ours.clone(),
sudo: false,
still_referenced_by: None,
}));
remove_file(&ours, false).unwrap();
assert!(!ours.exists());
}
#[test]
fn undo_sweeps_resolver_files_left_under_an_older_tld() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("oldtld");
std::fs::write(&old, macos_resolver_file(15353)).unwrap();
let theirs = dir.path().join("corp");
std::fs::write(&theirs, "nameserver 10.0.0.1\n").unwrap();
let found = managed_resolver_files(dir.path(), "test", true);
assert!(found.contains(&dir.path().join("test")), "the current TLD");
assert!(found.contains(&old), "the orphaned file");
assert!(!found.contains(&theirs), "somebody else's file");
}
#[test]
fn an_old_systemd_is_warned_about_rather_than_silently_ineffective() {
let mut c = ctx(Platform::Linux);
c.systemd_version = Some(245);
let p = plan(&c);
assert!(
p.manual.iter().any(|m| m.contains("too old")),
"expected a version warning: {:?}",
p.manual
);
assert!(
!plan(&ctx(Platform::Linux))
.manual
.iter()
.any(|m| m.contains("too old"))
);
}
#[test]
fn the_resolved_restart_is_unconditional() {
let steps = plan(&ctx(Platform::Linux)).steps;
let restart = steps
.iter()
.find(|s| s.summary.contains("restart systemd-resolved"))
.expect("linux plans a restart");
let Action::Run { skip_if, .. } = &restart.action else {
panic!("expected a command");
};
assert!(skip_if.is_none(), "the restart must not be guarded");
assert!(restart.summary.contains("interrupts DNS"));
}
#[test]
fn pf_keywords_are_whole_words_not_prefixes() {
assert!(!is_pf_translation("nat_if = \"en0\""));
assert!(!is_pf_translation("nat_if=\"en0\""));
assert!(!is_pf_filter("pass_hosts = \"{ 10.0.0.1 }\""));
assert!(!is_pf_filter("blocklist = \"{ 1.2.3.4 }\""));
assert!(is_pf_translation("rdr-anchor \"com.apple/*\""));
assert!(is_pf_translation(" nat on en0 from any to any"));
assert!(is_pf_filter("anchor \"com.apple/*\""));
assert!(is_pf_filter("pass out all"));
assert!(is_pf_filter("block in all"));
assert!(!is_pf_translation("set skip on lo0"));
assert!(!is_pf_filter("set skip on lo0"));
}
#[test]
fn a_macro_named_like_a_rule_does_not_move_the_block() {
let custom = "nat_if = \"en0\"\nset skip on lo0\nblock in all\n";
let out = splice_pf_block(custom, "rdr-anchor \"pitchfork\"");
let lines: Vec<&str> = out.lines().collect();
let ours = lines
.iter()
.position(|l| l.contains("rdr-anchor \"pitchfork\""))
.unwrap();
let macro_line = lines.iter().position(|l| l.starts_with("nat_if")).unwrap();
let filter = lines
.iter()
.position(|l| l.trim() == "block in all")
.unwrap();
assert!(
ours > macro_line,
"must not be treated as a translation rule"
);
assert!(ours < filter, "must still precede the filter");
}
#[test]
fn splice_block_inserts_replaces_and_removes() {
let original = "scrub-anchor \"com.apple/*\"\n";
let added = splice_block(original, "rdr-anchor \"pitchfork\"");
assert_eq!(
added,
"scrub-anchor \"com.apple/*\"\n\n# pitchfork-start\nrdr-anchor \"pitchfork\"\n# pitchfork-end\n"
);
let replaced = splice_block(&added, "rdr-anchor \"other\"");
assert!(replaced.contains("rdr-anchor \"other\""));
assert!(!replaced.contains("rdr-anchor \"pitchfork\""));
assert_eq!(replaced.matches(MARKER_START).count(), 1);
assert_eq!(splice_block(&added, ""), original);
}
#[test]
fn generated_files_carry_the_expected_contents() {
assert_eq!(
macos_resolver_file(15353),
"# Managed by pitchfork (pitchfork proxy setup)\nnameserver 127.0.0.1\nport 15353\n"
);
let dropin = resolved_dropin("test", 15353);
assert!(dropin.contains("[Resolve]"));
assert!(dropin.contains("DNS=127.0.0.1:15353"));
assert!(dropin.contains("Domains=~test"));
assert!(
pf_anchor_rules(443, 8443, "127.0.0.1")
.contains("lo0 inet proto tcp from any to any port 443 -> 127.0.0.1 port 8443")
);
}
#[test]
fn only_a_sudo_elevated_run_counts_as_sudo() {
assert_eq!(
sudo_user(true, Some("alice".into())).as_deref(),
Some("alice")
);
assert_eq!(sudo_user(true, None), None);
assert_eq!(sudo_user(true, Some("root".into())), None);
assert_eq!(sudo_user(false, Some("alice".into())), None);
}
#[test]
fn pac_leaves_the_local_namespace_alone_in_lan_mode() {
for platform in [Platform::MacOs, Platform::Linux, Platform::Other] {
let mut c = ctx(platform);
c.network_services = vec!["Wi-Fi".into()];
c.gnome = true;
c.pac = true;
c.lan = true;
c.tld = "local".into();
let p = plan(&c);
assert!(
!p.steps
.iter()
.any(|s| matches!(s.resource, Some(Resource::AutoProxy { .. }))),
"{platform:?} configured an automatic proxy in LAN mode: {:?}",
p.describe()
);
assert!(
!p.manual.iter().any(|m| m.contains("proxy.pac")),
"{platform:?} asked for a manual PAC URL in LAN mode"
);
}
}
#[test]
fn the_pf_reference_is_kept_and_given_back() {
let stderr = "No ALTQ support in kernel\nALTQ related functions disabled\n\
pf enabled\nToken : 11083498731209435137\n";
assert_eq!(
parse_pf_token(stderr).as_deref(),
Some("11083498731209435137")
);
assert_eq!(parse_pf_token("pf already enabled\n"), None);
assert_eq!(parse_pf_token("Token : ; rm -rf /\n"), None);
let mac = ctx(Platform::MacOs);
let forward = plan(&mac);
assert!(
forward
.steps
.iter()
.any(|s| matches!(s.action, Action::EnablePf { .. }))
);
assert!(
plan_undo(&mac)
.steps
.iter()
.any(|s| matches!(s.action, Action::ReleasePf { .. }))
);
assert!(
!plan_with_reconcile(&mac, std::slice::from_ref(&mac))
.steps
.iter()
.any(|s| matches!(s.action, Action::ReleasePf { .. }))
);
let mut pac = mac.clone();
pac.pac = true;
assert!(
plan_with_reconcile(&pac, std::slice::from_ref(&mac))
.steps
.iter()
.any(|s| matches!(s.action, Action::ReleasePf { .. }))
);
}
#[test]
fn the_redirect_follows_the_family_the_proxy_answers_on() {
let mut linux = ctx(Platform::Linux);
linux.contact_host = "[::1]".into();
let step = plan(&linux)
.steps
.into_iter()
.find(|s| matches!(s.resource, Some(Resource::Redirect { .. })))
.expect("a redirect for an unprivileged port");
let Action::Run { argv, skip_if, .. } = &step.action else {
panic!("unexpected action {:?}", step.action);
};
assert_eq!(argv[0], "ip6tables");
assert_eq!(skip_if.as_ref().unwrap().argv[0], "ip6tables");
let undo = plan_undo(&linux);
let undo = undo
.steps
.iter()
.find(|s| s.resource == step.resource)
.expect("undo removes the same redirect");
let Action::RunIfPresent { argv, .. } = &undo.action else {
panic!("unexpected action {:?}", undo.action);
};
assert_eq!(argv[0], "ip6tables");
let mut mac = ctx(Platform::MacOs);
mac.contact_host = "[::1]".into();
let anchor = plan(&mac)
.steps
.into_iter()
.find_map(|s| match s.action {
Action::WriteFile { path, content, .. } if path == mac.pf_anchor => Some(content),
_ => None,
})
.expect("an anchor file");
assert!(
anchor.contains("lo0 inet6 proto tcp from any to any port 443 -> ::1 port 8443"),
"{anchor}"
);
let step = plan(&ctx(Platform::Linux))
.steps
.into_iter()
.find(|s| matches!(s.resource, Some(Resource::Redirect { .. })))
.unwrap();
assert!(matches!(&step.action, Action::Run { argv, .. } if argv[0] == "iptables"));
}
}