CH-J Toolsserverhåndtering over SSH
Menu
Offentliggjort kilde

CH-J Tools

Gennemse mapper og filer til en specifik applikationsudgivelse.

Download kilde ZIP
CH-J / Server Manager 1704 filer
0.1.2-beta Tilføjet: · Bygget:
plan.rs Tilføjet: Dato ikke registreret 60,1 KB · 1157 linjer
Kode Download fil
1//! What a change of an interface or a firewall rule means on the tool that manages it: new file texts for a diff,
2//! and fixed actions for the helper on the server. Nothing runs here.
4use crate::core::error::{CoreError, CoreResult};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde_json::{json, Value};
8use std::net::IpAddr;
10fn invalid(message: impl Into<String>) -> CoreError {
11 CoreError::new("NETWORK_REQUEST_INVALID", message)
14static IFACE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,31}$").unwrap());
15static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$").unwrap());
17#[derive(Debug, Clone, PartialEq)]
18pub struct Addressing {
19 pub method: String,
20 pub addresses: Vec<String>,
21 pub gateway: Option<String>,
24#[derive(Debug, Clone, PartialEq)]
25pub struct Request {
26 pub interface: String,
27 pub ipv4: Addressing,
28 pub ipv6: Addressing,
29 pub dns: Vec<String>,
30 pub search: Vec<String>,
31 pub mtu: Option<u32>,
34/// A changed file, for the diff.
35#[derive(Debug, Clone, PartialEq, Default)]
36pub struct FileChange {
37 pub path: String,
38 pub before: String,
39 pub after: String,
42#[derive(Debug, Clone, PartialEq, Default)]
43pub struct Plan {
44 pub actions: Vec<Vec<String>>,
45 pub files: Vec<FileChange>,
46 pub commands: Vec<String>,
47 pub risks: Vec<Value>,
50fn strings(value: &Value) -> Vec<String> {
51 value.as_array().into_iter().flatten().filter_map(Value::as_str).map(str::trim).filter(|text| !text.is_empty()).map(str::to_string).collect()
54/// `192.0.2.5/24` of the right family; the host part must not be the network or broadcast address of a real subnet.
55pub fn check_cidr(text: &str, family: u8) -> CoreResult<(IpAddr, u8)> {
56 let (address, prefix) = text.split_once('/').ok_or_else(|| invalid(format!("{text}: the address needs a prefix, such as /24.")))?;
57 let ip: IpAddr = address.parse().map_err(|_| invalid(format!("{text} is not an IP address.")))?;
58 let prefix: u8 = prefix.parse().map_err(|_| invalid(format!("{text}: the prefix is not a number.")))?;
59 match (ip, family) {
60 (IpAddr::V4(v4), 4) => {
61 if prefix == 0 || prefix > 32 {
62 return Err(invalid(format!("{text}: the prefix must be 1 to 32.")));
63 }
64 if v4.is_loopback() || v4.is_multicast() || v4.is_unspecified() {
65 return Err(invalid(format!("{text} cannot be the address of an interface.")));
66 }
67 if prefix < 31 {
68 let bits = u32::from(v4);
69 let mask = u32::MAX << (32 - prefix);
70 if bits & !mask == 0 || bits | mask == u32::MAX {
71 return Err(invalid(format!("{text} is the network or broadcast address of its subnet.")));
72 }
73 }
74 }
75 (IpAddr::V6(v6), 6) => {
76 if prefix == 0 || prefix > 128 {
77 return Err(invalid(format!("{text}: the prefix must be 1 to 128.")));
78 }
79 if v6.is_loopback() || v6.is_multicast() || v6.is_unspecified() {
80 return Err(invalid(format!("{text} cannot be the address of an interface.")));
81 }
82 }
83 _ => return Err(invalid(format!("{text} belongs to the other IP version."))),
84 }
85 Ok((ip, prefix))
88fn same_subnet4(a: IpAddr, gateway: IpAddr, prefix: u8) -> bool {
89 match (a, gateway) {
90 (IpAddr::V4(a), IpAddr::V4(b)) => {
91 let mask = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) };
92 u32::from(a) & mask == u32::from(b) & mask
93 }
94 _ => false,
95 }
98fn addressing(value: &Value, family: u8, methods: &[&str]) -> CoreResult<Addressing> {
99 let method = value["method"].as_str().unwrap_or("").to_string();
100 if !methods.contains(&method.as_str()) {
101 return Err(invalid(format!("Unknown IPv{family} method.")));
102 }
103 let addresses = strings(&value["addresses"]);
104 let gateway = value["gateway"].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_string);
105 if method == "static" {
106 if addresses.is_empty() {
107 return Err(invalid(format!("A static IPv{family} configuration needs an address.")));
108 }
109 let parsed = addresses.iter().map(|text| check_cidr(text, family)).collect::<CoreResult<Vec<_>>>()?;
110 if let Some(gateway) = &gateway {
111 let ip: IpAddr = gateway.parse().map_err(|_| invalid(format!("The gateway {gateway} is not an IP address.")))?;
112 if (family == 4) != ip.is_ipv4() {
113 return Err(invalid(format!("The gateway {gateway} belongs to the other IP version.")));
114 }
115 if family == 4 && !parsed.iter().any(|(address, prefix)| same_subnet4(*address, ip, *prefix)) {
116 return Err(invalid(format!("The gateway {gateway} is outside the subnets of this interface.")));
117 }
118 if parsed.iter().any(|(address, _)| *address == ip) {
119 return Err(invalid("The gateway cannot be the address of this interface."));
120 }
121 }
122 } else if !addresses.is_empty() || gateway.is_some() {
123 return Err(invalid(format!("Addresses and a gateway belong only to the static IPv{family} method.")));
124 }
125 Ok(Addressing { method, addresses, gateway })
128pub fn parse_request(value: &Value) -> CoreResult<Request> {
129 let interface = value["interface"].as_str().unwrap_or("").to_string();
130 if !IFACE.is_match(&interface) || interface == "lo" {
131 return Err(invalid("Choose an interface."));
132 }
133 let dns = strings(&value["dns"]);
134 for server in &dns {
135 server.parse::<IpAddr>().map_err(|_| invalid(format!("The DNS server {server} is not an IP address.")))?;
136 }
137 let search = strings(&value["search"]);
138 static DOMAIN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$").unwrap());
139 if search.iter().any(|name| !DOMAIN.is_match(name)) {
140 return Err(invalid("A DNS search domain is not valid."));
141 }
142 let mtu = match &value["mtu"] {
143 Value::Null => None,
144 other => Some(other.as_u64().filter(|mtu| (68..=65535).contains(mtu)).ok_or_else(|| invalid("The MTU must be 68 to 65535."))? as u32),
145 };
146 Ok(Request {
147 interface,
148 ipv4: addressing(&value["ipv4"], 4, &["dhcp", "static", "disabled"])?,
149 ipv6: addressing(&value["ipv6"], 6, &["slaac", "dhcpv6", "slaac-dhcpv6", "static", "disabled"])?,
150 dns,
151 search,
152 mtu,
153 })
156fn yaml_list(lines: &mut Vec<String>, indent: &str, key: &str, items: &[String]) {
157 if !items.is_empty() {
158 lines.push(format!("{indent}{key}: [{}]", items.iter().map(|item| format!("\"{item}\"")).collect::<Vec<_>>().join(", ")));
159 }
162const NETPLAN_OWNED: &[&str] = &["dhcp4", "dhcp6", "accept-ra", "addresses", "routes", "gateway4", "gateway6", "nameservers", "mtu", "link-local"];
164/// One interface in `netplan get`.
165#[derive(Debug, PartialEq, Default)]
166pub struct NetplanStanza {
167 pub section: String,
168 pub id: String,
169 pub kept: Vec<String>,
170 pub routes: Vec<Vec<String>>,
173fn netplan_blocks(full: &str) -> Vec<(String, String, Vec<String>)> {
174 let mut blocks = Vec::new();
175 let mut section = String::new();
176 for line in full.lines() {
177 let trimmed = line.trim_end();
178 let depth = trimmed.len() - trimmed.trim_start().len();
179 if trimmed.trim().is_empty() {
180 continue;
181 }
182 if depth == 2 && trimmed.ends_with(':') {
183 section = trimmed.trim().trim_end_matches(':').to_string();
184 } else if depth == 4 && trimmed.ends_with(':') {
185 blocks.push((section.clone(), trimmed.trim().trim_end_matches(':').trim_matches('"').to_string(), Vec::new()));
186 } else if depth >= 6 {
187 if let Some(block) = blocks.last_mut() {
188 block.2.push(trimmed.to_string());
189 }
190 } else if depth < 4 {
191 section.clear();
192 }
193 }
194 blocks
197/// The logical name in netplan need not be the name of the device: `uplink:` with `set-name: eth0` or a matched MAC
198/// address is eth0 too. More than one candidate is an answer nobody can act on.
199pub fn netplan_stanza(full: &str, interface: &str, mac: Option<&str>) -> CoreResult<Option<NetplanStanza>> {
200 let blocks = netplan_blocks(full);
201 let value = |line: &str| line.split_once(':').map(|(_, value)| value.trim().trim_matches('"').to_lowercase());
202 let by_id = blocks.iter().find(|block| block.1 == interface);
203 let glob = |pattern: &str, name: &str| regex::Regex::new(&format!("^{}$", regex::escape(pattern).replace(r"\*", ".*").replace(r"\?", "."))).is_ok_and(|re| re.is_match(name));
204 let candidates: Vec<&(String, String, Vec<String>)> = blocks.iter().filter(|block| block.2.iter().any(|line| {
205 let key = line.trim().split(':').next().unwrap_or("");
206 let text = value(line).unwrap_or_default();
207 (key == "set-name" && text == interface.to_lowercase()) || (key == "macaddress" && mac.is_some_and(|mac| text == mac.to_lowercase())) || (key == "name" && line.len() - line.trim_start().len() > 6 && glob(&text, &interface.to_lowercase()))
208 })).collect();
209 // A glob or a second definition would keep applying beside the edited one.
210 if by_id.is_some_and(|block| candidates.iter().any(|other| other.1 != block.1)) {
211 return Err(invalid("Several netplan definitions match this interface. Edit them by hand."));
212 }
213 if candidates.iter().any(|block| block.2.iter().any(|line| line.trim().starts_with("name:") && line.contains(['*', '?']))) {
214 return Err(invalid("A netplan definition with a name pattern covers this interface and others. Edit it by hand."));
215 }
216 let found = match (by_id, candidates.as_slice()) {
217 (Some(block), _) => block,
218 (None, [block]) => block,
219 (None, []) => return Ok(None),
220 _ => return Err(invalid("Several netplan definitions match this interface. Edit them by hand.")),
221 };
222 let mut stanza = NetplanStanza { section: found.0.clone(), id: found.1.clone(), ..Default::default() };
223 let mut skipping = false;
224 let mut in_routes = false;
225 let mut route: Vec<String> = Vec::new();
226 for line in &found.2 {
227 let depth = line.len() - line.trim_start().len();
228 // netplan writes list items at the depth of their key.
229 let item = line.trim_start().starts_with('-');
230 if depth == 6 && !item {
231 if in_routes && !route.is_empty() {
232 stanza.routes.push(std::mem::take(&mut route));
233 }
234 let key = line.trim().split(':').next().unwrap_or("").to_string();
235 in_routes = key == "routes";
236 skipping = NETPLAN_OWNED.contains(&key.as_str());
237 }
238 if in_routes {
239 if depth == 6 && !item {
240 continue;
241 }
242 if item && depth <= 8 && !route.is_empty() {
243 stanza.routes.push(std::mem::take(&mut route));
244 }
245 route.push(line.trim().trim_start_matches('-').trim().to_string());
246 continue;
247 }
248 if !skipping {
249 stanza.kept.push(line.clone());
250 }
251 }
252 if !route.is_empty() {
253 stanza.routes.push(route);
254 }
255 Ok(Some(stanza))
258fn default_route(route: &[String]) -> bool {
259 route.iter().any(|line| line.split_once(':').is_some_and(|(key, value)| key.trim() == "to" && matches!(value.trim().trim_matches('"'), "default" | "0.0.0.0/0" | "::/0")))
262pub fn netplan_plan(request: &Request, full: &str, kind: &str, mac: Option<&str>) -> CoreResult<Plan> {
263 let found = netplan_stanza(full, &request.interface, mac)?;
264 let default_section = match kind {
265 "bridge" => "bridges",
266 "bond" => "bonds",
267 "vlan" => "vlans",
268 "wifi" => "wifis",
269 _ => "ethernets",
270 };
271 let existed = found.is_some();
272 let stanza = found.unwrap_or_else(|| NetplanStanza { section: default_section.to_string(), id: request.interface.clone(), ..Default::default() });
273 let (section, kept, id) = (stanza.section.clone(), stanza.kept.clone(), stanza.id.clone());
274 let mut risks = Vec::new();
275 // A default route with its own table or metric cannot be expressed in this form.
276 if stanza.routes.iter().any(|route| default_route(route) && route.len() > 2) {
277 risks.push(json!({ "code": "default-route-options-lost" }));
278 }
279 let other_routes: Vec<&Vec<String>> = stanza.routes.iter().filter(|route| !default_route(route)).collect();
280 let mut lines = vec!["network:".to_string(), " version: 2".to_string(), format!(" {section}:"), format!(" {id}:")];
281 lines.extend(kept);
282 let i = " ";
283 lines.push(format!("{i}dhcp4: {}", request.ipv4.method == "dhcp"));
284 lines.push(format!("{i}dhcp6: {}", matches!(request.ipv6.method.as_str(), "dhcpv6" | "slaac-dhcpv6")));
285 lines.push(format!("{i}accept-ra: {}", matches!(request.ipv6.method.as_str(), "slaac" | "dhcpv6" | "slaac-dhcpv6")));
286 if request.ipv6.method == "disabled" {
287 lines.push(format!("{i}link-local: []"));
288 }
289 let addresses: Vec<String> = request.ipv4.addresses.iter().chain(&request.ipv6.addresses).cloned().collect();
290 yaml_list(&mut lines, i, "addresses", &addresses);
291 let gateways: Vec<&String> = request.ipv4.gateway.iter().chain(&request.ipv6.gateway).collect();
292 if !gateways.is_empty() || !other_routes.is_empty() {
293 lines.push(format!("{i}routes:"));
294 for route in &other_routes {
295 for (index, line) in route.iter().enumerate() {
296 lines.push(format!("{i} {}{line}", if index == 0 { "- " } else { " " }));
297 }
298 }
299 for gateway in gateways {
300 let target = if gateway.contains(':') { "::/0" } else { "0.0.0.0/0" };
301 lines.push(format!("{i} - to: \"{target}\""));
302 lines.push(format!("{i} via: \"{gateway}\""));
303 if gateway.contains(':') {
304 lines.push(format!("{i} on-link: true"));
305 }
306 }
307 }
308 if !request.dns.is_empty() || !request.search.is_empty() {
309 lines.push(format!("{i}nameservers:"));
310 yaml_list(&mut lines, &format!("{i} "), "addresses", &request.dns);
311 yaml_list(&mut lines, &format!("{i} "), "search", &request.search);
312 }
313 if let Some(mtu) = request.mtu {
314 lines.push(format!("{i}mtu: {mtu}"));
315 }
316 let text = lines.join("\n");
317 let path = format!("/etc/netplan/90-chj-{}.yaml", request.interface);
318 let mut actions = Vec::new();
319 if existed {
320 actions.push(vec!["netplan-clear".to_string(), format!("{section}.{id}")]);
321 }
322 actions.push(vec!["write".to_string(), path.clone(), text.clone(), "600".to_string()]);
323 actions.push(vec!["netplan-check".to_string()]);
324 actions.push(vec!["netplan-apply".to_string()]);
325 Ok(Plan { actions, files: vec![FileChange { path, before: full.to_string(), after: text }], commands: vec!["netplan generate".into(), "netplan apply".into()], risks })
328pub fn nm_plan(request: &Request, connection: &str) -> Plan {
329 let list = |items: &[String]| items.join(",");
330 let ipv4_method = match request.ipv4.method.as_str() { "dhcp" => "auto", "static" => "manual", _ => "disabled" };
331 let ipv6_method = match request.ipv6.method.as_str() { "slaac" | "slaac-dhcpv6" => "auto", "dhcpv6" => "dhcp", "static" => "manual", _ => "disabled" };
332 let (dns4, dns6): (Vec<String>, Vec<String>) = request.dns.iter().cloned().partition(|server| !server.contains(':'));
333 let mut settings: Vec<(String, String)> = vec![
334 ("ipv4.method".into(), ipv4_method.into()),
335 ("ipv4.addresses".into(), list(&request.ipv4.addresses)),
336 ("ipv4.gateway".into(), request.ipv4.gateway.clone().unwrap_or_default()),
337 ("ipv4.dns".into(), list(&dns4)),
338 ("ipv6.method".into(), ipv6_method.into()),
339 ("ipv6.addresses".into(), list(&request.ipv6.addresses)),
340 ("ipv6.gateway".into(), request.ipv6.gateway.clone().unwrap_or_default()),
341 ("ipv6.dns".into(), list(&dns6)),
342 ("ipv4.dns-search".into(), list(&request.search)),
343 ];
344 if let Some(mtu) = request.mtu {
345 settings.push(("ethernet.mtu".into(), mtu.to_string()));
346 }
347 let mut modify = vec!["nm-modify".to_string(), connection.to_string()];
348 for (key, value) in &settings {
349 modify.push(key.clone());
350 modify.push(value.clone());
351 }
352 let shown: Vec<String> = settings.iter().map(|(key, value)| format!("{key} = {value}")).collect();
353 Plan {
354 actions: vec![modify, vec!["nm-up".to_string(), connection.to_string()]],
355 files: vec![FileChange { path: format!("NetworkManager: {connection}"), before: String::new(), after: shown.join("\n") }],
356 commands: vec![format!("nmcli connection modify \"{connection}\" …"), format!("nmcli connection up \"{connection}\"")],
357 risks: vec![],
358 }
361/// Does this `.network` file speak of more than the one device? Editing it would change the others too.
362pub fn networkd_shared(text: &str, interface: &str) -> bool {
363 let mut in_match = false;
364 let mut named = text.trim().is_empty();
365 for line in text.lines() {
366 let trimmed = line.trim();
367 if trimmed.starts_with('[') {
368 in_match = trimmed == "[Match]";
369 } else if in_match && !trimmed.is_empty() && !trimmed.starts_with('#') {
370 match trimmed.split_once('=') {
371 Some(("Name", names)) if names.split_whitespace().all(|name| name == interface) => named = true,
372 Some(("MACAddress", addresses)) if addresses.split_whitespace().count() == 1 => named = true,
373 _ => return true,
374 }
375 }
376 }
377 // Without a condition the file matches every device.
378 !named
381/// An edited `.network` file: its [Match], other sections, non-default routes and properties of a kept address stay.
382pub fn networkd_text(request: &Request, existing: Option<&str>) -> String {
383 const OWNED: &[&str] = &["DHCP", "Address", "Gateway", "DNS", "Domains", "IPv6AcceptRA", "LinkLocalAddressing"];
384 let mut sections: Vec<(String, Vec<String>)> = vec![(String::new(), Vec::new())];
385 for line in existing.unwrap_or("").lines() {
386 let trimmed = line.trim();
387 if trimmed.starts_with('[') && trimmed.ends_with(']') {
388 sections.push((trimmed.to_string(), Vec::new()));
389 } else if let Some(last) = sections.last_mut() {
390 last.1.push(line.to_string());
391 }
392 }
393 let values = |lines: &[String], key: &str| lines.iter().find_map(|line| line.trim().strip_prefix(key).map(str::to_string));
394 let wanted: Vec<&String> = request.ipv4.addresses.iter().chain(&request.ipv6.addresses).collect();
395 let mut kept_addresses: Vec<String> = Vec::new();
396 let mut output: Vec<String> = Vec::new();
397 let mut additions = Vec::new();
398 let mut saw_network = false;
399 if existing.is_none() {
400 output.extend(["[Match]".to_string(), format!("Name={}", request.interface), String::new()]);
401 }
402 for (name, lines) in &sections {
403 match name.as_str() {
404 "[Address]" => {
405 let address = values(lines, "Address=").unwrap_or_default();
406 if wanted.iter().any(|item| **item == address) {
407 kept_addresses.push(address);
408 } else {
409 continue;
410 }
411 }
412 "[Route]" => {
413 let destination = values(lines, "Destination=");
414 if destination.as_deref().is_none_or(|text| matches!(text, "0.0.0.0/0" | "::/0" | "")) {
415 continue;
416 }
417 }
418 _ => {}
419 }
420 if !name.is_empty() {
421 output.push(name.clone());
422 }
423 if name == "[Network]" {
424 saw_network = true;
425 additions = vec![output.len()];
426 output.extend(lines.iter().filter(|line| !OWNED.contains(&line.trim().split('=').next().unwrap_or("").trim())).cloned());
427 } else if name == "[Link]" {
428 output.extend(lines.iter().filter(|line| !line.trim().starts_with("MTUBytes=")).cloned());
429 } else {
430 output.extend(lines.iter().cloned());
431 }
432 }
433 let new_lines: Vec<String> = networkd_lines(request).into_iter().filter(|line| !line.strip_prefix("Address=").is_some_and(|address| kept_addresses.iter().any(|kept| kept == address))).collect();
434 match additions.first() {
435 Some(at) => {
436 let tail = output.split_off(*at);
437 output.extend(new_lines);
438 output.extend(tail);
439 }
440 None if !saw_network => {
441 output.push("[Network]".to_string());
442 output.extend(new_lines);
443 }
444 None => {}
445 }
446 if let Some(mtu) = request.mtu {
447 match output.iter().position(|line| line.trim() == "[Link]") {
448 Some(at) => output.insert(at + 1, format!("MTUBytes={mtu}")),
449 None => {
450 output.push(String::new());
451 output.push("[Link]".to_string());
452 output.push(format!("MTUBytes={mtu}"));
453 }
454 }
455 }
456 output.join("\n")
459fn networkd_lines(request: &Request) -> Vec<String> {
460 let dhcp = match (request.ipv4.method == "dhcp", matches!(request.ipv6.method.as_str(), "dhcpv6" | "slaac-dhcpv6")) {
461 (true, true) => "yes",
462 (true, false) => "ipv4",
463 (false, true) => "ipv6",
464 _ => "no",
465 };
466 let mut lines = vec![format!("DHCP={dhcp}")];
467 lines.push(format!("IPv6AcceptRA={}", matches!(request.ipv6.method.as_str(), "slaac" | "dhcpv6" | "slaac-dhcpv6")));
468 if request.ipv6.method == "disabled" {
469 lines.push("LinkLocalAddressing=no".to_string());
470 }
471 for address in request.ipv4.addresses.iter().chain(&request.ipv6.addresses) {
472 lines.push(format!("Address={address}"));
473 }
474 for gateway in request.ipv4.gateway.iter().chain(&request.ipv6.gateway) {
475 lines.push(format!("Gateway={gateway}"));
476 }
477 for server in &request.dns {
478 lines.push(format!("DNS={server}"));
479 }
480 if !request.search.is_empty() {
481 lines.push(format!("Domains={}", request.search.join(" ")));
482 }
483 lines
486pub fn networkd_plan(request: &Request, path: Option<&str>, existing: Option<&str>) -> Plan {
487 let text = networkd_text(request, existing);
488 // A default route in its own section may carry a table or a metric that this form does not express.
489 let default_section = existing.unwrap_or("").split("[Route]").skip(1).any(|section| {
490 let body = section.split("\n[").next().unwrap_or("");
491 let destination = body.lines().find_map(|line| line.trim().strip_prefix("Destination="));
492 destination.is_none_or(|text| matches!(text, "0.0.0.0/0" | "::/0")) && body.lines().map(str::trim).any(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("Gateway=") && !line.starts_with("Destination="))
493 });
494 let path = path.map(str::to_string).unwrap_or_else(|| format!("/etc/systemd/network/10-chj-{}.network", request.interface));
495 Plan {
496 actions: vec![vec!["write".to_string(), path.clone(), text.clone(), "644".to_string()], vec!["networkd-reload".to_string(), request.interface.clone()]],
497 files: vec![FileChange { path, before: existing.unwrap_or("").to_string(), after: text }],
498 commands: vec!["networkctl reload".into(), format!("networkctl reconfigure {}", request.interface)],
499 risks: if default_section { vec![json!({ "code": "default-route-options-lost" })] } else { vec![] },
500 }
503/// /etc/network/interfaces with the stanzas of one interface replaced. Other interfaces, comments and options such as
504/// bridge-ports stay; options and comments of a stanza stay with its IP family. Only the next stanza ends a stanza.
505pub fn interfaces_text(request: &Request, existing: &str) -> String {
506 const OWNED: &[&str] = &["address", "netmask", "gateway", "dns-nameservers", "dns-search", "mtu", "broadcast", "network"];
507 let iface = &request.interface;
508 let mut output: Vec<String> = Vec::new();
509 let mut family: Option<&str> = None;
510 let mut kept_v4: Vec<String> = Vec::new();
511 let mut kept_v6: Vec<String> = Vec::new();
512 let mut insert_at: Option<usize> = None;
513 let mut has_auto = false;
514 for line in existing.lines() {
515 let trimmed = line.trim();
516 let words: Vec<&str> = trimmed.split_whitespace().collect();
517 if matches!(words.first(), Some(&"iface" | &"auto" | &"allow-hotplug" | &"allow-auto" | &"mapping" | &"source" | &"source-directory" | &"rename")) {
518 family = None;
519 }
520 if matches!(words.first(), Some(&"auto" | &"allow-hotplug" | &"allow-auto")) && words.contains(&iface.as_str()) {
521 has_auto = true;
522 }
523 if words.first() == Some(&"iface") && words.get(1) == Some(&iface.as_str()) {
524 family = Some(if words.get(2) == Some(&"inet6") { "inet6" } else { "inet" });
525 insert_at.get_or_insert(output.len());
526 continue;
527 }
528 if let Some(current) = family {
529 if trimmed.is_empty() {
530 continue;
531 }
532 if !OWNED.contains(&words.first().copied().unwrap_or("")) {
533 let list = if current == "inet6" { &mut kept_v6 } else { &mut kept_v4 };
534 list.push(line.to_string());
535 }
536 continue;
537 }
538 output.push(line.to_string());
539 }
540 let mut stanza: Vec<String> = Vec::new();
541 if !has_auto {
542 stanza.push(format!("auto {iface}"));
543 }
544 let v4 = match request.ipv4.method.as_str() { "dhcp" => "dhcp", "static" => "static", _ => "manual" };
545 stanza.push(format!("iface {iface} inet {v4}"));
546 if let Some(address) = request.ipv4.addresses.first() {
547 stanza.push(format!(" address {address}"));
548 }
549 if let Some(gateway) = &request.ipv4.gateway {
550 stanza.push(format!(" gateway {gateway}"));
551 }
552 if let Some(mtu) = request.mtu {
553 stanza.push(format!(" mtu {mtu}"));
554 }
555 if !request.dns.is_empty() {
556 stanza.push(format!(" dns-nameservers {}", request.dns.join(" ")));
557 }
558 if !request.search.is_empty() {
559 stanza.push(format!(" dns-search {}", request.search.join(" ")));
560 }
561 stanza.extend(kept_v4);
562 match request.ipv6.method.as_str() {
563 "slaac" => stanza.push(format!("iface {iface} inet6 auto")),
564 "dhcpv6" | "slaac-dhcpv6" => stanza.push(format!("iface {iface} inet6 dhcp")),
565 "static" => {
566 stanza.push(format!("iface {iface} inet6 static"));
567 if let Some(address) = request.ipv6.addresses.first() {
568 stanza.push(format!(" address {address}"));
569 }
570 if let Some(gateway) = &request.ipv6.gateway {
571 stanza.push(format!(" gateway {gateway}"));
572 }
573 }
574 _ => {}
575 }
576 if request.ipv6.method != "disabled" {
577 stanza.extend(kept_v6);
578 }
579 if !output.iter().rev().take(1).any(|line| line.trim().is_empty()) && insert_at.is_none() && !output.is_empty() {
580 output.push(String::new());
581 }
582 let at = insert_at.unwrap_or(output.len()).min(output.len());
583 let tail = output.split_off(at);
584 output.extend(stanza);
585 if !tail.is_empty() && !tail[0].trim().is_empty() {
586 output.push(String::new());
587 }
588 output.extend(tail);
589 output.join("\n")
592pub fn interfaces_plan(request: &Request, path: &str, existing: &str, manager: &str) -> CoreResult<Plan> {
593 if request.ipv4.addresses.len() > 1 || request.ipv6.addresses.len() > 1 {
594 return Err(invalid("This system's interface file takes one address per IP version here."));
595 }
596 let text = interfaces_text(request, existing);
597 let write = vec!["write".to_string(), path.to_string(), text.clone(), "644".to_string()];
598 let (actions, commands) = if manager == "ifupdown2" {
599 (vec![write, vec!["ifreload".to_string()]], vec!["ifreload -a".to_string()])
600 } else {
601 // Down from the old file, or DHCP and the old address keep running beside the new ones.
602 (vec![vec!["iface-down".to_string(), request.interface.clone()], write, vec!["iface-up".to_string(), request.interface.clone()]], vec![format!("ifdown {0}; ifup {0}", request.interface)])
603 };
604 Ok(Plan { commands, actions, files: vec![FileChange { path: path.to_string(), before: existing.to_string(), after: text }], risks: vec![] })
607// --- firewall ------------------------------------------------------------------------------------
609#[derive(Debug, Clone, PartialEq)]
610pub struct Rule {
611 pub direction: String,
612 pub action: String,
613 pub family: String,
614 pub protocol: String,
615 pub ports: Vec<String>,
616 pub source: Option<String>,
617 pub destination: Option<String>,
618 pub interface: Option<String>,
619 pub comment: Option<String>,
620 pub zone: Option<String>,
623fn check_net(text: &str) -> CoreResult<String> {
624 let (address, prefix) = match text.split_once('/') { Some((a, p)) => (a, Some(p)), None => (text, None) };
625 let ip: IpAddr = address.parse().map_err(|_| invalid(format!("{text} is not an IP address or a network.")))?;
626 if let Some(prefix) = prefix {
627 let prefix: u8 = prefix.parse().map_err(|_| invalid(format!("{text}: the prefix is not a number.")))?;
628 if prefix > if ip.is_ipv4() { 32 } else { 128 } {
629 return Err(invalid(format!("{text}: the prefix is too long.")));
630 }
631 }
632 Ok(text.to_string())
635pub fn parse_rule(value: &Value) -> CoreResult<Rule> {
636 let pick = |name: &str, allowed: &[&str], default: &str| -> CoreResult<String> {
637 let text = value[name].as_str().unwrap_or(default);
638 if allowed.contains(&text) { Ok(text.to_string()) } else { Err(invalid(format!("Unknown value of {name}."))) }
639 };
640 let ports = strings(&value["ports"]);
641 static PORT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{1,5}(:\d{1,5})?$").unwrap());
642 for port in &ports {
643 if !PORT.is_match(port) || port.split(':').any(|part| part.parse::<u32>().map_or(true, |n| n == 0 || n > 65535)) {
644 return Err(invalid(format!("The port {port} is not valid.")));
645 }
646 }
647 let protocol = pick("protocol", &["tcp", "udp", "icmp", "ipv6-icmp", "any"], "tcp")?;
648 if !ports.is_empty() && !matches!(protocol.as_str(), "tcp" | "udp") {
649 return Err(invalid("Ports belong to TCP or UDP."));
650 }
651 if ports.is_empty() && matches!(protocol.as_str(), "tcp" | "udp") && value["allowAllPorts"] != true {
652 return Err(invalid("Enter a port."));
653 }
654 let text = |name: &str| value[name].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_string);
655 let source = text("source").map(|net| check_net(&net)).transpose()?;
656 let destination = text("destination").map(|net| check_net(&net)).transpose()?;
657 let interface = text("interface");
658 if interface.as_ref().is_some_and(|name| !IFACE.is_match(name)) {
659 return Err(invalid("The interface is not valid."));
660 }
661 let comment = text("comment");
662 static COMMENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[\p{L}\p{N} ._:/-]{1,80}$").unwrap());
663 if comment.as_ref().is_some_and(|text| !COMMENT.is_match(text)) {
664 return Err(invalid("The comment may contain letters, digits and . _ : / - up to 80 characters."));
665 }
666 let zone = text("zone");
667 if zone.as_ref().is_some_and(|name| !NAME.is_match(name)) {
668 return Err(invalid("The zone is not valid."));
669 }
670 let family = pick("family", &["both", "4", "6"], "both")?;
671 if let Some(net) = source.iter().chain(&destination).next() {
672 if (net.contains(':') && family == "4") || (!net.contains(':') && family == "6") {
673 return Err(invalid("The address belongs to the other IP version."));
674 }
675 }
676 Ok(Rule { direction: pick("direction", &["in", "out"], "in")?, action: pick("action", &["accept", "drop", "reject"], "accept")?, family, protocol, ports, source, destination, interface, comment, zone })
679/// The SSH connection a change has to keep alive.
680#[derive(Debug, Clone, Default)]
681pub struct SshPath {
682 pub port: Option<u16>,
683 pub client: Option<IpAddr>,
684 pub server: Option<IpAddr>,
685 pub interface: Option<String>,
688pub fn net_contains(net: &str, address: IpAddr) -> bool {
689 let (base, prefix) = match net.split_once('/') { Some((base, prefix)) => (base, prefix.parse::<u32>().ok()), None => (net, None) };
690 let Ok(base) = base.parse::<IpAddr>() else { return false };
691 match (base, address) {
692 (IpAddr::V4(a), IpAddr::V4(b)) => {
693 let bits = prefix.unwrap_or(32).min(32);
694 let mask = if bits == 0 { 0 } else { u32::MAX << (32 - bits) };
695 u32::from(a) & mask == u32::from(b) & mask
696 }
697 (IpAddr::V6(a), IpAddr::V6(b)) => {
698 let bits = prefix.unwrap_or(128).min(128);
699 let mask = if bits == 0 { 0 } else { u128::MAX << (128 - bits) };
700 u128::from(a) & mask == u128::from(b) & mask
701 }
702 _ => false,
703 }
706fn covers_port(ports: &[String], port: u16) -> bool {
707 ports.is_empty() || ports.iter().any(|item| match item.split_once(':') {
708 Some((from, to)) => from.parse::<u16>().is_ok_and(|from| from <= port) && to.parse::<u16>().is_ok_and(|to| to >= port),
709 None => item.parse::<u16>() == Ok(port),
710 })
713/// Would this new rule cut the running SSH connection? When it cannot be told, it counts as yes. The undo timer applies
714/// either way, the user should know.
715pub fn cuts_ssh(rule: &Rule, ssh: &SshPath) -> bool {
716 if rule.action == "accept" {
717 return false;
718 }
719 let Some(port) = ssh.port else { return true };
720 if rule.interface.as_ref().is_some_and(|name| ssh.interface.as_ref().is_some_and(|ssh_name| ssh_name != name)) {
721 return false;
722 }
723 let family_hits = |address: Option<IpAddr>| address.is_none_or(|address| rule.family == "both" || (rule.family == "4") == address.is_ipv4());
724 if !family_hits(ssh.client) {
725 return false;
726 }
727 if rule.direction == "out" {
728 // The answers of SSH go out from its port.
729 return rule.protocol == "any" || (rule.protocol == "tcp" && rule.ports.is_empty());
730 }
731 if rule.source.as_ref().is_some_and(|net| ssh.client.is_some_and(|client| !net_contains(net, client))) {
732 return false;
733 }
734 if rule.destination.as_ref().is_some_and(|net| ssh.server.is_some_and(|server| !net_contains(net, server))) {
735 return false;
736 }
737 rule.protocol == "any" || (rule.protocol == "tcp" && covers_port(&rule.ports, port))
740/// Does an existing rule (as read) certainly let the SSH connection in?
741pub fn keeps_ssh(rule: &Value, ssh: &SshPath) -> bool {
742 let Some(port) = ssh.port else { return false };
743 if rule["direction"] != "in" || rule["action"] != "accept" || rule["understood"] == false {
744 return false;
745 }
746 if !(rule["protocol"] == "tcp" || rule["protocol"].is_null() || rule["protocol"] == "any") {
747 return false;
748 }
749 let ports: Vec<String> = rule["ports"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
750 if ports.is_empty() || !covers_port(&ports, port) {
751 return false;
752 }
753 if let Some(client) = ssh.client {
754 let family_ok = match &rule["family"] { Value::Number(n) => (n.as_u64() == Some(4)) == client.is_ipv4(), _ => true };
755 if !family_ok || rule["source"].as_str().is_some_and(|net| !matches!(net, "any" | "0.0.0.0/0" | "::/0") && !net_contains(net, client)) {
756 return false;
757 }
758 }
759 if let Some(server) = ssh.server {
760 if rule["destination"].as_str().is_some_and(|net| !matches!(net, "any" | "0.0.0.0/0" | "::/0") && !net_contains(net, server)) {
761 return false;
762 }
763 }
764 rule["interface"].as_str().is_none_or(|name| ssh.interface.as_deref() == Some(name))
767fn word(action: &str) -> &'static str {
768 match action { "accept" => "allow", "reject" => "reject", _ => "deny" }
771pub fn ufw_add(rule: &Rule) -> CoreResult<Vec<String>> {
772 if matches!(rule.protocol.as_str(), "icmp" | "ipv6-icmp") {
773 return Err(invalid("ufw has no simple rules for ICMP. Its own rules already allow what IPv6 needs."));
774 }
775 let mut args = vec!["ufw".to_string(), word(&rule.action).to_string(), rule.direction.clone()];
776 if let Some(interface) = &rule.interface {
777 args.extend(["on".to_string(), interface.clone()]);
778 }
779 if rule.protocol != "any" {
780 args.extend(["proto".to_string(), rule.protocol.clone()]);
781 }
782 let any = match rule.family.as_str() { "4" => "0.0.0.0/0", "6" => "::/0", _ => "any" };
783 args.extend(["from".to_string(), rule.source.clone().unwrap_or_else(|| any.into()), "to".to_string(), rule.destination.clone().unwrap_or_else(|| any.into())]);
784 if !rule.ports.is_empty() {
785 args.extend(["port".to_string(), rule.ports.join(",")]);
786 }
787 if let Some(comment) = &rule.comment {
788 args.extend(["comment".to_string(), comment.clone()]);
789 }
790 Ok(args)
793pub fn firewalld_add(rule: &Rule, zone: &str, remove: bool) -> CoreResult<Vec<Vec<String>>> {
794 if rule.direction != "in" {
795 return Err(invalid("firewalld zones here describe incoming traffic only."));
796 }
797 if rule.interface.is_some() {
798 return Err(invalid("In firewalld an interface belongs to a zone. Choose the zone instead."));
799 }
800 let verb = if remove { "--remove-rich-rule" } else { "--add-rich-rule" };
801 let port_verb = if remove { "--remove-port" } else { "--add-port" };
802 let base = vec!["firewalld".to_string(), "--permanent".to_string(), format!("--zone={zone}")];
803 if rule.action == "accept" && rule.source.is_none() && rule.destination.is_none() && !rule.ports.is_empty() && rule.family == "both" {
804 return Ok(rule.ports.iter().map(|port| { let mut line = base.clone(); line.push(format!("{port_verb}={}/{}", port.replace(':', "-"), rule.protocol)); line }).collect());
805 }
806 let family = match rule.family.as_str() { "4" => "ipv4", "6" => "ipv6", _ => "" };
807 let action = match rule.action.as_str() { "accept" => "accept", "reject" => "reject", _ => "drop" };
808 let mut text = String::from("rule");
809 if !family.is_empty() {
810 text.push_str(&format!(" family=\"{family}\""));
811 } else if rule.source.is_some() || rule.destination.is_some() {
812 return Err(invalid("With addresses, choose IPv4 or IPv6."));
813 }
814 if let Some(source) = &rule.source {
815 text.push_str(&format!(" source address=\"{source}\""));
816 }
817 if let Some(destination) = &rule.destination {
818 text.push_str(&format!(" destination address=\"{destination}\""));
819 }
820 let mut lines = Vec::new();
821 let items: Vec<String> = if rule.ports.is_empty() { vec![String::new()] } else { rule.ports.clone() };
822 for port in items {
823 let mut one = text.clone();
824 if !port.is_empty() {
825 one.push_str(&format!(" port port=\"{}\" protocol=\"{}\"", port.replace(':', "-"), rule.protocol));
826 } else if rule.protocol != "any" {
827 one.push_str(&format!(" protocol value=\"{}\"", rule.protocol));
828 }
829 one.push_str(&format!(" {action}"));
830 let mut line = base.clone();
831 line.push(format!("{verb}={one}"));
832 lines.push(line);
833 }
834 Ok(lines)
837/// The nft words of a rule, for a table and chain that exist on the server.
838pub fn nft_words(rule: &Rule) -> Vec<String> {
839 let mut words = Vec::new();
840 if let Some(interface) = &rule.interface {
841 words.extend([if rule.direction == "in" { "iifname" } else { "oifname" }.to_string(), format!("\"{interface}\"")]);
842 }
843 if rule.family == "4" {
844 words.extend(["meta".to_string(), "nfproto".to_string(), "ipv4".to_string()]);
845 } else if rule.family == "6" {
846 words.extend(["meta".to_string(), "nfproto".to_string(), "ipv6".to_string()]);
847 }
848 let selector = |net: &String| if net.contains(':') { "ip6" } else { "ip" }.to_string();
849 if let Some(source) = &rule.source {
850 words.extend([selector(source), "saddr".to_string(), source.clone()]);
851 }
852 if let Some(destination) = &rule.destination {
853 words.extend([selector(destination), "daddr".to_string(), destination.clone()]);
854 }
855 match rule.protocol.as_str() {
856 "tcp" | "udp" => {
857 if rule.ports.is_empty() {
858 words.extend(["meta".to_string(), "l4proto".to_string(), rule.protocol.clone()]);
859 } else {
860 let list: Vec<String> = rule.ports.iter().map(|port| port.replace(':', "-")).collect();
861 let set = if list.len() == 1 { list[0].clone() } else { format!("{{ {} }}", list.join(", ")) };
862 words.extend([rule.protocol.clone(), "dport".to_string(), set]);
863 }
864 }
865 "icmp" => words.extend(["meta".to_string(), "l4proto".to_string(), "icmp".to_string()]),
866 "ipv6-icmp" => words.extend(["meta".to_string(), "l4proto".to_string(), "ipv6-icmp".to_string()]),
867 _ => {}
868 }
869 words.push(rule.action.clone());
870 // nft wants the comment last.
871 if let Some(comment) = &rule.comment {
872 words.extend(["comment".to_string(), format!("\"{comment}\"")]);
873 }
874 words
877pub fn iptables_words(rule: &Rule, family_v6: bool) -> Vec<String> {
878 let chain = if rule.direction == "in" { "INPUT" } else { "OUTPUT" };
879 let mut words = vec!["-I".to_string(), chain.to_string()];
880 if let Some(interface) = &rule.interface {
881 words.extend([if rule.direction == "in" { "-i" } else { "-o" }.to_string(), interface.clone()]);
882 }
883 if let Some(source) = &rule.source {
884 words.extend(["-s".to_string(), source.clone()]);
885 }
886 if let Some(destination) = &rule.destination {
887 words.extend(["-d".to_string(), destination.clone()]);
888 }
889 match rule.protocol.as_str() {
890 "tcp" | "udp" => {
891 words.extend(["-p".to_string(), rule.protocol.clone()]);
892 if rule.ports.len() == 1 {
893 words.extend(["--dport".to_string(), rule.ports[0].clone()]);
894 } else if rule.ports.len() > 1 {
895 words.extend(["-m".to_string(), "multiport".to_string(), "--dports".to_string(), rule.ports.join(",")]);
896 }
897 }
898 "icmp" if !family_v6 => words.extend(["-p".to_string(), "icmp".to_string()]),
899 "ipv6-icmp" if family_v6 => words.extend(["-p".to_string(), "ipv6-icmp".to_string()]),
900 _ => {}
901 }
902 if let Some(comment) = &rule.comment {
903 words.extend(["-m".to_string(), "comment".to_string(), "--comment".to_string(), comment.clone()]);
904 }
905 words.extend(["-j".to_string(), match rule.action.as_str() { "accept" => "ACCEPT", "reject" => "REJECT", _ => "DROP" }.to_string()]);
906 words
909/// Wi-Fi passwords and keys stay in the files, not in what the module shows.
910pub fn redact(text: &str) -> String {
911 static SECRET: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)psk|password|private-?key|secret|pre-?shared|wpa-|wireless-key|wep-key").unwrap());
912 let mut output = Vec::new();
913 // A hidden value may go on in the deeper lines below it.
914 let mut hidden_at: Option<usize> = None;
915 for line in text.lines() {
916 let depth = line.len() - line.trim_start().len();
917 if hidden_at.is_some_and(|at| depth > at && !line.trim().is_empty()) {
918 continue;
919 }
920 hidden_at = None;
921 if SECRET.is_match(line) {
922 hidden_at = Some(depth);
923 output.push(format!("{}{} •••", &line[..depth], line.trim().split([':', ' ', '=']).next().unwrap_or("")));
924 } else {
925 output.push(line.to_string());
926 }
927 }
928 output.join("\n")
931/// Plain summary of a plan for the module.
932pub fn plan_json(plan: &Plan) -> Value {
933 json!({
934 "files": plan.files.iter().map(|file| json!({ "path": file.path, "before": redact(&file.before), "after": redact(&file.after) })).collect::<Vec<_>>(),
935 "commands": plan.commands,
936 "risks": plan.risks,
937 })
940#[cfg(test)]
941mod tests {
942 use super::*;
944 fn request(v4: Value, v6: Value) -> Request {
945 parse_request(&json!({ "interface": "eth0", "ipv4": v4, "ipv6": v6 })).unwrap()
946 }
947 fn static_request() -> Request {
948 parse_request(&json!({ "interface": "eth0", "ipv4": { "method": "static", "addresses": ["192.0.2.10/24"], "gateway": "192.0.2.1" }, "ipv6": { "method": "slaac" }, "dns": ["192.0.2.53"], "search": ["example.org"], "mtu": 1400 })).unwrap()
949 }
951 #[test]
952 fn requests_are_checked() {
953 for (v4, why) in [
954 (json!({ "method": "static", "addresses": [] }), "no address"),
955 (json!({ "method": "static", "addresses": ["192.0.2.10"] }), "no prefix"),
956 (json!({ "method": "static", "addresses": ["192.0.2.0/24"] }), "network address"),
957 (json!({ "method": "static", "addresses": ["192.0.2.255/24"] }), "broadcast"),
958 (json!({ "method": "static", "addresses": ["2001:db8::1/64"] }), "other family"),
959 (json!({ "method": "static", "addresses": ["192.0.2.10/24"], "gateway": "198.51.100.1" }), "gateway elsewhere"),
960 (json!({ "method": "static", "addresses": ["192.0.2.10/24"], "gateway": "192.0.2.10" }), "gateway is itself"),
961 (json!({ "method": "dhcp", "addresses": ["192.0.2.10/24"] }), "address with dhcp"),
962 (json!({ "method": "x" }), "method"),
963 ] {
964 assert!(parse_request(&json!({ "interface": "eth0", "ipv4": v4, "ipv6": { "method": "slaac" } })).is_err(), "{why}");
965 }
966 for name in ["lo", "", "a b", "eth0;id", "$(id)"] {
967 assert!(parse_request(&json!({ "interface": name, "ipv4": { "method": "dhcp" }, "ipv6": { "method": "slaac" } })).is_err(), "{name}");
968 }
969 assert!(parse_request(&json!({ "interface": "eth0", "ipv4": { "method": "dhcp" }, "ipv6": { "method": "slaac" }, "dns": ["not-ip"] })).is_err());
970 assert!(parse_request(&json!({ "interface": "eth0", "ipv4": { "method": "dhcp" }, "ipv6": { "method": "slaac" }, "search": ["a b"] })).is_err());
971 assert!(parse_request(&json!({ "interface": "eth0", "ipv4": { "method": "dhcp" }, "ipv6": { "method": "slaac" }, "mtu": 10 })).is_err());
972 assert!(parse_request(&json!({ "interface": "eth0", "ipv4": { "method": "static", "addresses": ["192.0.2.0/31"] }, "ipv6": { "method": "disabled" } })).is_ok(), "/31 has no network address");
973 }
975 const NETPLAN: &str = "network:\n ethernets:\n eth0:\n dhcp4: true\n match:\n macaddress: \"52:54:00:90:00:00\"\n set-name: \"eth0\"\n nameservers:\n addresses:\n - \"192.0.2.53\"\n eth1:\n dhcp4: true\n version: 2\n";
977 #[test]
978 fn netplan_keeps_what_it_does_not_edit() {
979 let stanza = netplan_stanza(NETPLAN, "eth0", None).unwrap().unwrap();
980 assert_eq!(stanza.section, "ethernets");
981 assert!(stanza.kept.iter().any(|line| line.contains("macaddress")) && stanza.kept.iter().any(|line| line.contains("set-name")));
982 assert!(!stanza.kept.iter().any(|line| line.contains("192.0.2.53") || line.contains("dhcp4")));
983 assert_eq!(netplan_stanza(NETPLAN, "eth9", None).unwrap(), None);
984 let listed = "network:\n ethernets:\n eth0:\n addresses:\n - \"192.0.2.10/24\"\n - \"192.0.2.11/24\"\n set-name: \"eth0\"\n";
985 assert_eq!(netplan_stanza(listed, "eth0", None).unwrap().unwrap().kept, [" set-name: \"eth0\""]);
986 let plan = netplan_plan(&static_request(), NETPLAN, "ethernet", None).unwrap();
987 assert_eq!(plan.actions[0], ["netplan-clear", "ethernets.eth0"]);
988 let text = &plan.files[0].after;
989 for wanted in ["macaddress", "set-name", "dhcp4: false", "accept-ra: true", "addresses: [\"192.0.2.10/24\"]", "via: \"192.0.2.1\"", "mtu: 1400", "search: [\"example.org\"]"] {
990 assert!(text.contains(wanted), "{wanted} in {text}");
991 }
992 assert!(!text.contains("eth1"));
993 assert!(!netplan_plan(&request(json!({ "method": "dhcp" }), json!({ "method": "slaac" })), "network:\n", "ethernet", None).unwrap().actions.iter().any(|action| action[0] == "netplan-clear"));
994 }
996 #[test]
997 fn netplan_finds_the_logical_name_and_keeps_other_routes() {
998 let full = "network:\n ethernets:\n uplink:\n match:\n macaddress: \"52:54:00:00:00:01\"\n set-name: \"eth0\"\n routes:\n - to: \"198.51.100.0/24\"\n via: \"192.0.2.9\"\n metric: 50\n - to: \"default\"\n via: \"192.0.2.1\"\n version: 2\n";
999 let stanza = netplan_stanza(full, "eth0", None).unwrap().unwrap();
1000 assert_eq!(stanza.id, "uplink");
1001 assert_eq!(netplan_stanza(full, "eth5", Some("52:54:00:00:00:01")).unwrap().unwrap().id, "uplink");
1002 let plan = netplan_plan(&static_request(), full, "ethernet", None).unwrap();
1003 assert_eq!(plan.actions[0], ["netplan-clear", "ethernets.uplink"]);
1004 let text = &plan.files[0].after;
1005 assert!(text.contains(" uplink:"), "{text}");
1006 assert!(text.contains("- to: \"198.51.100.0/24\"") && text.contains("via: \"192.0.2.9\"") && text.contains("metric: 50"), "{text}");
1007 assert_eq!(text.matches("to:").count(), 2, "{text}");
1008 let two = "network:\n ethernets:\n a:\n set-name: eth0\n b:\n set-name: eth0\n";
1009 assert!(netplan_stanza(two, "eth0", None).is_err());
1010 let pattern = "network:\n ethernets:\n uplink:\n match:\n name: \"en*\"\n dhcp4: true\n";
1011 assert!(netplan_stanza(pattern, "ens18", None).is_err());
1014 #[test]
1015 fn secrets_are_not_shown() {
1016 let text = redact("wifis:\n wlan0:\n password: hunter2\n wpa-psk abc\n dhcp4: true");
1017 assert!(!text.contains("hunter2") && !text.contains("abc") && text.contains("dhcp4: true"), "{text}");
1018 let block = redact(" auth:\n password: |\n line-one\n line-two\n method: x\n wireless-key s:abc");
1019 assert!(!block.contains("line-one") && !block.contains("line-two") && !block.contains("s:abc") && block.contains("method: x"), "{block}");
1022 #[test]
1023 fn network_manager_gets_its_settings() {
1024 let plan = nm_plan(&static_request(), "cloud-init eth0");
1025 assert_eq!(plan.actions[0][1], "cloud-init eth0");
1026 let flat = plan.actions[0].join("|");
1027 for wanted in ["ipv4.method|manual", "ipv4.addresses|192.0.2.10/24", "ipv4.gateway|192.0.2.1", "ipv6.method|auto", "ethernet.mtu|1400"] {
1028 assert!(flat.contains(wanted), "{wanted}");
1030 assert_eq!(plan.actions[1], ["nm-up", "cloud-init eth0"]);
1033 #[test]
1034 fn a_name_pattern_with_a_question_mark_covers_others_too() {
1035 let full = "network:\n ethernets:\n lan:\n match:\n name: \"en?\"\n dhcp4: true\n";
1036 assert!(netplan_stanza(full, "enp", None).is_err());
1039 #[test]
1040 fn a_metric_on_the_default_route_is_a_lost_option() {
1041 let request = static_request();
1042 for (text, lost) in [("[Match]\nName=eth0\n[Route]\nGateway=192.0.2.1\nMetric=50\n", true), ("[Match]\nName=eth0\n[Route]\nGateway=192.0.2.1\n", false), ("[Match]\nName=eth0\n[Route]\nDestination=10.0.0.0/8\nGateway=192.0.2.1\nMetric=5\n", false)] {
1043 let plan = networkd_plan(&request, Some("/etc/systemd/network/10.network"), Some(text));
1044 assert_eq!(plan.risks.iter().any(|risk| risk["code"] == "default-route-options-lost"), lost, "{text}");
1048 #[test]
1049 fn networkd_keeps_match_and_replaces_addressing() {
1050 let existing = "[Match]\nMACAddress=52:54:00:90:00:11\nName=eth0\n\n[Network]\nDHCP=ipv4\nDNS=192.0.2.9\nLLMNR=no\n\n[Address]\nAddress=198.51.100.5/24\n\n[DHCPv4]\nUseDNS=no\n";
1051 let text = networkd_text(&static_request(), Some(existing));
1052 for wanted in ["MACAddress=52:54:00:90:00:11", "LLMNR=no", "[DHCPv4]", "UseDNS=no", "DHCP=no", "Address=192.0.2.10/24", "Gateway=192.0.2.1", "DNS=192.0.2.53", "MTUBytes=1400"] {
1053 assert!(text.contains(wanted), "{wanted} in {text}");
1055 assert!(!text.contains("198.51.100.5") && !text.contains("192.0.2.9"));
1056 let new = networkd_text(&static_request(), None);
1057 assert!(new.starts_with("[Match]\nName=eth0"));
1058 let routed = "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n\n[Route]\nDestination=198.51.100.0/24\nGateway=192.0.2.9\nMetric=5\n\n[Route]\nGateway=192.0.2.8\n\n[Link]\nMTUBytes=9000\n\n[Address]\nAddress=192.0.2.10/24\nLabel=main\n";
1059 let text = networkd_text(&static_request(), Some(routed));
1060 assert!(text.contains("Destination=198.51.100.0/24") && text.contains("Metric=5"), "another route stays: {text}");
1061 assert!(!text.contains("192.0.2.8"), "the old default goes: {text}");
1062 assert!(text.contains("MTUBytes=1400") && !text.contains("9000"), "{text}");
1063 assert!(text.contains("Label=main") && text.matches("Address=192.0.2.10/24").count() == 1, "{text}");
1064 assert!(networkd_shared("[Match]\nName=eth*\n", "eth0") && !networkd_shared("[Match]\nName=eth0\n", "eth0") && !networkd_shared("[Match]\nMACAddress=52:54:00:00:00:01\n", "eth0"));
1065 assert!(networkd_shared("[Match]\nType=ether\n", "eth0"));
1066 assert!(networkd_shared("[Match]\n\n[Network]\nDHCP=yes\n", "eth0") && networkd_shared("[Network]\nDHCP=yes\n", "eth0"));
1069 #[test]
1070 fn interface_files_keep_everything_else() {
1071 let existing = "# comment stays\nauto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n\nauto vmbr0\niface vmbr0 inet static\n address 192.0.2.1/24\n bridge-ports eth1\n bridge-stp off\n\nsource /etc/network/interfaces.d/*\n";
1072 let text = interfaces_text(&static_request(), existing);
1073 assert!(text.contains("# comment stays") && text.contains("iface lo inet loopback") && text.contains("source /etc/network/interfaces.d/*"));
1074 assert!(text.contains("iface eth0 inet static\n address 192.0.2.10/24\n gateway 192.0.2.1"), "{text}");
1075 assert!(text.contains("iface eth0 inet6 auto"));
1076 assert!(!text.contains("iface eth0 inet dhcp"));
1077 assert_eq!(text.matches("auto eth0").count(), 1);
1078 assert!(text.contains("iface vmbr0 inet static\n address 192.0.2.1/24\n bridge-ports eth1"), "another interface stays: {text}");
1079 // Options of a bridge stay when the bridge itself changes.
1080 let bridge = parse_request(&json!({ "interface": "vmbr0", "ipv4": { "method": "dhcp" }, "ipv6": { "method": "disabled" } })).unwrap();
1081 let text = interfaces_text(&bridge, existing);
1082 assert!(text.contains("iface vmbr0 inet dhcp") && text.contains("bridge-ports eth1") && !text.contains("192.0.2.1/24"), "{text}");
1083 // A blank line does not end a stanza, and hooks stay with their family.
1084 let hooks = "auto eth0\niface eth0 inet static\n address 192.0.2.5/24\n\n post-up /v4.sh\n # v4 note\niface eth0 inet6 static\n address 2001:db8::5/64\n post-up /v6.sh\n\nauto eth1\niface eth1 inet dhcp\n";
1085 let text = interfaces_text(&static_request(), hooks);
1086 assert!(!text.contains("192.0.2.5/24") && !text.contains("2001:db8::5"), "{text}");
1087 assert!(text.contains("inet static\n address 192.0.2.10/24\n gateway 192.0.2.1\n mtu 1400\n dns-nameservers 192.0.2.53\n dns-search example.org\n post-up /v4.sh\n # v4 note\niface eth0 inet6 auto\n post-up /v6.sh"), "{text}");
1088 assert!(text.contains("iface eth1 inet dhcp"), "{text}");
1089 // An interface nothing mentions yet.
1090 let other = parse_request(&json!({ "interface": "eth9", "ipv4": { "method": "dhcp" }, "ipv6": { "method": "disabled" } })).unwrap();
1091 assert!(interfaces_text(&other, existing).ends_with("auto eth9\niface eth9 inet dhcp"));
1094 #[test]
1095 fn an_interface_goes_down_under_its_old_file() {
1096 let plan = interfaces_plan(&static_request(), "/etc/network/interfaces", "auto eth0\niface eth0 inet dhcp", "ifupdown").unwrap();
1097 let verbs: Vec<&str> = plan.actions.iter().map(|action| action[0].as_str()).collect();
1098 assert_eq!(verbs, ["iface-down", "write", "iface-up"]);
1099 assert_eq!(interfaces_plan(&static_request(), "/etc/network/interfaces", "", "ifupdown2").unwrap().actions.last().unwrap()[0], "ifreload");
1102 fn rule(value: Value) -> Rule {
1103 parse_rule(&value).unwrap()
1106 #[test]
1107 fn rules_are_checked() {
1108 for value in [json!({ "ports": ["0"] }), json!({ "ports": ["70000"] }), json!({ "ports": ["80;id"] }), json!({ "ports": [] }), json!({ "protocol": "icmp", "ports": ["80"] }), json!({ "ports": ["80"], "source": "x" }), json!({ "ports": ["80"], "source": "192.0.2.0/99" }), json!({ "ports": ["80"], "comment": "a\"b" }), json!({ "ports": ["80"], "interface": "a b" }), json!({ "ports": ["80"], "family": "6", "source": "192.0.2.1" }), json!({ "ports": ["80"], "action": "x" })] {
1109 assert!(parse_rule(&value).is_err(), "{value}");
1113 #[test]
1114 fn ssh_lockouts_are_recognized() {
1115 let ssh = SshPath { port: Some(22), client: "192.0.2.50".parse().ok(), server: "192.0.2.10".parse().ok(), interface: Some("eth0".into()) };
1116 for value in [json!({ "action": "drop", "ports": ["22"] }), json!({ "action": "reject", "ports": ["20:30"] }), json!({ "action": "drop", "protocol": "any" }), json!({ "action": "drop", "ports": ["22"], "source": "0.0.0.0/0", "family": "4" }), json!({ "action": "drop", "ports": ["22"], "source": "192.0.2.50", "family": "4" }), json!({ "action": "drop", "allowAllPorts": true }), json!({ "action": "drop", "protocol": "any", "direction": "out" })] {
1117 assert!(cuts_ssh(&rule(value.clone()), &ssh), "{value}");
1119 for value in [json!({ "action": "accept", "ports": ["22"] }), json!({ "action": "drop", "ports": ["80"] }), json!({ "action": "drop", "ports": ["22"], "source": "198.51.100.1" }), json!({ "action": "drop", "ports": ["22"], "direction": "out" }), json!({ "action": "drop", "protocol": "udp", "ports": ["22"] }), json!({ "action": "drop", "ports": ["22"], "family": "6" }), json!({ "action": "drop", "ports": ["22"], "interface": "eth1" })] {
1120 assert!(!cuts_ssh(&rule(value.clone()), &ssh), "{value}");
1122 assert!(cuts_ssh(&rule(json!({ "action": "drop", "ports": ["80"] })), &SshPath::default()), "unknown counts as yes");
1123 for (value, keeps) in [
1124 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["22"] }), true),
1125 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["20:30"], "family": 4 }), true),
1126 (json!({ "direction": "in", "action": "accept", "protocol": "udp", "ports": ["22"] }), false),
1127 (json!({ "direction": "out", "action": "accept", "protocol": "tcp", "ports": ["22"] }), false),
1128 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["22"], "family": 6 }), false),
1129 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["22"], "source": "198.51.100.0/24" }), false),
1130 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["22"], "source": "192.0.2.0/24" }), true),
1131 (json!({ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["22"], "interface": "eth1" }), false),
1132 ] {
1133 assert_eq!(keeps_ssh(&value, &ssh), keeps, "{value}");
1137 #[test]
1138 fn each_firewall_gets_its_words() {
1139 let full = rule(json!({ "ports": ["80", "443"], "source": "192.0.2.0/24", "family": "4", "interface": "eth0", "comment": "web" }));
1140 assert_eq!(ufw_add(&full).unwrap(), ["ufw", "allow", "in", "on", "eth0", "proto", "tcp", "from", "192.0.2.0/24", "to", "0.0.0.0/0", "port", "80,443", "comment", "web"]);
1141 let nft = nft_words(&full).join(" ");
1142 assert!(nft.starts_with("iifname \"eth0\" meta nfproto ipv4 ip saddr 192.0.2.0/24 tcp dport { 80, 443 } accept"), "{nft}");
1143 assert!(nft.ends_with("comment \"web\""), "{nft}");
1144 let ipt = iptables_words(&full, false).join(" ");
1145 assert_eq!(ipt, "-I INPUT -i eth0 -s 192.0.2.0/24 -p tcp -m multiport --dports 80,443 -m comment --comment web -j ACCEPT");
1146 let simple = firewalld_add(&rule(json!({ "ports": ["8080:8090"] })), "public", false).unwrap();
1147 assert_eq!(simple[0], ["firewalld", "--permanent", "--zone=public", "--add-port=8080-8090/tcp"]);
1148 let rich = firewalld_add(&Rule { interface: None, ..full.clone() }, "public", false).unwrap();
1149 assert_eq!(rich.len(), 2);
1150 assert!(rich[0][3].contains("family=\"ipv4\" source address=\"192.0.2.0/24\" port port=\"80\" protocol=\"tcp\" accept"), "{:?}", rich[0]);
1151 assert!(firewalld_add(&rule(json!({ "ports": ["80"], "source": "192.0.2.1" })), "public", false).is_err(), "family needed");
1152 assert!(ufw_add(&rule(json!({ "protocol": "ipv6-icmp", "family": "6" }))).is_err());
1153 let v4 = ufw_add(&rule(json!({ "ports": ["22"], "family": "4" }))).unwrap();
1154 assert!(v4.join(" ").contains("from 0.0.0.0/0 to 0.0.0.0/0"), "{v4:?}");
1155 assert!(firewalld_add(&rule(json!({ "ports": ["80"], "interface": "eth0" })), "public", false).is_err());
SHA-2564067b40c1fc5baccf3dca13bc0e58e149a12b8bfdd8c5027042cbe4e612b17b2

CH-J Proprietary Software License 1.14 · Kilden leveres under CH-J Proprietary Software License 1.14. Dens tilgængelighed ændrer ikke licensbetingelserne eller giver yderligere rettigheder.

Arkiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Filer