reeve-cli 0.2.8

Localhost web dev stack manager: web servers, per-vhost PHP versions, SSL, and DNS — RunCloud, scaled down.
//! Local wildcard DNS for the dev TLD (default `.test`). A reeve-managed
//! dnsmasq answers `*.<tld>` with 127.0.0.1 on 127.0.0.1:53, and macOS is
//! pointed at it via `/etc/resolver/<tld>`.

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";

/// dnsmasq listens here. An unprivileged port so the daemon runs as the regular
/// user (binding :53 needs root); macOS reaches it via the resolver `port`
/// directive. 53 + 300 = a memorable, rarely-occupied choice.
const DNS_PORT: u16 = 5335;

/// Ensure dnsmasq is installed.
pub fn ensure_installed(brew: &Brew) -> Result<()> {
    if !brew.is_installed("dnsmasq") {
        println!("Installing dnsmasq…");
        brew.install("dnsmasq")?;
    }
    Ok(())
}

/// Our generated dnsmasq config path (kept out of the user's own dnsmasq.conf).
fn config_path() -> Result<PathBuf> {
    Ok(paths::generated_dir()?.join("dnsmasq.conf"))
}

/// Write a self-contained dnsmasq config that resolves `*.<tld>` to 127.0.0.1
/// for every configured TLD, listening only on the loopback so it never
/// interferes with system DNS.
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)
}

/// launchd service spec running our dnsmasq with our config in the foreground.
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"),
        // dnsmasq long options require the `--opt=value` form; space-separated
        // args are rejected as "junk found in command line".
        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,
    })
}

/// launchd service id (for status queries).
pub fn service_id() -> &'static str {
    SERVICE
}

/// Write `/etc/resolver/<tld>` so macOS routes that TLD to our dnsmasq, then
/// flush the DNS cache. `/etc/resolver` is root-owned; when we can't write it
/// directly we escalate via the native macOS admin-password dialog (osascript
/// `with administrator privileges`) — no password ever touches our process,
/// and it works from the TUI without disturbing the terminal.
pub fn setup_resolver(tlds: &[String]) -> Result<()> {
    let contents = format!("nameserver 127.0.0.1\nport {DNS_PORT}\n");

    // Fast path: already root / writable — write every resolver file directly.
    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(());
    }

    // Escalate the whole sequence as one admin action (single auth prompt).
    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)")
    })
}

/// Run a shell script as root via the native macOS authentication dialog.
/// Touch ID / password prompt is handled by the OS; nothing sensitive flows
/// through us. Requires a GUI session (true on a logged-in Mac).
fn run_as_admin(shell_script: &str) -> Result<()> {
    // Escape for embedding inside an AppleScript double-quoted string: the
    // shell's literal `\n` must survive as `\\n` so AppleScript hands `\n` back.
    let escaped = shell_script.replace('\\', "\\\\").replace('"', "\\\"");
    let applescript = format!("do shell script \"{escaped}\" with administrator privileges");
    // Capture (don't inherit) stdio: osascript echoes the shell script's result
    // to stdout, which would otherwise leak into and corrupt the TUI's alternate
    // screen. With `output()` nothing reaches the terminal.
    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(())
}

/// Flush DNS when already privileged (best-effort, no escalation).
fn flush_dns_unprivileged() {
    let _ = Command::new("dscacheutil").arg("-flushcache").status();
}

/// True when the resolver file exists and points at our dnsmasq port — guards
/// against a stale/typo'd file counting as "configured".
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,
    }
}

/// Full DNS setup: install dnsmasq, render its config, run it, and wire up the
/// system resolver for every configured TLD (escalating for the one root step).
/// Returns whether all TLDs now resolve.
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))
}

/// True when every configured TLD has a valid resolver file.
pub fn resolver_ok_all(tlds: &[String]) -> bool {
    !tlds.is_empty() && tlds.iter().all(|t| resolver_ok(t))
}

/// Is the resolver file already in place for a TLD?
pub fn resolver_ready(tld: &str) -> bool {
    PathBuf::from(format!("/etc/resolver/{tld}")).exists()
}