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 activity;
14pub mod dns;
15pub mod doctor;
16pub mod hostname;
17pub mod hosts;
18pub mod lan_ip;
19pub mod mdns;
20pub mod pac;
21pub mod server;
22pub mod setup;
23pub mod sni;
24pub mod trust;
25pub mod worktree;
26
27/// Rate limiter for a log line that an outside party can trigger at will.
28///
29/// The proxy and the resolver both refuse work under load, and a client can
30/// provoke those refusals as fast as it can open sockets. Logging each one
31/// hands that client a way to fill the disk, so the message is emitted at most
32/// once per interval and carries the number suppressed since.
33pub(crate) struct LogThrottle {
34    last: std::sync::Mutex<Option<std::time::Instant>>,
35    suppressed: std::sync::atomic::AtomicU64,
36}
37
38impl LogThrottle {
39    pub(crate) const fn new() -> Self {
40        Self {
41            last: std::sync::Mutex::new(None),
42            suppressed: std::sync::atomic::AtomicU64::new(0),
43        }
44    }
45
46    /// Whether to log now, and how many were suppressed since the last time.
47    ///
48    /// `None` means stay quiet. A poisoned lock logs rather than goes silent,
49    /// since losing the message entirely is the worse failure.
50    pub(crate) fn allow(&self, every: std::time::Duration) -> Option<u64> {
51        use std::sync::atomic::Ordering;
52        let now = std::time::Instant::now();
53        let mut last = match self.last.lock() {
54            Ok(g) => g,
55            Err(e) => e.into_inner(),
56        };
57        match *last {
58            Some(t) if now.duration_since(t) < every => {
59                self.suppressed.fetch_add(1, Ordering::Relaxed);
60                None
61            }
62            _ => {
63                *last = Some(now);
64                Some(self.suppressed.swap(0, Ordering::Relaxed))
65            }
66        }
67    }
68}
69
70/// Whether `name` is the TLD itself or a name beneath it.
71///
72/// One definition shared by the DNS responder, which uses it to decide what it
73/// is authoritative for, and the certificate resolver, which uses it to decide
74/// what the local CA is allowed to sign. Those two answers must agree: a name
75/// the proxy will not resolve is a name it must not issue a certificate for.
76///
77/// Comparison is ASCII case-insensitive, per RFC 4343, and a trailing root dot
78/// is ignored.
79pub(crate) fn owns_name(tld: &str, name: &str) -> bool {
80    let name = name.trim_end_matches('.');
81    let tld = tld.trim_matches('.');
82    if tld.is_empty() || name.is_empty() {
83        return false;
84    }
85    if name.eq_ignore_ascii_case(tld) {
86        return true;
87    }
88    // Byte comparison: a DNS label may hold non-UTF-8 data, so slicing a
89    // lossily-decoded string could land mid-character.
90    let (name, tld) = (name.as_bytes(), tld.as_bytes());
91    name.len() > tld.len() + 1
92        && name[name.len() - tld.len() - 1] == b'.'
93        && name[name.len() - tld.len()..].eq_ignore_ascii_case(tld)
94}
95
96/// Whether `name` sits strictly beneath `tld`, rather than being the TLD itself.
97///
98/// Used for the sibling wildcard on a minted certificate: `*.<tld>` would cover
99/// the entire TLD, which is broader than the one host the certificate is for.
100pub(crate) fn is_strictly_under_tld(tld: &str, name: &str) -> bool {
101    !name
102        .trim_end_matches('.')
103        .eq_ignore_ascii_case(tld.trim_matches('.'))
104        && owns_name(tld, name)
105}
106
107/// Lowercased keys that more than one spelling in `keys` maps to.
108///
109/// Host names are case-insensitive (RFC 4343), so such keys are ambiguous as
110/// routing targets no matter which spelling a request uses.
111pub(crate) fn ascii_case_collisions<'a>(
112    keys: impl Iterator<Item = &'a str>,
113) -> std::collections::HashSet<String> {
114    let mut seen = std::collections::HashSet::new();
115    let mut collisions = std::collections::HashSet::new();
116    for key in keys {
117        let folded = key.to_ascii_lowercase();
118        if !seen.insert(folded.clone()) {
119            collisions.insert(folded);
120        }
121    }
122    collisions
123}
124
125/// The TLD the proxy actually serves on.
126///
127/// LAN mode forces `.local`, because mDNS publishes names in that domain.
128pub fn effective_tld(s: &crate::settings::Settings) -> &str {
129    if s.proxy.lan || !s.proxy.lan_ip.is_empty() {
130        "local"
131    } else {
132        &s.proxy.tld
133    }
134}
135
136/// Build the URL for a proxy hostname: a legacy slug, or an automatic
137/// `<daemon>.<worktree>.<project>` host.
138///
139/// This is the one place a pitchfork URL is spelled out, so everything that
140/// shows a URL, injects `PITCHFORK_URL`, or renders a template agrees with what
141/// the proxy serves.
142///
143/// Returns `None` if:
144/// - `host` is `None` (not proxied)
145/// - Proxy is disabled in settings
146/// - `proxy.port` is invalid (out of range or zero)
147pub fn build_proxy_url(host: Option<&str>, s: &crate::settings::Settings) -> Option<String> {
148    if !s.proxy.enable {
149        return None;
150    }
151    let host = host?;
152
153    let scheme = if s.proxy.https { "https" } else { "http" };
154    let tld = effective_tld(s);
155    let standard_port = if s.proxy.https { 443u16 } else { 80u16 };
156
157    let effective_port = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0)?;
158
159    let authority = format!("{host}.{tld}");
160
161    Some(if effective_port == standard_port {
162        format!("{scheme}://{authority}")
163    } else {
164        format!("{scheme}://{authority}:{effective_port}")
165    })
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn a_throttled_message_reports_what_it_suppressed() {
174        use std::time::Duration;
175        let throttle = LogThrottle::new();
176        // First call goes through, with nothing suppressed yet.
177        assert_eq!(throttle.allow(Duration::from_secs(60)), Some(0));
178        // Everything inside the window stays quiet.
179        for _ in 0..5 {
180            assert_eq!(throttle.allow(Duration::from_secs(60)), None);
181        }
182        // A zero window always allows, and reports the five it swallowed.
183        assert_eq!(throttle.allow(Duration::ZERO), Some(5));
184        // The count resets after being reported.
185        assert_eq!(throttle.allow(Duration::ZERO), Some(0));
186    }
187
188    #[test]
189    fn owns_name_matches_the_apex_and_names_beneath_it() {
190        assert!(owns_name("localhost", "localhost"));
191        assert!(owns_name("localhost", "api.localhost"));
192        assert!(owns_name("localhost", "core.fix-refs.proj.localhost"));
193        assert!(owns_name("localhost", "API.LocalHost"));
194        assert!(owns_name("localhost", "api.localhost."));
195        assert!(owns_name("dev.internal", "api.dev.internal"));
196
197        assert!(!owns_name("localhost", "example.com"));
198        // Ends with the letters but not at a label boundary.
199        assert!(!owns_name("localhost", "notlocalhost"));
200        assert!(!owns_name("localhost", "localhost.evil.com"));
201        assert!(!owns_name("localhost", ""));
202        assert!(!owns_name("", "api.localhost"));
203    }
204
205    #[test]
206    fn is_strictly_under_tld_excludes_the_apex() {
207        assert!(is_strictly_under_tld("localhost", "api.localhost"));
208        // The apex itself is not "under" the TLD: a wildcard there would cover
209        // every name in it.
210        assert!(!is_strictly_under_tld("localhost", "localhost"));
211        assert!(!is_strictly_under_tld("dev.internal", "dev.internal"));
212        assert!(is_strictly_under_tld("dev.internal", "a.dev.internal"));
213        assert!(!is_strictly_under_tld("localhost", "example.com"));
214    }
215
216    #[test]
217    fn test_ascii_case_collisions() {
218        let none = ascii_case_collisions(["myapp", "other", "third"].into_iter());
219        assert!(none.is_empty());
220
221        let folded = ascii_case_collisions(["MyApp", "myapp", "other"].into_iter());
222        assert_eq!(folded.len(), 1);
223        assert!(folded.contains("myapp"));
224
225        // Identical spellings collide too, not just case-only variants.
226        let exact = ascii_case_collisions(["dup", "dup"].into_iter());
227        assert!(exact.contains("dup"));
228
229        // Folding is ASCII-only: DNS does not case-fold non-ASCII labels.
230        let unicode = ascii_case_collisions(["café", "CAFÉ"].into_iter());
231        assert!(unicode.is_empty());
232    }
233
234    fn proxy_settings() -> crate::settings::Settings {
235        let mut s = crate::settings::Settings::default();
236        s.proxy.enable = true;
237        s.proxy.https = true;
238        s.proxy.port = 443;
239        s.proxy.tld = "localhost".to_string();
240        s
241    }
242
243    /// The standard port is omitted, a custom one is not.
244    #[test]
245    fn test_build_proxy_url_port_suffix() {
246        let mut s = proxy_settings();
247        assert_eq!(
248            build_proxy_url(Some("api.myproj"), &s).as_deref(),
249            Some("https://api.myproj.localhost")
250        );
251        s.proxy.port = 8088;
252        assert_eq!(
253            build_proxy_url(Some("api.myproj"), &s).as_deref(),
254            Some("https://api.myproj.localhost:8088")
255        );
256        s.proxy.https = false;
257        s.proxy.port = 80;
258        assert_eq!(
259            build_proxy_url(Some("api.myproj"), &s).as_deref(),
260            Some("http://api.myproj.localhost")
261        );
262    }
263
264    /// Nothing advertises a URL while the proxy is off, and an unrouted daemon
265    /// has none either.
266    #[test]
267    fn test_build_proxy_url_requires_enabled_proxy_and_host() {
268        let mut s = proxy_settings();
269        assert_eq!(build_proxy_url(None, &s), None);
270        s.proxy.enable = false;
271        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
272    }
273
274    /// LAN mode serves `.local`, whatever `proxy.tld` says, so URLs must follow.
275    #[test]
276    fn test_build_proxy_url_uses_lan_tld() {
277        let mut s = proxy_settings();
278        s.proxy.tld = "test".to_string();
279        assert_eq!(effective_tld(&s), "test");
280
281        s.proxy.lan = true;
282        assert_eq!(effective_tld(&s), "local");
283        assert_eq!(
284            build_proxy_url(Some("api.myproj"), &s).as_deref(),
285            Some("https://api.myproj.local")
286        );
287
288        s.proxy.lan = false;
289        s.proxy.lan_ip = "192.168.1.42".to_string();
290        assert_eq!(
291            build_proxy_url(Some("api.myproj"), &s).as_deref(),
292            Some("https://api.myproj.local")
293        );
294    }
295
296    /// An out-of-range port has no URL to show rather than a broken one.
297    #[test]
298    fn test_build_proxy_url_rejects_invalid_port() {
299        let mut s = proxy_settings();
300        s.proxy.port = 0;
301        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
302        s.proxy.port = 70000;
303        assert_eq!(build_proxy_url(Some("api.myproj"), &s), None);
304    }
305}