Foinse foilsithe
Íosluchtaigh foinse ZIP
CH-J Tools
Brabhsáil comhadlanna agus comhaid chun feidhmchlár áirithe a scaoileadh.
CH-J / Server Manager
1704 Comhaid
0.1.2-beta
Suimigh: · Tógtha:
1
//! Reading, plans and transactions. Every remote command is an operation of the matrix.3
use super::plan::{self, Plan};4
use super::inventory;5
use crate::core::error::{CoreError, CoreResult};6
use crate::core::logging::Logger;7
use crate::core::matrix;8
use crate::core::ssh::platform::detect_platform;9
use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions};10
use crate::core::ssh::session::{ConnectionTarget, SessionManager};11
use base64::Engine;12
use parking_lot::Mutex;13
use serde_json::{json, Value};14
use std::collections::HashMap;15
use std::sync::Arc;16
use std::time::{Duration, Instant};17
use zeroize::Zeroizing;19
const PLAN_LIFETIME: Duration = Duration::from_secs(1800);20
pub const DEFAULT_SECONDS: u64 = 120;22
fn error(code: &str, message: &str) -> CoreError {23
CoreError::new(code, message)24
}26
struct Prepared {27
session_id: String,28
plan: Plan,29
manager: String,30
firewall: String,31
base: String,32
created: Instant,33
}35
struct Transaction {36
session_id: String,37
target: ConnectionTarget,38
os: String,39
use_sudo: bool,40
sudo: Option<Zeroizing<String>>,41
verified: bool,42
expected_address: Option<String>,43
}45
/// What a plan was made from. Counters of rules change by themselves and say nothing about the configuration.46
fn base_of(stdout: &str, handles_matter: bool) -> String {47
use sha2::{Digest, Sha256};48
static COUNTERS: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| regex::Regex::new(r#"(?m)\[\d+:\d+\]|"counter":\s*\{[^}]*\}|^# (Generated|Completed) .*$"#).unwrap());49
static HANDLES: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| regex::Regex::new(r#""handle":\s*\d+"#).unwrap());50
let mut hash = Sha256::new();51
for (name, text) in inventory::sections(stdout) {52
let relevant = (name.starts_with("file:") && name != "file:/etc/resolv.conf") || matches!(name.as_str(), "netplan-get" | "nm-connections" | "ufw-numbered" | "firewalld-permanent" | "nft-json" | "iptables" | "ip6tables");53
if relevant {54
hash.update(name.as_bytes());55
let plain = COUNTERS.replace_all(&text, "");56
// A native nft rule is deleted by its handle. Under iptables-nft handles change by themselves and mean nothing.57
if handles_matter {58
hash.update(plain.as_bytes());59
} else {60
hash.update(HANDLES.replace_all(&plain, "").as_bytes());61
}62
}63
}64
hex::encode(hash.finalize())65
}67
fn ssh_path(current: &Value) -> plan::SshPath {68
let ssh = ¤t["ssh"];69
plan::SshPath {70
port: ssh["serverPort"].as_u64().map(|port| port as u16),71
client: ssh["clientAddress"].as_str().and_then(|text| text.parse().ok()),72
server: ssh["serverAddress"].as_str().and_then(|text| text.parse().ok()),73
interface: ssh["interface"].as_str().map(str::to_string),74
}75
}77
#[derive(Default)]78
struct Inner {79
plans: HashMap<String, Prepared>,80
transactions: HashMap<String, Transaction>,81
}83
pub struct NetworkService {84
sessions: Arc<SessionManager>,85
executor: Arc<PrivilegedExecutor>,86
logger: Arc<Logger>,87
inner: Mutex<Inner>,88
}90
/// One action as a line for the helper: every field in base64, so a value can hold anything and still stay one word.91
/// A line break stands for an empty value, because the helper's command substitution drops it.92
pub fn encode_actions(actions: &[Vec<String>]) -> String {93
let engine = base64::engine::general_purpose::STANDARD;94
actions.iter().map(|action| action.iter().map(|field| if field.is_empty() { engine.encode("\n") } else { engine.encode(field) }).collect::<Vec<_>>().join(" ")).collect::<Vec<_>>().join("\n") + "\n"95
}97
/// The words of a line of `iptables-save`, with its quoting.98
pub fn split_words(line: &str) -> Vec<String> {99
let mut words = Vec::new();100
let mut current = String::new();101
let mut quoted = false;102
let mut any = false;103
let mut chars = line.chars().peekable();104
while let Some(c) = chars.next() {105
match c {106
'\\' if quoted => { if let Some(next) = chars.next() { current.push(next); } }107
'"' => { quoted = !quoted; any = true; }108
c if c.is_whitespace() && !quoted => { if any || !current.is_empty() { words.push(std::mem::take(&mut current)); any = false; } }109
c => current.push(c),110
}111
}112
if any || !current.is_empty() {113
words.push(current);114
}115
words116
}118
/// Where each system loads its nftables rules from at boot.119
fn nft_file(os: &str) -> &'static str {120
match os { "rocky" => "/etc/sysconfig/nftables.conf", "alpine" => "/etc/nftables.nft", _ => "/etc/nftables.conf" }121
}123
impl NetworkService {124
pub fn new(sessions: Arc<SessionManager>, executor: Arc<PrivilegedExecutor>, logger: Arc<Logger>) -> Arc<Self> {125
Arc::new(NetworkService { sessions, executor, logger, inner: Mutex::new(Inner::default()) })126
}128
async fn os(&self, session_id: &str) -> CoreResult<String> {129
let record = self.sessions.require_connected(session_id)?;130
let platform = detect_platform(&self.sessions, &record, false).await?;131
Ok(matrix::os_of(&platform).to_string())132
}134
async fn run(&self, session_id: &str, operation: &str, args: Vec<String>, privileged: bool, password: Option<&str>, frames: Vec<Frame>, timeout: Duration) -> CoreResult<String> {135
let options = RunOptions {136
operation: operation.into(),137
args,138
frames,139
privileged,140
sudo_password: password.filter(|p| !p.is_empty()).map(str::to_string),141
use_cached_password: privileged,142
purpose: operation.into(),143
error_prefix: "NETWORK_".into(),144
timeout,145
output_limit: 4 * 1024 * 1024,146
..Default::default()147
};148
Ok(self.executor.run(session_id, options).await?.stdout)149
}151
/// As root everything is readable; without it the addresses still are, and the result says what is missing.152
pub async fn inspect(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {153
let password = payload["sudoPassword"].as_str();154
let first = self.run(session_id, "network.inspect", vec![], true, password, vec![], Duration::from_secs(90)).await;155
let (stdout, sudo_error) = match first {156
Ok(stdout) => (stdout, Value::Null),157
Err(failure) if failure.code.starts_with("SUDO_") => (self.run(session_id, "network.inspect", vec![], false, None, vec![], Duration::from_secs(90)).await?, json!(failure.code)),158
Err(failure) => return Err(failure),159
};160
let mut result = inventory::parse(&stdout);161
result["sudoError"] = sudo_error;162
Ok(result)163
}165
async fn read_inventory(&self, session_id: &str, password: Option<&str>) -> CoreResult<(String, Value)> {166
let stdout = self.run(session_id, "network.inspect", vec![], true, password, vec![], Duration::from_secs(90)).await?;167
let parsed = inventory::parse(&stdout);168
Ok((stdout, parsed))169
}171
async fn read_file(&self, session_id: &str, path: &str, password: Option<&str>) -> CoreResult<String> {172
let text = self.run(session_id, "network.file.read", vec![path.to_string()], true, password, vec![], Duration::from_secs(30)).await?;173
Ok(text.trim_start_matches("\u{1e}CHJ_READY\n").to_string())174
}176
fn remember(&self, session_id: &str, built: Plan, manager: &str, firewall: &str, base: String) -> Value {177
let id = format!("plan-{}", uuid::Uuid::new_v4().simple());178
let mut result = plan::plan_json(&built);179
result["planId"] = json!(id);180
result["manager"] = json!(manager);181
result["firewall"] = json!(firewall);182
result["defaultSeconds"] = json!(DEFAULT_SECONDS);183
let mut inner = self.inner.lock();184
inner.plans.retain(|_, prepared| prepared.created.elapsed() < PLAN_LIFETIME);185
inner.plans.insert(id, Prepared { session_id: session_id.to_string(), plan: built, manager: manager.to_string(), firewall: firewall.to_string(), base, created: Instant::now() });186
result187
}189
/// What a new configuration of an interface would change. Nothing is written.190
pub async fn plan_interface(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {191
let request = plan::parse_request(payload)?;192
let password = payload["sudoPassword"].as_str();193
let (stdout, current) = self.read_inventory(session_id, password).await?;194
let interface = current["interfaces"].as_array().into_iter().flatten().find(|item| item["name"] == request.interface.as_str()).cloned();195
let manager = interface.as_ref().and_then(|item| item["managedBy"].as_str()).filter(|name| !name.is_empty() && *name != "unknown").or(current["config"]["manager"].as_str()).unwrap_or("unknown").to_string();196
let kind = interface.as_ref().and_then(|item| item["kind"].as_str()).unwrap_or("ethernet").to_string();197
let mut built = match manager.as_str() {198
"netplan" => {199
let full = self.run(session_id, "network.netplan.get", vec!["all".to_string()], true, password, vec![], Duration::from_secs(30)).await?;200
let mac = interface.as_ref().and_then(|item| item["mac"].as_str()).map(str::to_string);201
plan::netplan_plan(&request, full.trim_start_matches("\u{1e}CHJ_READY\n"), &kind, mac.as_deref())?202
}203
"networkmanager" => {204
let sections = inventory::sections(&stdout);205
let listing = sections.iter().find(|(name, _)| name == "nm-connections").map(|(_, text)| text.as_str()).unwrap_or("");206
let connection = listing.lines().find_map(|line| {207
let parts: Vec<&str> = line.split(':').collect();208
(parts.len() >= 4 && parts[parts.len() - 2] == request.interface).then(|| parts[0].replace("\\:", ":"))209
}).ok_or_else(|| error("NETWORK_NO_CONNECTION", "NetworkManager has no connection profile for this interface."))?;210
plan::nm_plan(&request, &connection)211
}212
"networkd" => {213
let path = interface.as_ref().and_then(|item| item["configuredIn"].as_str()).filter(|path| path.starts_with("/etc/systemd/network/")).map(str::to_string);214
let existing = match &path { Some(path) => Some(self.read_file(session_id, path, password).await?), None => None };215
if existing.as_deref().is_some_and(|text| plan::networkd_shared(text, &request.interface)) {216
return Err(error("NETWORK_SHARED_FILE", "This file also configures other interfaces. Give this interface its own file by hand."));217
}218
plan::networkd_plan(&request, path.as_deref(), existing.as_deref())219
}220
"ifupdown" | "ifupdown2" | "ifupdown-ng" => {221
let path = interface.as_ref().and_then(|item| item["configuredIn"].as_str()).filter(|path| path.starts_with("/etc/network/")).unwrap_or("/etc/network/interfaces").to_string();222
let existing = self.read_file(session_id, &path, password).await?;223
plan::interfaces_plan(&request, &path, existing.trim_end_matches('\n'), &manager)?224
}225
"netifd" => return Err(error("NETWORK_READ_ONLY", "OpenWrt is read-only in this version.")),226
_ => return Err(error("NETWORK_MANAGER_UNKNOWN", "It is not clear which tool manages this interface, so nothing would be safe to write.")),227
};228
let ssh = ¤t["ssh"];229
let on_ssh = interface.as_ref().is_some_and(|item| item["ssh"] == true);230
if on_ssh {231
built.risks.push(json!({ "code": "ssh-interface" }));232
let server = ssh["serverAddress"].as_str().unwrap_or("");233
let keeps = |method: &plan::Addressing| method.addresses.iter().any(|address| address.split('/').next() == Some(server));234
let family = if server.contains(':') { &request.ipv6 } else { &request.ipv4 };235
if family.method == "static" && !keeps(family) {236
built.risks.push(json!({ "code": "ssh-address-changes", "from": server, "to": family.addresses.first() }));237
}238
if family.method == "disabled" {239
built.risks.push(json!({ "code": "ssh-address-removed", "from": server }));240
}241
}242
if kind == "bridge" && current["system"]["osId"].as_str().is_some_and(|id| id.contains("pve")) || std::path::Path::new(&manager) == std::path::Path::new("ifupdown2") && kind == "bridge" {243
built.risks.push(json!({ "code": "bridge-carries-guests" }));244
}245
if request.ipv6.method == "slaac" && interface.as_ref().is_some_and(|item| item["ipv6Method"] == "static") {246
built.risks.push(json!({ "code": "ipv6-depends-on-router" }));247
}248
// The change leaves the firewall alone, and so must its undo: a reload would drop what exists only at run time.249
Ok(self.remember(session_id, built, &manager, "none", base_of(&stdout, false)))250
}252
/// Rules to add or remove, or a new default policy, in the tool that governs the firewall here.253
pub async fn plan_firewall(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {254
let password = payload["sudoPassword"].as_str();255
let os = self.os(session_id).await?;256
let (stdout, current) = self.read_inventory(session_id, password).await?;257
let firewall = ¤t["firewall"];258
if firewall["readable"] != true {259
return Err(error("SUDO_PASSWORD_REQUIRED", "This operation requires the sudo password of the SSH account."));260
}261
// A reload would drop what exists only at run time, possibly the SSH access, and the undo could not bring it back.262
if firewall["active"] == "firewalld" && firewall["rules"].as_array().into_iter().flatten().any(|rule| rule["managed"] == true && rule["runtime"] == true && rule["permanent"] == false && matches!(rule["kind"].as_str(), Some("port" | "service" | "rich" | "source" | "forward-port"))) {263
return Err(error("NETWORK_FIREWALL_RUNTIME_DIFFERS", "firewalld has rules that exist only while it runs, and a reload would drop them. Make them permanent or remove them first."));264
}265
let several = firewall["backends"].as_array().into_iter().flatten().filter(|backend| backend["active"] == true).count();266
let mut backend = firewall["active"].as_str().unwrap_or("none").to_string();267
if backend == "none" {268
let wanted = payload["backend"].as_str().unwrap_or("");269
let installed = firewall["backends"].as_array().into_iter().flatten().any(|item| item["name"] == wanted && item["installed"] == true);270
if !installed {271
return Err(error("NETWORK_NO_FIREWALL", "No firewall is active here. Choose an installed one to start with."));272
}273
backend = wanted.to_string();274
}275
if !matches!(backend.as_str(), "ufw" | "firewalld" | "nftables" | "iptables") {276
return Err(error("NETWORK_READ_ONLY", "This firewall is read-only in this version."));277
}278
let ssh = ssh_path(¤t);279
let ssh_port = ssh.port;280
let mut built = Plan::default();281
if several > 1 {282
built.risks.push(json!({ "code": "several-firewalls" }));283
}284
let adds: Vec<plan::Rule> = payload["add"].as_array().into_iter().flatten().map(plan::parse_rule).collect::<CoreResult<_>>()?;285
for rule in &adds {286
if plan::cuts_ssh(rule, &ssh) {287
built.risks.push(json!({ "code": "rule-cuts-ssh", "port": ssh_port }));288
}289
}290
let rules = firewall["rules"].as_array().cloned().unwrap_or_default();291
let removes: Vec<Value> = payload["remove"].as_array().into_iter().flatten().filter_map(Value::as_str).map(|id| rules.iter().find(|rule| rule["id"] == id && rule["managed"] == true).cloned().ok_or_else(|| error("NETWORK_RULE_CHANGED", "A rule has changed, or it belongs to the firewall's own machinery. Read the rules again."))).collect::<CoreResult<_>>()?;292
if removes.iter().any(|rule| rule["direction"] == "forward") {293
return Err(error("NETWORK_RULE_READ_ONLY", "Forwarding rules can only be read here."));294
}295
// What stays after this change, for the whole question of whether SSH still gets in.296
let removed_ids: Vec<&Value> = removes.iter().map(|rule| &rule["id"]).collect();297
let active_zones: Vec<&str> = firewall["zones"].as_array().into_iter().flatten().filter(|zone| zone["active"] == true).filter_map(|zone| zone["name"].as_str()).collect();298
let keeps_after = rules.iter().filter(|rule| rule["managed"] == true && !removed_ids.contains(&&rule["id"]) && rule["zone"].as_str().is_none_or(|zone| active_zones.is_empty() || active_zones.contains(&zone))).any(|rule| plan::keeps_ssh(rule, &ssh))299
|| adds.iter().any(|rule| rule.action == "accept" && rule.direction == "in" && matches!(rule.protocol.as_str(), "tcp" | "any") && plan::keeps_ssh(&json!({ "direction": "in", "action": "accept", "protocol": rule.protocol, "ports": rule.ports, "source": rule.source, "destination": rule.destination, "interface": rule.interface, "family": match rule.family.as_str() { "4" => json!(4), "6" => json!(6), _ => json!("both") } }), &ssh));300
if removes.iter().any(|rule| plan::keeps_ssh(rule, &ssh)) && !keeps_after && firewall["defaultPolicy"]["input"] != "accept" {301
built.risks.push(json!({ "code": "removes-ssh-rule", "port": ssh_port }));302
}303
let mut wanted = serde_json::Map::new();304
match &payload["policy"] {305
Value::Null => {}306
Value::String(text) => { wanted.insert("input".to_string(), json!(text)); }307
Value::Object(object) => {308
for (key, value) in object {309
if !matches!(key.as_str(), "input" | "output" | "forward") {310
return Err(error("NETWORK_REQUEST_INVALID", "Unknown default policy."));311
}312
wanted.insert(key.clone(), value.clone());313
}314
}315
_ => return Err(error("NETWORK_REQUEST_INVALID", "Unknown default policy.")),316
}317
for value in wanted.values() {318
if !matches!(value.as_str(), Some("accept" | "drop" | "reject")) {319
return Err(error("NETWORK_REQUEST_INVALID", "Unknown default policy."));320
}321
}322
let policy_of = |name: &str| wanted.get(name).and_then(Value::as_str);323
let strict = |value: Option<&str>| matches!(value, Some("drop" | "reject"));324
let input_after = policy_of("input").or(firewall["defaultPolicy"]["input"].as_str());325
let enabling = payload["enable"] == true;326
if (strict(policy_of("input")) || (enabling && input_after != Some("accept"))) && !keeps_after {327
built.risks.push(json!({ "code": "policy-cuts-ssh", "port": ssh_port }));328
}329
if strict(policy_of("output")) {330
built.risks.push(json!({ "code": "policy-cuts-ssh", "port": ssh_port }));331
}332
let start = enabling;333
let stop = payload["enable"] == false;334
if (start || stop) && backend != "ufw" {335
return Err(error("NETWORK_REQUEST_INVALID", "Only ufw is switched on and off here."));336
}337
match backend.as_str() {338
"ufw" => {339
let mut numbers: Vec<(u64, String)> = removes.iter().map(|rule| (rule["number"].as_u64().unwrap_or(0), rule["text"].as_str().unwrap_or("").to_string())).collect();340
// Deleting first and from the end keeps the other numbers valid.341
numbers.sort_by(|a, b| b.0.cmp(&a.0));342
for (number, text) in numbers {343
if number == 0 || text.is_empty() {344
return Err(error("NETWORK_READ_ONLY", "This rule belongs to the firewall's own machinery."));345
}346
built.actions.push(vec!["ufw-delete-checked".to_string(), number.to_string(), text]);347
}348
// The first match wins, so a block has to come before the existing allows to mean anything.349
let has_rules = removes.len() < rules.iter().filter(|rule| rule["number"].as_u64().is_some()).count();350
for rule in &adds {351
let mut line = plan::ufw_add(rule)?;352
if rule.action != "accept" && has_rules {353
line.splice(1..1, ["insert".to_string(), "1".to_string()]);354
}355
built.actions.push(line);356
}357
for (name, word) in [("input", "incoming"), ("output", "outgoing"), ("forward", "routed")] {358
if let Some(policy) = policy_of(name) {359
built.actions.push(vec!["ufw".to_string(), "default".to_string(), match policy { "accept" => "allow", "reject" => "reject", _ => "deny" }.to_string(), word.to_string()]);360
}361
}362
if start {363
built.actions.push(vec!["ufw".to_string(), "--force".to_string(), "enable".to_string()]);364
}365
if stop {366
built.actions.push(vec!["ufw".to_string(), "--force".to_string(), "disable".to_string()]);367
}368
}369
"firewalld" => {370
let zone = payload["zone"].as_str().map(str::to_string).or_else(|| firewall["zones"].as_array().into_iter().flatten().find(|zone| zone["active"] == true).and_then(|zone| zone["name"].as_str().map(str::to_string))).unwrap_or_else(|| "public".to_string());371
for rule in &adds {372
built.actions.extend(plan::firewalld_add(rule, rule.zone.as_deref().unwrap_or(&zone), false)?);373
}374
for rule in &removes {375
let on = rule["zone"].as_str().unwrap_or(&zone);376
let value = rule["value"].as_str().unwrap_or("");377
let flag = match rule["kind"].as_str().unwrap_or("") {378
"port" => "--remove-port",379
"service" => "--remove-service",380
"rich" => "--remove-rich-rule",381
_ => return Err(error("NETWORK_READ_ONLY", "This rule cannot be removed here.")),382
};383
if value.is_empty() {384
return Err(error("NETWORK_READ_ONLY", "This rule cannot be removed here."));385
}386
built.actions.push(vec!["firewalld".to_string(), "--permanent".to_string(), format!("--zone={on}"), format!("{flag}={value}")]);387
}388
if policy_of("output").is_some() || policy_of("forward").is_some() {389
return Err(error("NETWORK_REQUEST_INVALID", "A firewalld zone has a policy for incoming traffic only."));390
}391
if let Some(policy) = policy_of("input") {392
built.actions.push(vec!["firewalld".to_string(), "--permanent".to_string(), format!("--zone={zone}"), format!("--set-target={}", match policy { "accept" => "ACCEPT", "reject" => "REJECT", _ => "DROP" })]);393
}394
built.actions.push(vec!["firewalld".to_string(), "--reload".to_string()]);395
}396
"nftables" => {397
let sections = inventory::sections(&stdout);398
let ruleset: Value = sections.iter().find(|(name, _)| name == "nft-json").and_then(|(_, text)| serde_json::from_str(text).ok()).unwrap_or(Value::Null);399
// Every base chain of the hook: separate ip and ip6 tables are as common as one inet table.400
let chains = |hook: &str| -> Vec<(String, String, String)> {401
ruleset["nftables"].as_array().into_iter().flatten().filter_map(|entry| {402
let chain = &entry["chain"];403
(chain["hook"] == hook && chain["type"] == "filter" && matches!(chain["family"].as_str(), Some("inet" | "ip" | "ip6"))).then(|| (chain["family"].as_str().unwrap_or("").to_string(), chain["table"].as_str().unwrap_or("").to_string(), chain["name"].as_str().unwrap_or("").to_string()))404
}).collect()405
};406
let mut made = false;407
for rule in &adds {408
let hook = if rule.direction == "in" { "input" } else { "output" };409
let mut found = chains(hook);410
if found.is_empty() {411
if !made {412
built.actions.push(vec!["nft".to_string(), "add".to_string(), "table".to_string(), "inet".to_string(), "filter".to_string()]);413
for one in ["input", "output"] {414
built.actions.push(vec!["nft".to_string(), "add".to_string(), "chain".to_string(), "inet".to_string(), "filter".to_string(), one.to_string(), format!("{{ type filter hook {one} priority 0; policy accept; }}")]);415
}416
made = true;417
}418
found = vec![("inet".to_string(), "filter".to_string(), hook.to_string())];419
}420
if rule.family == "both" && !found.iter().any(|(family, _, _)| family == "inet") && !(found.iter().any(|(family, _, _)| family == "ip") && found.iter().any(|(family, _, _)| family == "ip6")) {421
return Err(error("NETWORK_REQUEST_INVALID", "This firewall filters only one IP version here. Choose IPv4 or IPv6 for the rule."));422
}423
let mut targets = 0;424
for (family, table, name) in found {425
let fits = match family.as_str() {426
"ip" => rule.family != "6" && rule.protocol != "ipv6-icmp" && !rule.source.iter().chain(&rule.destination).any(|net| net.contains(':')),427
"ip6" => rule.family != "4" && rule.protocol != "icmp" && !rule.source.iter().chain(&rule.destination).any(|net| !net.contains(':')),428
_ => true,429
};430
if !fits {431
continue;432
}433
targets += 1;434
// A block must come before the existing accepts.435
let verb = if rule.action == "accept" { "add" } else { "insert" };436
let mut line = vec!["nft".to_string(), verb.to_string(), "rule".to_string(), family, table, name];437
line.extend(plan::nft_words(rule));438
built.actions.push(line);439
}440
if targets == 0 {441
return Err(error("NETWORK_REQUEST_INVALID", "No chain here takes traffic of this IP version."));442
}443
}444
for rule in &removes {445
// The original entry knows the family of its table, which the summary does not keep.446
let entry: Value = serde_json::from_str(rule["raw"].as_str().unwrap_or("")).unwrap_or(Value::Null);447
let original = if entry["rule"].is_object() { &entry["rule"] } else { &entry };448
let handle = original["handle"].as_u64().or(rule["handle"].as_u64()).ok_or_else(|| error("NETWORK_READ_ONLY", "This rule cannot be removed here."))?;449
let (family, table, name) = (original["family"].as_str().unwrap_or(""), original["table"].as_str().unwrap_or(""), original["chain"].as_str().unwrap_or(""));450
if family.is_empty() || table.is_empty() || name.is_empty() {451
return Err(error("NETWORK_READ_ONLY", "This rule cannot be removed here."));452
}453
built.actions.push(vec!["nft".to_string(), "delete".to_string(), "rule".to_string(), family.to_string(), table.to_string(), name.to_string(), "handle".to_string(), handle.to_string()]);454
}455
for name in ["input", "output", "forward"] {456
if let Some(policy) = policy_of(name) {457
let found = chains(name);458
if found.is_empty() {459
return Err(error("NETWORK_REQUEST_INVALID", "There is no chain to set this policy on."));460
}461
for (family, table, chain) in found {462
built.actions.push(vec!["nft".to_string(), "add".to_string(), "chain".to_string(), family, table, chain, format!("{{ policy {}; }}", if policy == "accept" { "accept" } else { "drop" })]);463
}464
}465
}466
// The boot file becomes a copy of the running rules, so it must not hold anything that copy would lose.467
let path = nft_file(&os);468
let boot = self.read_file(session_id, path, password).await.unwrap_or_default();469
let docker = firewall["backends"].as_array().into_iter().flatten().count() > 0 && inventory::sections(&stdout).iter().any(|(name, text)| name == "services" && text.lines().any(|line| line.starts_with("docker active")));470
if boot.lines().any(|line| line.trim_start().starts_with("include")) || docker {471
built.risks.push(json!({ "code": "runtime-only" }));472
} else {473
built.actions.push(vec!["nft-save".to_string(), path.to_string()]);474
built.risks.push(json!({ "code": "nft-file-rewritten", "path": path }));475
built.files.push(plan::FileChange { path: path.to_string(), before: boot.clone(), after: boot });476
}477
}478
"iptables" => {479
for rule in &adds {480
if rule.family != "6" && rule.protocol != "ipv6-icmp" && !rule.source.iter().chain(&rule.destination).any(|net| net.contains(':')) {481
let mut line = vec!["iptables".to_string()];482
line.extend(plan::iptables_words(rule, false));483
built.actions.push(line);484
}485
if rule.family != "4" && rule.protocol != "icmp" && !rule.source.iter().chain(&rule.destination).any(|net| !net.contains(':')) {486
let mut line = vec!["ip6tables".to_string()];487
line.extend(plan::iptables_words(rule, true));488
built.actions.push(line);489
}490
}491
for rule in &removes {492
let raw = rule["raw"].as_str().unwrap_or("");493
let words = split_words(raw);494
if words.first().map(String::as_str) != Some("-A") {495
return Err(error("NETWORK_READ_ONLY", "This rule cannot be removed here."));496
}497
let tool = if rule["family"] == 6 { "ip6tables" } else { "iptables" };498
let mut line = vec![tool.to_string(), "-D".to_string()];499
line.extend(words.into_iter().skip(1));500
built.actions.push(line);501
}502
for (name, chain) in [("input", "INPUT"), ("output", "OUTPUT"), ("forward", "FORWARD")] {503
if let Some(policy) = policy_of(name) {504
for tool in ["iptables", "ip6tables"] {505
built.actions.push(vec![tool.to_string(), "-P".to_string(), chain.to_string(), if policy == "accept" { "ACCEPT" } else { "DROP" }.to_string()]);506
}507
}508
}509
let files: &[(&str, &str)] = match os.as_str() {510
"rocky" => &[("iptables-save", "/etc/sysconfig/iptables"), ("ip6tables-save", "/etc/sysconfig/ip6tables")],511
"arch" => &[("iptables-save", "/etc/iptables/iptables.rules"), ("ip6tables-save", "/etc/iptables/ip6tables.rules")],512
_ => &[("iptables-save", "/etc/iptables/rules.v4"), ("ip6tables-save", "/etc/iptables/rules.v6")],513
};514
let persistent = firewall["backends"].as_array().into_iter().flatten().any(|item| item["name"] == "iptables" && item["enabled"] == true);515
if persistent {516
for (tool, path) in files {517
built.actions.push(vec!["iptables-save-to".to_string(), tool.to_string(), path.to_string()]);518
}519
} else {520
built.risks.push(json!({ "code": "runtime-only" }));521
}522
}523
_ => {}524
}525
if built.actions.is_empty() {526
return Err(error("NETWORK_REQUEST_INVALID", "There is nothing to change."));527
}528
built.commands = built.actions.iter().map(|action| action.iter().map(|word| if word.contains(' ') { format!("\"{word}\"") } else { word.clone() }).collect::<Vec<_>>().join(" ")).collect();529
Ok(self.remember(session_id, built, "none", &backend, base_of(&stdout, backend == "nftables")))530
}532
/// Starts the transaction: from here the server undoes the change by itself unless it gets confirmed in time.533
pub async fn apply(&self, payload: &Value) -> CoreResult<Value> {534
if payload["confirm"] != true {535
return Err(error("NETWORK_CONFIRMATION_REQUIRED", "The change must be confirmed."));536
}537
let plan_id = payload["planId"].as_str().unwrap_or("");538
let prepared = self.inner.lock().plans.remove(plan_id).filter(|prepared| prepared.created.elapsed() < PLAN_LIFETIME).ok_or_else(|| error("NETWORK_PLAN_EXPIRED", "The preview is no longer valid. Prepare the change again."))?;539
let risks: Vec<String> = prepared.plan.risks.iter().filter_map(|risk| risk["code"].as_str().map(str::to_string)).collect();540
let accepted: Vec<String> = payload["acceptRisks"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();541
if risks.iter().any(|risk| !accepted.contains(risk)) {542
let session = prepared.session_id.clone();543
let plan_again = plan_id.to_string();544
self.inner.lock().plans.insert(plan_again, prepared);545
let _ = session;546
return Err(error("NETWORK_RISK_NOT_ACCEPTED", "Every risk of this change must be accepted."));547
}548
let seconds = payload["seconds"].as_u64().unwrap_or(DEFAULT_SECONDS);549
if !(30..=3600).contains(&seconds) {550
return Err(error("NETWORK_REQUEST_INVALID", "The time limit must be 30 to 3600 seconds."));551
}552
let session_id = prepared.session_id.clone();553
let password = payload["sudoPassword"].as_str();554
let (now, _) = self.read_inventory(&session_id, password).await?;555
if base_of(&now, prepared.firewall == "nftables" && prepared.manager == "none") != prepared.base {556
return Err(error("NETWORK_BASE_CHANGED", "The configuration on the server has changed since this preview. Prepare the change again."));557
}558
let os = self.os(&session_id).await?;559
let privilege = self.executor.probe(&session_id, false).await?;560
let use_sudo = privilege["mode"] != "root";561
// A secret for the new login that has to prove the change: the session may be gone by then.562
let target = self.sessions.connection_target(&session_id)?;563
let sudo = password.filter(|p| !p.is_empty()).map(|p| Zeroizing::new(p.to_string())).or_else(|| self.sessions.require_connected(&session_id).ok().and_then(|record| self.executor.cached_password(&record)));564
let id = format!("{:x}", uuid::Uuid::new_v4().as_u128() >> 64);565
let frames = vec![566
Frame { tag: "RUNNER".into(), data: matrix::script("network.sh").as_bytes().to_vec() },567
Frame { tag: "ACTIONS".into(), data: encode_actions(&prepared.plan.actions).into_bytes() },568
];569
self.run(&session_id, "network.tx.begin", vec![id.clone(), seconds.to_string(), prepared.manager.clone(), prepared.firewall.clone()], true, password, frames, Duration::from_secs(60)).await?;570
self.logger.warn("Network change started on the remote server.", json!({ "sessionId": session_id, "transaction": id, "seconds": seconds }));571
let expected_address = payload["expectAddress"].as_str().map(str::to_string);572
self.inner.lock().transactions.insert(id.clone(), Transaction { session_id, target, os, use_sudo, sudo, verified: false, expected_address });573
Ok(json!({ "id": id, "seconds": seconds }))574
}576
fn status_values(text: &str) -> Value {577
let mut state = json!({});578
for line in text.lines() {579
if let Some((key, value)) = line.split_once('=') {580
state[key.trim_start_matches('\u{1e}')] = json!(value);581
}582
}583
state584
}586
/// The state over a new login. It proves that SSH still works after the change, which the old session cannot.587
pub async fn verify(&self, payload: &Value) -> CoreResult<Value> {588
let id = payload["id"].as_str().unwrap_or("").to_string();589
let (mut target, os) = {590
let inner = self.inner.lock();591
let transaction = inner.transactions.get(&id).ok_or_else(|| error("NETWORK_TX_UNKNOWN", "This change is not known any more."))?;592
(transaction.target.clone(), transaction.os.clone())593
};594
let new_address = payload["address"].as_str().filter(|text| !text.is_empty()).map(str::to_string).or_else(|| self.inner.lock().transactions.get(&id).and_then(|transaction| transaction.expected_address.clone()));595
if let Some(address) = &new_address {596
address.parse::<std::net::IpAddr>().map_err(|_| error("NETWORK_REQUEST_INVALID", "The address to verify is not an IP address."))?;597
target.at_address(address.clone());598
}599
let credentials = json!({ "password": payload["password"], "passphrase": payload["passphrase"] });600
let result = self.executor.fresh_operation(&target, &credentials, &os, "network.tx.status", std::slice::from_ref(&id), None, Duration::from_secs(20)).await;601
match result {602
Ok(value) => {603
let state = Self::status_values(value["stdout"].as_str().unwrap_or(""));604
let verified = state["state"] == "applied";605
if let Some(transaction) = self.inner.lock().transactions.get_mut(&id) {606
transaction.verified = verified;607
if verified {608
transaction.target = target;609
}610
}611
Ok(json!({ "reachable": true, "verified": verified, "state": state["state"], "error": state["error"], "address": value["address"] }))612
}613
Err(failure) => {614
if let Some(transaction) = self.inner.lock().transactions.get_mut(&id) {615
transaction.verified = false;616
}617
Ok(json!({ "reachable": false, "verified": false, "code": failure.code, "message": failure.message }))618
}619
}620
}622
async fn on_server(&self, id: &str, operation: &str, args: Vec<String>, payload: &Value) -> CoreResult<String> {623
let (session_id, target, os, use_sudo, sudo) = {624
let inner = self.inner.lock();625
let transaction = inner.transactions.get(id).ok_or_else(|| error("NETWORK_TX_UNKNOWN", "This change is not known any more."))?;626
(transaction.session_id.clone(), transaction.target.clone(), transaction.os.clone(), transaction.use_sudo, transaction.sudo.clone())627
};628
let sudo = payload["sudoPassword"].as_str().filter(|p| !p.is_empty()).map(|p| Zeroizing::new(p.to_string())).or(sudo);629
if self.sessions.require_connected(&session_id).is_ok() {630
let password = sudo.as_ref().map(|p| p.as_str());631
if let Ok(output) = self.run(&session_id, operation, args.clone(), true, password, vec![], Duration::from_secs(30)).await {632
return Ok(output);633
}634
}635
let credentials = json!({ "password": payload["password"], "passphrase": payload["passphrase"] });636
let sudo_text = sudo.as_ref().map(|p| p.as_str().to_string());637
let password = if use_sudo { Some(sudo_text.as_deref().unwrap_or("")) } else { None };638
let value = self.executor.fresh_operation(&target, &credentials, &os, operation, &args, password, Duration::from_secs(30)).await?;639
if value["code"].as_i64().unwrap_or(0) != 0 {640
return Err(error("NETWORK_TX_FAILED", value["stderr"].as_str().unwrap_or("The server refused the operation.")));641
}642
Ok(value["stdout"].as_str().unwrap_or("").to_string())643
}645
/// Keeps the change. Only after a new login has proved that the server is still reachable.646
pub async fn confirm(&self, payload: &Value) -> CoreResult<Value> {647
let id = payload["id"].as_str().unwrap_or("").to_string();648
let verified = self.inner.lock().transactions.get(&id).is_some_and(|transaction| transaction.verified);649
if !verified {650
return Err(error("NETWORK_NOT_VERIFIED", "Verify a new SSH login first."));651
}652
// The proof is a login now, not one from minutes ago.653
let fresh = self.verify(payload).await?;654
if fresh["verified"] != true {655
return Err(error("NETWORK_NOT_VERIFIED", "A new SSH login does not work at this moment."));656
}657
self.on_server(&id, "network.tx.confirm", vec![id.clone()], payload).await?;658
self.inner.lock().transactions.remove(&id);659
self.logger.warn("Network change confirmed.", json!({ "transaction": id }));660
Ok(json!({ "state": "confirmed" }))661
}663
pub async fn rollback(&self, payload: &Value) -> CoreResult<Value> {664
let id = payload["id"].as_str().unwrap_or("").to_string();665
let output = self.on_server(&id, "network.tx.rollback", vec![id.clone(), "now".to_string()], payload).await?;666
self.inner.lock().transactions.remove(&id);667
// Another window confirmed it first, so nothing was undone.668
if output.lines().any(|line| line.trim() == "state=confirmed") {669
return Ok(json!({ "state": "confirmed" }));670
}671
self.logger.warn("Network change undone on request.", json!({ "transaction": id }));672
Ok(json!({ "state": "rolled-back" }))673
}675
pub async fn extend(&self, payload: &Value) -> CoreResult<Value> {676
let id = payload["id"].as_str().unwrap_or("").to_string();677
let seconds = payload["seconds"].as_u64().unwrap_or(DEFAULT_SECONDS);678
self.on_server(&id, "network.tx.extend", vec![id.clone(), seconds.to_string()], payload).await?;679
if let Some(transaction) = self.inner.lock().transactions.get_mut(&id) {680
transaction.verified = false;681
}682
Ok(json!({ "seconds": seconds }))683
}685
pub async fn transaction_log(&self, payload: &Value) -> CoreResult<Value> {686
let id = payload["id"].as_str().unwrap_or("").to_string();687
let output = self.on_server(&id, "network.tx.log", vec![id.clone()], payload).await?;688
Ok(json!({ "output": output.trim_start_matches("\u{1e}CHJ_READY\n") }))689
}691
/// A change that still waits on this server, for example after this application was restarted.692
pub async fn pending(&self, session_id: &str) -> CoreResult<Value> {693
let output = self.run(session_id, "network.tx.pending", vec![], false, None, vec![], Duration::from_secs(20)).await?;694
let state = Self::status_values(&output);695
// After a restart of this application the change can still be finished, from a new login like any other.696
if let (Some(id), Some(current)) = (state["id"].as_str(), state["state"].as_str()) {697
if matches!(current, "pending" | "applying" | "applied" | "rolling-back" | "rollback-failed") && !self.inner.lock().transactions.contains_key(id) {698
let os = self.os(session_id).await?;699
let use_sudo = self.executor.probe(session_id, false).await?["mode"] != "root";700
let target = self.sessions.connection_target(session_id)?;701
let sudo = self.sessions.require_connected(session_id).ok().and_then(|record| self.executor.cached_password(&record));702
self.inner.lock().transactions.insert(id.to_string(), Transaction { session_id: session_id.to_string(), target, os, use_sudo, sudo, verified: false, expected_address: None });703
}704
}705
Ok(state)706
}708
/// Why does this port not work? Each layer that can be seen from the server gives its answer; the path from the709
/// internet (router, NAT, cloud firewall) cannot be seen from here.710
pub async fn diagnose(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {711
let password = payload["sudoPassword"].as_str();712
let names: Vec<String> = payload["names"].as_array().into_iter().flatten().filter_map(Value::as_str).filter(|name| name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-') && !name.is_empty() && !name.starts_with('-')).take(8).map(str::to_string).collect();713
let names = if names.is_empty() { vec!["ch-j.de".to_string()] } else { names };714
let output = self.run(session_id, "network.diagnose", names, false, None, vec![], Duration::from_secs(90)).await?;715
let mut checks = json!({ "gateway": [], "dns": [], "internet": [] });716
let mut current = String::new();717
for line in output.lines() {718
if let Some(name) = line.strip_prefix("\u{1e}CHJ_SECTION=") {719
current = name.to_string();720
continue;721
}722
if line.starts_with('\u{1e}') {723
continue;724
}725
let words: Vec<&str> = line.split_whitespace().collect();726
let item = match (current.as_str(), words.as_slice()) {727
("gateway", [family, gateway, device, result]) => json!({ "family": family.trim_start_matches('-'), "gateway": gateway, "device": device, "ok": *result == "ok" }),728
("dns", [name, result, seconds, answer]) => json!({ "name": name, "ok": *result == "ok", "seconds": seconds.parse::<u64>().ok(), "answer": answer }),729
("internet", [family, result]) => json!({ "family": family.trim_start_matches('-'), "result": result }),730
_ => continue,731
};732
if let Some(list) = checks[current.as_str()].as_array_mut() {733
list.push(item);734
}735
}736
if let Some(port) = payload["port"].as_u64().filter(|port| (1..=65535).contains(port)) {737
let protocol = payload["protocol"].as_str().unwrap_or("tcp");738
let (_, current) = self.read_inventory(session_id, password).await?;739
checks["port"] = port_answer(¤t, port as u16, protocol);740
// From this computer too. A success here says nothing about the internet.741
if protocol == "tcp" {742
if let Some(record) = self.sessions.get(session_id) {743
let address = record.address.lock().clone();744
let reached = tokio::time::timeout(Duration::from_secs(5), tokio::net::TcpStream::connect((address.as_str(), port as u16))).await;745
checks["port"]["fromHere"] = json!(matches!(reached, Ok(Ok(_))));746
}747
}748
}749
Ok(checks)750
}751
}753
/// What the server itself knows about a port.754
pub fn port_answer(inventory: &Value, port: u16, protocol: &str) -> Value {755
let listeners: Vec<Value> = inventory["listening"].as_array().into_iter().flatten().filter(|item| item["port"] == port && item["protocol"].as_str().is_some_and(|name| name.starts_with(protocol))).cloned().collect();756
let only_local = !listeners.is_empty() && listeners.iter().all(|item| item["address"].as_str().is_some_and(|address| address.starts_with("127.") || address == "::1" || address == "[::1]"));757
let firewall = &inventory["firewall"];758
let text = port.to_string();759
let matches_port = |rule: &&Value| rule["direction"] == "in" && (rule["protocol"] == protocol || rule["protocol"].is_null() || rule["protocol"] == "any") && rule["ports"].as_array().is_some_and(|ports| ports.iter().filter_map(Value::as_str).any(|item| match item.split_once(':') {760
Some((from, to)) => from.parse::<u16>().is_ok_and(|from| from <= port) && to.parse::<u16>().is_ok_and(|to| to >= port),761
None => item == text,762
}));763
let rules: Vec<Value> = firewall["rules"].as_array().into_iter().flatten().filter(|rule| rule["plumbing"] != true).filter(matches_port).cloned().collect();764
let policy = firewall["defaultPolicy"]["input"].as_str().unwrap_or("");765
let blocked = rules.iter().any(|rule| rule["action"] == "drop" || rule["action"] == "reject");766
let allowed = rules.iter().any(|rule| rule["action"] == "accept");767
// A rule for only some sources, or an allow next to a block, depends on who asks and on the order.768
let scoped = rules.iter().any(|rule| !rule["source"].is_null() && !matches!(rule["source"].as_str(), Some("any" | "0.0.0.0/0" | "::/0")) || !rule["interface"].is_null());769
let firewall_answer = if firewall["active"] == "none" || firewall["active"].is_null() { "no-firewall" } else if firewall["readable"] != true || (allowed && blocked) || scoped { "unknown" } else if blocked { "blocked" } else if allowed { "allowed" } else if matches!(policy, "drop" | "reject") { "default-blocks" } else { "default-allows" };770
let verdict = if listeners.is_empty() { "nothing-listens" } else if only_local { "only-local" } else if matches!(firewall_answer, "blocked" | "default-blocks") { "firewall-blocks" } else if firewall_answer == "unknown" { "firewall-unknown" } else { "open-on-server" };771
json!({ "port": port, "protocol": protocol, "listeners": listeners, "firewall": firewall_answer, "rules": rules, "verdict": verdict })772
}774
#[cfg(test)]775
mod tests {776
use super::*;778
#[test]779
fn actions_stay_one_line_per_action() {780
let text = encode_actions(&[vec!["write".into(), "/etc/a b".into(), "two\nlines".into(), "600".into()], vec!["nm-modify".into(), "x".into(), "ipv4.gateway".into(), String::new()]]);781
let lines: Vec<&str> = text.lines().collect();782
assert_eq!(lines.len(), 2);783
assert_eq!(lines[0].split(' ').count(), 4);784
let engine = base64::engine::general_purpose::STANDARD;785
assert_eq!(engine.decode(lines[0].split(' ').nth(2).unwrap()).unwrap(), b"two\nlines");786
assert_eq!(engine.decode(lines[1].split(' ').nth(3).unwrap()).unwrap(), b"\n", "an empty value still has a word");787
}789
#[test]790
fn saved_iptables_lines_become_words() {791
assert_eq!(split_words(r#"-A INPUT -p tcp --dport 80 -m comment --comment "my web" -j ACCEPT"#), ["-A", "INPUT", "-p", "tcp", "--dport", "80", "-m", "comment", "--comment", "my web", "-j", "ACCEPT"]);792
assert_eq!(split_words(r#"-A X --comment "a \"b\"""#)[3], r#"a "b""#);793
assert_eq!(split_words(r#"--comment """#), ["--comment", ""]);794
}796
fn inventory(listening: Value, firewall: Value) -> Value {797
json!({ "listening": listening, "firewall": firewall })798
}800
#[test]801
fn a_port_gets_an_honest_answer() {802
let open = json!([{ "protocol": "tcp", "address": "0.0.0.0", "port": 80 }]);803
let local = json!([{ "protocol": "tcp", "address": "127.0.0.1", "port": 80 }]);804
let none = json!({ "active": "none", "readable": true });805
assert_eq!(port_answer(&inventory(json!([]), none.clone()), 80, "tcp")["verdict"], "nothing-listens");806
assert_eq!(port_answer(&inventory(local, none.clone()), 80, "tcp")["verdict"], "only-local");807
assert_eq!(port_answer(&inventory(open.clone(), none), 80, "tcp")["verdict"], "open-on-server");808
let drop_all = json!({ "active": "ufw", "readable": true, "defaultPolicy": { "input": "drop" }, "rules": [] });809
assert_eq!(port_answer(&inventory(open.clone(), drop_all), 80, "tcp")["verdict"], "firewall-blocks");810
let allowed = json!({ "active": "ufw", "readable": true, "defaultPolicy": { "input": "drop" }, "rules": [{ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["70:90"] }] });811
assert_eq!(port_answer(&inventory(open.clone(), allowed), 80, "tcp")["verdict"], "open-on-server");812
let blocked = json!({ "active": "ufw", "readable": true, "defaultPolicy": { "input": "accept" }, "rules": [{ "direction": "in", "action": "drop", "protocol": "tcp", "ports": ["80"] }] });813
assert_eq!(port_answer(&inventory(open.clone(), blocked), 80, "tcp")["verdict"], "firewall-blocks");814
let unread = json!({ "active": "ufw", "readable": false });815
assert_eq!(port_answer(&inventory(open.clone(), unread), 80, "tcp")["verdict"], "firewall-unknown");816
let both = json!({ "active": "ufw", "readable": true, "rules": [{ "direction": "in", "action": "drop", "protocol": "tcp", "ports": ["80"] }, { "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["80"] }] });817
assert_eq!(port_answer(&inventory(open.clone(), both), 80, "tcp")["verdict"], "firewall-unknown", "the order decides");818
let scoped = json!({ "active": "ufw", "readable": true, "rules": [{ "direction": "in", "action": "accept", "protocol": "tcp", "ports": ["80"], "source": "192.0.2.0/24" }] });819
assert_eq!(port_answer(&inventory(open, scoped), 80, "tcp")["verdict"], "firewall-unknown", "only for some sources");820
}822
#[test]823
fn counters_do_not_change_the_base_but_rules_do() {824
let a = "\u{1e}CHJ_SECTION=iptables\n# Generated by iptables-save on Mon 10:00:00\n:INPUT ACCEPT [1:2]\n-A INPUT -j ACCEPT\n\u{1e}CHJ_SECTION=nft-json\n{\"counter\": {\"packets\": 1, \"bytes\": 2}}\n";825
let b = "\u{1e}CHJ_SECTION=iptables\n# Generated by iptables-save on Mon 10:00:07\n:INPUT ACCEPT [9:9]\n-A INPUT -j ACCEPT\n\u{1e}CHJ_SECTION=nft-json\n{\"counter\": {\"packets\": 7, \"bytes\": 8}}\n";826
assert_eq!(base_of(a, true), base_of(b, true));827
assert_ne!(base_of(a, true), base_of("\u{1e}CHJ_SECTION=iptables\n:INPUT ACCEPT [1:2]\n-A INPUT -j DROP\n", true));828
let handles = |value: u32| format!("\u{1e}CHJ_SECTION=nft-json\n{{\"handle\": {value}}}\n");829
assert_ne!(base_of(&handles(5), true), base_of(&handles(6), true), "native nft deletes by handle");830
assert_eq!(base_of(&handles(5), false), base_of(&handles(6), false));831
}832
}SHA-256
b5cb77e3559209744f11bd168104e487cfeb3bd53c2ea16e9e512a92edd45ce9CH-J Proprietary Software License 1.14 · Tá Foinse ar fáil faoin CH-J Proprietary Software License 1.14. Ní chuireann a infhaighteacht a athrú ar na téarmaí ceadúnais nó cearta breise a dheonú.
Cartlann SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f