use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const QUERY_ID: u16 = 0x7f00;
const MAX_PAC_RESPONSE: u64 = 64 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Pass,
Warn,
Fail,
}
impl Status {
fn glyph(self) -> &'static str {
match self {
Status::Pass => "ok ",
Status::Warn => "warn",
Status::Fail => "fail",
}
}
}
#[derive(Clone, Debug)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
impl Check {
fn new(name: impl Into<String>, status: Status, detail: impl Into<String>) -> Self {
Check {
name: name.into(),
status,
detail: detail.into(),
}
}
pub fn line(&self) -> String {
format!(
"[{}] {:<22} {}",
self.status.glyph(),
self.name,
self.detail
)
}
}
fn probe_name(tld: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or_default();
static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nonce = nanos ^ std::process::id().rotate_left(11) ^ seq.rotate_left(23);
format!("pf-doctor-{nonce:08x}.{tld}")
}
async fn query_responder(
name: &str,
port: u16,
family: std::net::IpAddr,
) -> Result<std::net::IpAddr, String> {
let (qtype, rdlen) = match family {
std::net::IpAddr::V4(_) => (1u16, 4usize),
std::net::IpAddr::V6(_) => (28u16, 16usize),
};
let mut msg: Vec<u8> = Vec::new();
msg.extend_from_slice(&QUERY_ID.to_be_bytes()); msg.extend_from_slice(&0x0100u16.to_be_bytes()); msg.extend_from_slice(&1u16.to_be_bytes()); msg.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
for label in name.split('.') {
msg.push(u8::try_from(label.len()).map_err(|_| "label too long".to_string())?);
msg.extend_from_slice(label.as_bytes());
}
msg.push(0);
msg.extend_from_slice(&qtype.to_be_bytes());
msg.extend_from_slice(&1u16.to_be_bytes());
let sock = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.map_err(|e| e.to_string())?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
sock.send_to(&msg, addr).await.map_err(|e| e.to_string())?;
let deadline = tokio::time::Instant::now() + PROBE_TIMEOUT;
let mut buf = [0u8; 512];
let resp = loop {
let (len, from) = tokio::time::timeout_at(deadline, sock.recv_from(&mut buf))
.await
.map_err(|_| "no reply within 3s".to_string())?
.map_err(|e| e.to_string())?;
if from != addr {
continue;
}
if len < 12 {
return Err("reply was too short to be a DNS message".into());
}
if u16::from_be_bytes([buf[0], buf[1]]) != QUERY_ID {
continue;
}
break &buf[..len];
};
let rcode = u16::from_be_bytes([resp[2], resp[3]]) & 0x000F;
if rcode != 0 {
return Err(format!("responder returned rcode {rcode}"));
}
let mut pos = 12;
while let Some(&l) = resp.get(pos) {
pos += 1 + usize::from(l);
if l == 0 {
break;
}
}
pos += 4; let rdata = resp
.get(pos + 12..pos + 12 + rdlen)
.ok_or_else(|| "reply carried no address record".to_string())?;
Ok(match family {
std::net::IpAddr::V4(_) => {
std::net::IpAddr::V4(Ipv4Addr::new(rdata[0], rdata[1], rdata[2], rdata[3]))
}
std::net::IpAddr::V6(_) => {
let octets: [u8; 16] = rdata.try_into().map_err(|_| "short AAAA record")?;
std::net::IpAddr::V6(octets.into())
}
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PortProbe {
Closed,
Foreign,
Silent,
Pitchfork,
}
async fn probe_port(ip: std::net::IpAddr, port: u16) -> PortProbe {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addr = SocketAddr::from((ip, port));
let connect = tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(addr)).await;
let Ok(Ok(mut stream)) = connect else {
return PortProbe::Closed;
};
const REQUEST: &str = "GET / HTTP/1.1\r\nHost: pf-doctor.invalid\r\nConnection: close\r\n\r\n";
let exchange = async {
stream.write_all(REQUEST.as_bytes()).await?;
let mut buf = vec![0u8; 2048];
let n = stream.read(&mut buf).await?;
buf.truncate(n);
Ok::<_, std::io::Error>(buf)
};
match tokio::time::timeout(PROBE_TIMEOUT, exchange).await {
Ok(Ok(buf)) => {
let head = String::from_utf8_lossy(&buf).to_ascii_lowercase();
if head.contains("x-pitchfork") {
PortProbe::Pitchfork
} else {
PortProbe::Foreign
}
}
Ok(Err(_)) => PortProbe::Foreign,
Err(_) => PortProbe::Silent,
}
}
fn load_configured_cert(cert_path: &std::path::Path, key: &str) -> Result<(), String> {
if !cert_path.exists() {
return Err(format!(
"proxy.tls_cert {} does not exist, so the HTTPS listener cannot start",
cert_path.display()
));
}
if key.is_empty() {
return Err("proxy.tls_cert is set but proxy.tls_key is empty".to_string());
}
let key_path = std::path::Path::new(key);
if !key_path.exists() {
return Err(format!(
"proxy.tls_key {} does not exist, so the HTTPS listener cannot start",
key_path.display()
));
}
#[cfg(feature = "proxy-tls")]
{
use rustls_pemfile::{certs, private_key};
let cert_pem = std::fs::read(cert_path)
.map_err(|e| format!("cannot read {}: {e}", cert_path.display()))?;
let found: Vec<_> = certs(&mut cert_pem.as_slice())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("cannot parse {}: {e}", cert_path.display()))?;
if found.is_empty() {
return Err(format!("no certificate found in {}", cert_path.display()));
}
let key_pem = std::fs::read(key_path)
.map_err(|e| format!("cannot read {}: {e}", key_path.display()))?;
let key_der = private_key(&mut key_pem.as_slice())
.map_err(|e| format!("cannot parse {}: {e}", key_path.display()))?
.ok_or_else(|| format!("no private key found in {}", key_path.display()))?;
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
.map_err(|e| format!("cannot use the key in {}: {e}", key_path.display()))?;
rustls::sign::CertifiedKey::new(found, signing_key)
.keys_match()
.map_err(|e| {
format!(
"proxy.tls_key {} does not match proxy.tls_cert {}: {e}",
key_path.display(),
cert_path.display()
)
})?;
}
Ok(())
}
async fn bounded_blocking<T, F>(f: F) -> Option<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
bounded_blocking_for(PROBE_TIMEOUT, f).await
}
async fn bounded_blocking_for<T, F>(budget: Duration, f: F) -> Option<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
tokio::time::timeout(budget, rx).await.ok()?.ok()
}
async fn ca_is_trusted(ca_path: &std::path::Path) -> Option<bool> {
let probe = ca_path.to_path_buf();
let budget = crate::proxy::trust::TRUST_PROBE_TIMEOUT + Duration::from_secs(1);
bounded_blocking_for(budget, move || crate::proxy::trust::ca_trust_state(&probe))
.await
.flatten()
}
enum Answer {
Said(String),
Nothing,
Unknown,
}
fn means_no_such_setting(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("no schemas installed") || s.contains("no such schema") || s.contains("no such key")
}
async fn pac_configured(pac_url: &str) -> Option<bool> {
async fn output(argv: &[&str]) -> Answer {
let run = tokio::process::Command::new(argv[0])
.args(&argv[1..])
.env("LC_ALL", "C")
.env("LANGUAGE", "C")
.env("LANG", "C")
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.output();
match tokio::time::timeout(PROBE_TIMEOUT, run).await {
Err(_) => Answer::Unknown,
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Answer::Nothing,
Ok(Err(_)) => Answer::Unknown,
Ok(Ok(out)) if out.status.success() => {
Answer::Said(String::from_utf8_lossy(&out.stdout).into_owned())
}
Ok(Ok(out)) if means_no_such_setting(&String::from_utf8_lossy(&out.stderr)) => {
Answer::Nothing
}
Ok(Ok(_)) => Answer::Unknown,
}
}
let mut unknown = false;
if cfg!(target_os = "macos") {
let services = match output(&["networksetup", "-listallnetworkservices"]).await {
Answer::Said(o) => o,
Answer::Nothing => return Some(false),
Answer::Unknown => return None,
};
let mut probes = tokio::task::JoinSet::new();
for svc in services
.lines()
.skip(1) .filter(|l| !l.trim().is_empty() && !l.starts_with('*'))
.map(|l| l.trim().to_string())
{
let url = pac_url.to_string();
probes.spawn(async move {
match output(&["networksetup", "-getautoproxyurl", &svc]).await {
Answer::Said(o) => {
let enabled = o.lines().any(|l| {
let l = l.trim().to_ascii_lowercase();
l.starts_with("enabled:") && l.ends_with("yes")
});
Some(o.contains(&url) && enabled)
}
Answer::Nothing => Some(false),
Answer::Unknown => None,
}
});
}
while let Some(res) = probes.join_next().await {
match res.unwrap_or(None) {
Some(true) => return Some(true),
Some(false) => {}
None => unknown = true,
}
}
return (!unknown).then_some(false);
}
let mode = output(&["gsettings", "get", "org.gnome.system.proxy", "mode"]).await;
if !matches!(&mode, Answer::Said(m) if m.trim().trim_matches('\'') == "auto") {
return decide_gnome(mode, None, pac_url);
}
let url = output(&[
"gsettings",
"get",
"org.gnome.system.proxy",
"autoconfig-url",
])
.await;
decide_gnome(mode, Some(url), pac_url)
}
fn decide_gnome(mode: Answer, url: Option<Answer>, pac_url: &str) -> Option<bool> {
match mode {
Answer::Unknown => return None,
Answer::Nothing => return Some(false),
Answer::Said(m) if m.trim().trim_matches('\'') != "auto" => return Some(false),
Answer::Said(_) => {}
}
match url {
Some(Answer::Said(u)) => Some(u.contains(pac_url)),
Some(Answer::Nothing) => Some(false),
Some(Answer::Unknown) => None,
None => None,
}
}
async fn fetch_pac(url: &str) -> Result<(), String> {
let addr = url
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or_default()
.to_string();
let path = super::pac::PAC_PATH;
let host = addr.clone();
let exchange = async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = tokio::net::TcpStream::connect(&addr).await?;
let req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
stream.write_all(req.as_bytes()).await?;
let mut body = Vec::new();
tokio::io::AsyncReadExt::take(&mut stream, MAX_PAC_RESPONSE)
.read_to_end(&mut body)
.await?;
Ok::<_, std::io::Error>(String::from_utf8_lossy(&body).into_owned())
};
match tokio::time::timeout(PROBE_TIMEOUT, exchange).await {
Ok(Ok(body)) if body.contains("FindProxyForURL") => Ok(()),
Ok(Ok(_)) => Err("the response was not a PAC script".to_string()),
Ok(Err(e)) => Err(e.to_string()),
Err(_) => Err("no reply within 3s".to_string()),
}
}
fn resolution_check(
name: &str,
tld: &str,
pac_ready: Option<bool>,
lan: bool,
resolved: Option<Result<Vec<std::net::IpAddr>, String>>,
) -> Check {
match resolved {
Some(Ok(ips)) if !ips.is_empty() => Check::new(
"system resolution",
Status::Pass,
format!(
"{name} resolves to {}",
ips.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(", ")
),
),
_ if pac_ready == Some(true) => Check::new(
"system resolution",
Status::Pass,
format!("not needed: the system proxy sends *.{tld} to pitchfork (PAC)"),
),
None => Check::new(
"system resolution",
Status::Warn,
format!("the system resolver did not answer in time, so {name} could not be checked"),
),
_ if lan => Check::new(
"system resolution",
Status::Warn,
format!(
"{name} did not resolve. In LAN mode *.{tld} is answered over \
mDNS, which `proxy setup` does not configure: check that the \
supervisor is publishing and that this host resolves mDNS names"
),
),
_ if pac_ready.is_none() => Check::new(
"system resolution",
Status::Warn,
format!(
"{name} does not resolve, but the system would not say whether \
a proxy auto-config file is in use, which would explain it"
),
),
Some(Err(e)) => Check::new(
"system resolution",
Status::Fail,
format!("{name} does not resolve ({e}) — run `pitchfork proxy setup`"),
),
Some(Ok(_)) => Check::new(
"system resolution",
Status::Fail,
format!("{name} does not resolve — run `pitchfork proxy setup`"),
),
}
}
enum SystemName {
Published(String),
NonePublished,
Unreadable,
AllAmbiguous(Vec<String>),
}
fn published_slug_name(tld: &str) -> SystemName {
let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
if let Some(slug) = slugs
.keys()
.find(|slug| !crate::pitchfork_toml::PitchforkToml::slug_is_ambiguous(slug, &slugs))
{
return SystemName::Published(format!("{}.{tld}", slug.to_ascii_lowercase()));
}
if slugs.is_empty() {
SystemName::NonePublished
} else {
SystemName::AllAmbiguous(slugs.keys().cloned().collect())
}
}
pub async fn run(s: &crate::settings::Settings) -> Vec<Check> {
let mut checks = Vec::new();
if !s.proxy.enable {
checks.push(Check::new(
"proxy",
Status::Fail,
"disabled — set proxy.enable = true",
));
return checks;
}
let tld = crate::proxy::effective_tld(s).to_string();
let dns_port = super::dns::dns_port(s);
let Some(proxy_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) else {
checks.push(Check::new(
"proxy port",
Status::Fail,
format!(
"proxy.port is {}, which is not a usable port — the proxy \
cannot start until it is set between 1 and 65535",
s.proxy.port
),
));
return checks;
};
let standard_port = if s.proxy.https { 443 } else { 80 };
let proxy_ip: std::net::IpAddr = match s.proxy.host.parse() {
Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
Ok(std::net::IpAddr::V6(ip)) if ip.is_unspecified() => std::net::Ipv6Addr::LOCALHOST.into(),
Ok(ip) => ip,
Err(_) => Ipv4Addr::LOCALHOST.into(),
};
let proxy_at = |port: u16| match proxy_ip {
std::net::IpAddr::V6(ip) => format!("[{ip}]:{port}"),
std::net::IpAddr::V4(ip) => format!("{ip}:{port}"),
};
let pac_url = format!("http://{}{}", proxy_at(proxy_port), super::pac::PAC_PATH);
let resolver_family = {
let cfg = super::dns::config_from_settings(s, None);
match (cfg.ipv4, cfg.ipv6) {
(Some(ip), _) => std::net::IpAddr::V4(ip),
(None, Some(ip)) => std::net::IpAddr::V6(ip),
(None, None) => std::net::IpAddr::V4(Ipv4Addr::LOCALHOST),
}
};
let pac_ready = pac_configured(&pac_url).await;
checks.push(match probe_port(proxy_ip, proxy_port).await {
PortProbe::Pitchfork => Check::new(
"proxy listener",
Status::Pass,
format!("the pitchfork proxy answers on {}", proxy_at(proxy_port)),
),
PortProbe::Foreign => Check::new(
"proxy listener",
Status::Fail,
format!(
"something other than pitchfork holds {} — proxy URLs would reach it instead",
proxy_at(proxy_port)
),
),
PortProbe::Silent => Check::new(
"proxy listener",
Status::Warn,
format!(
"something holds {} but did not answer in time, so it could not \
be identified — it may be the proxy under load",
proxy_at(proxy_port)
),
),
PortProbe::Closed => Check::new(
"proxy listener",
Status::Fail,
format!(
"nothing is listening on {} — start it with `pitchfork supervisor start`",
proxy_at(proxy_port)
),
),
});
let name = probe_name(&tld);
if !s.proxy.dns {
checks.push(Check::new(
"dns resolver",
Status::Warn,
"proxy.dns is false, so pitchfork answers no DNS queries",
));
} else {
match query_responder(&name, dns_port, resolver_family).await {
Ok(ip) => checks.push(Check::new(
"dns resolver",
Status::Pass,
format!("127.0.0.1:{dns_port} answers *.{tld} with {ip}"),
)),
Err(e) => checks.push(Check::new(
"dns resolver",
Status::Fail,
format!("127.0.0.1:{dns_port} did not answer: {e}"),
)),
}
}
let lan = s.proxy.lan || !s.proxy.lan_ip.is_empty();
let published_only = lan || (!s.proxy.dns && pac_ready != Some(true));
let system_name = if published_only {
let for_tld = tld.clone();
bounded_blocking(move || published_slug_name(&for_tld))
.await
.unwrap_or(SystemName::Unreadable)
} else {
SystemName::Published(name.clone())
};
match system_name {
SystemName::NonePublished if lan => checks.push(Check::new(
"system resolution",
Status::Pass,
"nothing is published yet — add one with `pitchfork proxy add <slug>`",
)),
SystemName::NonePublished => checks.push(Check::new(
"system resolution",
if tld.eq_ignore_ascii_case("localhost") {
Status::Warn
} else {
Status::Fail
},
format!(
"proxy.dns is false and no slug is published, so *.{tld} names resolve \
only where the system or browser does so itself — enable proxy.dns and \
run `pitchfork proxy setup`, or register a slug with proxy.sync_hosts on"
),
)),
SystemName::AllAmbiguous(slugs) => checks.push(Check::new(
"system resolution",
Status::Warn,
format!(
"every registered slug collides with another that differs only by case \
({}), so the proxy routes none of them — remove one of each pair with \
`pitchfork proxy remove <slug>`",
slugs.join(", ")
),
)),
SystemName::Unreadable => checks.push(Check::new(
"system resolution",
Status::Warn,
"the slug registry did not open in time, so there was no name to check",
)),
SystemName::Published(lookup) => {
let lookup_name = lookup.clone();
let resolved = bounded_blocking(move || {
use std::net::ToSocketAddrs;
(lookup_name.as_str(), 80u16)
.to_socket_addrs()
.map(|addrs| addrs.map(|a| a.ip()).collect::<Vec<_>>())
.map_err(|e| e.to_string())
})
.await;
let mut check = resolution_check(&lookup, &tld, pac_ready, lan, resolved);
if !s.proxy.dns && !lan && check.status == Status::Fail {
check.detail = format!(
"{lookup} does not resolve, and proxy.dns is false — enable proxy.dns \
and run `pitchfork proxy setup`, or keep proxy.sync_hosts on"
);
}
checks.push(check);
}
}
if pac_ready == Some(true) {
checks.push(match fetch_pac(&pac_url).await {
Ok(()) => Check::new(
"pac file",
Status::Pass,
format!("the system proxy uses {pac_url}, and it is being served"),
),
Err(e) => Check::new(
"pac file",
Status::Fail,
format!("the system proxy uses {pac_url}, but it could not be fetched: {e}"),
),
});
}
if s.proxy.https
&& let Some(problem) =
crate::proxy::server::tls_pair_problem(&s.proxy.tls_cert, &s.proxy.tls_key)
{
checks.push(Check::new("certificate", Status::Fail, problem));
} else if s.proxy.https {
let custom = !s.proxy.tls_cert.is_empty();
let ca_path = if custom {
std::path::PathBuf::from(&s.proxy.tls_cert)
} else {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
};
checks.push(if custom {
let (cert_arg, key_arg) = (ca_path.clone(), s.proxy.tls_key.clone());
match bounded_blocking(move || load_configured_cert(&cert_arg, &key_arg)).await {
Some(Ok(())) => Check::new(
"certificate",
Status::Pass,
format!("serving your certificate from {}", ca_path.display()),
),
Some(Err(e)) => Check::new("certificate", Status::Fail, e),
None => Check::new(
"certificate",
Status::Warn,
format!(
"reading {} did not finish in time, so the certificate \
could not be checked",
ca_path.display()
),
),
}
} else if !ca_path.exists() {
Check::new(
"certificate",
Status::Fail,
format!(
"no CA at {} — start the supervisor once to generate it",
ca_path.display()
),
)
} else {
match ca_is_trusted(&ca_path).await {
Some(true) => {
Check::new("certificate", Status::Pass, "the pitchfork CA is trusted")
}
Some(false) => Check::new(
"certificate",
Status::Fail,
"the pitchfork CA is not trusted — run `pitchfork proxy trust`",
),
None => Check::new(
"certificate",
Status::Warn,
"could not tell whether the pitchfork CA is trusted: \
the trust store did not answer in time",
),
}
});
}
if proxy_port != standard_port && pac_ready == Some(true) {
checks.push(Check::new(
"standard port",
Status::Pass,
format!("not needed: the PAC file sends requests to port {proxy_port}"),
));
} else if proxy_port != standard_port && pac_ready.is_none() {
checks.push(Check::new(
"standard port",
Status::Warn,
format!(
"the system would not say whether a proxy auto-config file is \
in use, so port {standard_port} was not checked"
),
));
} else if proxy_port != standard_port {
checks.push(match probe_port(proxy_ip, standard_port).await {
PortProbe::Pitchfork => Check::new(
"standard port",
Status::Pass,
format!("port {standard_port} reaches the proxy on {proxy_port}"),
),
PortProbe::Foreign => Check::new(
"standard port",
Status::Fail,
format!(
"port {standard_port} is held by something other than pitchfork, \
so proxy URLs without a port would reach it instead"
),
),
PortProbe::Silent => Check::new(
"standard port",
Status::Warn,
format!(
"something holds port {standard_port} but did not answer in \
time, so it could not be identified"
),
),
PortProbe::Closed => Check::new(
"standard port",
Status::Warn,
format!(
"port {standard_port} is not redirected, so URLs need :{proxy_port} — \
run `pitchfork proxy setup`"
),
),
});
}
checks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_missing_setting_counts_as_an_answer() {
for absent in [
"No schemas installed\n",
"No such schema \u{201c}org.gnome.system.proxy\u{201d}\n",
"No such key \u{201c}autoconfig-url\u{201d}\n",
] {
assert!(
means_no_such_setting(absent),
"not recognised as an absent setting: {absent:?}"
);
}
for unclear in [
"Failed to connect to the session bus: No such file or directory\n",
"Error spawning command line \u{201c}dbus-launch\u{201d}\n",
"** Error: The parameters were not valid.\n",
"Operation not permitted\n",
"",
] {
assert!(
!means_no_such_setting(unclear),
"treated as proof that nothing is configured: {unclear:?}"
);
}
}
#[test]
fn a_machine_with_no_pac_gives_a_definite_answer() {
let ours = "http://127.0.0.1:8443/proxy.pac";
let said = |s: &str| Answer::Said(s.to_string());
assert_eq!(decide_gnome(Answer::Nothing, None, ours), Some(false));
assert_eq!(decide_gnome(said("'manual'\n"), None, ours), Some(false));
assert_eq!(
decide_gnome(said("'auto'\n"), Some(said(&format!("'{ours}'\n"))), ours),
Some(true)
);
assert_eq!(
decide_gnome(
said("'auto'\n"),
Some(said("'https://corp.example/proxy.pac'\n")),
ours
),
Some(false)
);
assert_eq!(decide_gnome(Answer::Unknown, None, ours), None);
assert_eq!(
decide_gnome(said("'auto'\n"), Some(Answer::Unknown), ours),
None
);
}
#[tokio::test]
async fn a_listener_that_hangs_up_is_a_port_conflict_not_an_unknown() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepting = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
drop(stream);
}
});
assert!(
matches!(probe_port(addr.ip(), addr.port()).await, PortProbe::Foreign),
"a listener that said nothing usable was not reported as a conflict"
);
accepting.abort();
}
#[test]
fn the_trust_probe_is_given_longer_than_it_gives_itself() {
assert!(
crate::proxy::trust::TRUST_PROBE_TIMEOUT >= PROBE_TIMEOUT,
"the shared probe budget would cut the trust probe short"
);
let budget = crate::proxy::trust::TRUST_PROBE_TIMEOUT + Duration::from_secs(1);
assert!(
budget > crate::proxy::trust::TRUST_PROBE_TIMEOUT,
"the outer deadline would fire before the probe could reap its child"
);
}
#[test]
fn a_resolver_that_timed_out_is_not_reported_as_a_broken_name() {
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
let no_pac = Some(false);
let timed_out = resolution_check("app.test", "test", no_pac, false, None);
assert_eq!(timed_out.status, Status::Warn);
assert!(
timed_out.detail.contains("did not answer in time"),
"the timeout was not explained: {}",
timed_out.detail
);
let refused = resolution_check(
"app.test",
"test",
no_pac,
false,
Some(Err("Name or service not known".into())),
);
assert_eq!(refused.status, Status::Fail);
assert!(refused.detail.contains("Name or service not known"));
assert_eq!(
resolution_check("app.test", "test", no_pac, false, Some(Ok(vec![]))).status,
Status::Fail
);
let found = resolution_check(
"app.test",
"test",
no_pac,
false,
Some(Ok(vec![ip("127.0.0.1")])),
);
assert_eq!(found.status, Status::Pass);
assert!(found.detail.contains("127.0.0.1"));
for answer in [None, Some(Err("boom".to_string())), Some(Ok(vec![]))] {
assert_eq!(
resolution_check("app.test", "test", Some(true), false, answer).status,
Status::Pass,
"a PAC setup was told to fix its DNS"
);
}
for answer in [None, Some(Err("boom".to_string())), Some(Ok(vec![]))] {
assert_eq!(
resolution_check("app.test", "test", None, false, answer).status,
Status::Warn,
"an unreadable proxy configuration was reported as broken DNS"
);
}
for answer in [Some(Err("boom".to_string())), Some(Ok(vec![]))] {
let c = resolution_check("app.local", "local", no_pac, true, answer);
assert_eq!(c.status, Status::Warn, "a broken mDNS path was hidden");
assert!(
c.detail.contains("mDNS"),
"the warning did not say where to look: {}",
c.detail
);
assert!(
!c.detail.contains("run `pitchfork proxy setup`"),
"LAN mode was told to re-run setup: {}",
c.detail
);
}
for (pac, lan) in [(no_pac, true), (None, false)] {
let c = resolution_check("app.local", "local", pac, lan, None);
assert_eq!(c.status, Status::Warn);
assert!(c.detail.contains("did not answer in time"), "{}", c.detail);
}
assert_eq!(
resolution_check(
"app.local",
"local",
no_pac,
true,
Some(Ok(vec![ip("192.168.1.10")]))
)
.status,
Status::Pass
);
assert_eq!(
resolution_check(
"app.test",
"test",
None,
false,
Some(Ok(vec![ip("127.0.0.1")]))
)
.status,
Status::Pass
);
}
#[tokio::test(start_paused = true)]
async fn a_blocking_probe_that_never_answers_gives_up() {
let (release, wait) = std::sync::mpsc::channel::<()>();
let stuck = bounded_blocking(move || {
let _ = wait.recv();
"answered"
});
assert_eq!(stuck.await, None, "the deadline did not hold");
drop(release);
}
#[tokio::test]
async fn a_blocking_probe_that_answers_returns_its_value() {
assert_eq!(bounded_blocking(|| "answered").await, Some("answered"));
}
#[test]
fn probe_names_are_unique_and_under_the_tld() {
let a = probe_name("test");
let b = probe_name("test");
assert!(a.ends_with(".test"));
assert_ne!(a, b, "each run must use a name nothing could have cached");
}
#[test]
fn the_standard_port_is_not_expected_under_pac() {
let pac = Check::new(
"standard port",
Status::Pass,
"not needed: the PAC file sends requests to port 8443",
);
assert_eq!(pac.status, Status::Pass);
assert!(pac.line().contains("not needed"));
}
#[test]
fn a_pac_url_only_counts_when_automatic_proxy_is_on() {
let enabled_yes = |o: &str| {
o.lines().any(|l| {
let l = l.trim().to_ascii_lowercase();
l.starts_with("enabled:") && l.ends_with("yes")
})
};
assert!(enabled_yes(
"URL: http://127.0.0.1:8443/proxy.pac\nEnabled: Yes\n"
));
assert!(!enabled_yes(
"URL: http://127.0.0.1:8443/proxy.pac\nEnabled: No\n"
));
let is_auto = |o: &str| o.trim().trim_matches('\'') == "auto";
assert!(is_auto("'auto'\n"));
assert!(!is_auto("'none'\n"));
assert!(!is_auto("'manual'\n"));
}
#[test]
fn check_lines_are_single_line_and_labelled() {
let line = Check::new("dns resolver", Status::Fail, "no reply").line();
assert!(!line.contains('\n'));
assert!(line.starts_with("[fail] dns resolver"));
assert!(line.ends_with("no reply"));
}
#[tokio::test]
async fn responder_probe_reads_back_the_answer() {
let cancel = tokio_util::sync::CancellationToken::new();
let (tx, rx) = tokio::sync::oneshot::channel();
let addr = super::super::dns::free_udp_and_tcp_addr().await;
let task = tokio::spawn({
let cancel = cancel.clone();
async move {
super::super::dns::serve(
super::super::dns::ResolverConfig::loopback("test"),
addr,
tx,
cancel,
)
.await
}
});
rx.await.unwrap().unwrap();
let v4 = std::net::IpAddr::V4(Ipv4Addr::LOCALHOST);
let ip = query_responder(&probe_name("test"), addr.port(), v4)
.await
.expect("responder answers");
assert_eq!(ip, v4);
let v6 = std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST);
let dual = super::super::dns::ResolverConfig::for_bind("test", v6);
assert_eq!(dual.ipv4, None);
let err = query_responder("example.com", addr.port(), v4)
.await
.unwrap_err();
assert!(err.contains("rcode 5"), "unexpected error: {err}");
cancel.cancel();
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("the resolver did not stop within 5s of cancellation")
.expect("the resolver task panicked")
.expect("the resolver returned an error");
}
}