use std::net::IpAddr;
use std::path::PathBuf;
use anyhow::{Context, Result};
use log::{info, warn};
pub const JOURNAL_FILE_NAME: &str = "dns-restore.json";
pub struct DnsGuard {
restore: imp::Restore,
}
impl Drop for DnsGuard {
fn drop(&mut self) {
if imp::restore(&self.restore) {
remove_journal();
info!("system resolver restored");
} else {
warn!(
"failed to restore the system resolver; keeping {} so a later \
`shadowvpn-client --restore-dns` can retry",
journal_path().display()
);
}
}
}
pub fn apply(proxy: IpAddr, port: u16, direct_src: IpAddr) -> Result<Option<DnsGuard>> {
if port != 53 {
warn!(
"not setting the system resolver automatically: proxy port is {port}, but the OS \
resolver only supports port 53 — point DNS at {proxy} (port 53) yourself, or set \
dns_listen to a :53 address"
);
return Ok(None);
}
if let Some(stale) = read_journal() {
warn!(
"found a DNS restore journal from a run that did not exit cleanly; restoring the \
original resolver configuration before applying"
);
if !imp::restore(&stale) {
warn!("could not restore from the stale journal; continuing");
}
remove_journal();
}
let restore = imp::snapshot(proxy, direct_src)?;
if let Err(e) = write_journal(&restore) {
warn!(
"could not write the DNS restore journal ({e}); if this run dies without cleaning \
up, the resolver will stay pointed at {proxy} until the next connect"
);
}
imp::engage(&restore, proxy)?;
info!("system resolver pointed at {proxy} (restored automatically on exit)");
Ok(Some(DnsGuard { restore }))
}
pub fn restore_from_journal() -> Result<bool> {
let Some(restore) = read_journal() else {
return Ok(false);
};
if !imp::restore(&restore) {
anyhow::bail!(
"failed to restore the resolver configuration recorded in {}",
journal_path().display()
);
}
remove_journal();
info!("system resolver restored from journal");
Ok(true)
}
fn journal_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
.join(JOURNAL_FILE_NAME)
}
fn read_journal() -> Option<imp::Restore> {
let path = journal_path();
let data = std::fs::read(&path).ok()?;
match serde_json::from_slice(&data) {
Ok(r) => Some(r),
Err(e) => {
warn!(
"ignoring unreadable DNS restore journal {}: {e}",
path.display()
);
let _ = std::fs::remove_file(&path);
None
}
}
}
fn write_journal(restore: &imp::Restore) -> Result<()> {
let path = journal_path();
let tmp = path.with_extension("json.tmp");
let data = serde_json::to_vec_pretty(restore).context("serializing the restore state")?;
std::fs::write(&tmp, data).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
Ok(())
}
fn remove_journal() {
let _ = std::fs::remove_file(journal_path());
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod imp {
use super::*;
use anyhow::{bail, Context};
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Serialize, Deserialize)]
pub struct Restore {
service: String,
prev: Vec<String>,
}
pub fn snapshot(proxy: IpAddr, direct_src: IpAddr) -> Result<Restore> {
let _ = direct_src; let service = primary_service()
.context("could not determine the primary network service to configure DNS on")?;
let prev = sanitize_prev(get_dns(&service), proxy);
Ok(Restore { service, prev })
}
pub fn engage(r: &Restore, proxy: IpAddr) -> Result<()> {
set_dns(&r.service, &[proxy.to_string()])?;
flush();
Ok(())
}
pub fn restore(r: &Restore) -> bool {
let servers: Vec<String> = if r.prev.is_empty() {
vec!["empty".to_string()]
} else {
r.prev.clone()
};
let ok = match set_dns(&r.service, &servers) {
Ok(()) => true,
Err(e) => {
warn!("restoring DNS on service '{}': {e}", r.service);
false
}
};
flush();
ok
}
fn sanitize_prev(prev: Vec<String>, proxy: IpAddr) -> Vec<String> {
if prev == [proxy.to_string()] {
warn!(
"current DNS ({proxy}) is this proxy itself (left by an earlier run?); will \
restore to automatic DNS instead"
);
return Vec::new();
}
prev
}
fn set_dns(service: &str, servers: &[String]) -> Result<()> {
let mut cmd = Command::new("networksetup");
cmd.arg("-setdnsservers").arg(service).args(servers);
let out = cmd
.output()
.context("running networksetup -setdnsservers")?;
if !out.status.success() {
bail!(
"networksetup -setdnsservers {service} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn get_dns(service: &str) -> Vec<String> {
let out = match Command::new("networksetup")
.arg("-getdnsservers")
.arg(service)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
let text = String::from_utf8_lossy(&out.stdout);
if text.contains("aren't any") {
return Vec::new();
}
text.lines()
.map(str::trim)
.filter(|l| l.parse::<IpAddr>().is_ok())
.map(String::from)
.collect()
}
fn primary_service() -> Option<String> {
let iface = default_iface()?;
let out = Command::new("networksetup")
.arg("-listnetworkserviceorder")
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let mut current: Option<String> = None;
for line in text.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix('(') {
if let Some((num, name)) = rest.split_once(')') {
if num.chars().all(|c| c.is_ascii_digit()) {
current = Some(name.trim().to_string());
continue;
}
}
}
if t.contains(&format!("Device: {iface})")) {
return current.take();
}
}
None
}
fn default_iface() -> Option<String> {
let out = Command::new("route")
.args(["-n", "get", "default"])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).lines().find_map(|l| {
l.trim()
.strip_prefix("interface:")
.map(|s| s.trim().to_string())
})
}
fn flush() {
let _ = Command::new("dscacheutil").arg("-flushcache").status();
let _ = Command::new("killall")
.args(["-HUP", "mDNSResponder"])
.status();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_drops_own_proxy_address() {
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
assert!(sanitize_prev(vec!["127.0.0.1".to_string()], proxy).is_empty());
let mixed = vec!["127.0.0.1".to_string(), "1.1.1.1".to_string()];
assert_eq!(sanitize_prev(mixed.clone(), proxy), mixed);
let real = vec!["192.168.0.1".to_string()];
assert_eq!(sanitize_prev(real.clone(), proxy), real);
}
#[test]
fn restore_journal_round_trips() {
let r = Restore {
service: "Wi-Fi".to_string(),
prev: vec!["192.168.0.1".to_string(), "8.8.8.8".to_string()],
};
let json = serde_json::to_string(&r).unwrap();
let back: Restore = serde_json::from_str(&json).unwrap();
assert_eq!(back.service, r.service);
assert_eq!(back.prev, r.prev);
}
}
}
#[cfg(target_os = "linux")]
mod imp {
use super::*;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::fs;
use std::os::unix::fs::symlink;
use std::path::PathBuf;
const PATH: &str = "/etc/resolv.conf";
const MARKER: &str = "# shadowvpn split-DNS";
#[derive(Serialize, Deserialize)]
pub enum Restore {
Symlink(PathBuf),
File(Vec<u8>),
Absent,
}
pub fn snapshot(proxy: IpAddr, direct_src: IpAddr) -> Result<Restore> {
let _ = proxy;
let _ = direct_src; Ok(match fs::symlink_metadata(PATH) {
Ok(m) if m.file_type().is_symlink() => {
let target = fs::read_link(PATH).context("reading resolv.conf symlink")?;
Restore::Symlink(target)
}
Ok(_) => {
let content = fs::read(PATH).unwrap_or_default();
if content.starts_with(MARKER.as_bytes()) {
warn!(
"current /etc/resolv.conf was written by a previous shadowvpn run; it \
will be removed on restore so the resolver daemon can regenerate it"
);
Restore::Absent
} else {
Restore::File(content)
}
}
Err(_) => Restore::Absent,
})
}
pub fn engage(r: &Restore, proxy: IpAddr) -> Result<()> {
let _ = r;
let _ = fs::remove_file(PATH);
fs::write(PATH, format!("{MARKER}\nnameserver {proxy}\n"))
.context("writing /etc/resolv.conf")?;
Ok(())
}
pub fn restore(r: &Restore) -> bool {
match r {
Restore::Symlink(target) => {
let _ = fs::remove_file(PATH);
symlink(target, PATH).is_ok()
}
Restore::File(content) => fs::write(PATH, content).is_ok(),
Restore::Absent => {
let _ = fs::remove_file(PATH);
true
}
}
}
}
#[cfg(windows)]
mod imp {
use super::*;
use anyhow::{bail, Context};
use serde::{Deserialize, Serialize};
use std::io;
use std::process::Command;
#[derive(Serialize, Deserialize)]
pub enum Restore {
Dhcp { alias: String },
Static { alias: String, servers: Vec<String> },
}
pub fn snapshot(proxy: IpAddr, direct_src: IpAddr) -> Result<Restore> {
let alias = primary_alias(direct_src)
.context("could not determine the primary network interface to configure DNS on")?;
Ok(sanitize(read_current(&alias), proxy))
}
pub fn engage(r: &Restore, proxy: IpAddr) -> Result<()> {
let alias = match r {
Restore::Dhcp { alias } | Restore::Static { alias, .. } => alias,
};
set_static(alias, &[proxy.to_string()])?;
flush();
Ok(())
}
pub fn restore(r: &Restore) -> bool {
let ok = match r {
Restore::Dhcp { alias } => netsh(&[
"interface",
"ipv4",
"set",
"dnsservers",
&name_arg(alias),
"dhcp",
])
.map(|o| o.status.success())
.unwrap_or(false),
Restore::Static { alias, servers } => set_static(alias, servers).is_ok(),
};
flush();
ok
}
fn sanitize(r: Restore, proxy: IpAddr) -> Restore {
match r {
Restore::Static { alias, servers } if servers == [proxy.to_string()] => {
warn!(
"current DNS ({proxy}) is this proxy itself (left by an earlier run?); \
will restore to DHCP DNS instead"
);
Restore::Dhcp { alias }
}
other => other,
}
}
fn primary_alias(direct_src: IpAddr) -> Option<String> {
if !direct_src.is_unspecified() {
if let Some(alias) = interface_for_ip(direct_src) {
return Some(alias);
}
}
default_route_alias()
}
fn interface_for_ip(ip: IpAddr) -> Option<String> {
let out = netsh(&["interface", "ipv4", "show", "addresses"]).ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let want = ip.to_string();
let mut current: Option<String> = None;
for line in text.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("Configuration for interface ") {
current = Some(rest.trim().trim_matches('"').to_string());
} else if t.split_whitespace().any(|tok| tok == want) {
if let Some(name) = current.as_ref() {
return Some(name.clone());
}
}
}
None
}
fn default_route_alias() -> Option<String> {
let out = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | \
Sort-Object RouteMetric | Select-Object -First 1 -ExpandProperty InterfaceAlias",
])
.output()
.ok()?;
let alias = String::from_utf8_lossy(&out.stdout).trim().to_string();
if alias.is_empty() {
None
} else {
Some(alias)
}
}
fn read_current(alias: &str) -> Restore {
let out = netsh(&["interface", "ipv4", "show", "dnsservers", &name_arg(alias)]);
let text = out
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
if text.contains("through DHCP") {
return Restore::Dhcp {
alias: alias.to_string(),
};
}
let servers: Vec<String> = text
.split_whitespace()
.filter_map(|tok| tok.parse::<IpAddr>().ok().map(|ip| ip.to_string()))
.collect();
if servers.is_empty() {
Restore::Dhcp {
alias: alias.to_string(),
}
} else {
Restore::Static {
alias: alias.to_string(),
servers,
}
}
}
fn set_static(alias: &str, servers: &[String]) -> Result<()> {
let (first, rest) = servers
.split_first()
.context("refusing to set an empty DNS server list")?;
run_checked(&[
"interface",
"ipv4",
"set",
"dnsservers",
&name_arg(alias),
"static",
first,
"primary",
"validate=no",
])?;
for (i, srv) in rest.iter().enumerate() {
run_checked(&[
"interface",
"ipv4",
"add",
"dnsservers",
&name_arg(alias),
srv,
&format!("index={}", i + 2),
"validate=no",
])?;
}
Ok(())
}
fn name_arg(alias: &str) -> String {
format!("name={alias}")
}
fn netsh(args: &[&str]) -> io::Result<std::process::Output> {
Command::new("netsh").args(args).output()
}
fn run_checked(args: &[&str]) -> Result<()> {
let out = netsh(args).context("running netsh")?;
if !out.status.success() {
bail!(
"netsh {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn flush() {
let _ = Command::new("ipconfig").arg("/flushdns").status();
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "ios", windows)))]
mod imp {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Restore;
pub fn snapshot(_proxy: IpAddr, _direct_src: IpAddr) -> Result<Restore> {
anyhow::bail!("automatic DNS configuration is not supported on this platform")
}
pub fn engage(_r: &Restore, _proxy: IpAddr) -> Result<()> {
Ok(())
}
pub fn restore(_r: &Restore) -> bool {
true
}
}