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_ACTIONS`).
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: High. Changes DNS resolution for the named interface; affects all
52/// processes resolving names through systemd-resolved on that interface.
53/// Redirecting DNS is a MitM primitive, which is why the spec below gates it at
54/// High and Admin, matching `SetDnsServers`.
55/// Cross-distro: works on any systemd-resolved host.
56///
57/// `interface` is the network interface name (e.g. `"eth0"`, `"wlp1s0"`).
58/// `servers` lists one or more parsed DNS server addresses. Accepting `IpAddr`
59/// instead of `&str` pushes validation to the call site so future callers
60/// cannot pass malformed or flag-like strings through to `resolvectl`.
61pub fn resolvectl_set_dns(interface: &str, servers: &[IpAddr]) -> ActionSpec {
62    // argv: resolvectl dns <iface> <server1> [server2 …]
63    let mut args = vec!["dns".to_string(), interface.to_string()];
64    args.extend(servers.iter().map(|ip| ip.to_string()));
65    ActionSpec {
66        action_name: "ResolvectlSetDns",
67        mechanism: super::ActionMechanism::Command {
68            program: "sudo",
69            args: {
70                let mut full = vec!["resolvectl".to_string()];
71                full.extend(args);
72                full
73            },
74        },
75        // High: setting an interface's DNS servers redirects all name
76        // resolution through an operator-chosen resolver — a DNS-hijack / MitM
77        // primitive (T1557; NIST SC-7), identical in effect to `SetDnsServers`.
78        // Per-interface scope is not a mitigation: the primary interface is the
79        // attack surface. Gated at Admin to match `SetDnsServers`.
80        risk_level: RiskLevel::High,
81        reboot_required: false,
82        rollback_available: false,
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Tests
88// ---------------------------------------------------------------------------
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::actions::ActionMechanism;
94    use std::str::FromStr;
95
96    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
97        match &spec.mechanism {
98            ActionMechanism::Command { program, args } => (*program, args.clone()),
99            _ => panic!("expected Command mechanism"),
100        }
101    }
102
103    // ── resolvectl_status ────────────────────────────────────────────────────
104
105    #[test]
106    fn resolvectl_status_action_name() {
107        assert_eq!(resolvectl_status().action_name, "ResolvectlStatus");
108    }
109
110    #[test]
111    fn resolvectl_status_risk_is_low() {
112        assert_eq!(resolvectl_status().risk_level, RiskLevel::Low);
113    }
114
115    #[test]
116    fn resolvectl_status_argv() {
117        let spec = resolvectl_status();
118        let (prog, args) = extract_args(&spec);
119        assert_eq!(prog, "resolvectl");
120        assert_eq!(args, vec!["status"]);
121    }
122
123    #[test]
124    fn resolvectl_status_no_sudo() {
125        let (prog, _) = extract_args(&resolvectl_status());
126        assert_ne!(prog, "sudo");
127    }
128
129    // ── resolvectl_set_dns ───────────────────────────────────────────────────
130
131    fn ip(s: &str) -> IpAddr {
132        IpAddr::from_str(s).expect("test fixture must be a valid IP")
133    }
134
135    #[test]
136    fn resolvectl_set_dns_action_name() {
137        assert_eq!(
138            resolvectl_set_dns("eth0", &[ip("1.1.1.1")]).action_name,
139            "ResolvectlSetDns"
140        );
141    }
142
143    #[test]
144    fn resolvectl_set_dns_risk_is_high() {
145        // Parity with SetDnsServers: redirecting DNS is a MitM primitive.
146        assert_eq!(
147            resolvectl_set_dns("eth0", &[ip("1.1.1.1")]).risk_level,
148            RiskLevel::High
149        );
150    }
151
152    #[test]
153    fn resolvectl_set_dns_argv_ordering() {
154        // argv must be: sudo resolvectl dns <iface> <server>…
155        let spec = resolvectl_set_dns("eth0", &[ip("1.1.1.1"), ip("8.8.8.8")]);
156        let (prog, args) = extract_args(&spec);
157        assert_eq!(prog, "sudo");
158        let a: Vec<&str> = args.iter().map(String::as_str).collect();
159        assert_eq!(a[0], "resolvectl");
160        assert_eq!(a[1], "dns");
161        assert_eq!(a[2], "eth0");
162        assert_eq!(a[3], "1.1.1.1");
163        assert_eq!(a[4], "8.8.8.8");
164    }
165
166    #[test]
167    fn resolvectl_set_dns_single_server() {
168        let spec = resolvectl_set_dns("wlp1s0", &[ip("9.9.9.9")]);
169        let (_, args) = extract_args(&spec);
170        let a: Vec<&str> = args.iter().map(String::as_str).collect();
171        assert!(a.contains(&"wlp1s0"));
172        assert!(a.contains(&"9.9.9.9"));
173    }
174
175    #[test]
176    fn resolvectl_set_dns_accepts_ipv6() {
177        // Coverage for the IpAddr type guarantee — IPv6 server is rendered
178        // verbatim by IpAddr::to_string, no URL-style brackets.
179        let spec = resolvectl_set_dns("eth0", &[ip("2606:4700:4700::1111")]);
180        let (_, args) = extract_args(&spec);
181        let a: Vec<&str> = args.iter().map(String::as_str).collect();
182        assert!(a.contains(&"2606:4700:4700::1111"));
183    }
184
185    // ── specs() completeness ─────────────────────────────────────────────────
186
187    #[test]
188    fn specs_covers_all_action_names() {
189        let expected = ["ResolvectlStatus", "ResolvectlSetDns"];
190        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
191        for name in &expected {
192            assert!(spec_names.contains(name), "specs() missing {name}");
193        }
194    }
195}