pitchfork-cli 2.27.0

Daemons with DX
Documentation
//! Proxy auto-config (PAC) generation.
//!
//! The PAC file is the no-sudo path to working hostnames: instead of teaching
//! the system resolver about the TLD, the browser is told to send every request
//! for `*.<tld>` through the proxy and everything else direct. The proxy then
//! resolves the name itself, so nothing has to be written to `/etc/resolver` or
//! `/etc/hosts`.
//!
//! The supervisor serves the generated script at `/proxy.pac` on the proxy's
//! own listener, which is the one HTTP endpoint that always exists when the
//! proxy is enabled.

/// Path the supervisor serves the PAC script from.
pub const PAC_PATH: &str = "/proxy.pac";

/// Longest a host name may be, in bytes (RFC 1035).
///
/// Shared with the hostname builder rather than defined twice: two copies of a
/// limit are two chances for one module to accept a name another will not
/// route.
use crate::proxy::hostname::MAX_HOSTNAME_LEN as MAX_HOSTNAME;

/// Longest a single DNS label may be, in bytes (RFC 1035).
const MAX_LABEL: usize = 63;

/// Whether `tld` is a valid DNS suffix that leaves room for a name under it.
///
/// The TLD is user-supplied and lands inside a JavaScript string literal, in a
/// privileged file path, and in a systemd unit, so it is validated rather than
/// escaped: anything that is not a real DNS suffix is a configuration error
/// worth reporting.
///
/// Each label must be non-empty, at most 63 bytes, free of leading and trailing
/// hyphens, and made only of ASCII letters, digits and hyphens. The whole suffix
/// must also leave room for at least a one-character label and its dot, since
/// every name pitchfork builds is `<something>.<tld>`.
pub fn is_valid_tld(tld: &str) -> bool {
    if tld.is_empty() || tld.len() > MAX_HOSTNAME - 2 {
        return false;
    }
    tld.split('.').all(|label| {
        !label.is_empty()
            && label.len() <= MAX_LABEL
            && !label.starts_with('-')
            && !label.ends_with('-')
            && label
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
    })
}

/// Whether `label` is a legal DNS label and `<label>.<tld>` fits in a host name.
///
/// Two separate limits: a label may not exceed 63 bytes however short the
/// suffix is, and the whole name may not exceed 253 however short the label is.
pub fn hostname_fits(label: &str, tld: &str) -> bool {
    label.len() <= MAX_LABEL && label.len() + 1 + tld.len() <= MAX_HOSTNAME
}

/// Generate the PAC script routing `*.<tld>` through `host:port`.
///
/// Returns an error if `tld` contains characters that do not belong in a host
/// name.
pub fn generate(tld: &str, host: &str, port: u16) -> crate::Result<String> {
    if !is_valid_tld(tld) {
        miette::bail!(
            "proxy.tld {tld:?} is not a valid host name suffix, so no PAC file can be generated"
        );
    }
    let tld = tld.to_ascii_lowercase();
    Ok(format!(
        "// Generated by pitchfork. Routes *.{tld} through the local proxy.\n\
         function FindProxyForURL(url, host) {{\n\
         \x20   host = host.toLowerCase();\n\
         \x20   if (host === \"{tld}\" || dnsDomainIs(host, \".{tld}\")) {{\n\
         \x20       return \"PROXY {host_}:{port}\";\n\
         \x20   }}\n\
         \x20   return \"DIRECT\";\n\
         }}\n",
        host_ = host,
    ))
}

/// URL the PAC script is served from.
pub fn url(host: &str, port: u16) -> String {
    format!("http://{host}:{port}{PAC_PATH}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generates_a_script_that_routes_the_tld_and_nothing_else() {
        let pac = generate("localhost", "127.0.0.1", 8443).unwrap();
        assert!(pac.contains("function FindProxyForURL(url, host)"));
        assert!(pac.contains(r#"host === "localhost""#));
        assert!(pac.contains(r#"dnsDomainIs(host, ".localhost")"#));
        assert!(pac.contains(r#"return "PROXY 127.0.0.1:8443";"#));
        assert!(pac.contains(r#"return "DIRECT";"#));
    }

    #[test]
    fn tld_is_lowercased_and_host_matching_is_case_insensitive() {
        let pac = generate("TEST", "127.0.0.1", 80).unwrap();
        assert!(pac.contains(r#"dnsDomainIs(host, ".test")"#));
        assert!(pac.contains("host = host.toLowerCase();"));
    }

    #[test]
    fn multi_label_tld_is_accepted() {
        let pac = generate("dev.internal", "127.0.0.1", 443).unwrap();
        assert!(pac.contains(r#"dnsDomainIs(host, ".dev.internal")"#));
    }

    #[test]
    fn invalid_tlds_are_rejected_rather_than_escaped() {
        for bad in [
            "", "a\"b", "a b", "a;b", ".test", "test.", "a\nb", "a..b", "a/b",
        ] {
            assert!(
                generate(bad, "127.0.0.1", 443).is_err(),
                "expected {bad:?} to be rejected"
            );
        }
    }

    #[test]
    fn labels_must_be_real_dns_labels() {
        // Boundary hyphens and over-long labels are not valid DNS, and a
        // suffix that fills the name budget leaves nowhere to put a slug.
        for bad in [
            "-test",
            "test-",
            "a.-b",
            "a.b-",
            &"x".repeat(64),
            &format!("a.{}", "x".repeat(64)),
            &"a".repeat(252),
        ] {
            assert!(!is_valid_tld(bad), "expected {bad:?} to be rejected");
        }
        for good in ["localhost", "test", "dev.internal", "my-tld", "a-b.c-d"] {
            assert!(is_valid_tld(good), "expected {good:?} to be allowed");
        }
        assert!(is_valid_tld(&"x".repeat(63)));
    }

    #[test]
    fn a_name_longer_than_a_hostname_does_not_fit() {
        assert!(hostname_fits("api", "localhost"));
        // 251 + '.' + 1 is exactly 253.
        assert!(hostname_fits("a", &"x".repeat(251)));
        assert!(!hostname_fits("ab", &"x".repeat(251)));
    }

    #[test]
    fn a_label_over_63_bytes_does_not_fit_even_in_a_short_name() {
        // The two limits are independent: `<64 chars>.localhost` is only 74
        // bytes, well inside the host name limit, but the label is not legal.
        assert!(hostname_fits(&"a".repeat(63), "localhost"));
        assert!(!hostname_fits(&"a".repeat(64), "localhost"));
    }

    #[test]
    fn url_points_at_the_proxy_listener() {
        assert_eq!(url("127.0.0.1", 8443), "http://127.0.0.1:8443/proxy.pac");
    }
}