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 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
30fn 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
44pub 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
54pub 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 let ip = if lan_enabled {
69 if !s.proxy.lan_ip.is_empty() {
71 s.proxy.lan_ip.clone()
72 } else {
73 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
86fn 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
103pub fn clean_hosts_file() {
107 write_hosts_block(&[]);
108}
109
110fn 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 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
164fn 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
173fn 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 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 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}