use anyhow::{Context, Result};
use tracing::info;
const DNS_PROXY_IP: &str = "127.0.0.53";
const MANAGED_LIST: &str = "/tmp/portkube-managed-resolvers";
#[cfg(target_os = "macos")]
const RESOLVER_DIR: &str = "/etc/resolver";
#[cfg(target_os = "macos")]
fn resolver_content() -> String {
format!(
"# portkube — do not edit\nnameserver {DNS_PROXY_IP}\nport 53\ntimeout 2\n"
)
}
#[cfg(target_os = "macos")]
pub async fn install(namespaces: &[String]) -> Result<()> {
run_cmd("ifconfig", &["lo0", "alias", DNS_PROXY_IP])
.await
.context("create loopback alias 127.0.0.53")?;
std::fs::create_dir_all(RESOLVER_DIR).context("create /etc/resolver")?;
let content = resolver_content();
let mut managed = Vec::new();
for ns in namespaces {
let path = format!("{RESOLVER_DIR}/{ns}");
let backup = format!("{path}.portkube-backup");
if std::path::Path::new(&path).exists() && !std::path::Path::new(&backup).exists() {
let _ = std::fs::rename(&path, &backup);
}
std::fs::write(&path, &content)
.with_context(|| format!("write {path}"))?;
managed.push(ns.clone());
}
std::fs::write(MANAGED_LIST, managed.join("\n")).context("save managed list")?;
info!(count = managed.len(), "resolver files installed");
Ok(())
}
#[cfg(target_os = "macos")]
pub async fn uninstall() -> Result<()> {
let managed = std::fs::read_to_string(MANAGED_LIST).unwrap_or_default();
for ns in managed.lines().filter(|l| !l.trim().is_empty()) {
let path = format!("{RESOLVER_DIR}/{ns}");
let backup = format!("{path}.portkube-backup");
let _ = std::fs::remove_file(&path);
if std::path::Path::new(&backup).exists() {
let _ = std::fs::rename(&backup, &path);
}
}
let _ = run_cmd("ifconfig", &["lo0", "-alias", DNS_PROXY_IP]).await;
let _ = std::fs::remove_file(MANAGED_LIST);
info!("resolver files uninstalled");
Ok(())
}
#[cfg(target_os = "linux")]
pub async fn install(namespaces: &[String]) -> Result<()> {
run_cmd("ip", &["addr", "add", &format!("{DNS_PROXY_IP}/32"), "dev", "lo"])
.await
.context("create loopback alias 127.0.0.53")?;
let resolv = "/etc/resolv.conf";
let backup = "/etc/resolv.conf.portkube-backup";
if std::path::Path::new(resolv).exists() && !std::path::Path::new(backup).exists() {
let _ = std::fs::copy(resolv, backup);
}
let existing = std::fs::read_to_string(resolv).unwrap_or_default();
let mut lines = vec![
format!("# portkube — do not edit this line"),
format!("nameserver {DNS_PROXY_IP}"),
];
for line in existing.lines() {
if !line.contains("portkube") && !line.contains(DNS_PROXY_IP) {
lines.push(line.to_string());
}
}
std::fs::write(resolv, lines.join("\n") + "\n")
.context("write /etc/resolv.conf")?;
std::fs::write(MANAGED_LIST, namespaces.join("\n")).context("save managed list")?;
info!(count = namespaces.len(), "DNS resolver configured");
Ok(())
}
#[cfg(target_os = "linux")]
pub async fn uninstall() -> Result<()> {
let resolv = "/etc/resolv.conf";
let backup = "/etc/resolv.conf.portkube-backup";
if std::path::Path::new(backup).exists() {
let _ = std::fs::rename(backup, resolv);
} else {
if let Ok(content) = std::fs::read_to_string(resolv) {
let cleaned: Vec<&str> = content.lines()
.filter(|l| !l.contains("portkube") && !l.contains(DNS_PROXY_IP))
.collect();
let _ = std::fs::write(resolv, cleaned.join("\n") + "\n");
}
}
let _ = run_cmd("ip", &["addr", "del", &format!("{DNS_PROXY_IP}/32"), "dev", "lo"]).await;
let _ = std::fs::remove_file(MANAGED_LIST);
info!("DNS resolver uninstalled");
Ok(())
}
#[cfg(windows)]
pub async fn install(namespaces: &[String]) -> Result<()> {
run_cmd("netsh", &["interface", "ip", "add", "dns", "Loopback Pseudo-Interface 1", DNS_PROXY_IP, "index=1"])
.await
.context("configure DNS via netsh")?;
std::fs::write(MANAGED_LIST, namespaces.join("\n")).context("save managed list")?;
info!(count = namespaces.len(), "DNS resolver configured");
Ok(())
}
#[cfg(windows)]
pub async fn uninstall() -> Result<()> {
let _ = run_cmd("netsh", &["interface", "ip", "delete", "dns", "Loopback Pseudo-Interface 1", DNS_PROXY_IP]).await;
let _ = std::fs::remove_file(MANAGED_LIST);
info!("DNS resolver uninstalled");
Ok(())
}
async fn run_cmd(cmd: &str, args: &[&str]) -> Result<()> {
let output = tokio::process::Command::new(cmd)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.with_context(|| format!("{cmd} {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("{cmd} {} failed: {}", args.join(" "), stderr.trim());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn test_resolver_content_contains_nameserver() {
let content = resolver_content();
assert!(content.contains("nameserver 127.0.0.53"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_resolver_content_contains_port() {
let content = resolver_content();
assert!(content.contains("port 53"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_resolver_content_contains_timeout() {
let content = resolver_content();
assert!(content.contains("timeout 2"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_resolver_content_has_portkube_marker() {
let content = resolver_content();
assert!(content.contains("portkube"));
}
#[cfg(target_os = "macos")]
#[test]
fn test_resolver_content_ends_with_newline() {
let content = resolver_content();
assert!(content.ends_with('\n'));
}
#[test]
fn test_resolver_constants() {
assert_eq!(DNS_PROXY_IP, "127.0.0.53");
assert_eq!(MANAGED_LIST, "/tmp/portkube-managed-resolvers");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn test_install_creates_resolver_files() {
let dir = tempfile::tempdir().unwrap();
let dir_path = dir.path().to_str().unwrap().to_string();
let content = resolver_content();
let ns = "testns";
let path = format!("{dir_path}/{ns}");
std::fs::write(&path, &content).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, content);
assert!(written.contains("nameserver 127.0.0.53"));
}
#[test]
fn test_managed_list_format() {
let namespaces = vec!["default".to_string(), "monitoring".to_string()];
let content = namespaces.join("\n");
assert_eq!(content, "default\nmonitoring");
let parsed: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(parsed, vec!["default", "monitoring"]);
}
}