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/// Path to the hosts file on the current platform.
20fn hosts_path() -> std::path::PathBuf {
21    if cfg!(windows) {
22        let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
23        std::path::PathBuf::from(system_root)
24            .join("System32")
25            .join("drivers")
26            .join("etc")
27            .join("hosts")
28    } else {
29        std::path::PathBuf::from("/etc/hosts")
30    }
31}
32
33/// Sync all registered slug hostnames into /etc/hosts.
34///
35/// Reads the current slug table from global config, builds the expected
36/// hosts block, and replaces (or appends) the pitchfork-managed block.
37///
38/// Best-effort: logs a warning on failure (e.g. permission denied) and
39/// does not prevent proxy startup.
40pub fn sync_hosts_file(bind_ip: &str, tld: &str) {
41    let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
42    let mut entries: Vec<String> = Vec::new();
43    for slug in slugs.keys() {
44        entries.push(format!("{bind_ip} {slug}.{tld}"));
45    }
46    write_hosts_block(&entries);
47}
48
49/// Refresh `/etc/hosts` from the current settings if sync is enabled.
50///
51/// Used when slug registrations change while the proxy is already running.
52pub fn sync_hosts_from_settings() {
53    let s = settings();
54    if s.proxy.enable && s.proxy.sync_hosts {
55        sync_hosts_file(&s.proxy.host, &s.proxy.tld);
56    }
57}
58
59/// Remove the pitchfork-managed block from /etc/hosts.
60///
61/// Called on proxy shutdown to clean up stale entries.
62pub fn clean_hosts_file() {
63    write_hosts_block(&[]);
64}
65
66/// Read /etc/hosts, replace the marked block, write back atomically.
67fn write_hosts_block(entries: &[String]) {
68    let path = hosts_path();
69
70    let content = match std::fs::read_to_string(&path) {
71        Ok(c) => c,
72        Err(e) => {
73            if !entries.is_empty() {
74                log::warn!(
75                    "Failed to read {} for hosts sync: {e}. \
76                     Set proxy.sync_hosts = false to suppress this warning.",
77                    path.display()
78                );
79            }
80            return;
81        }
82    };
83
84    let cleaned = remove_block(&content);
85
86    let new_content = if entries.is_empty() {
87        cleaned
88    } else {
89        let block = build_block(entries);
90        format!("{}\n{block}\n", cleaned.trim_end())
91    };
92
93    // Atomic write: write to a temp file in the same directory, then rename.
94    let parent = path.parent().unwrap_or(std::path::Path::new("/etc"));
95    let tmp_path = parent.join(format!(".pitchfork-hosts-tmp-{}", std::process::id()));
96
97    if let Err(e) = std::fs::write(&tmp_path, &new_content) {
98        log::warn!(
99            "Failed to write {} for hosts sync: {e}. \
100             Writing to /etc/hosts may require sudo. \
101             Set proxy.sync_hosts = false to suppress this warning.",
102            tmp_path.display()
103        );
104        let _ = std::fs::remove_file(&tmp_path);
105        return;
106    }
107
108    if let Err(e) = std::fs::rename(&tmp_path, &path) {
109        log::warn!(
110            "Failed to rename {} to {}: {e}. \
111             Writing to /etc/hosts may require sudo. \
112             Set proxy.sync_hosts = false to suppress this warning.",
113            tmp_path.display(),
114            path.display()
115        );
116        let _ = std::fs::remove_file(&tmp_path);
117    }
118}
119
120/// Build the pitchfork-managed block for the given entries.
121fn build_block(entries: &[String]) -> String {
122    if entries.is_empty() {
123        return String::new();
124    }
125    let lines = entries.join("\n");
126    format!("{MARKER_START}\n{lines}\n{MARKER_END}")
127}
128
129/// Remove the pitchfork-managed block from /etc/hosts content and return
130/// the cleaned content with trailing newlines normalized.
131fn remove_block(content: &str) -> String {
132    let start_idx = match content.find(MARKER_START) {
133        Some(i) => i,
134        None => return content.to_string(),
135    };
136    let end_idx = match content[start_idx..].find(MARKER_END) {
137        Some(i) => start_idx + i + MARKER_END.len(),
138        None => return content.to_string(),
139    };
140    let before = &content[..start_idx];
141    let after = &content[end_idx..];
142    let result = format!("{before}{after}");
143    // Normalize excessive blank lines caused by removing the block
144    let re = BLANK_LINES_RE.get_or_init(|| regex::Regex::new(r"\n{3,}").unwrap());
145    re.replace_all(&result, "\n\n").trim_end().to_string() + "\n"
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_build_block() {
154        let entries = vec![
155            "127.0.0.1 myapp.localhost".to_string(),
156            "127.0.0.1 api.myapp.localhost".to_string(),
157        ];
158        let block = build_block(&entries);
159        assert!(block.starts_with("# pitchfork-start\n"));
160        assert!(block.ends_with("\n# pitchfork-end"));
161        assert!(block.contains("127.0.0.1 myapp.localhost"));
162        assert!(block.contains("127.0.0.1 api.myapp.localhost"));
163    }
164
165    #[test]
166    fn test_build_block_empty() {
167        assert!(build_block(&[]).is_empty());
168    }
169
170    #[test]
171    fn test_remove_block() {
172        let content =
173            "127.0.0.1 localhost\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
174        let cleaned = remove_block(content);
175        assert!(!cleaned.contains("pitchfork-start"));
176        assert!(!cleaned.contains("myapp.localhost"));
177        assert!(cleaned.contains("127.0.0.1 localhost"));
178    }
179
180    #[test]
181    fn test_remove_block_no_markers() {
182        let content = "127.0.0.1 localhost\n";
183        let cleaned = remove_block(content);
184        assert_eq!(cleaned, content);
185    }
186
187    #[test]
188    fn test_remove_block_normalizes_blank_lines() {
189        let content = "127.0.0.1 localhost\n\n\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n\n\n";
190        let cleaned = remove_block(content);
191        assert!(!cleaned.contains("\n\n\n"));
192    }
193
194    #[test]
195    fn test_remove_block_ignores_end_marker_before_start_marker() {
196        let content = "127.0.0.1 localhost\n# pitchfork-end\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
197        let cleaned = remove_block(content);
198        assert_eq!(cleaned, "127.0.0.1 localhost\n# pitchfork-end\n");
199    }
200}