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(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))
}
#[cfg(target_os = "macos")]
pub fn resolver_location(tld: &str) -> String {
format!("/etc/resolver/{tld}")
}
#[cfg(target_os = "macos")]
pub fn gui_escalation_available() -> bool {
if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() {
return false;
}
match Command::new("stat")
.args(["-f", "%Su", "/dev/console"])
.output()
{
Ok(o) => {
let owner = String::from_utf8_lossy(&o.stdout);
let owner = owner.trim();
!owner.is_empty() && owner != "root"
}
Err(_) => false,
}
}
#[cfg(target_os = "macos")]
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)")
})
}
#[cfg(target_os = "macos")]
fn run_as_admin(shell_script: &str) -> Result<()> {
if gui_escalation_available() {
run_as_admin_gui(shell_script)
} else {
run_as_admin_sudo(shell_script)
}
}
#[cfg(target_os = "macos")]
fn run_as_admin_gui(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(())
}
#[cfg(target_os = "macos")]
fn run_as_admin_sudo(shell_script: &str) -> Result<()> {
let status = Command::new("sudo")
.arg("bash")
.arg("-c")
.arg(shell_script)
.status()
.context("Failed to run sudo for admin escalation")?;
if !status.success() {
bail!("admin authorization was cancelled or failed");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn flush_dns_unprivileged() {
let _ = Command::new("dscacheutil").arg("-flushcache").status();
}
#[cfg(target_os = "macos")]
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,
}
}
#[cfg(target_os = "macos")]
pub fn resolver_ready(tld: &str) -> bool {
PathBuf::from(format!("/etc/resolver/{tld}")).exists()
}
#[cfg(target_os = "linux")]
const RESOLVED_DROPIN: &str = "/etc/systemd/resolved.conf.d/reeve.conf";
#[cfg(target_os = "linux")]
pub fn resolver_location(_tld: &str) -> String {
RESOLVED_DROPIN.to_string()
}
#[cfg(target_os = "linux")]
fn resolved_dropin(tlds: &[String]) -> String {
let domains = tlds
.iter()
.map(|t| format!("~{t}"))
.collect::<Vec<_>>()
.join(" ");
format!(
"# Generated by reeve — do not edit by hand.\n\
[Resolve]\n\
DNS=127.0.0.1:{DNS_PORT}\n\
Domains={domains}\n"
)
}
#[cfg(target_os = "linux")]
pub fn gui_escalation_available() -> bool {
false
}
#[cfg(target_os = "linux")]
pub fn setup_resolver(tlds: &[String]) -> Result<()> {
if !resolved_available() {
bail!(
"systemd-resolved is not available on this host, so reeve can't wire up \
`.{tld}` resolution automatically. Point your resolver at 127.0.0.1:{port} \
manually, or add hosts entries. (Environments like OrbStack manage DNS \
themselves.)",
tld = tlds.first().map(String::as_str).unwrap_or("test"),
port = DNS_PORT,
);
}
let contents = resolved_dropin(tlds);
let dir = std::path::Path::new(RESOLVED_DROPIN).parent().unwrap();
if fs::create_dir_all(dir).is_ok() && fs::write(RESOLVED_DROPIN, &contents).is_ok() {
let _ = Command::new("systemctl")
.args(["restart", "systemd-resolved"])
.status();
flush_dns_unprivileged();
return Ok(());
}
let printf_body = contents.replace('\\', "\\\\").replace('\'', "'\\''");
let script = format!(
"mkdir -p {dir} && printf '%s' '{printf_body}' > {file} && \
{{ systemctl restart systemd-resolved || true; }} && \
{{ resolvectl flush-caches || true; }}",
dir = dir.display(),
file = RESOLVED_DROPIN,
);
run_as_admin(&script).context("Could not write the systemd-resolved drop-in (sudo was needed)")
}
#[cfg(target_os = "linux")]
fn resolved_available() -> bool {
match Command::new("systemctl")
.args(["is-enabled", "systemd-resolved"])
.output()
{
Ok(o) => {
let state = String::from_utf8_lossy(&o.stdout);
let state = state.trim();
!state.is_empty() && state != "masked"
}
Err(_) => false,
}
}
#[cfg(target_os = "linux")]
fn run_as_admin(shell_script: &str) -> Result<()> {
let status = Command::new("sudo")
.arg("bash")
.arg("-c")
.arg(shell_script)
.status()
.context("Failed to run sudo for admin escalation")?;
if !status.success() {
bail!(
"admin authorization failed. Run this manually:\n sudo bash -c '{}'",
shell_script
);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn flush_dns_unprivileged() {
let _ = Command::new("resolvectl").arg("flush-caches").status();
}
#[cfg(target_os = "linux")]
pub fn resolver_ok(tld: &str) -> bool {
match fs::read_to_string(RESOLVED_DROPIN) {
Ok(c) => c.contains(&format!("127.0.0.1:{DNS_PORT}")) && c.contains(&format!("~{tld}")),
Err(_) => false,
}
}
#[cfg(target_os = "linux")]
pub fn resolver_ready(tld: &str) -> bool {
resolver_ok(tld)
}