Skip to main content

pitchfork_cli/proxy/
hosts.rs

1//! /etc/hosts management for the reverse proxy.
2//!
3//! Automatically syncs registered slug hostnames into `/etc/hosts` so that
4//! browsers can resolve them (needed for Safari, which doesn't auto-resolve
5//! `.localhost` subdomains, and for custom TLDs like `.test`).
6//!
7//! Entries are managed inside a marked block delimited by
8//! `# pitchfork-start` / `# pitchfork-end`.  The block is replaced on each
9//! sync and removed entirely on proxy shutdown.
10
11use crate::settings::settings;
12use std::sync::OnceLock;
13
14/// Marker lines for the pitchfork-managed block in /etc/hosts.
15const MARKER_START: &str = "# pitchfork-start";
16const MARKER_END: &str = "# pitchfork-end";
17static BLANK_LINES_RE: OnceLock<regex::Regex> = OnceLock::new();
18
19/// Sync the given slug hostnames into /etc/hosts.
20///
21/// Builds the expected hosts block and replaces (or appends) the
22/// pitchfork-managed block.
23///
24/// Best-effort: logs a warning on failure (e.g. permission denied) and
25/// does not prevent proxy startup.
26fn sync_hosts_file_with_slugs(bind_ip: &str, tld: &str, slug_names: &[String]) {
27    write_hosts_block(&hosts_entries(bind_ip, tld, slug_names));
28}
29
30/// One `<ip> <slug>.<tld>` line per routable slug.
31///
32/// Slugs that collide with another by case are left out: host names are
33/// case-insensitive, so the proxy refuses to route them, and an entry here
34/// would point at an address it will not serve.
35fn hosts_entries(bind_ip: &str, tld: &str, slug_names: &[String]) -> Vec<String> {
36    let collisions = crate::proxy::ascii_case_collisions(slug_names.iter().map(String::as_str));
37    slug_names
38        .iter()
39        .filter(|slug| !collisions.contains(&slug.to_ascii_lowercase()))
40        .map(|slug| format!("{bind_ip} {slug}.{tld}"))
41        .collect()
42}
43
44/// Refresh `/etc/hosts` from the current settings if sync is enabled.
45///
46/// Used when slug registrations change while the proxy is already running.
47/// In LAN mode, entries are mapped to the detected LAN IP instead of 127.0.0.1.
48pub fn sync_hosts_from_settings() {
49    let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
50    let slug_names: Vec<String> = slugs.keys().cloned().collect();
51    sync_hosts_from_settings_with_slugs(&slug_names);
52}
53
54/// Like `sync_hosts_from_settings`, but with a caller-provided slug list.
55///
56/// Use this from code that holds the global config fslock: reading the slug
57/// registry here would re-acquire that lock via `PitchforkToml::read()`, and
58/// flock(2) is per open file description, so the same process would deadlock
59/// against its own held lock.
60pub fn sync_hosts_from_settings_with_slugs(slug_names: &[String]) {
61    let s = settings();
62    if s.proxy.enable && s.proxy.sync_hosts {
63        warn_deprecated_once();
64        let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
65        let tld = if lan_enabled { "local" } else { &s.proxy.tld };
66        // In LAN mode, map to the LAN IP (or configured lan_ip) so that
67        // /etc/hosts entries work for mDNS-resolved hostnames on the LAN.
68        let ip = if lan_enabled {
69            // Try the configured lan_ip first, otherwise fall back to the proxy.host.
70            if !s.proxy.lan_ip.is_empty() {
71                s.proxy.lan_ip.clone()
72            } else {
73                // Auto-detected LAN IP is async and not available here.
74                // Fall back to proxy.host (which defaults to 127.0.0.1
75                // but may be overridden by the user).  The mDNS records
76                // handle the actual resolution on the LAN.
77                s.proxy.host.clone()
78            }
79        } else {
80            s.proxy.host.clone()
81        };
82        sync_hosts_file_with_slugs(&ip, tld, slug_names);
83    }
84}
85
86/// Warn once per process that `/etc/hosts` sync is on its way out.
87///
88/// The loopback resolver covers every name under the TLD without touching a
89/// root-owned file, so this path exists only to keep existing setups working
90/// for one release.
91fn warn_deprecated_once() {
92    static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
93    if WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
94        return;
95    }
96    log::warn!(
97        "proxy.sync_hosts is deprecated and will be removed in a future release. \
98         Run `pitchfork proxy setup` to use the loopback DNS resolver instead, \
99         then set proxy.sync_hosts = false."
100    );
101}
102
103/// Remove the pitchfork-managed block from /etc/hosts.
104///
105/// Called on proxy shutdown to clean up stale entries.
106pub fn clean_hosts_file() {
107    write_hosts_block(&[]);
108}
109
110/// Read /etc/hosts, replace the marked block, write back atomically.
111fn write_hosts_block(entries: &[String]) {
112    let path = crate::env::PITCHFORK_HOSTS_FILE.clone();
113
114    let content = match std::fs::read_to_string(&path) {
115        Ok(c) => c,
116        Err(e) => {
117            if !entries.is_empty() {
118                log::warn!(
119                    "Failed to read {} for hosts sync: {e}. \
120                     Set proxy.sync_hosts = false to suppress this warning.",
121                    path.display()
122                );
123            }
124            return;
125        }
126    };
127
128    let cleaned = remove_block(&content);
129
130    let new_content = if entries.is_empty() {
131        cleaned
132    } else {
133        let block = build_block(entries);
134        format!("{}\n{block}\n", cleaned.trim_end())
135    };
136
137    // Atomic write: write to a temp file in the same directory, then rename.
138    let parent = path.parent().unwrap_or(std::path::Path::new("/etc"));
139    let tmp_path = parent.join(format!(".pitchfork-hosts-tmp-{}", std::process::id()));
140
141    if let Err(e) = std::fs::write(&tmp_path, &new_content) {
142        log::warn!(
143            "Failed to write {} for hosts sync: {e}. \
144             Writing to /etc/hosts may require sudo. \
145             Set proxy.sync_hosts = false to suppress this warning.",
146            tmp_path.display()
147        );
148        let _ = std::fs::remove_file(&tmp_path);
149        return;
150    }
151
152    if let Err(e) = std::fs::rename(&tmp_path, &path) {
153        log::warn!(
154            "Failed to rename {} to {}: {e}. \
155             Writing to /etc/hosts may require sudo. \
156             Set proxy.sync_hosts = false to suppress this warning.",
157            tmp_path.display(),
158            path.display()
159        );
160        let _ = std::fs::remove_file(&tmp_path);
161    }
162}
163
164/// Build the pitchfork-managed block for the given entries.
165fn build_block(entries: &[String]) -> String {
166    if entries.is_empty() {
167        return String::new();
168    }
169    let lines = entries.join("\n");
170    format!("{MARKER_START}\n{lines}\n{MARKER_END}")
171}
172
173/// Remove the pitchfork-managed block from /etc/hosts content and return
174/// the cleaned content with trailing newlines normalized.
175fn remove_block(content: &str) -> String {
176    let start_idx = match content.find(MARKER_START) {
177        Some(i) => i,
178        None => return content.to_string(),
179    };
180    let end_idx = match content[start_idx..].find(MARKER_END) {
181        Some(i) => start_idx + i + MARKER_END.len(),
182        None => return content.to_string(),
183    };
184    let before = &content[..start_idx];
185    let after = &content[end_idx..];
186    let result = format!("{before}{after}");
187    // Normalize excessive blank lines caused by removing the block
188    let re = BLANK_LINES_RE.get_or_init(|| regex::Regex::new(r"\n{3,}").unwrap());
189    re.replace_all(&result, "\n\n").trim_end().to_string() + "\n"
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_build_block() {
198        let entries = vec![
199            "127.0.0.1 myapp.localhost".to_string(),
200            "127.0.0.1 api.myapp.localhost".to_string(),
201        ];
202        let block = build_block(&entries);
203        assert!(block.starts_with("# pitchfork-start\n"));
204        assert!(block.ends_with("\n# pitchfork-end"));
205        assert!(block.contains("127.0.0.1 myapp.localhost"));
206        assert!(block.contains("127.0.0.1 api.myapp.localhost"));
207    }
208
209    #[test]
210    fn test_hosts_entries_omits_case_collisions() {
211        let slugs = ["myapp".to_string(), "API".to_string(), "api".to_string()];
212        let entries = hosts_entries("127.0.0.1", "localhost", &slugs);
213        // Neither spelling of the colliding slug is published.
214        assert_eq!(entries, vec!["127.0.0.1 myapp.localhost".to_string()]);
215    }
216
217    #[test]
218    fn test_build_block_empty() {
219        assert!(build_block(&[]).is_empty());
220    }
221
222    #[test]
223    fn test_remove_block() {
224        let content =
225            "127.0.0.1 localhost\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
226        let cleaned = remove_block(content);
227        assert!(!cleaned.contains("pitchfork-start"));
228        assert!(!cleaned.contains("myapp.localhost"));
229        assert!(cleaned.contains("127.0.0.1 localhost"));
230    }
231
232    #[test]
233    fn test_remove_block_no_markers() {
234        let content = "127.0.0.1 localhost\n";
235        let cleaned = remove_block(content);
236        assert_eq!(cleaned, content);
237    }
238
239    #[test]
240    fn test_remove_block_normalizes_blank_lines() {
241        let content = "127.0.0.1 localhost\n\n\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n\n\n";
242        let cleaned = remove_block(content);
243        assert!(!cleaned.contains("\n\n\n"));
244    }
245
246    #[test]
247    fn test_remove_block_ignores_end_marker_before_start_marker() {
248        let content = "127.0.0.1 localhost\n# pitchfork-end\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
249        let cleaned = remove_block(content);
250        assert_eq!(cleaned, "127.0.0.1 localhost\n# pitchfork-end\n");
251    }
252}