CH-J Tools透過 SSH 管理伺服器
選單
已發布的原始碼

CH-J Tools

瀏覽特定應用程式版本的目錄與檔案。

下載原始碼 ZIP
CH-J / Server Manager 1704 個檔案
0.1.2-beta 新增時間: · 建置時間:
service.rs 新增時間: 未記錄日期 53.5 KB · 832 行
程式碼 下載檔案
1//! Reading, plans and transactions. Every remote command is an operation of the matrix.
3use super::plan::{self, Plan};
4use super::inventory;
5use crate::core::error::{CoreError, CoreResult};
6use crate::core::logging::Logger;
7use crate::core::matrix;
8use crate::core::ssh::platform::detect_platform;
9use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions};
10use crate::core::ssh::session::{ConnectionTarget, SessionManager};
11use base64::Engine;
12use parking_lot::Mutex;
13use serde_json::{json, Value};
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use zeroize::Zeroizing;
19const PLAN_LIFETIME: Duration = Duration::from_secs(1800);
20pub const DEFAULT_SECONDS: u64 = 120;
22fn error(code: &str, message: &str) -> CoreError {
23 CoreError::new(code, message)
26struct Prepared {
27 session_id: String,
28 plan: Plan,
29 manager: String,
30 firewall: String,
31 base: String,
32 created: Instant,
35struct 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>,
45/// What a plan was made from. Counters of rules change by themselves and say nothing about the configuration.
46fn 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())
67fn ssh_path(current: &Value) -> plan::SshPath {
68 let ssh = &current["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 }
77#[derive(Default)]
78struct Inner {
79 plans: HashMap<String, Prepared>,
80 transactions: HashMap<String, Transaction>,
83pub struct NetworkService {
84 sessions: Arc<SessionManager>,
85 executor: Arc<PrivilegedExecutor>,
86 logger: Arc<Logger>,
87 inner: Mutex<Inner>,
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.
92pub 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"
97/// The words of a line of `iptables-save`, with its quoting.
98pub 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 words
118/// Where each system loads its nftables rules from at boot.
119fn nft_file(os: &str) -> &'static str {
120 match os { "rocky" => "/etc/sysconfig/nftables.conf", "alpine" => "/etc/nftables.nft", _ => "/etc/nftables.conf" }
123impl 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 result
187 }
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 = &current["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 = &current["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(&current);
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 state
584 }
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 the
709 /// 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(&current, 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 }
753/// What the server itself knows about a port.
754pub 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 })
774#[cfg(test)]
775mod 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 }
SHA-256b5cb77e3559209744f11bd168104e487cfeb3bd53c2ea16e9e512a92edd45ce9

CH-J Proprietary Software License 1.14 · 原始碼依 CH-J Proprietary Software License 1.14 提供。公開原始碼不會改變授權條款,也不會授予額外的權利。

壓縮檔 SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
檔案