use crate::settings::settings;
use std::sync::OnceLock;
const MARKER_START: &str = "# pitchfork-start";
const MARKER_END: &str = "# pitchfork-end";
static BLANK_LINES_RE: OnceLock<regex::Regex> = OnceLock::new();
fn sync_hosts_file_with_slugs(bind_ip: &str, tld: &str, slug_names: &[String]) {
write_hosts_block(&hosts_entries(bind_ip, tld, slug_names));
}
fn hosts_entries(bind_ip: &str, tld: &str, slug_names: &[String]) -> Vec<String> {
let collisions = crate::proxy::ascii_case_collisions(slug_names.iter().map(String::as_str));
slug_names
.iter()
.filter(|slug| !collisions.contains(&slug.to_ascii_lowercase()))
.map(|slug| format!("{bind_ip} {slug}.{tld}"))
.collect()
}
pub fn sync_hosts_from_settings() {
let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
let slug_names: Vec<String> = slugs.keys().cloned().collect();
sync_hosts_from_settings_with_slugs(&slug_names);
}
pub fn sync_hosts_from_settings_with_slugs(slug_names: &[String]) {
let s = settings();
if s.proxy.enable && s.proxy.sync_hosts {
warn_deprecated_once();
let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
let tld = if lan_enabled { "local" } else { &s.proxy.tld };
let ip = if lan_enabled {
if !s.proxy.lan_ip.is_empty() {
s.proxy.lan_ip.clone()
} else {
s.proxy.host.clone()
}
} else {
s.proxy.host.clone()
};
sync_hosts_file_with_slugs(&ip, tld, slug_names);
}
}
fn warn_deprecated_once() {
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
return;
}
log::warn!(
"proxy.sync_hosts is deprecated and will be removed in a future release. \
Run `pitchfork proxy setup` to use the loopback DNS resolver instead, \
then set proxy.sync_hosts = false."
);
}
pub fn clean_hosts_file() {
write_hosts_block(&[]);
}
fn write_hosts_block(entries: &[String]) {
let path = crate::env::PITCHFORK_HOSTS_FILE.clone();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
if !entries.is_empty() {
log::warn!(
"Failed to read {} for hosts sync: {e}. \
Set proxy.sync_hosts = false to suppress this warning.",
path.display()
);
}
return;
}
};
let cleaned = remove_block(&content);
let new_content = if entries.is_empty() {
cleaned
} else {
let block = build_block(entries);
format!("{}\n{block}\n", cleaned.trim_end())
};
let parent = path.parent().unwrap_or(std::path::Path::new("/etc"));
let tmp_path = parent.join(format!(".pitchfork-hosts-tmp-{}", std::process::id()));
if let Err(e) = std::fs::write(&tmp_path, &new_content) {
log::warn!(
"Failed to write {} for hosts sync: {e}. \
Writing to /etc/hosts may require sudo. \
Set proxy.sync_hosts = false to suppress this warning.",
tmp_path.display()
);
let _ = std::fs::remove_file(&tmp_path);
return;
}
if let Err(e) = std::fs::rename(&tmp_path, &path) {
log::warn!(
"Failed to rename {} to {}: {e}. \
Writing to /etc/hosts may require sudo. \
Set proxy.sync_hosts = false to suppress this warning.",
tmp_path.display(),
path.display()
);
let _ = std::fs::remove_file(&tmp_path);
}
}
fn build_block(entries: &[String]) -> String {
if entries.is_empty() {
return String::new();
}
let lines = entries.join("\n");
format!("{MARKER_START}\n{lines}\n{MARKER_END}")
}
fn remove_block(content: &str) -> String {
let start_idx = match content.find(MARKER_START) {
Some(i) => i,
None => return content.to_string(),
};
let end_idx = match content[start_idx..].find(MARKER_END) {
Some(i) => start_idx + i + MARKER_END.len(),
None => return content.to_string(),
};
let before = &content[..start_idx];
let after = &content[end_idx..];
let result = format!("{before}{after}");
let re = BLANK_LINES_RE.get_or_init(|| regex::Regex::new(r"\n{3,}").unwrap());
re.replace_all(&result, "\n\n").trim_end().to_string() + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_block() {
let entries = vec![
"127.0.0.1 myapp.localhost".to_string(),
"127.0.0.1 api.myapp.localhost".to_string(),
];
let block = build_block(&entries);
assert!(block.starts_with("# pitchfork-start\n"));
assert!(block.ends_with("\n# pitchfork-end"));
assert!(block.contains("127.0.0.1 myapp.localhost"));
assert!(block.contains("127.0.0.1 api.myapp.localhost"));
}
#[test]
fn test_hosts_entries_omits_case_collisions() {
let slugs = ["myapp".to_string(), "API".to_string(), "api".to_string()];
let entries = hosts_entries("127.0.0.1", "localhost", &slugs);
assert_eq!(entries, vec!["127.0.0.1 myapp.localhost".to_string()]);
}
#[test]
fn test_build_block_empty() {
assert!(build_block(&[]).is_empty());
}
#[test]
fn test_remove_block() {
let content =
"127.0.0.1 localhost\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
let cleaned = remove_block(content);
assert!(!cleaned.contains("pitchfork-start"));
assert!(!cleaned.contains("myapp.localhost"));
assert!(cleaned.contains("127.0.0.1 localhost"));
}
#[test]
fn test_remove_block_no_markers() {
let content = "127.0.0.1 localhost\n";
let cleaned = remove_block(content);
assert_eq!(cleaned, content);
}
#[test]
fn test_remove_block_normalizes_blank_lines() {
let content = "127.0.0.1 localhost\n\n\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n\n\n";
let cleaned = remove_block(content);
assert!(!cleaned.contains("\n\n\n"));
}
#[test]
fn test_remove_block_ignores_end_marker_before_start_marker() {
let content = "127.0.0.1 localhost\n# pitchfork-end\n# pitchfork-start\n127.0.0.1 myapp.localhost\n# pitchfork-end\n";
let cleaned = remove_block(content);
assert_eq!(cleaned, "127.0.0.1 localhost\n# pitchfork-end\n");
}
}