Skip to main content

pitchfork_cli/proxy/
pac.rs

1//! Proxy auto-config (PAC) generation.
2//!
3//! The PAC file is the no-sudo path to working hostnames: instead of teaching
4//! the system resolver about the TLD, the browser is told to send every request
5//! for `*.<tld>` through the proxy and everything else direct. The proxy then
6//! resolves the name itself, so nothing has to be written to `/etc/resolver` or
7//! `/etc/hosts`.
8//!
9//! The supervisor serves the generated script at `/proxy.pac` on the proxy's
10//! own listener, which is the one HTTP endpoint that always exists when the
11//! proxy is enabled.
12
13/// Path the supervisor serves the PAC script from.
14pub const PAC_PATH: &str = "/proxy.pac";
15
16/// Longest a host name may be, in bytes (RFC 1035).
17///
18/// Shared with the hostname builder rather than defined twice: two copies of a
19/// limit are two chances for one module to accept a name another will not
20/// route.
21use crate::proxy::hostname::MAX_HOSTNAME_LEN as MAX_HOSTNAME;
22
23/// Longest a single DNS label may be, in bytes (RFC 1035).
24const MAX_LABEL: usize = 63;
25
26/// Whether `tld` is a valid DNS suffix that leaves room for a name under it.
27///
28/// The TLD is user-supplied and lands inside a JavaScript string literal, in a
29/// privileged file path, and in a systemd unit, so it is validated rather than
30/// escaped: anything that is not a real DNS suffix is a configuration error
31/// worth reporting.
32///
33/// Each label must be non-empty, at most 63 bytes, free of leading and trailing
34/// hyphens, and made only of ASCII letters, digits and hyphens. The whole suffix
35/// must also leave room for at least a one-character label and its dot, since
36/// every name pitchfork builds is `<something>.<tld>`.
37pub fn is_valid_tld(tld: &str) -> bool {
38    if tld.is_empty() || tld.len() > MAX_HOSTNAME - 2 {
39        return false;
40    }
41    tld.split('.').all(|label| {
42        !label.is_empty()
43            && label.len() <= MAX_LABEL
44            && !label.starts_with('-')
45            && !label.ends_with('-')
46            && label
47                .bytes()
48                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
49    })
50}
51
52/// Whether `label` is a legal DNS label and `<label>.<tld>` fits in a host name.
53///
54/// Two separate limits: a label may not exceed 63 bytes however short the
55/// suffix is, and the whole name may not exceed 253 however short the label is.
56pub fn hostname_fits(label: &str, tld: &str) -> bool {
57    label.len() <= MAX_LABEL && label.len() + 1 + tld.len() <= MAX_HOSTNAME
58}
59
60/// Generate the PAC script routing `*.<tld>` through `host:port`.
61///
62/// Returns an error if `tld` contains characters that do not belong in a host
63/// name.
64pub fn generate(tld: &str, host: &str, port: u16) -> crate::Result<String> {
65    if !is_valid_tld(tld) {
66        miette::bail!(
67            "proxy.tld {tld:?} is not a valid host name suffix, so no PAC file can be generated"
68        );
69    }
70    let tld = tld.to_ascii_lowercase();
71    Ok(format!(
72        "// Generated by pitchfork. Routes *.{tld} through the local proxy.\n\
73         function FindProxyForURL(url, host) {{\n\
74         \x20   host = host.toLowerCase();\n\
75         \x20   if (host === \"{tld}\" || dnsDomainIs(host, \".{tld}\")) {{\n\
76         \x20       return \"PROXY {host_}:{port}\";\n\
77         \x20   }}\n\
78         \x20   return \"DIRECT\";\n\
79         }}\n",
80        host_ = host,
81    ))
82}
83
84/// URL the PAC script is served from.
85pub fn url(host: &str, port: u16) -> String {
86    format!("http://{host}:{port}{PAC_PATH}")
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn generates_a_script_that_routes_the_tld_and_nothing_else() {
95        let pac = generate("localhost", "127.0.0.1", 8443).unwrap();
96        assert!(pac.contains("function FindProxyForURL(url, host)"));
97        assert!(pac.contains(r#"host === "localhost""#));
98        assert!(pac.contains(r#"dnsDomainIs(host, ".localhost")"#));
99        assert!(pac.contains(r#"return "PROXY 127.0.0.1:8443";"#));
100        assert!(pac.contains(r#"return "DIRECT";"#));
101    }
102
103    #[test]
104    fn tld_is_lowercased_and_host_matching_is_case_insensitive() {
105        let pac = generate("TEST", "127.0.0.1", 80).unwrap();
106        assert!(pac.contains(r#"dnsDomainIs(host, ".test")"#));
107        assert!(pac.contains("host = host.toLowerCase();"));
108    }
109
110    #[test]
111    fn multi_label_tld_is_accepted() {
112        let pac = generate("dev.internal", "127.0.0.1", 443).unwrap();
113        assert!(pac.contains(r#"dnsDomainIs(host, ".dev.internal")"#));
114    }
115
116    #[test]
117    fn invalid_tlds_are_rejected_rather_than_escaped() {
118        for bad in [
119            "", "a\"b", "a b", "a;b", ".test", "test.", "a\nb", "a..b", "a/b",
120        ] {
121            assert!(
122                generate(bad, "127.0.0.1", 443).is_err(),
123                "expected {bad:?} to be rejected"
124            );
125        }
126    }
127
128    #[test]
129    fn labels_must_be_real_dns_labels() {
130        // Boundary hyphens and over-long labels are not valid DNS, and a
131        // suffix that fills the name budget leaves nowhere to put a slug.
132        for bad in [
133            "-test",
134            "test-",
135            "a.-b",
136            "a.b-",
137            &"x".repeat(64),
138            &format!("a.{}", "x".repeat(64)),
139            &"a".repeat(252),
140        ] {
141            assert!(!is_valid_tld(bad), "expected {bad:?} to be rejected");
142        }
143        for good in ["localhost", "test", "dev.internal", "my-tld", "a-b.c-d"] {
144            assert!(is_valid_tld(good), "expected {good:?} to be allowed");
145        }
146        assert!(is_valid_tld(&"x".repeat(63)));
147    }
148
149    #[test]
150    fn a_name_longer_than_a_hostname_does_not_fit() {
151        assert!(hostname_fits("api", "localhost"));
152        // 251 + '.' + 1 is exactly 253.
153        assert!(hostname_fits("a", &"x".repeat(251)));
154        assert!(!hostname_fits("ab", &"x".repeat(251)));
155    }
156
157    #[test]
158    fn a_label_over_63_bytes_does_not_fit_even_in_a_short_name() {
159        // The two limits are independent: `<64 chars>.localhost` is only 74
160        // bytes, well inside the host name limit, but the label is not legal.
161        assert!(hostname_fits(&"a".repeat(63), "localhost"));
162        assert!(!hostname_fits(&"a".repeat(64), "localhost"));
163    }
164
165    #[test]
166    fn url_points_at_the_proxy_listener() {
167        assert_eq!(url("127.0.0.1", 8443), "http://127.0.0.1:8443/proxy.pac");
168    }
169}