已发布的源代码
下载源代码 ZIP
CH-J Tools
浏览特定应用版本的目录和文件。
CH-J / Server Manager
1704 个文件
0.1.2-beta
添加时间: · 构建时间:
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.4
use crate::core::error::{CoreError, CoreResult};5
use once_cell::sync::Lazy;6
use regex::Regex;7
use serde_json::{json, Value};8
use std::net::IpAddr;10
fn invalid(message: impl Into<String>) -> CoreError {11
CoreError::new("NETWORK_REQUEST_INVALID", message)12
}14
static IFACE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,31}$").unwrap());15
static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$").unwrap());17
#[derive(Debug, Clone, PartialEq)]18
pub struct Addressing {19
pub method: String,20
pub addresses: Vec<String>,21
pub gateway: Option<String>,22
}24
#[derive(Debug, Clone, PartialEq)]25
pub 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>,32
}34
/// A changed file, for the diff.35
#[derive(Debug, Clone, PartialEq, Default)]36
pub struct FileChange {37
pub path: String,38
pub before: String,39
pub after: String,40
}42
#[derive(Debug, Clone, PartialEq, Default)]43
pub struct Plan {44
pub actions: Vec<Vec<String>>,45
pub files: Vec<FileChange>,46
pub commands: Vec<String>,47
pub risks: Vec<Value>,48
}50
fn 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()52
}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.55
pub 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))86
}88
fn 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) & mask93
}94
_ => false,95
}96
}98
fn 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 })126
}128
pub 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
})154
}156
fn 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
}160
}162
const 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)]166
pub struct NetplanStanza {167
pub section: String,168
pub id: String,169
pub kept: Vec<String>,170
pub routes: Vec<Vec<String>>,171
}173
fn 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
blocks195
}197
/// The logical name in netplan need not be the name of the device: `uplink:` with `set-name: eth0` or a matched MAC198
/// address is eth0 too. More than one candidate is an answer nobody can act on.199
pub 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))256
}258
fn 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")))260
}262
pub 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 })326
}328
pub 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
}359
}361
/// Does this `.network` file speak of more than the one device? Editing it would change the others too.362
pub 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
!named379
}381
/// An edited `.network` file: its [Match], other sections, non-default routes and properties of a kept address stay.382
pub 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 §ions {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")457
}459
fn 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
lines484
}486
pub 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
}501
}503
/// /etc/network/interfaces with the stanzas of one interface replaced. Other interfaces, comments and options such as504
/// bridge-ports stay; options and comments of a stanza stay with its IP family. Only the next stanza ends a stanza.505
pub 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")590
}592
pub 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![] })605
}607
// --- firewall ------------------------------------------------------------------------------------609
#[derive(Debug, Clone, PartialEq)]610
pub 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>,621
}623
fn 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())633
}635
pub 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 })677
}679
/// The SSH connection a change has to keep alive.680
#[derive(Debug, Clone, Default)]681
pub struct SshPath {682
pub port: Option<u16>,683
pub client: Option<IpAddr>,684
pub server: Option<IpAddr>,685
pub interface: Option<String>,686
}688
pub 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) & mask696
}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) & mask701
}702
_ => false,703
}704
}706
fn 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
})711
}713
/// Would this new rule cut the running SSH connection? When it cannot be told, it counts as yes. The undo timer applies714
/// either way, the user should know.715
pub 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))738
}740
/// Does an existing rule (as read) certainly let the SSH connection in?741
pub 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))765
}767
fn word(action: &str) -> &'static str {768
match action { "accept" => "allow", "reject" => "reject", _ => "deny" }769
}771
pub 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)791
}793
pub 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)835
}837
/// The nft words of a rule, for a table and chain that exist on the server.838
pub 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
words875
}877
pub 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
words907
}909
/// Wi-Fi passwords and keys stay in the files, not in what the module shows.910
pub 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")929
}931
/// Plain summary of a plan for the module.932
pub 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
})938
}940
#[cfg(test)]941
mod 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());1012
}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}");1020
}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}");1029
}1030
assert_eq!(plan.actions[1], ["nm-up", "cloud-init eth0"]);1031
}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());1037
}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}");1045
}1046
}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}");1054
}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"));1067
}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"));1092
}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");1100
}1102
fn rule(value: Value) -> Rule {1103
parse_rule(&value).unwrap()1104
}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}");1110
}1111
}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}");1118
}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}");1121
}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}");1134
}1135
}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());1156
}1157
}SHA-256
4067b40c1fc5baccf3dca13bc0e58e149a12b8bfdd8c5027042cbe4e612b17b2CH-J Proprietary Software License 1.14 · 源代码依据 CH-J Proprietary Software License 1.14 提供。公开源代码不会改变许可条款,也不会授予额外权利。
压缩包 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f