pitchfork_cli/proxy/
hosts.rs1use crate::settings::settings;
12use std::sync::OnceLock;
13
14const MARKER_START: &str = "# pitchfork-start";
16const MARKER_END: &str = "# pitchfork-end";
17static BLANK_LINES_RE: OnceLock<regex::Regex> = OnceLock::new();
18
19fn 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
33pub 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
49pub fn sync_hosts_from_settings() {
54 let s = settings();
55 if s.proxy.enable && s.proxy.sync_hosts {
56 let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
57 let tld = if lan_enabled { "local" } else { &s.proxy.tld };
58 let ip = if lan_enabled {
61 if !s.proxy.lan_ip.is_empty() {
63 s.proxy.lan_ip.clone()
64 } else {
65 s.proxy.host.clone()
70 }
71 } else {
72 s.proxy.host.clone()
73 };
74 sync_hosts_file(&ip, tld);
75 }
76}
77
78pub fn clean_hosts_file() {
82 write_hosts_block(&[]);
83}
84
85fn write_hosts_block(entries: &[String]) {
87 let path = hosts_path();
88
89 let content = match std::fs::read_to_string(&path) {
90 Ok(c) => c,
91 Err(e) => {
92 if !entries.is_empty() {
93 log::warn!(
94 "Failed to read {} for hosts sync: {e}. \
95 Set proxy.sync_hosts = false to suppress this warning.",
96 path.display()
97 );
98 }
99 return;
100 }
101 };
102
103 let cleaned = remove_block(&content);
104
105 let new_content = if entries.is_empty() {
106 cleaned
107 } else {
108 let block = build_block(entries);
109 format!("{}\n{block}\n", cleaned.trim_end())
110 };
111
112 let parent = path.parent().unwrap_or(std::path::Path::new("/etc"));
114 let tmp_path = parent.join(format!(".pitchfork-hosts-tmp-{}", std::process::id()));
115
116 if let Err(e) = std::fs::write(&tmp_path, &new_content) {
117 log::warn!(
118 "Failed to write {} for hosts sync: {e}. \
119 Writing to /etc/hosts may require sudo. \
120 Set proxy.sync_hosts = false to suppress this warning.",
121 tmp_path.display()
122 );
123 let _ = std::fs::remove_file(&tmp_path);
124 return;
125 }
126
127 if let Err(e) = std::fs::rename(&tmp_path, &path) {
128 log::warn!(
129 "Failed to rename {} to {}: {e}. \
130 Writing to /etc/hosts may require sudo. \
131 Set proxy.sync_hosts = false to suppress this warning.",
132 tmp_path.display(),
133 path.display()
134 );
135 let _ = std::fs::remove_file(&tmp_path);
136 }
137}
138
139fn build_block(entries: &[String]) -> String {
141 if entries.is_empty() {
142 return String::new();
143 }
144 let lines = entries.join("\n");
145 format!("{MARKER_START}\n{lines}\n{MARKER_END}")
146}
147
148fn remove_block(content: &str) -> String {
151 let start_idx = match content.find(MARKER_START) {
152 Some(i) => i,
153 None => return content.to_string(),
154 };
155 let end_idx = match content[start_idx..].find(MARKER_END) {
156 Some(i) => start_idx + i + MARKER_END.len(),
157 None => return content.to_string(),
158 };
159 let before = &content[..start_idx];
160 let after = &content[end_idx..];
161 let result = format!("{before}{after}");
162 let re = BLANK_LINES_RE.get_or_init(|| regex::Regex::new(r"\n{3,}").unwrap());
164 re.replace_all(&result, "\n\n").trim_end().to_string() + "\n"
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn test_build_block() {
173 let entries = vec![
174 "127.0.0.1 myapp.localhost".to_string(),
175 "127.0.0.1 api.myapp.localhost".to_string(),
176 ];
177 let block = build_block(&entries);
178 assert!(block.starts_with("# pitchfork-start\n"));
179 assert!(block.ends_with("\n# pitchfork-end"));
180 assert!(block.contains("127.0.0.1 myapp.localhost"));
181 assert!(block.contains("127.0.0.1 api.myapp.localhost"));
182 }
183
184 #[test]
185 fn test_build_block_empty() {
186 assert!(build_block(&[]).is_empty());
187 }
188
189 #[test]
190 fn test_remove_block() {
191 let content =
192 "127.0.0.1 localhost\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
193 let cleaned = remove_block(content);
194 assert!(!cleaned.contains("pitchfork-start"));
195 assert!(!cleaned.contains("myapp.localhost"));
196 assert!(cleaned.contains("127.0.0.1 localhost"));
197 }
198
199 #[test]
200 fn test_remove_block_no_markers() {
201 let content = "127.0.0.1 localhost\n";
202 let cleaned = remove_block(content);
203 assert_eq!(cleaned, content);
204 }
205
206 #[test]
207 fn test_remove_block_normalizes_blank_lines() {
208 let content = "127.0.0.1 localhost\n\n\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n\n\n";
209 let cleaned = remove_block(content);
210 assert!(!cleaned.contains("\n\n\n"));
211 }
212
213 #[test]
214 fn test_remove_block_ignores_end_marker_before_start_marker() {
215 let content = "127.0.0.1 localhost\n# pitchfork-end\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
216 let cleaned = remove_block(content);
217 assert_eq!(cleaned, "127.0.0.1 localhost\n# pitchfork-end\n");
218 }
219}