Skip to main content

pitchfork_cli/proxy/
doctor.rs

1//! `pitchfork proxy doctor` — one line per thing that has to be true for a
2//! proxy URL to work in a browser.
3//!
4//! Each check is independent and non-destructive, so a failure early on does
5//! not hide the state of everything after it.
6
7use std::net::{Ipv4Addr, SocketAddr};
8use std::time::Duration;
9
10/// How long any single probe is allowed to take.
11const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
12
13/// Transaction ID on the resolver probe, echoed in the reply.
14///
15/// Checked on the way back so a datagram from another process on this loopback
16/// port is not mistaken for the responder's answer.
17const QUERY_ID: u16 = 0x7f00;
18
19/// Most of a PAC response doctor will read before giving up on it.
20const MAX_PAC_RESPONSE: u64 = 64 * 1024;
21
22/// Result of one check.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Status {
25    Pass,
26    Warn,
27    Fail,
28}
29
30impl Status {
31    fn glyph(self) -> &'static str {
32        match self {
33            Status::Pass => "ok  ",
34            Status::Warn => "warn",
35            Status::Fail => "fail",
36        }
37    }
38}
39
40/// One named check and what it found.
41#[derive(Clone, Debug)]
42pub struct Check {
43    pub name: String,
44    pub status: Status,
45    pub detail: String,
46}
47
48impl Check {
49    fn new(name: impl Into<String>, status: Status, detail: impl Into<String>) -> Self {
50        Check {
51            name: name.into(),
52            status,
53            detail: detail.into(),
54        }
55    }
56
57    /// The single line this check prints.
58    pub fn line(&self) -> String {
59        format!(
60            "[{}] {:<22} {}",
61            self.status.glyph(),
62            self.name,
63            self.detail
64        )
65    }
66}
67
68/// A name under the TLD that nothing could have cached or registered.
69///
70/// The resolver is meant to answer every name under the TLD, so a random one
71/// proves wildcard resolution rather than a leftover `/etc/hosts` entry.
72fn probe_name(tld: &str) -> String {
73    // Clock nanoseconds mixed with the pid: unique enough for a label that only
74    // has to be one nothing has seen before, without pulling in a RNG crate.
75    let nanos = std::time::SystemTime::now()
76        .duration_since(std::time::UNIX_EPOCH)
77        .map(|d| d.subsec_nanos())
78        .unwrap_or_default();
79    static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
80    let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
81    let nonce = nanos ^ std::process::id().rotate_left(11) ^ seq.rotate_left(23);
82    format!("pf-doctor-{nonce:08x}.{tld}")
83}
84
85/// Ask the loopback responder directly, bypassing the system resolver.
86///
87/// Returns the address it answered with.
88async fn query_responder(
89    name: &str,
90    port: u16,
91    family: std::net::IpAddr,
92) -> Result<std::net::IpAddr, String> {
93    // Ask for the record the proxy's bind address means it will answer. With
94    // `proxy.host = "::1"` there is deliberately no A record, and asking only
95    // for A would read that as the resolver being down.
96    let (qtype, rdlen) = match family {
97        std::net::IpAddr::V4(_) => (1u16, 4usize),
98        std::net::IpAddr::V6(_) => (28u16, 16usize),
99    };
100    let mut msg: Vec<u8> = Vec::new();
101    msg.extend_from_slice(&QUERY_ID.to_be_bytes()); // ID
102    msg.extend_from_slice(&0x0100u16.to_be_bytes()); // RD
103    msg.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
104    msg.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
105    for label in name.split('.') {
106        msg.push(u8::try_from(label.len()).map_err(|_| "label too long".to_string())?);
107        msg.extend_from_slice(label.as_bytes());
108    }
109    msg.push(0);
110    msg.extend_from_slice(&qtype.to_be_bytes());
111    msg.extend_from_slice(&1u16.to_be_bytes()); // QCLASS IN
112
113    let sock = tokio::net::UdpSocket::bind("127.0.0.1:0")
114        .await
115        .map_err(|e| e.to_string())?;
116    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
117    sock.send_to(&msg, addr).await.map_err(|e| e.to_string())?;
118    // Read until the responder answers, rather than trusting whatever arrives
119    // first. Another local process can write to this ephemeral port, and a
120    // datagram from somewhere else, or one carrying a different transaction
121    // ID, would otherwise be reported as the resolver's answer — a false pass
122    // or a false failure in a command whose whole job is to be believed.
123    let deadline = tokio::time::Instant::now() + PROBE_TIMEOUT;
124    let mut buf = [0u8; 512];
125    let resp = loop {
126        let (len, from) = tokio::time::timeout_at(deadline, sock.recv_from(&mut buf))
127            .await
128            .map_err(|_| "no reply within 3s".to_string())?
129            .map_err(|e| e.to_string())?;
130        if from != addr {
131            continue;
132        }
133        if len < 12 {
134            return Err("reply was too short to be a DNS message".into());
135        }
136        if u16::from_be_bytes([buf[0], buf[1]]) != QUERY_ID {
137            continue;
138        }
139        break &buf[..len];
140    };
141    let rcode = u16::from_be_bytes([resp[2], resp[3]]) & 0x000F;
142    if rcode != 0 {
143        return Err(format!("responder returned rcode {rcode}"));
144    }
145    // Skip the echoed question, then read the first answer's RDATA.
146    let mut pos = 12;
147    while let Some(&l) = resp.get(pos) {
148        pos += 1 + usize::from(l);
149        if l == 0 {
150            break;
151        }
152    }
153    pos += 4; // QTYPE + QCLASS
154    let rdata = resp
155        .get(pos + 12..pos + 12 + rdlen)
156        .ok_or_else(|| "reply carried no address record".to_string())?;
157    Ok(match family {
158        std::net::IpAddr::V4(_) => {
159            std::net::IpAddr::V4(Ipv4Addr::new(rdata[0], rdata[1], rdata[2], rdata[3]))
160        }
161        std::net::IpAddr::V6(_) => {
162            let octets: [u8; 16] = rdata.try_into().map_err(|_| "short AAAA record")?;
163            std::net::IpAddr::V6(octets.into())
164        }
165    })
166}
167
168/// What a probe of a TCP port found.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170enum PortProbe {
171    /// Nothing accepted a connection.
172    Closed,
173    /// Something accepted, and answered without pitchfork's header.
174    Foreign,
175    /// Something accepted but did not answer in time, so who holds the port is
176    /// still an open question.
177    ///
178    /// Kept apart from `Foreign` because the two call for opposite advice. A
179    /// foreign listener means the port is taken and URLs would reach the wrong
180    /// service; a slow one is just as likely to be pitchfork itself, busy with
181    /// tunnels or a handshake. Reporting the first when the truth is the second
182    /// hands the reader a confident, wrong diagnosis.
183    Silent,
184    /// The pitchfork proxy answered.
185    Pitchfork,
186}
187
188/// Probe a loopback port and find out whether the pitchfork proxy is behind it.
189///
190/// A bare TCP connect is not enough: a proxy bind failure leaves the supervisor
191/// running, and some unrelated service may hold the port. Every pitchfork
192/// response carries the `x-pitchfork` header, including the redirect the HTTPS
193/// listener sends for a plain-HTTP request, so one plain request over the port
194/// distinguishes the two without needing to complete a TLS handshake.
195async fn probe_port(ip: std::net::IpAddr, port: u16) -> PortProbe {
196    use tokio::io::{AsyncReadExt, AsyncWriteExt};
197
198    let addr = SocketAddr::from((ip, port));
199    let connect = tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(addr)).await;
200    let Ok(Ok(mut stream)) = connect else {
201        return PortProbe::Closed;
202    };
203
204    const REQUEST: &str = "GET / HTTP/1.1\r\nHost: pf-doctor.invalid\r\nConnection: close\r\n\r\n";
205    let exchange = async {
206        stream.write_all(REQUEST.as_bytes()).await?;
207        let mut buf = vec![0u8; 2048];
208        let n = stream.read(&mut buf).await?;
209        buf.truncate(n);
210        Ok::<_, std::io::Error>(buf)
211    };
212    match tokio::time::timeout(PROBE_TIMEOUT, exchange).await {
213        Ok(Ok(buf)) => {
214            let head = String::from_utf8_lossy(&buf).to_ascii_lowercase();
215            if head.contains("x-pitchfork") {
216                PortProbe::Pitchfork
217            } else {
218                PortProbe::Foreign
219            }
220        }
221        // Accepted and then refused to talk: a reset, a broken pipe, a
222        // protocol that hangs up on an HTTP request. The proxy answers every
223        // plain request with its header, including the redirect the HTTPS
224        // listener sends, so whatever did this is not it. That is the same
225        // conclusion as an unrecognised reply, and a port conflict is what the
226        // reader needs to hear.
227        Ok(Err(_)) => PortProbe::Foreign,
228        // Nothing came back inside the budget, which settles nothing: a
229        // pitchfork proxy busy with tunnels or a handshake looks like this.
230        Err(_) => PortProbe::Silent,
231    }
232}
233
234/// Check that a configured `tls_cert` / `tls_key` pair loads.
235///
236/// Mirrors what the TLS listener does at startup, so a failure here is the same
237/// failure the proxy would hit.
238fn load_configured_cert(cert_path: &std::path::Path, key: &str) -> Result<(), String> {
239    if !cert_path.exists() {
240        return Err(format!(
241            "proxy.tls_cert {} does not exist, so the HTTPS listener cannot start",
242            cert_path.display()
243        ));
244    }
245    if key.is_empty() {
246        return Err("proxy.tls_cert is set but proxy.tls_key is empty".to_string());
247    }
248    let key_path = std::path::Path::new(key);
249    if !key_path.exists() {
250        return Err(format!(
251            "proxy.tls_key {} does not exist, so the HTTPS listener cannot start",
252            key_path.display()
253        ));
254    }
255    #[cfg(feature = "proxy-tls")]
256    {
257        use rustls_pemfile::{certs, private_key};
258        let cert_pem = std::fs::read(cert_path)
259            .map_err(|e| format!("cannot read {}: {e}", cert_path.display()))?;
260        let found: Vec<_> = certs(&mut cert_pem.as_slice())
261            .collect::<Result<Vec<_>, _>>()
262            .map_err(|e| format!("cannot parse {}: {e}", cert_path.display()))?;
263        if found.is_empty() {
264            return Err(format!("no certificate found in {}", cert_path.display()));
265        }
266        let key_pem = std::fs::read(key_path)
267            .map_err(|e| format!("cannot read {}: {e}", key_path.display()))?;
268        let key_der = private_key(&mut key_pem.as_slice())
269            .map_err(|e| format!("cannot parse {}: {e}", key_path.display()))?
270            .ok_or_else(|| format!("no private key found in {}", key_path.display()))?;
271        // The pair also has to belong together, or the listener starts and then
272        // fails every handshake.
273        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
274            .map_err(|e| format!("cannot use the key in {}: {e}", key_path.display()))?;
275        rustls::sign::CertifiedKey::new(found, signing_key)
276            .keys_match()
277            .map_err(|e| {
278                format!(
279                    "proxy.tls_key {} does not match proxy.tls_cert {}: {e}",
280                    key_path.display(),
281                    cert_path.display()
282                )
283            })?;
284    }
285    Ok(())
286}
287
288/// Run a blocking call with a deadline, without letting it outlive the answer.
289///
290/// `spawn_blocking` is the obvious tool and the wrong one here. Its tasks are
291/// not cancellable, and the runtime waits for them when it shuts down, so a
292/// probe wedged in `getaddrinfo` or a keychain lookup would still hang `proxy
293/// doctor` at exit even with a timeout on the handle. A detached OS thread is
294/// not tracked by the runtime: dropping the receiver abandons it and the
295/// process exits regardless.
296///
297/// `None` means the deadline passed, which every caller reports as an unknown
298/// rather than a failure, because nothing was learned either way.
299async fn bounded_blocking<T, F>(f: F) -> Option<T>
300where
301    F: FnOnce() -> T + Send + 'static,
302    T: Send + 'static,
303{
304    bounded_blocking_for(PROBE_TIMEOUT, f).await
305}
306
307/// [`bounded_blocking`] with a deadline of its own.
308///
309/// For work that already gives up after some time internally. Such a probe
310/// needs longer here than it allows itself, because its own deadline is what
311/// cleans up after it — a child process killed and reaped, say. An outer
312/// deadline that fired first would return, let the command exit, and leave
313/// that cleanup unrun.
314async fn bounded_blocking_for<T, F>(budget: Duration, f: F) -> Option<T>
315where
316    F: FnOnce() -> T + Send + 'static,
317    T: Send + 'static,
318{
319    let (tx, rx) = tokio::sync::oneshot::channel();
320    std::thread::spawn(move || {
321        let _ = tx.send(f());
322    });
323    tokio::time::timeout(budget, rx).await.ok()?.ok()
324}
325
326/// Whether the CA is in the system trust store, or `None` when the lookup did
327/// not finish in time.
328///
329/// The lookup shells out on Linux and reads the keychain on macOS, so it runs
330/// off the runtime rather than stalling it, and under a deadline: `security
331/// verify-cert` can block on a keychain prompt or a revocation check.
332///
333/// The `None` is kept rather than folded into `false`. A lookup that timed out
334/// says nothing about whether the CA is trusted, and telling someone to run
335/// `proxy trust` on that basis would send them to fix something that may well
336/// already be right.
337async fn ca_is_trusted(ca_path: &std::path::Path) -> Option<bool> {
338    let probe = ca_path.to_path_buf();
339    // `ca_trust_state`, not `is_ca_trusted`: the latter reports a trust store
340    // that would not answer as "not trusted", which this check would then
341    // print as a failure telling the reader to run `proxy trust`.
342    // Longer than the probe allows itself, so its own deadline is the one that
343    // fires: that is what kills and reaps `security verify-cert`. Waiting the
344    // same 3s as everything else here would always win the race, return, and
345    // let the command exit with the child still running — the orphan
346    // `ca_trust_state`'s kill-and-reap was added to prevent.
347    let budget = crate::proxy::trust::TRUST_PROBE_TIMEOUT + Duration::from_secs(1);
348    bounded_blocking_for(budget, move || crate::proxy::trust::ca_trust_state(&probe))
349        .await
350        .flatten()
351}
352
353/// Whether the system's automatic proxy URL points at pitchfork's PAC file.
354///
355/// Read from the same places `proxy setup --pac` writes, so a working PAC
356/// configuration is recognised rather than reported as broken DNS.
357///
358/// Every command gets its own `PROBE_TIMEOUT`, like the other probes here, and
359/// for a concrete reason: `gsettings` blocks on the session bus, so on a
360/// headless box or over an SSH session with no bus it can hang indefinitely.
361/// This runs before any check is printed, so an unbounded call means `proxy
362/// doctor` produces no output at all rather than one unknown line.
363///
364/// The budget is per command rather than shared across the probe, and the
365/// per-service queries run concurrently, so the total does not grow with the
366/// number of network services. A shared deadline would let a machine with
367/// enough active services exhaust it before reaching the configured one and
368/// report a working `--pac` setup as absent.
369///
370/// The commands are spawned asynchronously rather than on a blocking thread so
371/// that the timeout actually reaches them: dropping the future kills the child,
372/// whereas a `spawn_blocking` thread stuck in `wait` cannot be cancelled and
373/// would go on to hold up runtime shutdown.
374/// What a settings command had to say.
375///
376/// Three outcomes, not two, and the middle one is the whole point. A machine
377/// with no `gsettings` or no GNOME schema has no such proxy setting to read:
378/// that is the ordinary state of a Linux box without GNOME, and calling it
379/// unreadable would downgrade every dependent check to a warning, so real
380/// breakage would stop being reported. A command that failed for some other
381/// reason — no session bus, no authorisation, a service that is down — settles
382/// nothing, and neither does one that never returned.
383enum Answer {
384    Said(String),
385    /// There is no such setting on this machine, which is an answer.
386    Nothing,
387    /// Nothing was learned either way.
388    Unknown,
389}
390
391/// Whether a failed settings command proves the setting does not exist here.
392///
393/// Matched on what the tool said rather than on its exit status, because the
394/// status is the same for "no such schema" as for "the session bus is not
395/// running", and only the first is evidence.
396fn means_no_such_setting(stderr: &str) -> bool {
397    let s = stderr.to_ascii_lowercase();
398    // `gsettings` with no schemas at all, without this schema, or without the
399    // key. The quotes around the name are typographic, so the match stops
400    // before them.
401    s.contains("no schemas installed") || s.contains("no such schema") || s.contains("no such key")
402}
403
404async fn pac_configured(pac_url: &str) -> Option<bool> {
405    async fn output(argv: &[&str]) -> Answer {
406        let run = tokio::process::Command::new(argv[0])
407            .args(&argv[1..])
408            // The classifier below reads what the tool printed, so the tool has
409            // to print the messages it was written against. Without this a
410            // French or Japanese desktop gets translated errors, none of which
411            // match, and every one of them is then read as "could not tell" —
412            // which is exactly the misreport this classification exists to
413            // avoid, just restricted to people who do not work in English.
414            .env("LC_ALL", "C")
415            .env("LANGUAGE", "C")
416            .env("LANG", "C")
417            // Captured, not discarded: what the tool says is the only way to
418            // tell "there is no such setting" from "I could not reach it".
419            .stderr(std::process::Stdio::piped())
420            .kill_on_drop(true)
421            .output();
422        // On a timeout the future is dropped, which kills the child.
423        match tokio::time::timeout(PROBE_TIMEOUT, run).await {
424            Err(_) => Answer::Unknown,
425            // The tool is not installed, so the mechanism it configures is not
426            // in use here. Any other spawn failure is not that clear.
427            Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Answer::Nothing,
428            Ok(Err(_)) => Answer::Unknown,
429            Ok(Ok(out)) if out.status.success() => {
430                Answer::Said(String::from_utf8_lossy(&out.stdout).into_owned())
431            }
432            Ok(Ok(out)) if means_no_such_setting(&String::from_utf8_lossy(&out.stderr)) => {
433                Answer::Nothing
434            }
435            // Ran and failed for a reason that proves nothing: no session bus,
436            // no authorisation, a service that is down.
437            Ok(Ok(_)) => Answer::Unknown,
438        }
439    }
440
441    // Whether any command gave up, which is the difference between "no PAC is
442    // configured" and "the system would not say".
443    let mut unknown = false;
444    if cfg!(target_os = "macos") {
445        // `networksetup -getautoproxyurl` prints the URL and an `Enabled`
446        // line. A leftover URL with automatic proxy switched off routes
447        // nothing, so both have to hold.
448        let services = match output(&["networksetup", "-listallnetworkservices"]).await {
449            Answer::Said(o) => o,
450            Answer::Nothing => return Some(false),
451            Answer::Unknown => return None,
452        };
453
454        let mut probes = tokio::task::JoinSet::new();
455        for svc in services
456            .lines()
457            .skip(1) // header line explaining the asterisk
458            // A leading asterisk marks a disabled service.
459            .filter(|l| !l.trim().is_empty() && !l.starts_with('*'))
460            .map(|l| l.trim().to_string())
461        {
462            let url = pac_url.to_string();
463            probes.spawn(async move {
464                match output(&["networksetup", "-getautoproxyurl", &svc]).await {
465                    Answer::Said(o) => {
466                        let enabled = o.lines().any(|l| {
467                            let l = l.trim().to_ascii_lowercase();
468                            l.starts_with("enabled:") && l.ends_with("yes")
469                        });
470                        Some(o.contains(&url) && enabled)
471                    }
472                    // No such service is a definite "not on this one"; a
473                    // failure that proves nothing leaves the whole answer open.
474                    Answer::Nothing => Some(false),
475                    Answer::Unknown => None,
476                }
477            });
478        }
479        while let Some(res) = probes.join_next().await {
480            match res.unwrap_or(None) {
481                // `JoinSet` aborts the rest of the probes when dropped.
482                Some(true) => return Some(true),
483                Some(false) => {}
484                None => unknown = true,
485            }
486        }
487        // One service that would not answer could have been the configured
488        // one, so "none of them" is only true when all of them answered.
489        return (!unknown).then_some(false);
490    }
491
492    // Under GNOME the URL is only consulted in `auto` mode. No `gsettings`, or
493    // no schema for it, means this machine has no GNOME proxy setting at all,
494    // which is a definite answer rather than an unreadable one.
495    let mode = output(&["gsettings", "get", "org.gnome.system.proxy", "mode"]).await;
496    if !matches!(&mode, Answer::Said(m) if m.trim().trim_matches('\'') == "auto") {
497        return decide_gnome(mode, None, pac_url);
498    }
499    let url = output(&[
500        "gsettings",
501        "get",
502        "org.gnome.system.proxy",
503        "autoconfig-url",
504    ])
505    .await;
506    decide_gnome(mode, Some(url), pac_url)
507}
508
509/// Read the two GNOME proxy settings into an answer.
510///
511/// Separated from the commands that produce them so every combination can be
512/// checked without a session bus, a desktop or a particular host. `url` is
513/// `None` when the mode ruled the question out before it was worth asking.
514fn decide_gnome(mode: Answer, url: Option<Answer>, pac_url: &str) -> Option<bool> {
515    match mode {
516        Answer::Unknown => return None,
517        // No schema, no key, no `gsettings`: this machine has no GNOME proxy
518        // setting, so nothing is configured through one.
519        Answer::Nothing => return Some(false),
520        Answer::Said(m) if m.trim().trim_matches('\'') != "auto" => return Some(false),
521        Answer::Said(_) => {}
522    }
523    match url {
524        Some(Answer::Said(u)) => Some(u.contains(pac_url)),
525        Some(Answer::Nothing) => Some(false),
526        Some(Answer::Unknown) => None,
527        // `auto` mode with no URL read is not a state the caller produces.
528        None => None,
529    }
530}
531
532/// Fetch the PAC file the system is configured to use.
533async fn fetch_pac(url: &str) -> Result<(), String> {
534    let addr = url
535        .trim_start_matches("http://")
536        .split('/')
537        .next()
538        .unwrap_or_default()
539        .to_string();
540    let path = super::pac::PAC_PATH;
541    let host = addr.clone();
542    let exchange = async move {
543        use tokio::io::{AsyncReadExt, AsyncWriteExt};
544        let mut stream = tokio::net::TcpStream::connect(&addr).await?;
545        let req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
546        stream.write_all(req.as_bytes()).await?;
547        // Bounded: whatever answers this port need not be pitchfork, and an
548        // endless response would otherwise be read into memory in full. A PAC
549        // script is a few hundred bytes.
550        let mut body = Vec::new();
551        tokio::io::AsyncReadExt::take(&mut stream, MAX_PAC_RESPONSE)
552            .read_to_end(&mut body)
553            .await?;
554        Ok::<_, std::io::Error>(String::from_utf8_lossy(&body).into_owned())
555    };
556    match tokio::time::timeout(PROBE_TIMEOUT, exchange).await {
557        Ok(Ok(body)) if body.contains("FindProxyForURL") => Ok(()),
558        Ok(Ok(_)) => Err("the response was not a PAC script".to_string()),
559        Ok(Err(e)) => Err(e.to_string()),
560        Err(_) => Err("no reply within 3s".to_string()),
561    }
562}
563
564/// Run every check against the current settings.
565/// Turn the system resolver's answer into a check.
566///
567/// `resolved` is `None` when the lookup did not finish in time, which is not
568/// the same as a name that does not resolve and must not be reported as one.
569fn resolution_check(
570    name: &str,
571    tld: &str,
572    pac_ready: Option<bool>,
573    lan: bool,
574    resolved: Option<Result<Vec<std::net::IpAddr>, String>>,
575) -> Check {
576    match resolved {
577        Some(Ok(ips)) if !ips.is_empty() => Check::new(
578            "system resolution",
579            Status::Pass,
580            format!(
581                "{name} resolves to {}",
582                ips.iter()
583                    .map(|i| i.to_string())
584                    .collect::<Vec<_>>()
585                    .join(", ")
586            ),
587        ),
588        // `proxy setup --pac` deliberately leaves system DNS alone: the browser
589        // sends these names to the proxy instead of resolving them. Reporting a
590        // failure there would be telling the user to fix a setup that works.
591        _ if pac_ready == Some(true) => Check::new(
592            "system resolution",
593            Status::Pass,
594            format!("not needed: the system proxy sends *.{tld} to pitchfork (PAC)"),
595        ),
596        // A lookup that never came back is not the same as a name that does
597        // not resolve. Saying it failed would send someone to re-run setup
598        // over a wedged resolver that setup cannot fix. Ahead of the LAN and
599        // unknown-PAC arms, which both explain a name that did not resolve.
600        None => Check::new(
601            "system resolution",
602            Status::Warn,
603            format!("the system resolver did not answer in time, so {name} could not be checked"),
604        ),
605        // LAN mode serves `.local`, which belongs to mDNS, and setup leaves
606        // that namespace alone on purpose. So a name that does not resolve is
607        // not evidence that setup failed, and telling the reader to run it
608        // again would be wrong. It is still evidence that a browser on this
609        // machine cannot reach the advertised URLs — the publisher may not
610        // have started, or the host may have no mDNS resolver — and passing it
611        // silently would hide exactly the broken path this command is for.
612        // Warn, and say where to look.
613        _ if lan => Check::new(
614            "system resolution",
615            Status::Warn,
616            format!(
617                "{name} did not resolve. In LAN mode *.{tld} is answered over \
618                 mDNS, which `proxy setup` does not configure: check that the \
619                 supervisor is publishing and that this host resolves mDNS names"
620            ),
621        ),
622        // The system would not say whether a PAC file is in use, and under one
623        // this check does not apply at all. Calling it a failure would be a
624        // guess in the direction that sends someone to re-run setup.
625        _ if pac_ready.is_none() => Check::new(
626            "system resolution",
627            Status::Warn,
628            format!(
629                "{name} does not resolve, but the system would not say whether \
630                 a proxy auto-config file is in use, which would explain it"
631            ),
632        ),
633        // The resolver's own words, rather than a guess at what went wrong.
634        Some(Err(e)) => Check::new(
635            "system resolution",
636            Status::Fail,
637            format!("{name} does not resolve ({e}) — run `pitchfork proxy setup`"),
638        ),
639        Some(Ok(_)) => Check::new(
640            "system resolution",
641            Status::Fail,
642            format!("{name} does not resolve — run `pitchfork proxy setup`"),
643        ),
644    }
645}
646
647/// Which name the system-resolution check should look up.
648enum SystemName {
649    /// Look this one up.
650    Published(String),
651    /// LAN mode with an empty registry: nothing is advertised to resolve.
652    NonePublished,
653    /// The registry could not be read, so there is no name and no verdict.
654    Unreadable,
655    /// Every registered slug collides with another by case. mDNS still
656    /// publishes them, but the proxy routes none, so resolving one would pass
657    /// a setup whose URLs all fail.
658    AllAmbiguous(Vec<String>),
659}
660
661/// Which published hostname to look up, from the global slug registry.
662///
663/// mDNS publishes `<slug>.<tld>` for each registered slug and nothing else, so
664/// this is the only name whose resolution says anything about whether the LAN
665/// path works. Only an unambiguous slug is a fair probe: the proxy routes it.
666/// A registry whose slugs all collide by case is its own answer — mDNS still
667/// publishes those names, so it is not "nothing yet", but none of them route.
668///
669/// Reads the global config, which takes a file lock and can therefore wait on
670/// another process, so callers run it under a deadline like every other probe
671/// here rather than on the async task.
672fn published_slug_name(tld: &str) -> SystemName {
673    let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
674    if let Some(slug) = slugs
675        .keys()
676        .find(|slug| !crate::pitchfork_toml::PitchforkToml::slug_is_ambiguous(slug, &slugs))
677    {
678        return SystemName::Published(format!("{}.{tld}", slug.to_ascii_lowercase()));
679    }
680    if slugs.is_empty() {
681        SystemName::NonePublished
682    } else {
683        SystemName::AllAmbiguous(slugs.keys().cloned().collect())
684    }
685}
686
687pub async fn run(s: &crate::settings::Settings) -> Vec<Check> {
688    let mut checks = Vec::new();
689
690    if !s.proxy.enable {
691        checks.push(Check::new(
692            "proxy",
693            Status::Fail,
694            "disabled — set proxy.enable = true",
695        ));
696        return checks;
697    }
698
699    let tld = crate::proxy::effective_tld(s).to_string();
700    let dns_port = super::dns::dns_port(s);
701    // Not coerced to 443. The proxy itself refuses to start on an out-of-range
702    // `proxy.port`, so silently probing 443 instead would report either a
703    // listener that is nothing to do with pitchfork or a missing one, and in
704    // both cases hide the misconfiguration this command exists to surface.
705    let Some(proxy_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) else {
706        checks.push(Check::new(
707            "proxy port",
708            Status::Fail,
709            format!(
710                "proxy.port is {}, which is not a usable port — the proxy \
711                 cannot start until it is set between 1 and 65535",
712                s.proxy.port
713            ),
714        ));
715        return checks;
716    };
717    let standard_port = if s.proxy.https { 443 } else { 80 };
718    // Probe the address the proxy actually listens on. `proxy.host = "::1"`
719    // means nothing is on IPv4 loopback, and probing there would report a
720    // failure that is not real.
721    let proxy_ip: std::net::IpAddr = match s.proxy.host.parse() {
722        Ok(std::net::IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
723        Ok(std::net::IpAddr::V6(ip)) if ip.is_unspecified() => std::net::Ipv6Addr::LOCALHOST.into(),
724        Ok(ip) => ip,
725        Err(_) => Ipv4Addr::LOCALHOST.into(),
726    };
727    let proxy_at = |port: u16| match proxy_ip {
728        std::net::IpAddr::V6(ip) => format!("[{ip}]:{port}"),
729        std::net::IpAddr::V4(ip) => format!("{ip}:{port}"),
730    };
731    let pac_url = format!("http://{}{}", proxy_at(proxy_port), super::pac::PAC_PATH);
732    // Which record the resolver serves comes from the resolver's own
733    // configuration, not from the proxy's bind address. The two differ in LAN
734    // mode, where the resolver hands out the detected IPv4 interface address
735    // whatever `proxy.host` is, and asking for the wrong record would read a
736    // working resolver as down.
737    //
738    // `None` for the LAN address is deliberate: detecting it is not needed to
739    // know the family, and LAN mode is IPv4 either way.
740    let resolver_family = {
741        let cfg = super::dns::config_from_settings(s, None);
742        match (cfg.ipv4, cfg.ipv6) {
743            (Some(ip), _) => std::net::IpAddr::V4(ip),
744            (None, Some(ip)) => std::net::IpAddr::V6(ip),
745            // Nothing served at all; ask for A so the failure is reported
746            // against the record a caller would expect.
747            (None, None) => std::net::IpAddr::V4(Ipv4Addr::LOCALHOST),
748        }
749    };
750    // Probed once: each call spawns a blocking task that runs `networksetup`
751    // per active macOS service, or two `gsettings` invocations.
752    let pac_ready = pac_configured(&pac_url).await;
753
754    // 1. The proxy itself.
755    checks.push(match probe_port(proxy_ip, proxy_port).await {
756        PortProbe::Pitchfork => Check::new(
757            "proxy listener",
758            Status::Pass,
759            format!("the pitchfork proxy answers on {}", proxy_at(proxy_port)),
760        ),
761        PortProbe::Foreign => Check::new(
762            "proxy listener",
763            Status::Fail,
764            format!(
765                "something other than pitchfork holds {} — proxy URLs would reach it instead",
766                proxy_at(proxy_port)
767            ),
768        ),
769        PortProbe::Silent => Check::new(
770            "proxy listener",
771            Status::Warn,
772            format!(
773                "something holds {} but did not answer in time, so it could not \
774                 be identified — it may be the proxy under load",
775                proxy_at(proxy_port)
776            ),
777        ),
778        PortProbe::Closed => Check::new(
779            "proxy listener",
780            Status::Fail,
781            format!(
782                "nothing is listening on {} — start it with `pitchfork supervisor start`",
783                proxy_at(proxy_port)
784            ),
785        ),
786    });
787
788    // 2. The resolver, queried directly.
789    let name = probe_name(&tld);
790    if !s.proxy.dns {
791        checks.push(Check::new(
792            "dns resolver",
793            Status::Warn,
794            "proxy.dns is false, so pitchfork answers no DNS queries",
795        ));
796    } else {
797        match query_responder(&name, dns_port, resolver_family).await {
798            Ok(ip) => checks.push(Check::new(
799                "dns resolver",
800                Status::Pass,
801                format!("127.0.0.1:{dns_port} answers *.{tld} with {ip}"),
802            )),
803            Err(e) => checks.push(Check::new(
804                "dns resolver",
805                Status::Fail,
806                format!("127.0.0.1:{dns_port} did not answer: {e}"),
807            )),
808        }
809    }
810
811    // 3. The system resolver, which is what a browser actually uses.
812    // Under a deadline like every other probe: `getaddrinfo` consults
813    // `resolv.conf`, the configured nameservers and whatever NSS modules are
814    // installed, any of which can stall indefinitely. Nothing is printed until
815    // this function returns, so an unbounded lookup means no output at all.
816    let lan = s.proxy.lan || !s.proxy.lan_ip.is_empty();
817    // Which name to ask the *system* resolver about. Not the same question as
818    // the one above: the loopback responder answers anything under the TLD, so
819    // a name nothing could have cached is the right probe for it. mDNS does
820    // not. LAN mode publishes one record per registered slug and no wildcard,
821    // so a random name never resolves there however healthy the setup is, and
822    // asking for one would make this check warn on every LAN machine.
823    //
824    // The same holds with `proxy.dns = false`: nothing answers wildcards then,
825    // and only names written to /etc/hosts (by `proxy.sync_hosts`, or by hand)
826    // resolve, which are the published slugs.
827    //
828    // Not under a working PAC file, though: names never reach the system
829    // resolver there, and the regular check already passes it as such.
830    let published_only = lan || (!s.proxy.dns && pac_ready != Some(true));
831    let system_name = if published_only {
832        // Off the async task and under the same budget as the rest: this reads
833        // the global config behind a file lock, so a concurrent `proxy add` or
834        // a wedged process holding it would otherwise hang the whole command
835        // before a single line had been printed.
836        let for_tld = tld.clone();
837        // Not flattened: the outer `None` is the deadline elapsing, which is
838        // not the same as the registry being empty, and reporting one as the
839        // other would claim nothing is published when the truth is unknown.
840        bounded_blocking(move || published_slug_name(&for_tld))
841            .await
842            .unwrap_or(SystemName::Unreadable)
843    } else {
844        SystemName::Published(name.clone())
845    };
846
847    match system_name {
848        // LAN mode with nothing registered yet. There is no published name to
849        // look up, so there is nothing this check can find out.
850        SystemName::NonePublished if lan => checks.push(Check::new(
851            "system resolution",
852            Status::Pass,
853            "nothing is published yet — add one with `pitchfork proxy add <slug>`",
854        )),
855        // The resolver is off and no slug is written anywhere, so automatic
856        // project hostnames have nothing to resolve them. Only a warning under
857        // `localhost`, which browsers resolve on their own; any other TLD has
858        // nothing at all and fails.
859        SystemName::NonePublished => checks.push(Check::new(
860            "system resolution",
861            if tld.eq_ignore_ascii_case("localhost") {
862                Status::Warn
863            } else {
864                Status::Fail
865            },
866            format!(
867                "proxy.dns is false and no slug is published, so *.{tld} names resolve \
868                 only where the system or browser does so itself — enable proxy.dns and \
869                 run `pitchfork proxy setup`, or register a slug with proxy.sync_hosts on"
870            ),
871        )),
872        SystemName::AllAmbiguous(slugs) => checks.push(Check::new(
873            "system resolution",
874            Status::Warn,
875            format!(
876                "every registered slug collides with another that differs only by case \
877                 ({}), so the proxy routes none of them — remove one of each pair with \
878                 `pitchfork proxy remove <slug>`",
879                slugs.join(", ")
880            ),
881        )),
882        SystemName::Unreadable => checks.push(Check::new(
883            "system resolution",
884            Status::Warn,
885            "the slug registry did not open in time, so there was no name to check",
886        )),
887        SystemName::Published(lookup) => {
888            let lookup_name = lookup.clone();
889            let resolved = bounded_blocking(move || {
890                use std::net::ToSocketAddrs;
891                (lookup_name.as_str(), 80u16)
892                    .to_socket_addrs()
893                    .map(|addrs| addrs.map(|a| a.ip()).collect::<Vec<_>>())
894                    .map_err(|e| e.to_string())
895            })
896            .await;
897            let mut check = resolution_check(&lookup, &tld, pac_ready, lan, resolved);
898            // Setup installs no resolver route when the resolver is off, so
899            // "run setup" would not help; say what would.
900            if !s.proxy.dns && !lan && check.status == Status::Fail {
901                check.detail = format!(
902                    "{lookup} does not resolve, and proxy.dns is false — enable proxy.dns \
903                     and run `pitchfork proxy setup`, or keep proxy.sync_hosts on"
904                );
905            }
906            checks.push(check);
907        }
908    }
909
910    // 4. The PAC file, when the system is pointed at it.
911    if pac_ready == Some(true) {
912        checks.push(match fetch_pac(&pac_url).await {
913            Ok(()) => Check::new(
914                "pac file",
915                Status::Pass,
916                format!("the system proxy uses {pac_url}, and it is being served"),
917            ),
918            Err(e) => Check::new(
919                "pac file",
920                Status::Fail,
921                format!("the system proxy uses {pac_url}, but it could not be fetched: {e}"),
922            ),
923        });
924    }
925
926    // 5. Certificate trust.
927    if s.proxy.https
928        && let Some(problem) =
929            crate::proxy::server::tls_pair_problem(&s.proxy.tls_cert, &s.proxy.tls_key)
930    {
931        checks.push(Check::new("certificate", Status::Fail, problem));
932    } else if s.proxy.https {
933        let custom = !s.proxy.tls_cert.is_empty();
934        let ca_path = if custom {
935            std::path::PathBuf::from(&s.proxy.tls_cert)
936        } else {
937            crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
938        };
939        checks.push(if custom {
940            // A configured certificate is served as-is, so the thing worth
941            // checking is that it loads at all: a missing or unreadable pair
942            // stops the HTTPS listener from starting.
943            // Reading and parsing the pair is blocking work, so it goes off
944            // the runtime rather than stalling it mid-check.
945            // Under the same deadline as the other probes: these paths are
946            // configured, so they can point at a stalled network mount, and an
947            // unbounded read would hang the command with nothing printed.
948            let (cert_arg, key_arg) = (ca_path.clone(), s.proxy.tls_key.clone());
949            match bounded_blocking(move || load_configured_cert(&cert_arg, &key_arg)).await {
950                Some(Ok(())) => Check::new(
951                    "certificate",
952                    Status::Pass,
953                    format!("serving your certificate from {}", ca_path.display()),
954                ),
955                Some(Err(e)) => Check::new("certificate", Status::Fail, e),
956                None => Check::new(
957                    "certificate",
958                    Status::Warn,
959                    format!(
960                        "reading {} did not finish in time, so the certificate \
961                         could not be checked",
962                        ca_path.display()
963                    ),
964                ),
965            }
966        } else if !ca_path.exists() {
967            Check::new(
968                "certificate",
969                Status::Fail,
970                format!(
971                    "no CA at {} — start the supervisor once to generate it",
972                    ca_path.display()
973                ),
974            )
975        } else {
976            match ca_is_trusted(&ca_path).await {
977                Some(true) => {
978                    Check::new("certificate", Status::Pass, "the pitchfork CA is trusted")
979                }
980                Some(false) => Check::new(
981                    "certificate",
982                    Status::Fail,
983                    "the pitchfork CA is not trusted — run `pitchfork proxy trust`",
984                ),
985                None => Check::new(
986                    "certificate",
987                    Status::Warn,
988                    "could not tell whether the pitchfork CA is trusted: \
989                     the trust store did not answer in time",
990                ),
991            }
992        });
993    }
994
995    // 6. Reaching the proxy on the port the URL implies.
996    //
997    // Not under PAC: the browser is told to connect to `proxy.port` directly,
998    // so setup installs no redirect and reporting a missing one would send the
999    // user to fix a configuration that works.
1000    if proxy_port != standard_port && pac_ready == Some(true) {
1001        checks.push(Check::new(
1002            "standard port",
1003            Status::Pass,
1004            format!("not needed: the PAC file sends requests to port {proxy_port}"),
1005        ));
1006    } else if proxy_port != standard_port && pac_ready.is_none() {
1007        // A PAC setup installs no redirect, so whether a missing one is a
1008        // problem depends on an answer the system declined to give.
1009        checks.push(Check::new(
1010            "standard port",
1011            Status::Warn,
1012            format!(
1013                "the system would not say whether a proxy auto-config file is \
1014                 in use, so port {standard_port} was not checked"
1015            ),
1016        ));
1017    } else if proxy_port != standard_port {
1018        checks.push(match probe_port(proxy_ip, standard_port).await {
1019            PortProbe::Pitchfork => Check::new(
1020                "standard port",
1021                Status::Pass,
1022                format!("port {standard_port} reaches the proxy on {proxy_port}"),
1023            ),
1024            PortProbe::Foreign => Check::new(
1025                "standard port",
1026                Status::Fail,
1027                format!(
1028                    "port {standard_port} is held by something other than pitchfork, \
1029                     so proxy URLs without a port would reach it instead"
1030                ),
1031            ),
1032            PortProbe::Silent => Check::new(
1033                "standard port",
1034                Status::Warn,
1035                format!(
1036                    "something holds port {standard_port} but did not answer in \
1037                     time, so it could not be identified"
1038                ),
1039            ),
1040            PortProbe::Closed => Check::new(
1041                "standard port",
1042                Status::Warn,
1043                format!(
1044                    "port {standard_port} is not redirected, so URLs need :{proxy_port} — \
1045                     run `pitchfork proxy setup`"
1046                ),
1047            ),
1048        });
1049    }
1050
1051    checks
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    /// Two findings on this branch pulled in opposite directions: reading every
1059    /// failure as "unknown" hides real breakage on a machine without GNOME,
1060    /// and reading every failure as "not configured" misreports a PAC setup
1061    /// whose settings service is simply unreachable. The exit status is the
1062    /// same either way, so the tool's own words are what decide.
1063    #[test]
1064    fn only_a_missing_setting_counts_as_an_answer() {
1065        // Verbatim from `gsettings` on a host with no GNOME schemas, including
1066        // the typographic quotes it puts around the schema name.
1067        for absent in [
1068            "No schemas installed\n",
1069            "No such schema \u{201c}org.gnome.system.proxy\u{201d}\n",
1070            "No such key \u{201c}autoconfig-url\u{201d}\n",
1071        ] {
1072            assert!(
1073                means_no_such_setting(absent),
1074                "not recognised as an absent setting: {absent:?}"
1075            );
1076        }
1077
1078        // These establish nothing about whether a PAC file is in use.
1079        for unclear in [
1080            "Failed to connect to the session bus: No such file or directory\n",
1081            "Error spawning command line \u{201c}dbus-launch\u{201d}\n",
1082            "** Error: The parameters were not valid.\n",
1083            "Operation not permitted\n",
1084            "",
1085        ] {
1086            assert!(
1087                !means_no_such_setting(unclear),
1088                "treated as proof that nothing is configured: {unclear:?}"
1089            );
1090        }
1091    }
1092
1093    /// A machine with no proxy auto-config set up has to give a definite "no",
1094    /// not an "I could not tell". Returning the latter downgrades every
1095    /// dependent check to a warning, so an ordinary Linux box without GNOME —
1096    /// where `gsettings` is missing or has no schema — would stop reporting
1097    /// broken DNS and a missing port redirect at all.
1098    ///
1099    /// Driven from constructed answers rather than the host's own `gsettings`,
1100    /// so it says the same thing on a developer's GNOME desktop, in a
1101    /// container with no session bus, and on a macOS runner.
1102    #[test]
1103    fn a_machine_with_no_pac_gives_a_definite_answer() {
1104        let ours = "http://127.0.0.1:8443/proxy.pac";
1105        let said = |s: &str| Answer::Said(s.to_string());
1106
1107        // No schema, no key, no `gsettings` at all: nothing is configured.
1108        assert_eq!(decide_gnome(Answer::Nothing, None, ours), Some(false));
1109
1110        // Configured, but not at automatic: the URL is not consulted.
1111        assert_eq!(decide_gnome(said("'manual'\n"), None, ours), Some(false));
1112
1113        // Automatic, pointing at us.
1114        assert_eq!(
1115            decide_gnome(said("'auto'\n"), Some(said(&format!("'{ours}'\n"))), ours),
1116            Some(true)
1117        );
1118
1119        // Automatic, pointing at somebody else's proxy auto-config.
1120        assert_eq!(
1121            decide_gnome(
1122                said("'auto'\n"),
1123                Some(said("'https://corp.example/proxy.pac'\n")),
1124                ours
1125            ),
1126            Some(false)
1127        );
1128
1129        // Only a probe that settled nothing leaves the answer open.
1130        assert_eq!(decide_gnome(Answer::Unknown, None, ours), None);
1131        assert_eq!(
1132            decide_gnome(said("'auto'\n"), Some(Answer::Unknown), ours),
1133            None
1134        );
1135    }
1136
1137    /// A listener that takes the connection and then refuses to talk is not
1138    /// pitchfork, which answers every plain request with its header. Reporting
1139    /// that as indeterminate would bury a port conflict behind a warning
1140    /// suggesting the proxy is merely busy.
1141    #[tokio::test]
1142    async fn a_listener_that_hangs_up_is_a_port_conflict_not_an_unknown() {
1143        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1144        let addr = listener.local_addr().unwrap();
1145        let accepting = tokio::spawn(async move {
1146            // Accept and drop, so the probe's read ends without an answer.
1147            while let Ok((stream, _)) = listener.accept().await {
1148                drop(stream);
1149            }
1150        });
1151
1152        assert!(
1153            matches!(probe_port(addr.ip(), addr.port()).await, PortProbe::Foreign),
1154            "a listener that said nothing usable was not reported as a conflict"
1155        );
1156        accepting.abort();
1157    }
1158
1159    /// The trust probe cleans up after itself when its own deadline fires:
1160    /// it kills and reaps the child. An outer deadline of the same length
1161    /// always wins that race, so the command would return and exit with the
1162    /// child still running. The outer one has to be the longer of the two.
1163    #[test]
1164    fn the_trust_probe_is_given_longer_than_it_gives_itself() {
1165        assert!(
1166            crate::proxy::trust::TRUST_PROBE_TIMEOUT >= PROBE_TIMEOUT,
1167            "the shared probe budget would cut the trust probe short"
1168        );
1169        let budget = crate::proxy::trust::TRUST_PROBE_TIMEOUT + Duration::from_secs(1);
1170        assert!(
1171            budget > crate::proxy::trust::TRUST_PROBE_TIMEOUT,
1172            "the outer deadline would fire before the probe could reap its child"
1173        );
1174    }
1175
1176    /// A probe that gave up is not a probe that found a problem. Only `Fail`
1177    /// prints the "run `pitchfork proxy setup`" summary, so mapping a timeout
1178    /// to it would send someone to re-run setup over a wedged resolver that
1179    /// setup cannot fix.
1180    #[test]
1181    fn a_resolver_that_timed_out_is_not_reported_as_a_broken_name() {
1182        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1183        // No PAC file in use, and the system said so.
1184        let no_pac = Some(false);
1185
1186        let timed_out = resolution_check("app.test", "test", no_pac, false, None);
1187        assert_eq!(timed_out.status, Status::Warn);
1188        assert!(
1189            timed_out.detail.contains("did not answer in time"),
1190            "the timeout was not explained: {}",
1191            timed_out.detail
1192        );
1193
1194        // A resolver that answered "no such name" is a real failure, and says
1195        // what the resolver said rather than guessing.
1196        let refused = resolution_check(
1197            "app.test",
1198            "test",
1199            no_pac,
1200            false,
1201            Some(Err("Name or service not known".into())),
1202        );
1203        assert_eq!(refused.status, Status::Fail);
1204        assert!(refused.detail.contains("Name or service not known"));
1205
1206        // An empty answer is a failure too, with nothing to quote.
1207        assert_eq!(
1208            resolution_check("app.test", "test", no_pac, false, Some(Ok(vec![]))).status,
1209            Status::Fail
1210        );
1211
1212        let found = resolution_check(
1213            "app.test",
1214            "test",
1215            no_pac,
1216            false,
1217            Some(Ok(vec![ip("127.0.0.1")])),
1218        );
1219        assert_eq!(found.status, Status::Pass);
1220        assert!(found.detail.contains("127.0.0.1"));
1221
1222        // Under PAC the system resolver is not consulted at all, so none of
1223        // the above is a problem worth reporting.
1224        for answer in [None, Some(Err("boom".to_string())), Some(Ok(vec![]))] {
1225            assert_eq!(
1226                resolution_check("app.test", "test", Some(true), false, answer).status,
1227                Status::Pass,
1228                "a PAC setup was told to fix its DNS"
1229            );
1230        }
1231
1232        // And when the system would not say whether a PAC file is in use, a
1233        // name that does not resolve is not yet evidence of anything: under a
1234        // PAC file it would be expected. Warn rather than send the user to
1235        // re-run setup.
1236        for answer in [None, Some(Err("boom".to_string())), Some(Ok(vec![]))] {
1237            assert_eq!(
1238                resolution_check("app.test", "test", None, false, answer).status,
1239                Status::Warn,
1240                "an unreadable proxy configuration was reported as broken DNS"
1241            );
1242        }
1243        // LAN mode answers over mDNS, which setup does not configure, so a
1244        // name that will not resolve is not grounds to re-run setup. It is
1245        // still a browser on this machine unable to reach the advertised URL,
1246        // so it must not pass silently either.
1247        for answer in [Some(Err("boom".to_string())), Some(Ok(vec![]))] {
1248            let c = resolution_check("app.local", "local", no_pac, true, answer);
1249            assert_eq!(c.status, Status::Warn, "a broken mDNS path was hidden");
1250            assert!(
1251                c.detail.contains("mDNS"),
1252                "the warning did not say where to look: {}",
1253                c.detail
1254            );
1255            // It names `proxy setup` only to say setup is not the fix here,
1256            // so the check is against the instruction the failure branch
1257            // gives, not against the words appearing at all.
1258            assert!(
1259                !c.detail.contains("run `pitchfork proxy setup`"),
1260                "LAN mode was told to re-run setup: {}",
1261                c.detail
1262            );
1263        }
1264        // A lookup that timed out says so, in LAN mode or with the PAC state
1265        // unknown, rather than being reported as a name that did not resolve.
1266        for (pac, lan) in [(no_pac, true), (None, false)] {
1267            let c = resolution_check("app.local", "local", pac, lan, None);
1268            assert_eq!(c.status, Status::Warn);
1269            assert!(c.detail.contains("did not answer in time"), "{}", c.detail);
1270        }
1271        // A name that does resolve in LAN mode passes as usual.
1272        assert_eq!(
1273            resolution_check(
1274                "app.local",
1275                "local",
1276                no_pac,
1277                true,
1278                Some(Ok(vec![ip("192.168.1.10")]))
1279            )
1280            .status,
1281            Status::Pass
1282        );
1283
1284        // A name that does resolve still passes, whatever the PAC state.
1285        assert_eq!(
1286            resolution_check(
1287                "app.test",
1288                "test",
1289                None,
1290                false,
1291                Some(Ok(vec![ip("127.0.0.1")]))
1292            )
1293            .status,
1294            Status::Pass
1295        );
1296    }
1297
1298    /// A probe that never finishes must not take `proxy doctor` with it. The
1299    /// assertion is on the result, not the elapsed time, so this cannot go
1300    /// flaky under load: if the deadline did not hold, the test would hang
1301    /// instead of failing intermittently. Time is paused, so it does not
1302    /// sleep through the deadline either.
1303    #[tokio::test(start_paused = true)]
1304    async fn a_blocking_probe_that_never_answers_gives_up() {
1305        let (release, wait) = std::sync::mpsc::channel::<()>();
1306        let stuck = bounded_blocking(move || {
1307            // Blocks until the sender is dropped at the end of the test, which
1308            // paused time places well past the deadline.
1309            let _ = wait.recv();
1310            "answered"
1311        });
1312        assert_eq!(stuck.await, None, "the deadline did not hold");
1313        drop(release);
1314    }
1315
1316    /// The companion case, on real time: paused time would fire the deadline
1317    /// before the thread could answer, so this cannot share the test above.
1318    #[tokio::test]
1319    async fn a_blocking_probe_that_answers_returns_its_value() {
1320        assert_eq!(bounded_blocking(|| "answered").await, Some("answered"));
1321    }
1322
1323    #[test]
1324    fn probe_names_are_unique_and_under_the_tld() {
1325        let a = probe_name("test");
1326        let b = probe_name("test");
1327        assert!(a.ends_with(".test"));
1328        assert_ne!(a, b, "each run must use a name nothing could have cached");
1329    }
1330
1331    #[test]
1332    fn the_standard_port_is_not_expected_under_pac() {
1333        // PAC sends the browser straight to `proxy.port`, so setup installs no
1334        // redirect. Reporting a missing one would point the user at a fix for
1335        // a configuration that already works.
1336        let pac = Check::new(
1337            "standard port",
1338            Status::Pass,
1339            "not needed: the PAC file sends requests to port 8443",
1340        );
1341        assert_eq!(pac.status, Status::Pass);
1342        assert!(pac.line().contains("not needed"));
1343    }
1344
1345    #[test]
1346    fn a_pac_url_only_counts_when_automatic_proxy_is_on() {
1347        // A leftover URL with automatic proxy switched off routes nothing, so
1348        // treating it as active would report a broken setup as healthy.
1349        let enabled_yes = |o: &str| {
1350            o.lines().any(|l| {
1351                let l = l.trim().to_ascii_lowercase();
1352                l.starts_with("enabled:") && l.ends_with("yes")
1353            })
1354        };
1355        assert!(enabled_yes(
1356            "URL: http://127.0.0.1:8443/proxy.pac\nEnabled: Yes\n"
1357        ));
1358        assert!(!enabled_yes(
1359            "URL: http://127.0.0.1:8443/proxy.pac\nEnabled: No\n"
1360        ));
1361
1362        // GNOME consults the URL only in `auto` mode, and gsettings quotes it.
1363        let is_auto = |o: &str| o.trim().trim_matches('\'') == "auto";
1364        assert!(is_auto("'auto'\n"));
1365        assert!(!is_auto("'none'\n"));
1366        assert!(!is_auto("'manual'\n"));
1367    }
1368
1369    #[test]
1370    fn check_lines_are_single_line_and_labelled() {
1371        let line = Check::new("dns resolver", Status::Fail, "no reply").line();
1372        assert!(!line.contains('\n'));
1373        assert!(line.starts_with("[fail] dns resolver"));
1374        assert!(line.ends_with("no reply"));
1375    }
1376
1377    #[tokio::test]
1378    async fn responder_probe_reads_back_the_answer() {
1379        let cancel = tokio_util::sync::CancellationToken::new();
1380        let (tx, rx) = tokio::sync::oneshot::channel();
1381        // The resolver binds UDP and TCP on one port; see the helper for why
1382        // it cannot simply be probed with TCP.
1383        let addr = super::super::dns::free_udp_and_tcp_addr().await;
1384        let task = tokio::spawn({
1385            let cancel = cancel.clone();
1386            async move {
1387                super::super::dns::serve(
1388                    super::super::dns::ResolverConfig::loopback("test"),
1389                    addr,
1390                    tx,
1391                    cancel,
1392                )
1393                .await
1394            }
1395        });
1396        rx.await.unwrap().unwrap();
1397
1398        let v4 = std::net::IpAddr::V4(Ipv4Addr::LOCALHOST);
1399        let ip = query_responder(&probe_name("test"), addr.port(), v4)
1400            .await
1401            .expect("responder answers");
1402        assert_eq!(ip, v4);
1403
1404        // An IPv6-only proxy serves AAAA and no A, so the probe has to ask for
1405        // the record the bind address implies or it would read a working
1406        // resolver as down.
1407        let v6 = std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST);
1408        let dual = super::super::dns::ResolverConfig::for_bind("test", v6);
1409        assert_eq!(dual.ipv4, None);
1410
1411        // A name outside the TLD comes back REFUSED, which the probe reports as
1412        // a failure rather than a bogus address.
1413        let err = query_responder("example.com", addr.port(), v4)
1414            .await
1415            .unwrap_err();
1416        assert!(err.contains("rcode 5"), "unexpected error: {err}");
1417
1418        cancel.cancel();
1419        tokio::time::timeout(Duration::from_secs(5), task)
1420            .await
1421            .expect("the resolver did not stop within 5s of cancellation")
1422            .expect("the resolver task panicked")
1423            .expect("the resolver returned an error");
1424    }
1425}