Skip to main content

sysknife_daemon/actions/
network.rs

1use super::{command_mechanism, ActionSpec};
2use sysknife_types::RiskLevel;
3
4pub fn specs() -> Vec<ActionSpec> {
5    vec![
6        configure_wifi("CafeHotspot", None),
7        set_dns_servers("wlp1s0", &["1.1.1.1", "8.8.8.8"]),
8        configure_firewall("public", "ssh", true),
9        get_firewall_state(),
10        get_network_status(),
11        get_listening_ports(),
12        get_nftables_ruleset(),
13        get_firewall_backend_state(),
14    ]
15}
16
17pub fn configure_wifi(ssid: &str, password: Option<&str>) -> ActionSpec {
18    // Build: nmcli device wifi connect <ssid> [password <pw>]
19    // Without a password, nmcli connects to open networks.
20    let mut args = vec![
21        "nmcli".to_string(),
22        "device".to_string(),
23        "wifi".to_string(),
24        "connect".to_string(),
25        ssid.to_string(),
26    ];
27    if let Some(pw) = password {
28        args.push("password".to_string());
29        args.push(pw.to_string());
30    }
31    ActionSpec {
32        action_name: "ConfigureWifi",
33        mechanism: super::ActionMechanism::Command {
34            program: "sudo",
35            args,
36        },
37        risk_level: RiskLevel::High,
38        reboot_required: false,
39        rollback_available: false,
40    }
41}
42
43pub fn set_dns_servers(interface: &str, servers: &[&str]) -> ActionSpec {
44    let args = std::iter::once("resolvectl")
45        .chain(std::iter::once("dns"))
46        .chain(std::iter::once(interface))
47        .chain(servers.iter().copied());
48
49    ActionSpec {
50        action_name: "SetDnsServers",
51        mechanism: command_mechanism("sudo", args),
52        risk_level: RiskLevel::High,
53        reboot_required: false,
54        rollback_available: false,
55    }
56}
57
58/// Configure a firewalld rule and reload so it takes effect.
59///
60/// The bounded helper runs the permanent mutation then reloads only on success.
61/// These are sequential commands, not an atomic firewalld transaction. Arguments
62/// are validated independently by the helper and never interpreted as a shell.
63pub fn configure_firewall(zone: &str, service: &str, enabled: bool) -> ActionSpec {
64    let verb = if enabled {
65        "add-service"
66    } else {
67        "remove-service"
68    };
69    ActionSpec {
70        action_name: "ConfigureFirewall",
71        mechanism: command_mechanism(
72            "sudo",
73            [
74                "/usr/lib/sysknife/action-steps",
75                "firewall",
76                zone,
77                service,
78                verb,
79            ],
80        ),
81        risk_level: RiskLevel::High,
82        reboot_required: false,
83        rollback_available: false,
84    }
85}
86
87pub fn get_firewall_state() -> ActionSpec {
88    // `--list-all` shows the active zone, interfaces, services, ports, and
89    // rich rules — the full picture. `--state` only returns "running"/"not
90    // running" which is useless for actual configuration inspection.
91    ActionSpec {
92        action_name: "GetFirewallState",
93        mechanism: command_mechanism("firewall-cmd", ["--list-all"]),
94        risk_level: RiskLevel::Low,
95        reboot_required: false,
96        rollback_available: false,
97    }
98}
99
100pub fn get_network_status() -> ActionSpec {
101    ActionSpec {
102        action_name: "GetNetworkStatus",
103        mechanism: command_mechanism("ip", ["-brief", "addr"]),
104        risk_level: RiskLevel::Low,
105        reboot_required: false,
106        rollback_available: false,
107    }
108}
109
110/// Inspect nftables without modifying rules or accepting caller-controlled argv.
111pub fn get_nftables_ruleset() -> ActionSpec {
112    ActionSpec {
113        action_name: "GetNftablesRuleset",
114        mechanism: command_mechanism("sudo", ["nft", "list", "ruleset"]),
115        risk_level: RiskLevel::Low,
116        reboot_required: false,
117        rollback_available: false,
118    }
119}
120
121/// Report nftables and frontend observations, preserving unknown probe results.
122pub fn get_firewall_backend_state() -> ActionSpec {
123    ActionSpec {
124        action_name: "GetFirewallBackendState",
125        mechanism: command_mechanism("/usr/lib/sysknife/firewall-state", [] as [&str; 0]),
126        risk_level: RiskLevel::Low,
127        reboot_required: false,
128        rollback_available: false,
129    }
130}
131
132/// List listening TCP/UDP sockets and, where the daemon has permission, the
133/// owning process (`ss -tulpnH`). Read-only; answers "what is listening on port
134/// X?". Run without sudo (like `GetNetworkStatus`'s `ip`); the socket/port list
135/// is complete regardless of privilege, process attribution is best-effort.
136pub fn get_listening_ports() -> ActionSpec {
137    ActionSpec {
138        action_name: "GetListeningPorts",
139        // -t tcp, -u udp, -l listening only, -p show process, -n numeric
140        // (no DNS/service-name lookups), -H suppress the header row.
141        mechanism: command_mechanism("ss", ["-tulpnH"]),
142        risk_level: RiskLevel::Low,
143        reboot_required: false,
144        rollback_available: false,
145    }
146}
147
148#[cfg(test)]
149mod firewall_tests {
150    use super::*;
151    use crate::actions::ActionMechanism;
152
153    #[test]
154    fn firewall_queries_have_fixed_read_only_mechanisms() {
155        for (spec, program, args) in [
156            (
157                get_nftables_ruleset(),
158                "sudo",
159                vec!["nft", "list", "ruleset"],
160            ),
161            (
162                get_firewall_backend_state(),
163                "/usr/lib/sysknife/firewall-state",
164                vec![],
165            ),
166        ] {
167            assert_eq!(spec.risk_level, RiskLevel::Low);
168            assert!(!spec.reboot_required && !spec.rollback_available);
169            assert_eq!(
170                spec.mechanism,
171                ActionMechanism::Command {
172                    program,
173                    args: args.into_iter().map(String::from).collect(),
174                }
175            );
176        }
177    }
178}