Skip to main content

pitchfork_cli/proxy/
mod.rs

1//! Reverse proxy server for pitchfork daemons.
2//!
3//! Routes `<slug>.<tld>:<port>` to the daemon's actual listening port.
4//! Slugs are defined in the global config (`~/.config/pitchfork/config.toml`)
5//! under `[slugs]`. Each slug maps to a project directory and daemon name.
6//!
7//! # URL Routing
8//!
9//! ```text
10//! myapp.localhost:7777          →  localhost:8080  (via slug)
11//! ```
12
13pub mod hostname;
14pub mod hosts;
15pub mod lan_ip;
16pub mod mdns;
17pub mod server;
18pub mod trust;
19pub mod worktree;
20
21/// Lowercased keys that more than one spelling in `keys` maps to.
22///
23/// Host names are case-insensitive (RFC 4343), so such keys are ambiguous as
24/// routing targets no matter which spelling a request uses.
25pub(crate) fn ascii_case_collisions<'a>(
26    keys: impl Iterator<Item = &'a str>,
27) -> std::collections::HashSet<String> {
28    let mut seen = std::collections::HashSet::new();
29    let mut collisions = std::collections::HashSet::new();
30    for key in keys {
31        let folded = key.to_ascii_lowercase();
32        if !seen.insert(folded.clone()) {
33            collisions.insert(folded);
34        }
35    }
36    collisions
37}
38
39/// The TLD the proxy actually serves on.
40///
41/// LAN mode forces `.local`, because mDNS publishes names in that domain.
42pub fn effective_tld(s: &crate::settings::Settings) -> &str {
43    if s.proxy.lan || !s.proxy.lan_ip.is_empty() {
44        "local"
45    } else {
46        &s.proxy.tld
47    }
48}
49
50/// Build the URL for a proxy hostname: a legacy slug, or an automatic
51/// `<daemon>.<worktree>.<project>` host.
52///
53/// This is the one place a pitchfork URL is spelled out, so everything that
54/// shows a URL, injects `PITCHFORK_URL`, or renders a template agrees with what
55/// the proxy serves.
56///
57/// Returns `None` if:
58/// - `host` is `None` (not proxied)
59/// - Proxy is disabled in settings
60/// - `proxy.port` is invalid (out of range or zero)
61pub fn build_proxy_url(host: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
62    if !s.proxy.enable {
63        return None;
64    }
65    let host = host?;
66
67    let scheme = if s.proxy.https { "https" } else { "http" };
68    let tld = effective_tld(s);
69    let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
70
71    let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
72
73    let authority = format!("{host}.{tld}");
74
75    Some(if effective_port == standard_port {
76        format!("{scheme}://{authority}")
77    } else {
78        format!("{scheme}://{authority}:{effective_port}")
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_ascii_case_collisions() {
88        let none = ascii_case_collisions(["myapp", "other", "third"].into_iter());
89        assert!(none.is_empty());
90
91        let folded = ascii_case_collisions(["MyApp", "myapp", "other"].into_iter());
92        assert_eq!(folded.len(), 1);
93        assert!(folded.contains("myapp"));
94
95        // Identical spellings collide too, not just case-only variants.
96        let exact = ascii_case_collisions(["dup", "dup"].into_iter());
97        assert!(exact.contains("dup"));
98
99        // Folding is ASCII-only: DNS does not case-fold non-ASCII labels.
100        let unicode = ascii_case_collisions(["café", "CAFÉ"].into_iter());
101        assert!(unicode.is_empty());
102    }
103
104    fn proxy_settings() -> crate::settings::Settings {
105        let mut s = crate::settings::Settings::default();
106        s.proxy.enable = true;
107        s.proxy.https = true;
108        s.proxy.port = 443;
109        s.proxy.tld = "localhost".to_string();
110        s
111    }
112
113    /// The standard port is omitted, a custom one is not.
114    #[test]
115    fn test_build_proxy_url_port_suffix() {
116        let mut s = proxy_settings();
117        assert_eq!(
118            build_proxy_url(Some("api.myproj"), &s).as_deref(),
119            Some("https://api.myproj.localhost")
120        );
121        s.proxy.port = 8088;
122        assert_eq!(
123            build_proxy_url(Some("api.myproj"), &s).as_deref(),
124            Some("https://api.myproj.localhost:8088")
125        );
126        s.proxy.https = false;
127        s.proxy.port = 80;
128        assert_eq!(
129            build_proxy_url(Some("api.myproj"), &s).as_deref(),
130            Some("http://api.myproj.localhost")
131        );
132    }
133
134    /// Nothing advertises a URL while the proxy is off, and an unrouted daemon
135    /// has none either.
136    #[test]
137    fn test_build_proxy_url_requires_enabled_proxy_and_host() {
138        let mut s = proxy_settings();
139        assert_eq!(build_proxy_url(None, &s), None);
140        s.proxy.enable = false;
141        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
142    }
143
144    /// LAN mode serves `.local`, whatever `proxy.tld` says, so URLs must follow.
145    #[test]
146    fn test_build_proxy_url_uses_lan_tld() {
147        let mut s = proxy_settings();
148        s.proxy.tld = "test".to_string();
149        assert_eq!(effective_tld(&s), "test");
150
151        s.proxy.lan = true;
152        assert_eq!(effective_tld(&s), "local");
153        assert_eq!(
154            build_proxy_url(Some("api.myproj"), &s).as_deref(),
155            Some("https://api.myproj.local")
156        );
157
158        s.proxy.lan = false;
159        s.proxy.lan_ip = "192.168.1.42".to_string();
160        assert_eq!(
161            build_proxy_url(Some("api.myproj"), &s).as_deref(),
162            Some("https://api.myproj.local")
163        );
164    }
165
166    /// An out-of-range port has no URL to show rather than a broken one.
167    #[test]
168    fn test_build_proxy_url_rejects_invalid_port() {
169        let mut s = proxy_settings();
170        s.proxy.port = 0;
171        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
172        s.proxy.port = 70000;
173        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
174    }
175}