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 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1382 let addr = probe.local_addr().unwrap();
1383 drop(probe);
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}