use crate::config::parse_server_spec;
use crate::json::Value;
use std::error::Error;
use std::fs;
use std::io;
use std::net::IpAddr;
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::OwnedObjectPath;
const BUS_NAME: &str = "org.freedesktop.resolve1";
const MANAGER_PATH: &str = "/org/freedesktop/resolve1";
const MANAGER_INTERFACE: &str = "org.freedesktop.resolve1.Manager";
const NETWORK_BUS_NAME: &str = "org.freedesktop.network1";
const NETWORK_MANAGER_PATH: &str = "/org/freedesktop/network1";
const NETWORK_MANAGER_INTERFACE: &str = "org.freedesktop.network1.Manager";
const LINK_BUSY_ERROR: &str = "org.freedesktop.resolve1.LinkBusy";
pub fn is_command(command: &str) -> bool {
matches!(
command,
"dns"
| "domain"
| "default-route"
| "llmnr"
| "mdns"
| "dnsovertls"
| "dnssec"
| "nta"
| "revert"
)
}
pub fn execute(
command: &str,
arguments: &[String],
json: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let connection = Connection::system()?;
let proxy = Proxy::new(&connection, BUS_NAME, MANAGER_PATH, MANAGER_INTERFACE)?;
if command != "revert" && arguments.len() <= 1 {
return show(&connection, &proxy, command, arguments, json);
}
let network = Proxy::new(
&connection,
NETWORK_BUS_NAME,
NETWORK_MANAGER_PATH,
NETWORK_MANAGER_INTERFACE,
)?;
match command {
"dns" => set_dns(&proxy, &network, arguments),
"domain" => set_domains(&proxy, &network, arguments),
"default-route" => set_default_route(&proxy, &network, arguments),
"llmnr" => set_mode(&proxy, &network, "SetLinkLLMNR", arguments),
"mdns" => set_mode(&proxy, &network, "SetLinkMulticastDNS", arguments),
"dnsovertls" => set_mode(&proxy, &network, "SetLinkDNSOverTLS", arguments),
"dnssec" => set_mode(&proxy, &network, "SetLinkDNSSEC", arguments),
"nta" => set_negative_trust_anchors(&proxy, &network, arguments),
"revert" => revert(&proxy, &network, arguments),
_ => Err(format!("unsupported D-Bus command: {command}").into()),
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum ResolvconfAction {
Add,
Delete,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum ResolvconfLookupType {
Regular,
Private,
Exclusive,
}
struct ResolvconfOptions {
action: ResolvconfAction,
lookup_type: ResolvconfLookupType,
permissive: bool,
interface: String,
}
pub fn resolvconf_requires_input(arguments: &[String]) -> Result<bool, Box<dyn Error>> {
Ok(parse_resolvconf_arguments(arguments)?
.is_some_and(|options| options.action == ResolvconfAction::Add))
}
fn parse_resolvconf_arguments(
arguments: &[String],
) -> Result<Option<ResolvconfOptions>, Box<dyn Error>> {
let mut action = None;
let mut lookup_type = if std::env::var_os("IF_PRIVATE").is_some() {
ResolvconfLookupType::Private
} else if std::env::var_os("IF_EXCLUSIVE").is_some() {
ResolvconfLookupType::Exclusive
} else {
ResolvconfLookupType::Regular
};
let mut permissive = false;
let mut interface = None;
let mut index = 0;
while index < arguments.len() {
match arguments[index].as_str() {
"-a" => action = Some(ResolvconfAction::Add),
"-d" => action = Some(ResolvconfAction::Delete),
"-p" => lookup_type = ResolvconfLookupType::Private,
"-x" => lookup_type = ResolvconfLookupType::Exclusive,
"-f" => permissive = true,
"-u" => return Ok(None),
"-m" => {
index += 1;
if index >= arguments.len() {
return Err("resolvconf -m requires an argument".into());
}
}
value if value.starts_with("-m") && value.len() > 2 => {}
value if value.starts_with('-') => {
return Err(format!("unsupported resolvconf option: {value}").into());
}
value => {
if interface.replace(value.to_owned()).is_some() {
return Err("resolvconf requires exactly one interface".into());
}
}
}
index += 1;
}
let action = action.ok_or("resolvconf requires either -a or -d")?;
let interface = interface.ok_or("resolvconf requires an interface")?;
Ok(Some(ResolvconfOptions {
action,
lookup_type,
permissive,
interface,
}))
}
pub fn execute_resolvconf(arguments: &[String], input: &str) -> Result<(), Box<dyn Error>> {
let Some(options) = parse_resolvconf_arguments(arguments)? else {
return Ok(());
};
let Some(interface) = resolvconf_interface(&options.interface) else {
if options.permissive {
return Ok(());
}
return Err(format!("interface not found: {}", options.interface).into());
};
if options.action == ResolvconfAction::Delete {
return execute("revert", &[interface], None);
}
let (servers, mut domains) = parse_resolvconf_input(input);
if servers.is_empty() {
return Err("no DNS servers specified, refusing operation".into());
}
if options.lookup_type == ResolvconfLookupType::Exclusive {
domains.push("~.".to_owned());
}
if options.lookup_type == ResolvconfLookupType::Private {
execute("default-route", &[interface.clone(), "no".to_owned()], None)?;
}
let mut dns_arguments = Vec::with_capacity(servers.len() + 1);
dns_arguments.push(interface.clone());
dns_arguments.extend(servers);
execute("dns", &dns_arguments, None)?;
let mut domain_arguments = Vec::with_capacity(domains.len() + 1);
domain_arguments.push(interface);
if domains.is_empty() {
domain_arguments.push(String::new());
} else {
domain_arguments.extend(domains);
}
execute("domain", &domain_arguments, None)
}
fn parse_resolvconf_input(input: &str) -> (Vec<String>, Vec<String>) {
let mut servers = Vec::new();
let mut domains = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
let mut words = line.split_whitespace();
match words.next() {
Some("nameserver") => servers.extend(words.map(str::to_owned)),
Some("domain" | "search") => domains.extend(words.map(str::to_owned)),
_ => {}
}
}
(servers, domains)
}
fn resolvconf_interface(value: &str) -> Option<String> {
let mut candidate = value;
loop {
if parse_ifindex(candidate).is_ok() {
return Some(candidate.to_owned());
}
let (prefix, _) = candidate.rsplit_once('.')?;
candidate = prefix;
}
}
fn show(
connection: &Connection,
manager: &Proxy<'_>,
command: &str,
arguments: &[String],
json: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let Some(interface) = arguments.first() else {
return show_global(manager, command, json);
};
let ifindex = parse_ifindex(interface)?;
let (path,): (OwnedObjectPath,) = manager.call("GetLink", &(ifindex,))?;
let proxy = Proxy::new(
connection,
BUS_NAME,
path.as_str(),
"org.freedesktop.resolve1.Link",
)?;
let ifname = interface_name(ifindex).unwrap_or_else(|| interface.clone());
let value = match command {
"dns" => {
let entries: Vec<(i32, Vec<u8>, u16, String)> = proxy.get_property("DNSEx")?;
let servers = entries
.into_iter()
.map(|(family, address, port, name)| dns_server_value(family, address, port, name))
.collect::<Result<Vec<_>, _>>()?;
Value::object([("servers", Value::Array(servers))])
}
"domain" => {
let entries: Vec<(String, bool)> = proxy.get_property("Domains")?;
Value::object([(
"searchDomains",
Value::Array(
entries
.into_iter()
.map(|(name, route_only)| {
Value::object([
("name", Value::String(name)),
("routeOnly", Value::Bool(route_only)),
])
})
.collect(),
),
)])
}
"default-route" => Value::object([(
"defaultRoute",
Value::Bool(proxy.get_property("DefaultRoute")?),
)]),
"llmnr" => Value::object([("llmnr", Value::String(proxy.get_property("LLMNR")?))]),
"mdns" => Value::object([("mDNS", Value::String(proxy.get_property("MulticastDNS")?))]),
"dnsovertls" => Value::object([(
"dnsOverTLS",
Value::String(proxy.get_property("DNSOverTLS")?),
)]),
"dnssec" => Value::object([("dnssec", Value::String(proxy.get_property("DNSSEC")?))]),
"nta" => {
let names: Vec<String> = proxy.get_property("DNSSECNegativeTrustAnchors")?;
Value::object([(
"negativeTrustAnchors",
Value::Array(names.into_iter().map(Value::String).collect()),
)])
}
"revert" => return Err("revert requires a link and has no query form".into()),
_ => return Err(format!("unsupported D-Bus command: {command}").into()),
};
print_link_value(ifindex, &ifname, command, value, json)
}
fn show_global(
manager: &Proxy<'_>,
command: &str,
json: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let value = match command {
"dns" => {
let entries: Vec<(i32, i32, Vec<u8>, u16, String)> = manager.get_property("DNSEx")?;
let servers = entries
.into_iter()
.map(|(_, family, address, port, name)| {
dns_server_value(family, address, port, name)
})
.collect::<Result<Vec<_>, _>>()?;
Value::object([("servers", Value::Array(servers))])
}
"domain" => {
let entries: Vec<(i32, String, bool)> = manager.get_property("Domains")?;
let domains = entries
.into_iter()
.filter(|(ifindex, _, _)| *ifindex == 0)
.map(|(_, name, route_only)| {
Value::object([
("name", Value::String(name)),
("routeOnly", Value::Bool(route_only)),
])
})
.collect();
Value::object([("searchDomains", Value::Array(domains))])
}
_ => return Err(format!("{command} requires a link").into()),
};
print_link_value(0, "Global", command, value, json)
}
fn print_link_value(
ifindex: i32,
ifname: &str,
command: &str,
value: Value,
json: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let Value::Object(mut fields) = value else {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid link value").into());
};
fields.insert("ifindex".to_owned(), Value::Number(i128::from(ifindex)));
fields.insert("ifname".to_owned(), Value::String(ifname.to_owned()));
let value = Value::Object(fields);
if !matches!(json, None | Some("off")) {
let value = Value::Array(vec![value]);
println!(
"{}",
if json == Some("pretty") {
value.to_json_pretty()
} else {
value.to_json()
}
);
return Ok(());
}
let label = if ifindex == 0 {
"Global".to_owned()
} else {
format!("Link {ifindex} ({ifname})")
};
match command {
"dns" => {
let values = value
.get("servers")
.and_then(Value::as_array)
.unwrap_or_default()
.iter()
.filter_map(|server| server.get("addressString").and_then(Value::as_str))
.collect::<Vec<_>>();
println!("{label}: {}", values.join(" "));
}
"domain" => {
let values = value
.get("searchDomains")
.and_then(Value::as_array)
.unwrap_or_default()
.iter()
.filter_map(|domain| {
let name = domain.get("name")?.as_str()?;
Some(
if domain.get("routeOnly").and_then(Value::as_bool) == Some(true) {
format!("~{name}")
} else {
name.to_owned()
},
)
})
.collect::<Vec<_>>();
println!("{label}: {}", values.join(" "));
}
"default-route" => println!(
"{label}: {}",
if value.get("defaultRoute").and_then(Value::as_bool) == Some(true) {
"yes"
} else {
"no"
}
),
"llmnr" => print_string_field(&label, &value, "llmnr"),
"mdns" => print_string_field(&label, &value, "mDNS"),
"dnsovertls" => print_string_field(&label, &value, "dnsOverTLS"),
"dnssec" => print_string_field(&label, &value, "dnssec"),
"nta" => {
let values = value
.get("negativeTrustAnchors")
.and_then(Value::as_array)
.unwrap_or_default()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
println!("{label}: {}", values.join(" "));
}
_ => return Err(format!("unsupported D-Bus command: {command}").into()),
}
Ok(())
}
fn print_string_field(label: &str, value: &Value, field: &str) {
println!(
"{label}: {}",
value.get(field).and_then(Value::as_str).unwrap_or("")
);
}
fn dns_server_value(
family: i32,
address: Vec<u8>,
port: u16,
name: String,
) -> Result<Value, Box<dyn Error>> {
let text = decode_address(family, &address)?;
Ok(Value::object([
("family", Value::Number(i128::from(family))),
(
"address",
Value::Array(
address
.into_iter()
.map(|byte| Value::Number(i128::from(byte)))
.collect(),
),
),
("addressString", Value::String(text)),
("port", Value::Number(i128::from(port))),
(
"name",
if name.is_empty() {
Value::Null
} else {
Value::String(name)
},
),
]))
}
fn decode_address(family: i32, bytes: &[u8]) -> Result<String, Box<dyn Error>> {
match (family, bytes) {
(2, [a, b, c, d]) => Ok(std::net::Ipv4Addr::new(*a, *b, *c, *d).to_string()),
(10, bytes) if bytes.len() == 16 => {
let octets: [u8; 16] = bytes.try_into()?;
Ok(std::net::Ipv6Addr::from(octets).to_string())
}
_ => Err("invalid D-Bus DNS address".into()),
}
}
fn interface_name(ifindex: i32) -> Option<String> {
fs::read_dir("/sys/class/net")
.ok()?
.filter_map(Result::ok)
.find_map(|entry| {
let index = fs::read_to_string(entry.path().join("ifindex")).ok()?;
(index.trim().parse::<i32>().ok()? == ifindex)
.then(|| entry.file_name().to_string_lossy().into_owned())
})
}
fn set_dns(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
let (link, values) = link_and_values("dns", arguments)?;
let values = if values == [""] { &[] } else { values };
let mut servers = Vec::with_capacity(values.len());
for value in values {
let spec = parse_server_spec(value)?;
if let Some(interface) = &spec.interface {
let specified = parse_ifindex(interface)?;
if specified != link {
return Err(format!(
"DNS server {value} is scoped to interface {specified}, not {link}"
)
.into());
}
}
let (family, bytes) = encode_address(spec.address.ip());
servers.push((
family,
bytes,
spec.address.port(),
spec.server_name.unwrap_or_default(),
));
}
call_with_networkd(proxy.call("SetLinkDNSEx", &(link, &servers)), || {
network.call("SetLinkDNSEx", &(link, &servers))
})?;
Ok(())
}
fn set_domains(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
let (link, values) = link_and_values("domain", arguments)?;
let values = if values == [""] { &[] } else { values };
let mut domains = Vec::with_capacity(values.len());
for value in values {
let (route_only, name) = value
.strip_prefix('~')
.map_or((false, value.as_str()), |name| (true, name));
let name = name.trim_end_matches('.');
let name = if name.is_empty() && value.ends_with('.') {
"."
} else {
name
};
if name.is_empty() {
return Err(format!("invalid domain: {value}").into());
}
domains.push((name.to_owned(), route_only));
}
call_with_networkd(proxy.call("SetLinkDomains", &(link, &domains)), || {
network.call("SetLinkDomains", &(link, &domains))
})?;
Ok(())
}
fn set_default_route(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
require_exact("default-route", arguments, 2)?;
let link = parse_ifindex(&arguments[0])?;
let enabled = parse_boolean(&arguments[1])?;
call_with_networkd(proxy.call("SetLinkDefaultRoute", &(link, enabled)), || {
network.call("SetLinkDefaultRoute", &(link, enabled))
})?;
Ok(())
}
fn set_mode(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
method: &str,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
require_exact(method, arguments, 2)?;
let link = parse_ifindex(&arguments[0])?;
let mode = arguments[1].as_str();
call_with_networkd(proxy.call(method, &(link, mode)), || {
network.call(method, &(link, mode))
})?;
Ok(())
}
fn set_negative_trust_anchors(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
let (link, values) = link_and_values("nta", arguments)?;
let names = parse_negative_trust_anchors(values)?;
call_with_networkd(
proxy.call("SetLinkDNSSECNegativeTrustAnchors", &(link, &names)),
|| network.call("SetLinkDNSSECNegativeTrustAnchors", &(link, &names)),
)?;
Ok(())
}
fn parse_negative_trust_anchors(values: &[String]) -> Result<Vec<String>, Box<dyn Error>> {
if values == [""] {
return Ok(Vec::new());
}
let names = values
.iter()
.map(|name| name.trim_end_matches('.'))
.map(str::to_owned)
.collect::<Vec<_>>();
if names.iter().any(String::is_empty) {
return Err("negative trust anchors must not be empty".into());
}
Ok(names)
}
fn revert(
proxy: &Proxy<'_>,
network: &Proxy<'_>,
arguments: &[String],
) -> Result<(), Box<dyn Error>> {
require_exact("revert", arguments, 1)?;
let link = parse_ifindex(&arguments[0])?;
call_with_networkd(proxy.call("RevertLink", &(link,)), || {
network.call("RevertLinkDNS", &(link,))
})?;
Ok(())
}
fn call_with_networkd(
resolver_result: Result<(), zbus::Error>,
network_call: impl FnOnce() -> Result<(), zbus::Error>,
) -> Result<(), zbus::Error> {
match resolver_result {
Ok(()) => Ok(()),
Err(error) if is_link_busy(&error) => network_call(),
Err(error) => Err(error),
}
}
fn is_link_busy(error: &zbus::Error) -> bool {
matches!(
error,
zbus::Error::MethodError(name, _, _) if is_link_busy_name(name.as_str())
)
}
fn is_link_busy_name(name: &str) -> bool {
name == LINK_BUSY_ERROR
}
fn link_and_values<'a>(
command: &str,
arguments: &'a [String],
) -> Result<(i32, &'a [String]), Box<dyn Error>> {
let Some((link, values)) = arguments.split_first() else {
return Err(format!("{command} requires a link").into());
};
Ok((parse_ifindex(link)?, values))
}
fn require_exact(
command: &str,
arguments: &[String],
expected: usize,
) -> Result<(), Box<dyn Error>> {
if arguments.len() != expected {
return Err(format!(
"{command} requires {expected} argument{}",
if expected == 1 { "" } else { "s" }
)
.into());
}
Ok(())
}
fn parse_ifindex(value: &str) -> Result<i32, Box<dyn Error>> {
Ok(crate::interface::resolve_ifindex(value)?)
}
fn parse_boolean(value: &str) -> Result<bool, Box<dyn Error>> {
match value.to_ascii_lowercase().as_str() {
"1" | "yes" | "y" | "true" | "t" | "on" => Ok(true),
"0" | "no" | "n" | "false" | "f" | "off" => Ok(false),
_ => Err(format!("invalid boolean value: {value}").into()),
}
}
fn encode_address(address: IpAddr) -> (i32, Vec<u8>) {
match address {
IpAddr::V4(address) => (2, address.octets().to_vec()),
IpAddr::V6(address) => (10, address.octets().to_vec()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_link_management_commands() {
assert!(is_command("dns"));
assert!(is_command("revert"));
assert!(!is_command("query"));
assert!(!is_command("unknown"));
}
#[test]
fn recognizes_only_the_resolved_managed_link_error_name() {
assert!(is_link_busy_name("org.freedesktop.resolve1.LinkBusy"));
assert!(!is_link_busy_name("org.freedesktop.resolve1.NoSuchLink"));
assert!(!is_link_busy_name("org.freedesktop.network1.LinkBusy"));
}
#[test]
fn empty_negative_trust_anchor_argument_clears_the_list() {
assert!(parse_negative_trust_anchors(&[String::new()])
.unwrap()
.is_empty());
assert_eq!(
parse_negative_trust_anchors(&["private.test.".to_owned()]).unwrap(),
["private.test"]
);
assert!(parse_negative_trust_anchors(&[String::new(), "private.test".to_owned()]).is_err());
}
#[test]
fn parses_boolean_spellings() {
assert_eq!(parse_boolean("yes").unwrap(), true);
assert_eq!(parse_boolean("t").unwrap(), true);
assert_eq!(parse_boolean("OFF").unwrap(), false);
assert_eq!(parse_boolean("N").unwrap(), false);
assert!(parse_boolean("maybe").is_err());
}
#[test]
fn accepts_only_existing_interface_indices() {
let loopback = parse_ifindex("lo").expect("loopback ifindex");
assert_eq!(parse_ifindex(&loopback.to_string()).unwrap(), loopback);
assert!(parse_ifindex("0").is_err());
assert!(parse_ifindex("-1").is_err());
assert!(parse_ifindex("2147483647").is_err());
}
#[test]
fn parses_resolvconf_servers_and_domains() {
let (servers, domains) = parse_resolvconf_input(
"# generated\nnameserver 192.0.2.1 2001:db8::1\n\
domain example.test corp.test\nsearch search.test\nunknown ignored\n",
);
assert_eq!(servers, ["192.0.2.1", "2001:db8::1"]);
assert_eq!(domains, ["example.test", "corp.test", "search.test"]);
}
#[test]
fn resolvconf_reads_input_only_for_the_final_add_mode() {
assert!(resolvconf_requires_input(&["-a".to_owned(), "lo".to_owned()]).unwrap());
assert!(
!resolvconf_requires_input(&["-a".to_owned(), "-d".to_owned(), "lo".to_owned()])
.unwrap()
);
assert!(
resolvconf_requires_input(&["-d".to_owned(), "-a".to_owned(), "lo".to_owned()])
.unwrap()
);
assert!(!resolvconf_requires_input(&["-u".to_owned()]).unwrap());
assert!(
resolvconf_requires_input(&["-m100".to_owned(), "-a".to_owned(), "lo".to_owned()])
.unwrap()
);
}
#[test]
fn renders_dns_server_json_without_inventing_a_name() {
let value = dns_server_value(2, vec![192, 0, 2, 53], 53, String::new()).unwrap();
assert_eq!(
value.get("addressString").and_then(Value::as_str),
Some("192.0.2.53")
);
assert_eq!(value.get("name"), Some(&Value::Null));
}
#[test]
fn rejects_invalid_dbus_address_lengths() {
assert!(decode_address(2, &[127, 0, 0]).is_err());
assert!(decode_address(10, &[0; 15]).is_err());
}
}