use crate::brew::Brew;
use crate::daemon::{self, ServiceSpec};
use crate::paths;
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
const SERVICE: &str = "dnsmasq";
const DNS_PORT: u16 = 5335;
pub fn ensure_installed(brew: &Brew) -> Result<()> {
if !brew.is_installed("dnsmasq") {
println!("Installing dnsmasq…");
brew.install("dnsmasq")?;
}
Ok(())
}
fn config_path() -> Result<PathBuf> {
Ok(paths::generated_dir()?.join("dnsmasq.conf"))
}
pub fn write_config(tlds: &[String]) -> Result<PathBuf> {
paths::ensure_dirs()?;
let mut conf = format!(
"# Generated by reeve — do not edit by hand.\n\
port={DNS_PORT}\n\
listen-address=127.0.0.1\n\
bind-interfaces\n\
no-resolv\n"
);
for tld in tlds {
conf.push_str(&format!("address=/.{tld}/127.0.0.1\n"));
}
let path = config_path()?;
fs::write(&path, conf).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(path)
}
pub fn service_spec(brew: &Brew) -> Result<ServiceSpec> {
let conf = config_path()?;
if !conf.exists() {
bail!("dnsmasq config not generated yet");
}
Ok(ServiceSpec {
service: SERVICE.to_string(),
program: brew.opt("dnsmasq").join("sbin/dnsmasq"),
args: vec![
"--keep-in-foreground".into(),
format!("--conf-file={}", conf.display()),
],
log: paths::logs_dir()?.join("dnsmasq.log"),
keep_alive: true,
run_at_load: true,
})
}
pub fn service_id() -> &'static str {
SERVICE
}
pub fn setup_resolver(tlds: &[String]) -> Result<()> {
let contents = format!("nameserver 127.0.0.1\nport {DNS_PORT}\n");
if fs::create_dir_all("/etc/resolver").is_ok()
&& tlds
.iter()
.all(|t| fs::write(PathBuf::from("/etc/resolver").join(t), &contents).is_ok())
{
flush_dns_unprivileged();
return Ok(());
}
let mut cmds = vec!["mkdir -p /etc/resolver".to_string()];
for tld in tlds {
cmds.push(format!(
"printf 'nameserver 127.0.0.1\\nport {DNS_PORT}\\n' > /etc/resolver/{tld}"
));
}
cmds.push("dscacheutil -flushcache".to_string());
cmds.push("killall -HUP mDNSResponder".to_string());
let script = cmds.join(" && ");
run_as_admin(&script).with_context(|| {
let list = tlds.join(", ");
format!("Could not write /etc/resolver files for: {list} (admin auth was needed)")
})
}
fn run_as_admin(shell_script: &str) -> Result<()> {
let escaped = shell_script.replace('\\', "\\\\").replace('"', "\\\"");
let applescript = format!("do shell script \"{escaped}\" with administrator privileges");
let out = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.context("Failed to run osascript for admin escalation")?;
if !out.status.success() {
bail!("admin authorization was cancelled or failed");
}
Ok(())
}
fn flush_dns_unprivileged() {
let _ = Command::new("dscacheutil").arg("-flushcache").status();
}
pub fn resolver_ok(tld: &str) -> bool {
match fs::read_to_string(format!("/etc/resolver/{tld}")) {
Ok(c) => c.contains("127.0.0.1") && c.contains(&format!("port {DNS_PORT}")),
Err(_) => false,
}
}
pub fn setup(brew: &Brew, tlds: &[String]) -> Result<bool> {
ensure_installed(brew)?;
write_config(tlds)?;
let spec = service_spec(brew)?;
daemon::install(&spec)?;
daemon::restart(SERVICE)?;
setup_resolver(tlds)?;
Ok(resolver_ok_all(tlds))
}
pub fn resolver_ok_all(tlds: &[String]) -> bool {
!tlds.is_empty() && tlds.iter().all(|t| resolver_ok(t))
}
pub fn resolver_ready(tld: &str) -> bool {
PathBuf::from(format!("/etc/resolver/{tld}")).exists()
}