Skip to main content

sysknife_daemon/actions/
resolvectl.rs

1//! resolvectl actions — DNS status and configuration via systemd-resolved.
2//!
3//! Both actions are cross-distro: they work on any host running
4//! `systemd-resolved`, including Fedora Atomic and Ubuntu. Registration in
5//! `KNOWN_ACTION_NAMES` only (not `DEBIAN_ONLY_ACTION_NAMES`).
6
7use std::net::IpAddr;
8
9use super::{command_mechanism, ActionSpec};
10use sysknife_types::RiskLevel;
11
12// ---------------------------------------------------------------------------
13// specs() — for action_consistency tests
14// ---------------------------------------------------------------------------
15
16/// Return one representative `ActionSpec` per action name.
17pub fn specs() -> Vec<ActionSpec> {
18    use std::str::FromStr;
19    vec![
20        resolvectl_status(),
21        resolvectl_set_dns(
22            "eth0",
23            &[
24                IpAddr::from_str("1.1.1.1").unwrap(),
25                IpAddr::from_str("8.8.8.8").unwrap(),
26            ],
27        ),
28    ]
29}
30
31// ---------------------------------------------------------------------------
32// Action constructors
33// ---------------------------------------------------------------------------
34
35/// Show DNS resolution status for all network interfaces (`resolvectl status`).
36///
37/// Risk: Low. Read-only query of systemd-resolved configuration.
38/// Cross-distro: works on any systemd-resolved host.
39pub fn resolvectl_status() -> ActionSpec {
40    ActionSpec {
41        action_name: "ResolvectlStatus",
42        mechanism: command_mechanism("resolvectl", ["status"]),
43        risk_level: RiskLevel::Low,
44        reboot_required: false,
45        rollback_available: false,
46    }
47}
48
49/// Set DNS servers for a network interface (`sudo resolvectl dns <iface> <server>…`).
50///
51/// Risk: Medium. Changes DNS resolution for the named interface; affects all
52/// processes resolving names through systemd-resolved on that interface.
53/// Cross-distro: works on any systemd-resolved host.
54///
55/// `interface` is the network interface name (e.g. `"eth0"`, `"wlp1s0"`).
56/// `servers` lists one or more parsed DNS server addresses. Accepting `IpAddr`
57/// instead of `&str` pushes validation to the call site so future callers
58/// cannot pass malformed or flag-like strings through to `resolvectl`.
59pub fn resolvectl_set_dns(interface: &str, servers: &[IpAddr]) -> ActionSpec {
60    // argv: resolvectl dns <iface> <server1> [server2 …]
61    let mut args = vec!["dns".to_string(), interface.to_string()];
62    args.extend(servers.iter().map(|ip| ip.to_string()));
63    ActionSpec {
64        action_name: "ResolvectlSetDns",
65        mechanism: super::ActionMechanism::Command {
66            program: "sudo",
67            args: {
68                let mut full = vec!["resolvectl".to_string()];
69                full.extend(args);
70                full
71            },
72        },
73        // High: setting an interface's DNS servers redirects all name
74        // resolution through an operator-chosen resolver — a DNS-hijack / MitM
75        // primitive (T1557; NIST SC-7), identical in effect to `SetDnsServers`.
76        // Per-interface scope is not a mitigation: the primary interface is the
77        // attack surface. Gated at Admin to match `SetDnsServers`.
78        risk_level: RiskLevel::High,
79        reboot_required: false,
80        rollback_available: false,
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Tests
86// ---------------------------------------------------------------------------
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::actions::ActionMechanism;
92    use std::str::FromStr;
93
94    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
95        match &spec.mechanism {
96            ActionMechanism::Command { program, args } => (*program, args.clone()),
97            _ => panic!("expected Command mechanism"),
98        }
99    }
100
101    // ── resolvectl_status ────────────────────────────────────────────────────
102
103    #[test]
104    fn resolvectl_status_action_name() {
105        assert_eq!(resolvectl_status().action_name, "ResolvectlStatus");
106    }
107
108    #[test]
109    fn resolvectl_status_risk_is_low() {
110        assert_eq!(resolvectl_status().risk_level, RiskLevel::Low);
111    }
112
113    #[test]
114    fn resolvectl_status_argv() {
115        let spec = resolvectl_status();
116        let (prog, args) = extract_args(&spec);
117        assert_eq!(prog, "resolvectl");
118        assert_eq!(args, vec!["status"]);
119    }
120
121    #[test]
122    fn resolvectl_status_no_sudo() {
123        let (prog, _) = extract_args(&resolvectl_status());
124        assert_ne!(prog, "sudo");
125    }
126
127    // ── resolvectl_set_dns ───────────────────────────────────────────────────
128
129    fn ip(s: &str) -> IpAddr {
130        IpAddr::from_str(s).expect("test fixture must be a valid IP")
131    }
132
133    #[test]
134    fn resolvectl_set_dns_action_name() {
135        assert_eq!(
136            resolvectl_set_dns("eth0", &[ip("1.1.1.1")]).action_name,
137            "ResolvectlSetDns"
138        );
139    }
140
141    #[test]
142    fn resolvectl_set_dns_risk_is_high() {
143        // Parity with SetDnsServers: redirecting DNS is a MitM primitive.
144        assert_eq!(
145            resolvectl_set_dns("eth0", &[ip("1.1.1.1")]).risk_level,
146            RiskLevel::High
147        );
148    }
149
150    #[test]
151    fn resolvectl_set_dns_argv_ordering() {
152        // argv must be: sudo resolvectl dns <iface> <server>…
153        let spec = resolvectl_set_dns("eth0", &[ip("1.1.1.1"), ip("8.8.8.8")]);
154        let (prog, args) = extract_args(&spec);
155        assert_eq!(prog, "sudo");
156        let a: Vec<&str> = args.iter().map(String::as_str).collect();
157        assert_eq!(a[0], "resolvectl");
158        assert_eq!(a[1], "dns");
159        assert_eq!(a[2], "eth0");
160        assert_eq!(a[3], "1.1.1.1");
161        assert_eq!(a[4], "8.8.8.8");
162    }
163
164    #[test]
165    fn resolvectl_set_dns_single_server() {
166        let spec = resolvectl_set_dns("wlp1s0", &[ip("9.9.9.9")]);
167        let (_, args) = extract_args(&spec);
168        let a: Vec<&str> = args.iter().map(String::as_str).collect();
169        assert!(a.contains(&"wlp1s0"));
170        assert!(a.contains(&"9.9.9.9"));
171    }
172
173    #[test]
174    fn resolvectl_set_dns_accepts_ipv6() {
175        // Coverage for the IpAddr type guarantee — IPv6 server is rendered
176        // verbatim by IpAddr::to_string, no URL-style brackets.
177        let spec = resolvectl_set_dns("eth0", &[ip("2606:4700:4700::1111")]);
178        let (_, args) = extract_args(&spec);
179        let a: Vec<&str> = args.iter().map(String::as_str).collect();
180        assert!(a.contains(&"2606:4700:4700::1111"));
181    }
182
183    // ── specs() completeness ─────────────────────────────────────────────────
184
185    #[test]
186    fn specs_covers_all_action_names() {
187        let expected = ["ResolvectlStatus", "ResolvectlSetDns"];
188        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
189        for name in &expected {
190            assert!(spec_names.contains(name), "specs() missing {name}");
191        }
192    }
193}