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    ]
13}
14
15pub fn configure_wifi(ssid: &str, password: Option<&str>) -> ActionSpec {
16    // Build: nmcli device wifi connect <ssid> [password <pw>]
17    // Without a password, nmcli connects to open networks.
18    let mut args = vec![
19        "nmcli".to_string(),
20        "device".to_string(),
21        "wifi".to_string(),
22        "connect".to_string(),
23        ssid.to_string(),
24    ];
25    if let Some(pw) = password {
26        args.push("password".to_string());
27        args.push(pw.to_string());
28    }
29    ActionSpec {
30        action_name: "ConfigureWifi",
31        mechanism: super::ActionMechanism::Command {
32            program: "sudo",
33            args,
34        },
35        risk_level: RiskLevel::High,
36        reboot_required: false,
37        rollback_available: false,
38    }
39}
40
41pub fn set_dns_servers(interface: &str, servers: &[&str]) -> ActionSpec {
42    let args = std::iter::once("resolvectl")
43        .chain(std::iter::once("dns"))
44        .chain(std::iter::once(interface))
45        .chain(servers.iter().copied());
46
47    ActionSpec {
48        action_name: "SetDnsServers",
49        mechanism: command_mechanism("sudo", args),
50        risk_level: RiskLevel::High,
51        reboot_required: false,
52        rollback_available: false,
53    }
54}
55
56/// Configure a firewalld rule and reload so it takes effect.
57///
58/// Uses `sh -c` to chain `firewall-cmd --permanent ... && firewall-cmd --reload`
59/// atomically; firewalld has no single-call equivalent that updates the
60/// permanent rule and reloads runtime in one shot.
61///
62/// **Shell-injection safety:** `zone` and `service` are interpolated into the
63/// script via `format!`, so any shell metacharacter in either would be
64/// expanded by `/bin/sh`. Defence-in-depth:
65///   1. Both flow through `validated_safe_arg` upstream, which enforces a
66///      strict ASCII allowlist (`[A-Za-z0-9._:/+@-]`, no leading dash, ≤254
67///      bytes) and rejects every shell metacharacter at the boundary.
68///   2. The interpolated values are wrapped in single quotes so a future
69///      validator regression cannot escape the surrounding quotes.
70///   3. `verb` is selected from a fixed pair of literals (`add-service` /
71///      `remove-service`); it is never attacker-influenced.
72pub fn configure_firewall(zone: &str, service: &str, enabled: bool) -> ActionSpec {
73    let verb = if enabled {
74        "add-service"
75    } else {
76        "remove-service"
77    };
78    let script = format!(
79        "firewall-cmd --permanent --zone='{}' --{}='{}' && firewall-cmd --reload",
80        zone, verb, service
81    );
82
83    ActionSpec {
84        action_name: "ConfigureFirewall",
85        mechanism: super::ActionMechanism::Command {
86            program: "sudo",
87            args: vec!["sh".to_string(), "-c".to_string(), script],
88        },
89        risk_level: RiskLevel::High,
90        reboot_required: false,
91        rollback_available: false,
92    }
93}
94
95pub fn get_firewall_state() -> ActionSpec {
96    // `--list-all` shows the active zone, interfaces, services, ports, and
97    // rich rules — the full picture. `--state` only returns "running"/"not
98    // running" which is useless for actual configuration inspection.
99    ActionSpec {
100        action_name: "GetFirewallState",
101        mechanism: command_mechanism("firewall-cmd", ["--list-all"]),
102        risk_level: RiskLevel::Low,
103        reboot_required: false,
104        rollback_available: false,
105    }
106}
107
108pub fn get_network_status() -> ActionSpec {
109    ActionSpec {
110        action_name: "GetNetworkStatus",
111        mechanism: command_mechanism("ip", ["-brief", "addr"]),
112        risk_level: RiskLevel::Low,
113        reboot_required: false,
114        rollback_available: false,
115    }
116}
117
118/// List listening TCP/UDP sockets and, where the daemon has permission, the
119/// owning process (`ss -tulpnH`). Read-only; answers "what is listening on port
120/// X?". Run without sudo (like `GetNetworkStatus`'s `ip`); the socket/port list
121/// is complete regardless of privilege, process attribution is best-effort.
122pub fn get_listening_ports() -> ActionSpec {
123    ActionSpec {
124        action_name: "GetListeningPorts",
125        // -t tcp, -u udp, -l listening only, -p show process, -n numeric
126        // (no DNS/service-name lookups), -H suppress the header row.
127        mechanism: command_mechanism("ss", ["-tulpnH"]),
128        risk_level: RiskLevel::Low,
129        reboot_required: false,
130        rollback_available: false,
131    }
132}