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() {
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
59pub fn clean_hosts_file() {
63 write_hosts_block(&[]);
64}
65
66fn 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 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
120fn 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
129fn 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 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}