CH-J Toolsserverbeheer over SSH
Menu
Gepubliceerde bron

CH-J Tools

Blader door mappen en bestanden voor een specifieke toepassing release.

Download bron ZIP
CH-J / Server Manager 1704 bestanden
0.1.2-beta Toegevoegd: · Gebouwd:
firewall.rs Toegevoegd: Datum niet geregistreerd 77,6 KB · 2058 regels
Rubriek Bestand downloaden
1use super::inventory::{body, finding, pairs, uci, words, Uci};
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet};
5fn text(v: &Value) -> &str {
6 v.as_str().unwrap_or("")
7}
8fn field<'a>(m: &'a BTreeMap<String, String>, k: &str) -> &'a str {
9 m.get(k).map(String::as_str).unwrap_or("")
11fn action(s: &str) -> &str {
12 match s {
13 "ACCEPT" | "ALLOW" | "accept" | "allow" => "accept",
14 "DROP" | "DENY" | "drop" | "deny" => "drop",
15 "REJECT" | "reject" => "reject",
16 "LOG" | "log" => "log",
17 _ => "other",
18 }
20fn direction(s: &str) -> Option<&'static str> {
21 let l = s.to_ascii_lowercase();
22 if l == "input" || l.starts_with("input_") || l.contains("-input") {
23 Some("in")
24 } else if l == "output" || l.starts_with("output_") || l.contains("-output") {
25 Some("out")
26 } else if l == "forward" || l.starts_with("forward_") || l.contains("-forward") {
27 Some("forward")
28 } else {
29 None
30 }
32fn rule(backend: &str, raw: &str) -> Value {
33 json!({"id": null, "backend": backend, "direction": null, "action": "other", "family": "both", "protocol": null,
34 "ports": [], "source": null, "destination": null, "interface": null, "comment": null, "zone": null,
35 "table": null, "chain": null, "handle": null, "understood": false, "raw": raw, "number": null,
36 "managed": false, "plumbing": false})
38fn policy() -> Value {
39 json!({"input": null, "output": null, "forward": null})
41fn merge_policy(dest: &mut Value, src: &Value) {
42 for key in ["input", "output", "forward"] {
43 if !src[key].is_null() {
44 let rank = |v: &Value| match text(v) {
45 "drop" => 3,
46 "reject" => 2,
47 "accept" => 1,
48 _ => 0,
49 };
50 if rank(&src[key]) > rank(&dest[key]) {
51 dest[key] = src[key].clone();
52 }
53 }
54 }
56fn value_string(v: &Value) -> Option<String> {
57 match v {
58 Value::String(s) => Some(s.clone()),
59 Value::Number(n) => Some(n.to_string()),
60 Value::Object(m) if m.contains_key("prefix") => Some(format!(
61 "{}/{}",
62 text(&v["prefix"]["addr"]),
63 v["prefix"]["len"]
64 )),
65 Value::Object(m) if m.contains_key("range") => v["range"]
66 .as_array()
67 .filter(|a| a.len() == 2)
68 .and_then(|a| Some(format!("{}:{}", value_string(&a[0])?, value_string(&a[1])?))),
69 _ => None,
70 }
72fn values(v: &Value) -> Vec<String> {
73 if let Some(a) = v
74 .get("set")
75 .and_then(Value::as_array)
76 .or_else(|| v.as_array())
77 {
78 a.iter().filter_map(value_string).collect()
79 } else {
80 value_string(v).into_iter().collect()
81 }
84// Retain the actual JSON text for each ruleset entry, including its original spacing.
85fn json_entries(s: &str) -> Vec<&str> {
86 let mut result = Vec::new();
87 let Some(key) = s.find("\"nftables\"") else {
88 return result;
89 };
90 let Some(start) = s[key..].find('[').map(|i| i + key) else {
91 return result;
92 };
93 let mut depth = 0usize;
94 let mut quoted = false;
95 let mut escape = false;
96 let mut entry = None;
97 for (offset, c) in s[start + 1..].char_indices() {
98 let pos = start + 1 + offset;
99 if quoted {
100 if escape {
101 escape = false;
102 } else if c == '\\' {
103 escape = true;
104 } else if c == '"' {
105 quoted = false;
106 }
107 continue;
108 }
109 match c {
110 '"' => quoted = true,
111 '{' | '[' => {
112 if depth == 0 {
113 entry = Some(pos);
114 }
115 depth += 1;
116 }
117 '}' | ']' if depth > 0 => {
118 depth -= 1;
119 if depth == 0 {
120 if let Some(a) = entry.take() {
121 result.push(&s[a..pos + 1]);
122 }
123 }
124 }
125 ']' => break,
126 _ => {}
127 }
128 }
129 result
132#[derive(Default)]
133struct Nft {
134 rules: Vec<Value>,
135 nat: Vec<Value>,
136 policies: BTreeMap<String, Value>,
137 owners: BTreeSet<String>,
138 valid: bool,
139 v6_drop: BTreeSet<String>,
141fn owner(table: &str) -> &str {
142 let lower = table.to_ascii_lowercase();
143 if lower == "fw4" {
144 "fw4"
145 } else if lower.contains("firewalld") {
146 "firewalld"
147 } else if lower.contains("ufw") {
148 "ufw"
149 } else if lower.contains("pve") {
150 "pve-firewall"
151 } else if lower.contains("docker") {
152 "docker"
153 } else {
154 "nftables"
155 }
157fn nft_table(v: &Value) -> String {
158 format!("{}/{}", text(&v["family"]), text(&v["table"]))
160fn nft_backend<'a>(v: &'a Value, table_owners: &'a BTreeMap<String, String>) -> &'a str {
161 let base = owner(text(&v["table"]));
162 if base != "nftables" {
163 return base;
164 }
165 let chain = v["chain"]
166 .as_str()
167 .or_else(|| v["name"].as_str())
168 .unwrap_or("");
169 if chain.to_ascii_lowercase().contains("ufw") {
170 return "ufw";
171 }
172 if chain.contains("PVEFW") {
173 return "pve-firewall";
174 }
175 if chain.to_ascii_lowercase().contains("docker") {
176 return "docker";
177 }
178 if v["expr"].as_array().is_some_and(|a| {
179 a.iter().any(|e| {
180 e.get("jump")
181 .or_else(|| e.get("goto"))
182 .is_some_and(|j| text(&j["target"]).to_ascii_lowercase().starts_with("ufw"))
183 })
184 }) {
185 return "ufw";
186 }
187 if v["expr"].as_array().is_some_and(|a| {
188 a.iter().any(|e| {
189 text(&e["jump"]["target"])
190 .to_ascii_lowercase()
191 .contains("docker")
192 })
193 }) {
194 return "docker";
195 }
196 // Compatibility tables can also contain foreign rules. Ownership of a chain
197 // supplies base policies, but must not claim every rule in the same table.
198 if v.get("chain").is_some() {
199 return base;
200 }
201 table_owners
202 .get(&nft_table(v))
203 .map(String::as_str)
204 .unwrap_or(base)
206fn chain_key(v: &Value, name: &str) -> String {
207 format!("{}/{}/{}", text(&v["family"]), text(&v["table"]), name)
210fn nft_parse(s: &str) -> Nft {
211 let mut out = Nft::default();
212 let Ok(root) = serde_json::from_str::<Value>(s) else {
213 return out;
214 };
215 let Some(entries) = root["nftables"].as_array() else {
216 return out;
217 };
218 out.valid = true;
219 let mut table_owners = BTreeMap::new();
220 let mut docker_tables = BTreeSet::new();
221 for e in entries {
222 if let Some(c) = e.get("chain") {
223 let name = text(&c["name"]);
224 if name.to_ascii_lowercase().contains("docker") {
225 docker_tables.insert(nft_table(c));
226 }
227 let hint = if name.to_ascii_lowercase().contains("ufw") {
228 Some("ufw")
229 } else if name.contains("PVEFW") {
230 Some("pve-firewall")
231 } else {
232 None
233 };
234 if let Some(name) = hint {
235 table_owners.insert(nft_table(c), name.to_owned());
236 }
237 }
238 }
239 let raw = json_entries(s);
240 let mut dirs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
241 let mut edges = Vec::new();
242 for entry in entries {
243 if let Some(c) = entry.get("chain") {
244 let backend = nft_backend(c, &table_owners);
245 if let Some(dir) = direction(text(&c["hook"])) {
246 dirs.entry(chain_key(c, text(&c["name"])))
247 .or_default()
248 .insert(dir.into());
249 if matches!(text(&c["policy"]), "accept" | "drop" | "reject")
250 && c["type"] == "filter"
251 {
252 let p = out.policies.entry(backend.into()).or_insert_with(policy);
253 let hook = text(&c["hook"]);
254 let mut one = policy();
255 one[hook] = c["policy"].clone();
256 merge_policy(p, &one);
257 if c["policy"] != "accept"
258 && backend != "docker"
259 && !(hook == "forward" && docker_tables.contains(&nft_table(c)))
260 {
261 out.owners.insert(backend.into());
262 }
263 if hook == "input"
264 && c["policy"] != "accept"
265 && matches!(text(&c["family"]), "inet" | "ip6")
266 {
267 out.v6_drop.insert(backend.into());
268 }
269 }
270 }
271 }
272 if let Some(r) = entry.get("rule") {
273 let backend = nft_backend(r, &table_owners);
274 if backend != "docker" {
275 out.owners.insert(backend.into());
276 }
277 if let Some(exprs) = r["expr"].as_array() {
278 for e in exprs {
279 if let Some(target) = e
280 .get("jump")
281 .or_else(|| e.get("goto"))
282 .and_then(|j| j["target"].as_str())
283 {
284 edges.push((chain_key(r, text(&r["chain"])), chain_key(r, target)));
285 }
286 }
287 }
288 }
289 }
290 // A bounded fixed point also handles cycles and chains reached from several hooks.
291 for _ in 0..=edges.len() {
292 let mut changed = false;
293 for (from, to) in &edges {
294 let inherited = dirs.get(from).cloned().unwrap_or_default();
295 let dest = dirs.entry(to.clone()).or_default();
296 for d in inherited {
297 changed |= dest.insert(d);
298 }
299 }
300 if !changed {
301 break;
302 }
303 }
304 for (i, entry) in entries.iter().enumerate() {
305 let Some(r) = entry.get("rule") else {
306 continue;
307 };
308 let backend = nft_backend(r, &table_owners);
309 let fallback = entry.to_string();
310 let mut v = rule(
311 if backend == "docker" {
312 "nftables"
313 } else {
314 backend
315 },
316 raw.get(i).copied().unwrap_or(&fallback),
317 );
318 if backend == "docker" {
319 v["owner"] = json!("docker");
320 }
321 for k in ["table", "chain", "handle", "comment"] {
322 v[k] = r[k].clone();
323 }
324 v["id"] = json!(format!(
325 "nft:{}:{}:{}:{}",
326 text(&r["family"]),
327 text(&r["table"]),
328 text(&r["chain"]),
329 r["handle"]
330 .as_u64()
331 .map(|h| h.to_string())
332 .unwrap_or_else(|| r.to_string())
333 ));
334 v["plumbing"] = json!(matches!(backend, "ufw" | "firewalld" | "fw4"));
335 v["family"] = match text(&r["family"]) {
336 "ip" => json!(4),
337 "ip6" => json!(6),
338 "inet" => json!("both"),
339 _ => Value::Null,
340 };
341 let ds = dirs.get(&chain_key(r, text(&r["chain"])));
342 v["direction"] = json!(ds
343 .filter(|d| d.len() == 1)
344 .and_then(|d| d.first())
345 .map(String::as_str)
346 .or_else(|| direction(text(&r["chain"]))));
347 let mut understood = r["expr"].is_array();
348 let mut nat = None;
349 if let Some(exprs) = r["expr"].as_array() {
350 for e in exprs {
351 if let Some(m) = e.get("match") {
352 understood &= nft_match(&mut v, m);
353 } else if let Some(a) = ["accept", "drop", "reject", "log"]
354 .iter()
355 .find(|a| e.get(**a).is_some())
356 {
357 v["action"] = json!(a);
358 } else if e.get("counter").is_some() {
359 } else if let Some(a) = ["masquerade", "dnat", "snat", "redirect"]
360 .iter()
361 .find(|a| e.get(**a).is_some())
362 {
363 nat = Some((a.to_string(), e[*a].clone()));
364 } else {
365 understood = false;
366 }
367 }
368 }
369 v["understood"] = json!(understood && (v["action"] != "other" || nat.is_some()));
370 if let Some((kind, details)) = nat {
371 v["type"] = json!(kind);
372 v["translation"] = details;
373 out.nat.push(v.clone());
374 }
375 out.rules.push(v);
376 }
377 out
380fn nft_match(v: &mut Value, m: &Value) -> bool {
381 if m["op"] != "==" && m["op"] != "in" {
382 return false;
383 }
384 let left = &m["left"];
385 let right = &m["right"];
386 if let Some(meta) = left.get("meta") {
387 match text(&meta["key"]) {
388 "iif" | "oif" | "iifname" | "oifname" => {
389 v["interface"] = right.clone();
390 true
391 }
392 "l4proto" => {
393 v["protocol"] = right.clone();
394 right.is_string()
395 }
396 "nfproto" => {
397 v["family"] = match text(right) {
398 "ipv4" => json!(4),
399 "ipv6" => json!(6),
400 _ => return false,
401 };
402 true
403 }
404 _ => false,
405 }
406 } else if let Some(p) = left.get("payload") {
407 let proto = text(&p["protocol"]);
408 if matches!(proto, "icmp" | "icmpv6") {
409 v["protocol"] = json!(proto);
410 if proto == "icmpv6" {
411 v["family"] = json!(6);
412 }
413 }
414 match text(&p["field"]) {
415 "dport" => {
416 let vals = values(right);
417 let ok = !vals.is_empty();
418 v["protocol"] = json!(proto);
419 v["ports"] = json!(vals);
420 ok
421 }
422 "saddr" | "daddr" => {
423 let key = if p["field"] == "saddr" {
424 "source"
425 } else {
426 "destination"
427 };
428 v[key] = json!(value_string(right));
429 if proto == "ip" {
430 v["family"] = json!(4);
431 } else if proto == "ip6" {
432 v["family"] = json!(6);
433 }
434 !v[key].is_null()
435 }
436 _ => false,
437 }
438 } else {
439 false
440 }
443fn shell_words(s: &str) -> Vec<String> {
444 let mut result = Vec::new();
445 let mut token = String::new();
446 let mut quote = None;
447 let mut escape = false;
448 for c in s.chars() {
449 if escape {
450 token.push(c);
451 escape = false;
452 continue;
453 }
454 if c == '\\' {
455 escape = true;
456 continue;
457 }
458 if let Some(q) = quote {
459 if c == q {
460 quote = None;
461 } else {
462 token.push(c);
463 }
464 } else if c == '\'' || c == '"' {
465 quote = Some(c);
466 } else if c.is_whitespace() {
467 if !token.is_empty() {
468 result.push(std::mem::take(&mut token));
469 }
470 } else {
471 token.push(c);
472 }
473 }
474 if escape {
475 token.push('\\');
476 }
477 if !token.is_empty() {
478 result.push(token);
479 }
480 result
482fn opt<'a>(w: &'a [String], key: &str) -> Option<&'a str> {
483 w.windows(2).find(|p| p[0] == key).map(|p| p[1].as_str())
485fn ipt_owner(chain: &str) -> &str {
486 if chain.to_ascii_lowercase().contains("ufw") {
487 "ufw"
488 } else if chain.contains("PVEFW") {
489 "pve-firewall"
490 } else {
491 "iptables"
492 }
494#[derive(Default)]
495struct Ipt {
496 rules: Vec<Value>,
497 nat: Vec<Value>,
498 policy: Value,
499 active: bool,
500 valid: bool,
501 v6_drop: bool,
503fn iptables_parse(s: &str, family: u8) -> Ipt {
504 let mut out = Ipt {
505 policy: policy(),
506 ..Ipt::default()
507 };
508 let mut table = "";
509 let docker = s.contains("DOCKER");
510 let mut dirs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
511 let mut edges = Vec::new();
512 for line in s.lines() {
513 let w = shell_words(line);
514 if let (Some(chain), Some(target)) =
515 (opt(&w, "-A"), opt(&w, "-j").or_else(|| opt(&w, "-g")))
516 {
517 edges.push((chain.to_owned(), target.to_owned()));
518 }
519 }
520 for (c, d) in [("INPUT", "in"), ("OUTPUT", "out"), ("FORWARD", "forward")] {
521 dirs.entry(c.to_owned()).or_default().insert(d.into());
522 }
523 for _ in 0..=edges.len() {
524 let mut changed = false;
525 for (from, to) in &edges {
526 let inherited = dirs.get(from).cloned().unwrap_or_default();
527 for d in inherited {
528 changed |= dirs.entry(to.clone()).or_default().insert(d);
529 }
530 }
531 if !changed {
532 break;
533 }
534 }
535 for line in s.lines() {
536 if let Some(t) = line.strip_prefix('*') {
537 table = t.trim();
538 out.valid = true;
539 continue;
540 }
541 if let Some(rest) = line.strip_prefix(':') {
542 let w: Vec<_> = rest.split_whitespace().collect();
543 if w.len() > 1 && table == "filter" {
544 let key = match w[0] {
545 "INPUT" => "input",
546 "OUTPUT" => "output",
547 "FORWARD" => "forward",
548 _ => continue,
549 };
550 if action(w[1]) != "other" {
551 out.policy[key] = json!(action(w[1]));
552 }
553 if w[1] == "DROP" || w[1] == "REJECT" {
554 if key != "forward" || !docker {
555 out.active = true;
556 }
557 if family == 6 && key == "input" {
558 out.v6_drop = true;
559 }
560 }
561 }
562 continue;
563 }
564 if !line.trim_start().starts_with("-A ") && !line.trim_start().starts_with("-I ") {
565 continue;
566 }
567 let w = shell_words(line);
568 let chain = opt(&w, "-A").or_else(|| opt(&w, "-I")).unwrap_or("");
569 let jump = opt(&w, "-j").or_else(|| opt(&w, "-g")).unwrap_or("");
570 let backend = if ipt_owner(chain) != "iptables" {
571 ipt_owner(chain)
572 } else {
573 ipt_owner(jump)
574 };
575 let mut v = rule(backend, line);
576 v["plumbing"] = json!(backend == "ufw");
577 v["family"] = json!(family);
578 v["table"] = json!(table);
579 v["chain"] = json!(chain);
580 v["direction"] = json!(dirs
581 .get(chain)
582 .filter(|d| d.len() == 1)
583 .and_then(|d| d.first()));
584 v["action"] = json!(action(jump));
585 v["protocol"] = json!(opt(&w, "-p").or_else(|| opt(&w, "--protocol")));
586 for (key, names) in [
587 ("source", ["-s", "--source"]),
588 ("destination", ["-d", "--destination"]),
589 ("interface", ["-i", "-o"]),
590 ("comment", ["--comment", "--comment"]),
591 ] {
592 v[key] = json!(opt(&w, names[0]).or_else(|| opt(&w, names[1])));
593 }
594 v["ports"] = json!(opt(&w, "--dport")
595 .or_else(|| opt(&w, "--dports"))
596 .or_else(|| opt(&w, "--destination-port"))
597 .map(words)
598 .unwrap_or_default());
599 let mut understood = action(jump) != "other";
600 let mut i = 0;
601 while i < w.len() {
602 let option = w[i].as_str();
603 let known = matches!(
604 option,
605 "-A" | "-I"
606 | "-j"
607 | "-p"
608 | "--protocol"
609 | "-s"
610 | "--source"
611 | "-d"
612 | "--destination"
613 | "-i"
614 | "-o"
615 | "--dport"
616 | "--dports"
617 | "--destination-port"
618 | "--comment"
619 | "--reject-with"
620 );
621 if option == "-m"
622 && w.get(i + 1)
623 .is_some_and(|x| matches!(x.as_str(), "tcp" | "udp" | "multiport" | "comment"))
624 {
625 i += 2;
626 continue;
627 }
628 if known && i + 1 < w.len() {
629 i += 2;
630 } else {
631 understood = false;
632 i += 1;
633 }
634 }
635 v["understood"] = json!(understood);
636 if !chain.contains("DOCKER") && !jump.contains("DOCKER") && backend == "iptables" {
637 out.active = true;
638 }
639 if matches!(jump, "MASQUERADE" | "DNAT" | "SNAT" | "REDIRECT") {
640 v["type"] = json!(jump.to_ascii_lowercase());
641 v["translation"] = json!(opt(&w, "--to-destination")
642 .or_else(|| opt(&w, "--to-source"))
643 .or_else(|| opt(&w, "--to-ports")));
644 out.nat.push(v.clone());
645 }
646 out.rules.push(v);
647 }
648 out
651fn ufw_parse(verbose: &str, numbered: &str) -> (Vec<Value>, Value) {
652 let mut rules = Vec::new();
653 let mut p = policy();
654 for l in verbose.lines() {
655 if let Some(defaults) = l.strip_prefix("Default:") {
656 for part in defaults.split(',') {
657 let w: Vec<_> = part.split_whitespace().collect();
658 if w.len() < 2 {
659 continue;
660 }
661 let key = if part.contains("incoming") {
662 "input"
663 } else if part.contains("outgoing") {
664 "output"
665 } else if part.contains("routed") {
666 "forward"
667 } else {
668 continue;
669 };
670 let a = action(w[0]);
671 if a != "other" {
672 p[key] = json!(a);
673 }
674 }
675 }
676 }
677 let source = if numbered.lines().any(|l| l.trim_start().starts_with('[')) {
678 numbered
679 } else {
680 verbose
681 };
682 let mut in_rules = false;
683 for line in source.lines() {
684 if line.trim_start().starts_with("--") {
685 in_rules = true;
686 continue;
687 }
688 let mut t = line.trim();
689 if !in_rules && !t.starts_with('[') {
690 continue;
691 }
692 if t.is_empty() {
693 continue;
694 }
695 let mut number = None;
696 if let Some(rest) = t.strip_prefix('[') {
697 if let Some((n, r)) = rest.split_once(']') {
698 number = n.trim().parse::<u64>().ok();
699 t = r.trim();
700 }
701 }
702 let (row, comment) = t
703 .split_once('#')
704 .map_or((t, None), |(r, c)| (r.trim(), Some(c.trim())));
705 let mut v = rule("ufw", line);
706 v["managed"] = json!(true);
707 v["number"] = json!(number);
708 v["text"] = json!(row);
709 v["comment"] = json!(comment);
710 v["family"] = json!(if row.contains("(v6)") { 6 } else { 4 });
711 v["id"] = json!(format!("ufw:{}:{}", row, v["family"]));
712 let w: Vec<_> = row.split_whitespace().collect();
713 let Some(ai) = w
714 .iter()
715 .position(|s| matches!(*s, "ALLOW" | "DENY" | "REJECT" | "LIMIT" | "LOG"))
716 else {
717 rules.push(v);
718 continue;
719 };
720 v["direction"] = json!(if w.get(ai + 1) == Some(&"OUT") {
721 "out"
722 } else if w.get(ai + 1) == Some(&"FWD") {
723 "forward"
724 } else {
725 "in"
726 });
727 v["action"] = json!(action(w[ai]));
728 let target = w[..ai]
729 .iter()
730 .filter(|s| **s != "(v6)")
731 .copied()
732 .collect::<Vec<_>>();
733 if let Some(on) = target.iter().position(|s| *s == "on") {
734 v["interface"] = json!(target.get(on + 1));
735 }
736 if let Some(first) = target.first() {
737 let (port, proto) = first
738 .split_once('/')
739 .map_or((*first, None), |(a, b)| (a, Some(b)));
740 v["protocol"] = json!(proto);
741 if port
742 .chars()
743 .all(|c| c.is_ascii_digit() || c == ':' || c == ',')
744 {
745 v["ports"] = json!(port.split(',').collect::<Vec<_>>());
746 v["understood"] = json!(w[ai] != "LIMIT");
747 } else if port == "Anywhere" {
748 v["understood"] = json!(true);
749 }
750 }
751 let from_start = ai
752 + 1
753 + usize::from(
754 w.get(ai + 1)
755 .is_some_and(|s| matches!(*s, "IN" | "OUT" | "FWD")),
756 );
757 if let Some(from) = w.get(from_start) {
758 if *from != "Anywhere" {
759 let key = if v["direction"] == "out" {
760 "destination"
761 } else {
762 "source"
763 };
764 v[key] = json!(from);
765 }
766 }
767 rules.push(v);
768 }
769 (rules, p)
772fn firewalld_zones(s: &str) -> Vec<Value> {
773 let mut zones: Vec<Value> = Vec::new();
774 let mut section = String::new();
775 for line in s.lines() {
776 let t = line.trim();
777 if t.is_empty() {
778 continue;
779 }
780 if !line.starts_with(char::is_whitespace) && !t.contains(':') {
781 let name = t.split_whitespace().next().unwrap_or("");
782 zones.push(json!({"name": name, "active": t.contains("active)"), "interfaces": [], "sources": [], "target": null, "services": [], "ports": [], "details": {}, "richRules": [], "raw": line}));
783 section.clear();
784 } else if let Some(z) = zones.last_mut() {
785 if let Some(raw) = z["raw"].as_str() {
786 z["raw"] = json!(format!("{}\n{}", raw, line));
787 }
788 if section == "rich rules" && t.starts_with("rule ") {
789 if let Some(a) = z["richRules"].as_array_mut() {
790 a.push(json!(t));
791 }
792 } else if section == "forward-ports" && t.starts_with("port=") {
793 let old = text(&z["details"]["forward-ports"]);
794 z["details"]["forward-ports"] = json!(format!("{} {}", old, t).trim());
795 } else if let Some((k, val)) = t.split_once(':') {
796 let k = k.trim();
797 let val = val.trim();
798 section = k.to_owned();
799 if matches!(k, "interfaces" | "sources" | "services" | "ports") {
800 z[k] = json!(words(val));
801 } else if k == "target" {
802 z[k] = json!(val.to_ascii_lowercase());
803 }
804 z["details"][k] = json!(val);
805 if k == "rich rules" && !val.is_empty() {
806 if let Some(a) = z["richRules"].as_array_mut() {
807 a.push(json!(val));
808 }
809 }
810 }
811 }
812 }
813 zones
815fn service_port(service: &str) -> Option<(&str, &str)> {
816 match service {
817 "ssh" => Some(("22", "tcp")),
818 "http" => Some(("80", "tcp")),
819 "https" => Some(("443", "tcp")),
820 "dhcpv6-client" => Some(("546", "udp")),
821 "cockpit" => Some(("9090", "tcp")),
822 _ => None,
823 }
825fn zone_entry(zone: &str, kind: &str, value: &str, raw: &str) -> Value {
826 let mut v = rule("firewalld", raw);
827 v["managed"] = json!(true);
828 v["zone"] = json!(zone);
829 v["kind"] = json!(kind);
830 v["value"] = json!(value);
831 v["id"] = json!(format!("firewalld:{zone}:{kind}:{value}"));
832 match kind {
833 "port" | "source-port" | "service" | "protocol" => {
834 v["direction"] = json!("in");
835 v["action"] = json!("accept");
836 let port = if kind == "service" {
837 v["service"] = json!(value);
838 service_port(value)
839 } else if kind == "protocol" {
840 v["protocol"] = json!(value);
841 v["understood"] = json!(true);
842 None
843 } else {
844 value.split_once('/')
845 };
846 if let Some((p, proto)) = port {
847 if kind == "source-port" {
848 // A source port allowance does not open a destination port.
849 v["sourcePorts"] = json!([p.replace('-', ":")]);
850 } else {
851 v["ports"] = json!([p.replace('-', ":")]);
852 }
853 v["protocol"] = json!(proto);
854 v["understood"] = json!(true);
855 }
856 }
857 "forward-port" => {
858 v["type"] = json!("dnat");
859 v["translation"] = json!(value);
860 }
861 "masquerade" => {
862 v["type"] = json!("masquerade");
863 v["understood"] = json!(true);
864 }
865 "rich" => v["direction"] = json!("in"),
866 "source" => v["source"] = json!(value),
867 "interface" => v["interface"] = json!(value),
868 _ => {}
869 }
870 v
872fn zone_rules(s: &str) -> Vec<Value> {
873 let mut rules = Vec::new();
874 let mut zone = "";
875 let mut section = "";
876 for line in s.lines() {
877 let t = line.trim();
878 if t.is_empty() {
879 continue;
880 }
881 if !line.starts_with(char::is_whitespace) && !t.contains(':') {
882 zone = t.split_whitespace().next().unwrap_or("");
883 section = "";
884 continue;
885 }
886 if section == "rich rules" && t.starts_with("rule ") {
887 rules.push(zone_entry(zone, "rich", t, line));
888 continue;
889 }
890 if section == "forward-ports" && t.starts_with("port=") {
891 rules.push(zone_entry(zone, "forward-port", t, line));
892 continue;
893 }
894 let Some((key, val)) = t.split_once(':') else {
895 continue;
896 };
897 section = key.trim();
898 let val = val.trim();
899 let kind = match section {
900 "services" => "service",
901 "ports" => "port",
902 "protocols" => "protocol",
903 "sources" => "source",
904 "interfaces" => "interface",
905 "source-ports" => "source-port",
906 "icmp-blocks" => "icmp-block",
907 "forward-ports" => "forward-port",
908 "rich rules" => "rich",
909 "masquerade" | "forward" | "icmp-block-inversion" if val == "yes" => section,
910 _ => continue,
911 };
912 if kind == "rich" {
913 if !val.is_empty() {
914 rules.push(zone_entry(zone, kind, val, line));
915 }
916 } else {
917 for token in val.split_whitespace() {
918 rules.push(zone_entry(zone, kind, token, line));
919 }
920 }
921 }
922 rules
924fn merged_zone_rules(parts: &[(String, String)], runtime: &[Value]) -> (Vec<Value>, Vec<Value>) {
925 let mut rules: Vec<Value> = Vec::new();
926 let mut positions = BTreeMap::new();
927 for (section, is_runtime) in [("firewalld-runtime", true), ("firewalld-permanent", false)] {
928 for mut r in zone_rules(body(parts, section)) {
929 let id = text(&r["id"]).to_owned();
930 if let Some(&i) = positions.get(&id) {
931 let existing: &mut Value = &mut rules[i];
932 existing[if is_runtime { "runtime" } else { "permanent" }] = json!(true);
933 } else {
934 r["runtime"] = json!(is_runtime);
935 r["permanent"] = json!(!is_runtime);
936 r["zoneActive"] = json!(runtime
937 .iter()
938 .any(|z| z["name"] == r["zone"] && z["active"] == true));
939 positions.insert(id, rules.len());
940 rules.push(r);
941 }
942 }
943 }
944 let nat = rules
945 .iter()
946 .filter(|r| !r["type"].is_null())
947 .cloned()
948 .collect();
949 (rules, nat)
951fn zone_diff(runtime: &[Value], permanent: &[Value]) -> Vec<Value> {
952 let mut changes = Vec::new();
953 let names: BTreeSet<_> = runtime
954 .iter()
955 .chain(permanent)
956 .filter_map(|z| z["name"].as_str())
957 .collect();
958 for name in names {
959 let r = runtime.iter().find(|z| z["name"] == name);
960 let p = permanent.iter().find(|z| z["name"] == name);
961 let mut fields = BTreeSet::new();
962 for z in [r, p].into_iter().flatten() {
963 if let Some(m) = z["details"].as_object() {
964 fields.extend(m.keys().cloned());
965 }
966 }
967 for key in fields {
968 let rv = r.map(|z| z["details"][&key].clone()).unwrap_or(Value::Null);
969 let pv = p.map(|z| z["details"][&key].clone()).unwrap_or(Value::Null);
970 let normalized = |v: &Value| {
971 let mut w = words(text(v));
972 w.sort();
973 w
974 };
975 if normalized(&rv) != normalized(&pv) {
976 changes.push(json!({"zone": name, "field": key, "runtime": rv, "permanent": pv}));
977 }
978 }
979 let rv = r.map(|z| z["richRules"].clone()).unwrap_or(Value::Null);
980 let pv = p.map(|z| z["richRules"].clone()).unwrap_or(Value::Null);
981 if rv != pv {
982 changes
983 .push(json!({"zone": name, "field": "richRules", "runtime": rv, "permanent": pv}));
984 }
985 if r.is_none() || p.is_none() {
986 changes.push(json!({"zone": name, "field": "presence", "runtime": r.is_some(), "permanent": p.is_some()}));
987 }
988 }
989 changes
992fn uci_rules(blocks: &[Uci], network: &[Uci]) -> (Vec<Value>, Vec<Value>, Vec<Value>) {
993 let mut rules = Vec::new();
994 let mut zones = Vec::new();
995 let mut nat = Vec::new();
996 for b in blocks {
997 let m = &b.fields;
998 if b.kind == "zone" {
999 let nets = words(field(m, "network"));
1000 let interfaces: Vec<_> = network
1001 .iter()
1002 .filter(|n| nets.contains(&n.name))
1003 .filter_map(|n| n.fields.get("device").or_else(|| n.fields.get("ifname")))
1004 .flat_map(|s| words(s))
1005 .collect();
1006 zones.push(json!({"name": field(m, "name"), "active": !interfaces.is_empty(), "interfaces": interfaces, "sources": words(field(m, "subnet")), "target": field(m, "input").to_ascii_lowercase(), "services": [], "ports": [], "configured": true}));
1007 if field(m, "masq") == "1" || field(m, "masq6") == "1" {
1008 let mut v = rule("fw4", &b.raw);
1009 v["type"] = json!("masquerade");
1010 v["zone"] = json!(field(m, "name"));
1011 v["configured"] = json!(true);
1012 v["managed"] = json!(true);
1013 v["id"] = json!(format!("fw4:{}:{}:{}:masquerade", b.kind, b.name, json!(m)));
1014 v["family"] = if field(m, "masq6") == "1" {
1015 json!(6)
1016 } else {
1017 json!(4)
1018 };
1019 nat.push(v);
1021 } else if matches!(b.kind.as_str(), "rule" | "forwarding" | "redirect" | "nat") {
1022 let mut v = rule("fw4", &b.raw);
1023 v["configured"] = json!(true);
1024 v["managed"] = json!(true);
1025 v["id"] = json!(format!("fw4:{}:{}:{}", b.kind, b.name, json!(m)));
1026 v["enabled"] = json!(field(m, "enabled") != "0");
1027 v["direction"] = json!(if b.kind == "forwarding" || !field(m, "dest").is_empty() {
1028 "forward"
1029 } else if field(m, "src").is_empty() {
1030 "out"
1031 } else {
1032 "in"
1033 });
1034 v["family"] = match field(m, "family") {
1035 "ipv4" => json!(4),
1036 "ipv6" => json!(6),
1037 _ => json!("both"),
1038 };
1039 let proto = field(m, "proto");
1040 v["protocol"] = if proto == "icmp" && v["family"] == 6 {
1041 json!("icmpv6")
1042 } else if proto.is_empty() {
1043 Value::Null
1044 } else {
1045 json!(proto)
1046 };
1047 v["ports"] = json!(words(field(m, "dest_port")));
1048 v["source"] = m.get("src_ip").map_or(Value::Null, |s| json!(s));
1049 v["destination"] = m.get("dest_ip").map_or(Value::Null, |s| json!(s));
1050 v["zone"] = m.get("src").map_or(Value::Null, |s| json!(s));
1051 v["comment"] = m.get("name").map_or(Value::Null, |s| json!(s));
1052 v["action"] = json!(if b.kind == "forwarding" {
1053 "accept"
1054 } else {
1055 action(field(m, "target"))
1056 });
1057 v["understood"] = json!(
1058 v["action"] != "other"
1059 && m.keys().all(|k| matches!(
1060 k.as_str(),
1061 "name"
1062 | "src"
1063 | "dest"
1064 | "proto"
1065 | "src_ip"
1066 | "dest_ip"
1067 | "dest_port"
1068 | "target"
1069 | "family"
1070 | "enabled"
1071 ))
1072 );
1073 if b.kind == "redirect" || b.kind == "nat" {
1074 v["type"] = json!(if field(m, "target").is_empty() {
1075 "dnat".to_owned()
1076 } else {
1077 field(m, "target").to_ascii_lowercase()
1078 });
1079 v["ports"] = json!(words(field(m, "src_dport")));
1080 v["translation"] = json!({"address": m.get("dest_ip"), "port": m.get("dest_port")});
1081 nat.push(v.clone());
1083 rules.push(v);
1086 (rules, zones, nat)
1089fn service(parts: &[(String, String)], name: &str) -> (Option<bool>, Option<bool>, bool) {
1090 for line in body(parts, "services").lines() {
1091 let w: Vec<_> = line.split_whitespace().collect();
1092 if w.len() >= 2 && w[0] == name {
1093 return (
1094 Some(w[1] == "active"),
1095 w.get(2).and_then(|s| match *s {
1096 "enabled" | "enabled-runtime" => Some(true),
1097 "disabled" | "masked" | "not-found" => Some(false),
1098 _ => None,
1099 }),
1100 w.get(2) != Some(&"not-found") && w.len() >= 3,
1101 );
1104 (None, None, false)
1106fn denied(s: &str) -> bool {
1107 let s = s.to_ascii_lowercase();
1109 "permission denied",
1110 "operation not permitted",
1111 "need to be root",
1112 "must be root",
1113 "not authorized",
1114 "access denied",
1116 .iter()
1117 .any(|e| s.contains(e))
1119fn allows_port(v: &Value, port: u16) -> bool {
1120 if v["action"] != "accept" || v["direction"] != "in" || v["kind"] == "source-port" {
1121 return false;
1123 if !v["interface"].is_null() && (v["interface"] == "lo" || v["interface"] == 1) {
1124 return false;
1126 if v["service"] == "ssh" && port == 22 {
1127 return true;
1129 if !v["protocol"].is_null() && v["protocol"] != "tcp" && v["protocol"] != "6" {
1130 return false;
1132 let Some(ports) = v["ports"].as_array() else {
1133 return false;
1134 };
1135 if ports.is_empty() {
1136 return v["understood"] == true
1137 && v["source"].is_null()
1138 && v["destination"].is_null()
1139 && v["interface"].is_null();
1141 ports.iter().any(|p| {
1142 let p = text(p);
1143 if p == "ssh" {
1144 return port == 22;
1146 if let Some((a, b)) = p.split_once(':') {
1147 return a
1148 .parse::<u16>()
1149 .ok()
1150 .zip(b.parse::<u16>().ok())
1151 .is_some_and(|(a, b)| (a..=b).contains(&port));
1153 p.parse::<u16>().ok() == Some(port)
1154 })
1157struct Status {
1158 active: BTreeSet<String>,
1159 ufw_active: bool,
1160 ufw_inactive: bool,
1161 firewalld_active: bool,
1162 ipt_owned: bool,
1163 readable: bool,
1164 invalid_nft: bool,
1166fn status(parts: &[(String, String)], nft: &Nft, ip4: &Ipt, ip6: &Ipt) -> Status {
1167 let ufw_text = body(parts, "ufw");
1168 let numbered = body(parts, "ufw-numbered");
1169 let ufw_active = ufw_text
1170 .lines()
1171 .chain(body(parts, "ufw-verbose").lines())
1172 .chain(numbered.lines())
1173 .any(|l| l.trim() == "Status: active");
1174 let ufw_inactive = ufw_text
1175 .lines()
1176 .chain(body(parts, "ufw-verbose").lines())
1177 .chain(numbered.lines())
1178 .any(|l| l.trim() == "Status: inactive");
1179 let firewalld_active = body(parts, "firewalld-state").trim() == "running"
1180 || service(parts, "firewalld").0 == Some(true);
1181 let mut active: BTreeSet<String> = nft.owners.clone();
1182 if ufw_active {
1183 active.insert("ufw".into());
1185 if firewalld_active {
1186 active.insert("firewalld".into());
1188 if service(parts, "pve-firewall").0 == Some(true) {
1189 active.insert("pve-firewall".into());
1191 let ipt_owned = ip4
1192 .rules
1193 .iter()
1194 .chain(&ip6.rules)
1195 .any(|r| r["backend"] == "ufw" || r["backend"] == "pve-firewall");
1196 if (ip4.active || ip6.active)
1197 && !ufw_active
1198 && !firewalld_active
1199 && !ipt_owned
1200 && !active.contains("fw4")
1201 && !active.contains("nftables")
1203 active.insert("iptables".into());
1205 if ipt_owned && !ufw_inactive {
1206 for r in ip4.rules.iter().chain(&ip6.rules) {
1207 if r["backend"] == "ufw" || r["backend"] == "pve-firewall" {
1208 active.insert(text(&r["backend"]).into());
1212 if ufw_inactive && !nft.owners.contains("ufw") && !ipt_owned {
1213 active.remove("ufw");
1215 let unreadable = [
1216 "ufw",
1217 "ufw-verbose",
1218 "ufw-numbered",
1219 "firewalld-state",
1220 "firewalld-runtime",
1221 "firewalld-permanent",
1222 "nft-json",
1223 "iptables",
1224 "ip6tables",
1225 "uci-firewall",
1227 .iter()
1228 .any(|n| denied(body(parts, n)));
1229 let invalid_nft = !body(parts, "nft-json").trim().is_empty()
1230 && !nft.valid
1231 && !denied(body(parts, "nft-json"));
1232 let readable = !unreadable && !invalid_nft;
1233 Status {
1234 active,
1235 ufw_active,
1236 ufw_inactive,
1237 firewalld_active,
1238 ipt_owned,
1239 readable,
1240 invalid_nft,
1243fn runtime_zones(parts: &[(String, String)]) -> Vec<Value> {
1244 let mut runtime = firewalld_zones(body(parts, "firewalld-runtime"));
1245 for z in &mut runtime {
1246 let active_names: Vec<_> = body(parts, "firewalld-active")
1247 .lines()
1248 .filter(|l| !l.starts_with(char::is_whitespace))
1249 .filter_map(|l| l.split_whitespace().next())
1250 .collect();
1251 if active_names.contains(&text(&z["name"]))
1252 || z["interfaces"].as_array().is_some_and(|a| !a.is_empty())
1253 || z["sources"].as_array().is_some_and(|a| !a.is_empty())
1255 z["active"] = json!(true);
1258 runtime
1260fn default_policy(
1261 status: &Status,
1262 nft: &Nft,
1263 ip4: &Ipt,
1264 ip6: &Ipt,
1265 ufw_policy: &Value,
1266 runtime: &[Value],
1267) -> Value {
1268 let mut p = policy();
1269 if status.readable {
1270 for (name, pp) in &nft.policies {
1271 if status.active.contains(name) {
1272 merge_policy(&mut p, pp);
1275 if status.active.contains("iptables")
1276 || status.ipt_owned
1277 || (status.active.is_empty() && (ip4.valid || ip6.valid))
1279 merge_policy(&mut p, &ip4.policy);
1280 merge_policy(&mut p, &ip6.policy);
1282 if status.ufw_active {
1283 for key in ["input", "output", "forward"] {
1284 if !ufw_policy[key].is_null() {
1285 p[key] = ufw_policy[key].clone();
1289 if status.firewalld_active {
1290 let targets: BTreeSet<_> = runtime
1291 .iter()
1292 .filter(|z| z["active"] == true)
1293 .filter_map(|z| z["target"].as_str())
1294 .map(|target| match target {
1295 "default" | "%%reject%%" => "reject",
1296 other => other,
1297 })
1298 .collect();
1299 if targets.len() == 1 {
1300 if let Some(target) = targets.first() {
1301 if matches!(*target, "drop" | "reject" | "accept") {
1302 p["input"] = json!(target);
1310fn firewall_findings(
1311 status: &Status,
1312 p: &Value,
1313 rules: &[Value],
1314 ssh_port: Option<u16>,
1315 differences: &[Value],
1316 v6_drop: bool,
1317) -> Vec<Value> {
1318 let mut findings = Vec::new();
1319 if status.active.len() > 1 {
1320 findings.push(finding(
1321 "warning",
1322 "several-firewalls",
1323 json!({"backends": status.active}),
1324 ));
1326 if !differences.is_empty() {
1327 findings.push(finding(
1328 "warning",
1329 "firewall-runtime-differs",
1330 json!({"differences": differences}),
1331 ));
1333 if status.ufw_inactive {
1334 findings.push(finding("recommendation", "ufw-inactive", json!({})));
1336 if status.active.is_empty() && status.readable {
1337 findings.push(finding("recommendation", "no-firewall", json!({})));
1339 if status.invalid_nft {
1340 findings.push(finding(
1341 "warning",
1342 "firewall-data-invalid",
1343 json!({"section": "nft-json"}),
1344 ));
1346 let managed: Vec<_> = rules
1347 .iter()
1348 .filter(|r| {
1349 r["managed"] == true
1350 && r["enabled"] != false
1351 && r["runtime"] != false
1352 && (r["configured"] != true || status.active.contains(text(&r["backend"])))
1353 && (r["backend"] != "ufw" || status.active.contains("ufw"))
1354 && (r["backend"] != "firewalld"
1355 || (status.firewalld_active && r["zoneActive"] == true))
1356 })
1357 .collect();
1358 if matches!(text(&p["input"]), "drop" | "reject") {
1359 if let Some(port) = ssh_port {
1360 if !managed.iter().any(|r| allows_port(r, port)) {
1361 findings.push(finding(
1362 "warning",
1363 "ssh-may-be-blocked",
1364 json!({"port": port}),
1365 ));
1368 let icmp_protocol = |r: &Value| {
1369 r["protocol"] == "icmpv6" || r["protocol"] == "ipv6-icmp" || r["protocol"] == "58"
1370 };
1371 let icmp = managed.iter().any(|r| {
1372 r["direction"] == "in"
1373 && r["action"] == "accept"
1374 && icmp_protocol(r)
1375 && r["interface"] != "lo"
1376 });
1377 // UFW and firewalld allow essential ICMPv6 in their built-in policy.
1378 // Their terminal drops and generated anti-spoofing rejects are not user blocks.
1379 let tool_allows_icmp = status.ufw_active || status.firewalld_active;
1380 let explicit_block = managed.iter().any(|r| {
1381 (icmp_protocol(r) && matches!(text(&r["action"]), "drop" | "reject"))
1382 || r["kind"] == "icmp-block-inversion"
1383 || (r["kind"] == "icmp-block"
1384 && matches!(
1385 text(&r["value"]),
1386 "neighbour-solicitation"
1387 | "neighbour-advertisement"
1388 | "router-advertisement"
1389 | "packet-too-big"
1390 ))
1391 });
1392 if v6_drop && (explicit_block || (!icmp && !tool_allows_icmp)) {
1393 findings.push(finding("warning", "icmpv6-blocked", json!({})));
1396 findings
1398fn backends(parts: &[(String, String)], status: &Status) -> Vec<Value> {
1399 let tools = body(parts, "tools");
1400 let mut backends = Vec::new();
1401 for (name, tool, unit) in [
1402 ("ufw", "ufw", "ufw"),
1403 ("firewalld", "firewall-cmd", "firewalld"),
1404 ("nftables", "nft", "nftables"),
1405 ("iptables", "iptables-save", "iptables"),
1406 ("fw4", "fw4", "firewall"),
1407 ("pve-firewall", "pve-firewall", "pve-firewall"),
1408 ] {
1409 let (state, enabled, installed) = service(parts, unit);
1410 let installed =
1411 installed || tools.lines().any(|t| t == tool) || status.active.contains(name);
1412 let mut enabled = enabled;
1413 if name == "ufw" {
1414 match field(&pairs(body(parts, "file:/etc/ufw/ufw.conf")), "ENABLED") {
1415 "yes" => enabled = Some(true),
1416 "no" => enabled = Some(false),
1417 _ => {}
1420 backends.push(json!({"name": name, "installed": installed, "active": status.active.contains(name), "enabled": enabled,
1421 "detail": {"serviceActive": state, "status": if name == "ufw" && status.ufw_inactive { Some("inactive") } else { None }, "readable": status.readable}}));
1423 backends
1425fn finish_rules(rules: &mut [Value], status: &Status, nat: bool) {
1426 let tool_active = ["ufw", "firewalld", "fw4"]
1427 .iter()
1428 .any(|name| status.active.contains(*name));
1429 let mut counts = BTreeMap::<String, usize>::new();
1430 for r in rules {
1431 if r["managed"] != true && r["plumbing"] != true {
1432 r["managed"] =
1433 json!(!tool_active && matches!(text(&r["backend"]), "nftables" | "iptables"));
1435 let base = r["id"].as_str().map(str::to_owned).unwrap_or_else(|| {
1436 format!(
1437 "{}:{}:{}:{}:{}",
1438 text(&r["backend"]),
1439 r["family"],
1440 text(&r["table"]),
1441 text(&r["chain"]),
1442 text(&r["raw"])
1444 });
1445 let base = if nat { format!("{base}:nat") } else { base };
1446 let count = counts.entry(base.clone()).or_default();
1447 *count += 1;
1448 r["id"] = json!(if *count == 1 {
1449 base
1450 } else {
1451 format!("{base}:duplicate:{count}")
1452 });
1455pub fn parse(parts: &[(String, String)], ssh_port: Option<u16>) -> Value {
1456 let mut nft = nft_parse(body(parts, "nft-json"));
1457 let mut ip4 = iptables_parse(body(parts, "iptables"), 4);
1458 let mut ip6 = iptables_parse(body(parts, "ip6tables"), 6);
1459 let status = status(parts, &nft, &ip4, &ip6);
1460 let verbose = if body(parts, "ufw-verbose").is_empty() {
1461 body(parts, "ufw")
1462 } else {
1463 body(parts, "ufw-verbose")
1464 };
1465 let (ufw_rules, ufw_policy) = ufw_parse(verbose, body(parts, "ufw-numbered"));
1466 let runtime = runtime_zones(parts);
1467 let permanent = firewalld_zones(body(parts, "firewalld-permanent"));
1468 let compared = !body(parts, "firewalld-runtime").is_empty()
1469 && !body(parts, "firewalld-permanent").is_empty()
1470 && !denied(body(parts, "firewalld-permanent"));
1471 let differences = if compared {
1472 zone_diff(&runtime, &permanent)
1473 } else {
1474 Vec::new()
1475 };
1476 let (zone_rules, zone_nat) = merged_zone_rules(parts, &runtime);
1477 let uci_text = if body(parts, "uci-firewall").is_empty() {
1478 body(parts, "file:/etc/config/firewall")
1479 } else {
1480 body(parts, "uci-firewall")
1481 };
1482 let network_text = if body(parts, "uci-network").is_empty() {
1483 body(parts, "file:/etc/config/network")
1484 } else {
1485 body(parts, "uci-network")
1486 };
1487 let (uci_rules, mut uci_zones, uci_nat) = uci_rules(&uci(uci_text), &uci(network_text));
1488 for z in &mut uci_zones {
1489 let interfaces = z["interfaces"].as_array().cloned().unwrap_or_default();
1490 z["active"] = json!(
1491 status.active.contains("fw4")
1492 && nft
1493 .rules
1494 .iter()
1495 .any(|r| r["backend"] == "fw4" && interfaces.contains(&r["interface"]))
1496 );
1498 let p = default_policy(&status, &nft, &ip4, &ip6, &ufw_policy, &runtime);
1499 let v6_drop = ip6.v6_drop
1500 || nft.v6_drop.iter().any(|n| status.active.contains(n))
1501 || status.ufw_active
1502 || status.firewalld_active;
1503 let mut rules = std::mem::take(&mut nft.rules);
1504 if status.invalid_nft {
1505 rules.push(rule("nftables", body(parts, "nft-json")));
1507 rules.extend(std::mem::take(&mut ip4.rules));
1508 rules.extend(std::mem::take(&mut ip6.rules));
1509 rules.extend(ufw_rules);
1510 rules.extend(zone_rules);
1511 rules.extend(uci_rules);
1512 let mut nat = std::mem::take(&mut nft.nat);
1513 nat.extend(std::mem::take(&mut ip4.nat));
1514 nat.extend(std::mem::take(&mut ip6.nat));
1515 nat.extend(zone_nat);
1516 nat.extend(uci_nat);
1517 finish_rules(&mut rules, &status, false);
1518 finish_rules(&mut nat, &status, true);
1519 let findings = firewall_findings(&status, &p, &rules, ssh_port, &differences, v6_drop);
1520 let backends = backends(parts, &status);
1521 let active_name = [
1522 "fw4",
1523 "ufw",
1524 "firewalld",
1525 "pve-firewall",
1526 "nftables",
1527 "iptables",
1529 .iter()
1530 .find(|n| status.active.contains(**n))
1531 .copied()
1532 .unwrap_or("none");
1533 let mut zones = runtime.clone();
1534 zones.extend(uci_zones);
1535 json!({"backends": backends, "active": active_name, "readable": status.readable, "defaultPolicy": p,
1536 "rules": rules, "zones": zones, "nat": nat, "findings": findings, "differs": if compared { Some(!differences.is_empty()) } else { None },
1537 "differences": differences, "runtimeZones": runtime, "permanentZones": permanent})
1540#[cfg(test)]
1541mod tests {
1542 use super::*;
1543 fn run(parts: &[(&str, &str)], port: Option<u16>) -> Value {
1544 let parts = parts
1545 .iter()
1546 .map(|(a, b)| (a.to_string(), b.to_string()))
1547 .collect::<Vec<_>>();
1548 parse(&parts, port)
1550 fn code(v: &Value, code: &str) -> bool {
1551 v["findings"]
1552 .as_array()
1553 .unwrap()
1554 .iter()
1555 .any(|f| f["code"] == code)
1557 #[test]
1558 fn inactive_ufw_service_is_not_loaded_firewall() {
1559 let v = run(&[("tools", "ufw\niptables-save"), ("services", "ufw active enabled"), ("ufw", "Status: inactive"), ("file:/etc/ufw/ufw.conf", "ENABLED=no"),
1560 ("iptables", "*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT")], Some(22));
1561 assert_eq!(v["active"], "none");
1562 assert_eq!(v["backends"][0]["enabled"], false);
1563 assert!(code(&v, "ufw-inactive"));
1564 assert!(code(&v, "no-firewall"));
1565 assert!(!code(&v, "ssh-may-be-blocked"));
1567 #[test]
1568 fn ufw_numbers_families_ranges_and_ssh_port() {
1569 let v = run(&[("tools", "ufw"), ("ufw", "Status: active\nDefault: deny (incoming), allow (outgoing), deny (routed)"),
1570 ("ufw-numbered", "Status: active\n\n To Action From\n -- ------ ----\n[ 1] 2222/tcp ALLOW IN 192.0.2.0/24 # admin\n[ 2] 1000:2000/udp (v6) DENY IN Anywhere (v6)\n[ 3] OpenSSH ALLOW IN Anywhere")], Some(2222));
1571 assert_eq!(v["active"], "ufw");
1572 assert_eq!(
1573 v["defaultPolicy"],
1574 json!({"input":"drop", "output":"accept", "forward":"drop"})
1575 );
1576 assert_eq!(v["rules"][0]["number"], 1);
1577 assert_eq!(v["rules"][0]["source"], "192.0.2.0/24");
1578 assert_eq!(v["rules"][0]["comment"], "admin");
1579 assert_eq!(v["rules"][1]["family"], 6);
1580 assert_eq!(v["rules"][1]["ports"], json!(["1000:2000"]));
1581 assert_eq!(v["rules"][2]["understood"], false);
1582 assert!(!code(&v, "ssh-may-be-blocked"));
1583 assert!(!code(&v, "icmpv6-blocked"));
1585 #[test]
1586 fn nft_real_json_raw_and_unexpressible_rule_survive() {
1587 let s = r#"{"nftables": [ {"chain":{"family":"inet","table":"filter","name":"input","type":"filter","hook":"input","policy":"drop"}},
1588 { "rule": { "family":"inet", "table":"filter", "chain":"input", "handle":5, "expr":[{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":{"set":[2222,{"range":[8000,9000]}]}}},{"accept":null}]} },
1589 {"rule":{"family":"inet","table":"filter","chain":"input","handle":6,"expr":[{"strange-extension":{"something":true}}]}} ]}"#;
1590 let v = run(&[("tools", "nft"), ("nft-json", s)], Some(2222));
1591 assert_eq!(v["active"], "nftables");
1592 assert_eq!(v["rules"].as_array().unwrap().len(), 2);
1593 assert_eq!(v["rules"][0]["ports"], json!(["2222", "8000:9000"]));
1594 assert_eq!(v["rules"][0]["understood"], true);
1595 assert!(s.contains(v["rules"][0]["raw"].as_str().unwrap()));
1596 assert_eq!(v["rules"][1]["understood"], false);
1597 assert!(!code(&v, "ssh-may-be-blocked"));
1598 assert!(code(&v, "icmpv6-blocked"));
1599 assert_eq!(v, run(&[("tools", "nft"), ("nft-json", s)], Some(2222)));
1601 #[test]
1602 fn nft_hook_policy_not_overwritten_by_mangle() {
1603 let v = run(
1604 &[(
1605 "nft-json",
1606 r#"{"nftables":[{"chain":{"family":"inet","table":"fw4","name":"input","type":"filter","hook":"input","policy":"drop"}},{"chain":{"family":"inet","table":"fw4","name":"mangle_input","type":"filter","hook":"input","policy":"accept"}},{"rule":{"family":"inet","table":"fw4","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"icmpv6","field":"type"}},"right":"nd-neighbor-solicit"}},{"accept":null}]}}]}"#,
1607 )],
1608 Some(22),
1609 );
1610 assert_eq!(v["defaultPolicy"]["input"], "drop");
1611 assert_eq!(v["active"], "fw4");
1612 assert!(code(&v, "icmpv6-blocked"));
1613 assert!(code(&v, "ssh-may-be-blocked"));
1615 #[test]
1616 fn iptables_chains_nat_and_unsupported_matches() {
1617 let v = run(&[("tools", "iptables-save\nip6tables-save"), ("iptables", "*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n:custom - [0:0]\n-A INPUT -j custom\n-A custom -p tcp -m multiport --dports 2222,8000:9000 -j ACCEPT -m comment --comment \"admin ports\"\n-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT\nCOMMIT\n*nat\n:PREROUTING ACCEPT [0:0]\n-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 192.0.2.10:8080\nCOMMIT"),
1618 ("ip6tables", "*filter\n:INPUT DROP [0:0]\n-A INPUT -p ipv6-icmp -j ACCEPT\nCOMMIT")], Some(2222));
1619 assert_eq!(v["active"], "iptables");
1620 assert_eq!(v["rules"][1]["direction"], "in");
1621 assert_eq!(v["rules"][1]["comment"], "admin ports");
1622 assert_eq!(v["rules"][2]["understood"], false);
1623 assert_eq!(v["nat"][0]["type"], "dnat");
1624 assert_eq!(v["nat"][0]["translation"], "192.0.2.10:8080");
1625 assert!(!code(&v, "ssh-may-be-blocked"));
1626 assert!(!code(&v, "icmpv6-blocked"));
1628 #[test]
1629 fn docker_and_ufw_do_not_create_extra_backends() {
1630 let v = run(&[("ufw", "Status: active\nDefault: deny (incoming), allow (outgoing), deny (routed)"),
1631 ("iptables", "*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n-A INPUT -j ufw-user-input\n-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n-A FORWARD -j DOCKER\n-A DOCKER -p tcp --dport 8080 -j ACCEPT\nCOMMIT")], Some(22));
1632 assert_eq!(v["active"], "ufw");
1633 assert!(!code(&v, "several-firewalls"));
1634 assert_eq!(v["rules"].as_array().unwrap().len(), 4);
1635 // Only plumbing was captured; no managed SSH allowance is known.
1636 assert!(code(&v, "ssh-may-be-blocked"));
1638 #[test]
1639 fn firewalld_zones_and_runtime_difference() {
1640 let r = "public (active)\n target: DROP\n interfaces: eth0\n sources: 192.0.2.0/24\n services: ssh\n ports: 2222/tcp 8000-9000/udp\n masquerade: yes\n forward-ports: port=80:proto=tcp:toport=8080:toaddr=192.0.2.10\n rich rules:\n rule family=\"ipv4\" source address=\"192.0.2.0/24\" accept";
1641 let p = r.replace("2222/tcp", "3333/tcp").replace(" (active)", "");
1642 let v = run(
1643 &[
1644 ("tools", "firewall-cmd\nnft"),
1645 ("firewalld-state", "running"),
1646 ("firewalld-runtime", r),
1647 ("firewalld-permanent", &p),
1648 ],
1649 Some(2222),
1650 );
1651 assert_eq!(v["active"], "firewalld");
1652 assert_eq!(v["zones"][0]["name"], "public");
1653 assert_eq!(v["zones"][0]["active"], true);
1654 assert_eq!(v["differs"], true);
1655 assert!(code(&v, "firewall-runtime-differs"));
1656 assert_eq!(v["differences"][0]["field"], "ports");
1657 assert_eq!(v["nat"].as_array().unwrap().len(), 2);
1658 assert!(!code(&v, "ssh-may-be-blocked"));
1659 assert!(v["rules"]
1660 .as_array()
1661 .unwrap()
1662 .iter()
1663 .any(|r| r["understood"] == false && text(&r["raw"]).contains("rule family")));
1664 let equal = run(
1665 &[
1666 ("firewalld-state", "running"),
1667 ("firewalld-runtime", r),
1668 ("firewalld-permanent", &r.replace(" (active)", "")),
1669 ],
1670 None,
1671 );
1672 assert_eq!(equal["differs"], false);
1674 #[test]
1675 fn multiple_governing_tools_and_denied_data() {
1676 let v = run(
1677 &[("ufw", "Status: active"), ("firewalld-state", "running")],
1678 None,
1679 );
1680 assert!(code(&v, "several-firewalls"));
1681 let v = run(
1682 &[
1683 ("services", "ufw active enabled"),
1684 ("ufw", "ERROR: You need to be root to run this script"),
1685 ("nft-json", "Operation not permitted (you must be root)"),
1686 ],
1687 Some(22),
1688 );
1689 assert_eq!(v["readable"], false);
1690 assert!(!code(&v, "no-firewall"));
1691 assert!(!code(&v, "ssh-may-be-blocked"));
1692 assert!(v["defaultPolicy"]["input"].is_null());
1694 #[test]
1695 fn uci_config_is_distinct_from_runtime() {
1696 let v = run(&[("uci-network", "network.lan=interface\nnetwork.lan.device='br-lan'"),
1697 ("uci-firewall", "firewall.@zone[0]=zone\nfirewall.@zone[0].name='lan'\nfirewall.@zone[0].network='lan'\nfirewall.@zone[0].input='ACCEPT'\nfirewall.@zone[0].masq='1'\nfirewall.@rule[0]=rule\nfirewall.@rule[0].src='wan'\nfirewall.@rule[0].proto='tcp'\nfirewall.@rule[0].dest_port='2222'\nfirewall.@rule[0].target='ACCEPT'\nfirewall.@redirect[0]=redirect\nfirewall.@redirect[0].src='wan'\nfirewall.@redirect[0].src_dport='80'\nfirewall.@redirect[0].dest_ip='192.0.2.10'\nfirewall.@redirect[0].dest_port='8080'")], Some(2222));
1698 assert_eq!(v["active"], "none");
1699 assert_eq!(v["zones"][0]["interfaces"], json!(["br-lan"]));
1700 assert_eq!(v["rules"][0]["configured"], true);
1701 assert_eq!(v["nat"].as_array().unwrap().len(), 2);
1702 assert!(v["defaultPolicy"]["input"].is_null());
1704 #[test]
1705 fn hostile_input() {
1706 for s in [
1707 "".into(),
1708 "garbage".into(),
1709 "{\"nftables\":[{".into(),
1710 "[null]".into(),
1711 "é".repeat(1_000_000),
1712 ] {
1713 let v = run(
1714 &[
1715 ("nft-json", &s),
1716 ("iptables", &s),
1717 ("ufw", &s),
1718 ("firewalld-runtime", &s),
1719 ("uci-firewall", &s),
1720 ],
1721 None,
1722 );
1723 assert!(v["rules"].is_array());
1724 assert!(v["backends"].is_array());
1727 #[test]
1728 fn nft_ufw_chains_belong_to_ufw() {
1729 let v = run(
1730 &[
1731 ("ufw", "Status: active"),
1733 "nft-json",
1734 r#"{"nftables":[{"chain":{"family":"ip","table":"filter","name":"INPUT","type":"filter","hook":"input","policy":"drop"}},{"chain":{"family":"ip","table":"filter","name":"ufw-user-input"}},{"rule":{"family":"ip","table":"filter","chain":"INPUT","expr":[{"jump":{"target":"ufw-user-input"}}]}},{"rule":{"family":"ip","table":"filter","chain":"ufw-user-input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":22}},{"accept":null}]}}]}"#,
1735 ),
1736 ],
1737 Some(22),
1738 );
1739 assert_eq!(v["active"], "ufw");
1740 assert!(!code(&v, "several-firewalls"));
1741 // Only plumbing was captured; no managed SSH allowance is known.
1742 assert!(code(&v, "ssh-may-be-blocked"));
1743 assert!(v["rules"]
1744 .as_array()
1745 .unwrap()
1746 .iter()
1747 .all(|r| r["backend"] == "ufw"));
1749 #[test]
1750 fn docker_only_forward_policy_is_not_host_firewall() {
1751 let ipt = run(&[("iptables", "*filter\n:INPUT ACCEPT [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n:DOCKER - [0:0]\n-A FORWARD -j DOCKER\n-A DOCKER -p tcp --dport 80 -j ACCEPT\nCOMMIT")], None);
1752 let nft = run(
1753 &[(
1754 "nft-json",
1755 r#"{"nftables":[{"chain":{"family":"ip","table":"filter","name":"FORWARD","type":"filter","hook":"forward","policy":"drop"}},{"chain":{"family":"ip","table":"filter","name":"DOCKER"}},{"rule":{"family":"ip","table":"filter","chain":"FORWARD","expr":[{"jump":{"target":"DOCKER"}}]}},{"rule":{"family":"ip","table":"filter","chain":"DOCKER","expr":[{"accept":null}]}}]}"#,
1756 )],
1757 None,
1758 );
1759 for v in [ipt, nft] {
1760 assert_eq!(v["active"], "none");
1761 assert_eq!(v["rules"].as_array().unwrap().len(), 2);
1762 assert!(code(&v, "no-firewall"));
1765 #[test]
1766 fn ipv6_only_nft_policy_and_multiline_firewalld() {
1767 let v = run(
1768 &[(
1769 "nft-json",
1770 r#"{"nftables":[{"chain":{"family":"ip6","table":"filter","name":"input","type":"filter","hook":"input","policy":"drop"}}]}"#,
1771 )],
1772 None,
1773 );
1774 assert!(code(&v, "icmpv6-blocked"));
1775 let v4 = run(
1776 &[(
1777 "nft-json",
1778 r#"{"nftables":[{"chain":{"family":"ip","table":"filter","name":"input","type":"filter","hook":"input","policy":"drop"}}]}"#,
1779 )],
1780 None,
1781 );
1782 assert!(!code(&v4, "icmpv6-blocked"));
1783 let r = "public (active)\n target: DROP\n forward-ports:\n port=80:proto=tcp:toport=8080:toaddr=2001:db8::10\n rich rules:\n rule family=\"ipv6\" source address=\"2001:db8::/32\" accept";
1784 let v = run(
1785 &[("firewalld-state", "running"), ("firewalld-runtime", r)],
1786 None,
1787 );
1788 assert_eq!(v["nat"].as_array().unwrap().len(), 1);
1789 assert_eq!(v["rules"].as_array().unwrap().len(), 2);
1790 assert!(v["rules"]
1791 .as_array()
1792 .unwrap()
1793 .iter()
1794 .all(|entry| r.contains(text(&entry["raw"]))));
1795 assert_eq!(v["zones"][0]["richRules"].as_array().unwrap().len(), 1);
1797 #[test]
1798 fn ufw_managed_rows_plumbing_and_foreign_rules() {
1799 let nft = r#"{"nftables":[
1800 {"chain":{"family":"ip","table":"filter","name":"INPUT","type":"filter","hook":"input","policy":"accept"}},
1801 {"chain":{"family":"ip","table":"filter","name":"ufw-user-input"}},
1802 {"rule":{"family":"ip","table":"filter","chain":"INPUT","handle":1,"expr":[{"jump":{"target":"ufw-user-input"}}]}},
1803 {"rule":{"family":"ip","table":"filter","chain":"ufw-user-input","handle":2,"expr":[{"accept":null}]}},
1804 {"rule":{"family":"ip","table":"filter","chain":"INPUT","handle":3,"expr":[{"accept":null}]}},
1805 {"rule":{"family":"inet","table":"custom","chain":"input","handle":4,"expr":[{"accept":null}]}}
1806 ]}"#;
1807 let numbered = "Status: active\n[ 1] 22/tcp ALLOW IN Anywhere\n[ 2] 58123/tcp (v6) ALLOW IN 2001:db8::/32 (v6) # admin # note\n[ 3] mystery UNKNOWN row # retained";
1808 let ipt = "*filter\n:INPUT DROP [0:0]\n-A INPUT -j ufw-user-input\n-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n-A INPUT -s 192.0.2.0/24 -j DROP\nCOMMIT";
1809 let v = run(
1810 &[
1812 "ufw-verbose",
1813 "Status: active\nDefault: deny (incoming), allow (outgoing), deny (routed)",
1814 ),
1815 ("ufw-numbered", numbered),
1816 ("nft-json", nft),
1817 ("iptables", ipt),
1818 ],
1819 Some(22),
1820 );
1821 assert_eq!(v["defaultPolicy"]["input"], "drop");
1822 let rules = v["rules"].as_array().unwrap();
1823 assert_eq!(rules.len(), 10);
1824 assert_eq!(rules.iter().filter(|r| r["plumbing"] == true).count(), 4);
1825 assert_eq!(
1826 rules
1827 .iter()
1828 .filter(|r| r["managed"] == false && r["plumbing"] == false)
1829 .count(),
1831 );
1832 let managed: Vec<_> = rules.iter().filter(|r| r["managed"] == true).collect();
1833 assert_eq!(managed.len(), 3);
1834 assert!(managed.iter().all(|r| r["plumbing"] == false));
1835 assert_eq!(managed[1]["number"], 2);
1836 assert_eq!(
1837 managed[1]["text"],
1838 "58123/tcp (v6) ALLOW IN 2001:db8::/32 (v6)"
1839 );
1840 assert_eq!(managed[1]["comment"], "admin # note");
1841 assert_eq!(managed[1]["family"], 6);
1842 assert_eq!(managed[1]["ports"], json!(["58123"]));
1843 assert_eq!(managed[1]["protocol"], "tcp");
1844 assert_eq!(managed[1]["source"], "2001:db8::/32");
1845 assert_eq!(managed[1]["action"], "accept");
1846 assert_eq!(managed[2]["understood"], false);
1847 assert!(managed.iter().all(|r| numbered.contains(text(&r["raw"]))));
1848 assert!(!code(&v, "ssh-may-be-blocked"));
1849 assert!(!code(&v, "icmpv6-blocked"));
1852 #[test]
1853 fn firewalld_entries_merge_and_keep_removal_values() {
1854 let runtime = "public (default, active)\n target: default\n interfaces: eth0 eth1\n sources: 192.0.2.0/24 2001:db8::/32\n services: ssh http https dhcpv6-client cockpit unknown-service\n ports: 58123/tcp 59000/tcp\n protocols: ipv6-icmp\n source-ports: 53/udp 123/udp\n icmp-blocks: echo-request echo-reply\n masquerade: yes\n forward: yes\n icmp-block-inversion: no\n forward-ports:\n port=80:proto=tcp:toport=8080:toaddr=2001:db8::10\n port=443:proto=tcp:toport=8443:toaddr=192.0.2.10\n rich rules:\n rule family=\"ipv4\" source address=\"192.0.2.0/24\" port port=\"5000\" protocol=\"tcp\" drop";
1855 let permanent = runtime
1856 .replace("59000/tcp", "60000/udp")
1857 .replace(" (default, active)", "");
1858 let v = run(
1859 &[
1860 ("firewalld-state", "running"),
1861 ("firewalld-runtime", runtime),
1862 ("firewalld-permanent", &permanent),
1863 ],
1864 Some(22),
1865 );
1866 let rules = v["rules"].as_array().unwrap();
1867 assert_eq!(rules.len(), 23);
1868 assert!(rules
1869 .iter()
1870 .all(|r| r["managed"] == true && r["plumbing"] == false));
1871 assert_eq!(v["defaultPolicy"]["input"], "reject");
1872 assert!(!code(&v, "ssh-may-be-blocked"));
1873 assert!(!code(&v, "icmpv6-blocked"));
1874 let get = |kind: &str, value: &str| {
1875 rules
1876 .iter()
1877 .find(|r| r["kind"] == kind && r["value"] == value)
1878 .unwrap()
1879 };
1880 for (service, port, protocol) in [
1881 ("ssh", "22", "tcp"),
1882 ("http", "80", "tcp"),
1883 ("https", "443", "tcp"),
1884 ("dhcpv6-client", "546", "udp"),
1885 ("cockpit", "9090", "tcp"),
1886 ] {
1887 let r = get("service", service);
1888 assert_eq!(r["ports"], json!([port]));
1889 assert_eq!(r["protocol"], protocol);
1890 assert_eq!(r["runtime"], true);
1891 assert_eq!(r["permanent"], true);
1893 assert_eq!(get("port", "58123/tcp")["permanent"], true);
1894 assert_eq!(get("port", "59000/tcp")["runtime"], true);
1895 assert_eq!(get("port", "59000/tcp")["permanent"], false);
1896 assert_eq!(get("port", "60000/udp")["runtime"], false);
1897 assert_eq!(get("port", "60000/udp")["permanent"], true);
1898 let rich = rules.iter().find(|r| r["kind"] == "rich").unwrap();
1899 assert!(text(&rich["value"]).starts_with("rule family=\"ipv4\""));
1900 assert_eq!(rich["runtime"], true);
1901 assert_eq!(rich["permanent"], true);
1902 assert_eq!(rich["understood"], false);
1903 assert!(runtime.contains(text(&rich["raw"])));
1904 assert_eq!(
1905 get(
1906 "forward-port",
1907 "port=80:proto=tcp:toport=8080:toaddr=2001:db8::10"
1908 )["type"],
1909 "dnat"
1910 );
1911 assert_eq!(get("source", "2001:db8::/32")["source"], "2001:db8::/32");
1912 assert_eq!(get("interface", "eth1")["interface"], "eth1");
1913 assert_eq!(get("icmp-block", "echo-request")["value"], "echo-request");
1914 assert_eq!(v["nat"].as_array().unwrap().len(), 3);
1915 assert!(v["nat"]
1916 .as_array()
1917 .unwrap()
1918 .iter()
1919 .all(|r| r["runtime"] == true && r["permanent"] == true));
1922 #[test]
1923 fn firewalld_only_active_runtime_allowances_affect_findings() {
1924 let runtime = "public (active)\n target: default\n interfaces: eth0\n services:\n source-ports: 22/tcp\n icmp-blocks: packet-too-big\ntrusted\n target: ACCEPT\n services: ssh";
1925 let permanent = runtime.replace(" services:\n", " services: ssh\n");
1926 let v = run(
1927 &[
1928 ("firewalld-state", "running"),
1929 ("firewalld-runtime", runtime),
1930 ("firewalld-permanent", &permanent),
1931 ],
1932 Some(22),
1933 );
1934 assert!(code(&v, "ssh-may-be-blocked"));
1935 assert!(code(&v, "icmpv6-blocked"));
1936 let zones = runtime
1937 .replace(" source-ports: 22/tcp", " ports: 22/tcp")
1938 .replace(" icmp-blocks: packet-too-big", " icmp-blocks:");
1939 let v = run(
1940 &[
1941 ("firewalld-state", "running"),
1942 ("firewalld-runtime", &zones),
1943 ],
1944 Some(22),
1945 );
1946 assert!(!code(&v, "ssh-may-be-blocked"));
1947 assert!(!code(&v, "icmpv6-blocked"));
1948 let v = run(
1949 &[
1950 ("firewalld-state", "running"),
1951 ("firewalld-runtime", "public (active)\n target: %%REJECT%%"),
1952 ],
1953 None,
1954 );
1955 assert_eq!(v["defaultPolicy"]["input"], "reject");
1958 #[test]
1959 fn plain_rules_are_managed_in_base_and_custom_chains() {
1960 let nft = r#"{"nftables":[{"chain":{"family":"inet","table":"custom","name":"input","type":"filter","hook":"input","policy":"drop"}},
1961 {"rule":{"family":"inet","table":"custom","chain":"input","handle":1,"expr":[{"jump":{"target":"mine"}}]}},
1962 {"rule":{"family":"inet","table":"custom","chain":"mine","handle":2,"expr":[{"accept":null}]}}]}"#;
1963 let ipt = "*filter\n:INPUT DROP [0:0]\n-A INPUT -j mine\n-A mine -p tcp --dport 22 -j ACCEPT\nCOMMIT";
1964 for v in [
1965 run(&[("nft-json", nft)], Some(22)),
1966 run(&[("iptables", ipt)], Some(22)),
1967 ] {
1968 let rules = v["rules"].as_array().unwrap();
1969 assert_eq!(rules.len(), 2);
1970 assert!(rules
1971 .iter()
1972 .all(|r| r["managed"] == true && r["plumbing"] == false));
1973 assert!(!code(&v, "ssh-may-be-blocked"));
1977 #[test]
1978 fn fw4_uses_enabled_managed_configuration_for_findings() {
1979 let nft = r#"{"nftables":[{"chain":{"family":"inet","table":"fw4","name":"input","type":"filter","hook":"input","policy":"drop"}},
1980 {"rule":{"family":"inet","table":"fw4","chain":"input","handle":1,"expr":[{"jump":{"target":"handle_reject"}}]}}]}"#;
1981 let uci = "config rule 'ssh'\n option src 'wan'\n option proto 'tcp'\n option dest_port '22'\n option target 'ACCEPT'\nconfig rule 'icmp'\n option src 'wan'\n option proto 'icmp'\n option family 'ipv6'\n option target 'ACCEPT'";
1982 let v = run(&[("nft-json", nft), ("uci-firewall", uci)], Some(22));
1983 assert_eq!(v["rules"][0]["plumbing"], true);
1984 assert_eq!(v["rules"][0]["managed"], false);
1985 for r in &v["rules"].as_array().unwrap()[1..] {
1986 assert_eq!(r["managed"], true);
1987 assert_eq!(r["plumbing"], false);
1988 assert_eq!(r["configured"], true);
1990 assert!(!code(&v, "ssh-may-be-blocked"));
1991 assert!(!code(&v, "icmpv6-blocked"));
1992 let disabled = uci.replace(
1993 " option src 'wan'",
1994 " option enabled '0'\n option src 'wan'",
1995 );
1996 let v = run(&[("nft-json", nft), ("uci-firewall", &disabled)], Some(22));
1997 assert!(code(&v, "ssh-may-be-blocked"));
1998 assert!(code(&v, "icmpv6-blocked"));
2001 #[test]
2002 fn rule_ids_survive_added_rules_and_ufw_renumbering() {
2003 let nft = r#"{"nftables":[{"rule":{"family":"ip","table":"filter","chain":"input","handle":12,"expr":[{"accept":null}]}}]}"#;
2004 let ufw = "Status: active\n[ 1] 22/tcp ALLOW IN Anywhere";
2005 let zone = "public (active)\n target: default\n ports: 58123/tcp\n masquerade: yes";
2006 let ipt = "*filter\n-A INPUT -p tcp --dport 22 -j ACCEPT\n";
2007 let uci = "config rule 'ssh'\n option src 'wan'\n option proto 'tcp'\n option dest_port '22'\n option target 'ACCEPT'\n";
2008 let before_parts = [
2009 ("nft-json", nft),
2010 ("ufw-numbered", ufw),
2011 ("firewalld-runtime", zone),
2012 ("iptables", ipt),
2013 ("uci-firewall", uci),
2014 ];
2015 let before = run(&before_parts, None);
2016 assert_eq!(before, run(&before_parts, None));
2017 let mut extra_nft: Value = serde_json::from_str(nft).unwrap();
2018 extra_nft["nftables"].as_array_mut().unwrap().push(json!({"rule":{"family":"ip","table":"filter","chain":"input","handle":13,"expr":[{"drop":null}]}}));
2019 let extra_nft = extra_nft.to_string();
2020 let extra_ufw =
2021 "Status: active\n[ 8] 22/tcp ALLOW IN Anywhere\n[ 9] 80/tcp ALLOW IN Anywhere";
2022 let extra_zone = zone.replace("58123/tcp", "58123/tcp 59000/tcp");
2023 let extra_ipt = format!("{ipt}-A INPUT -p tcp --dport 80 -j ACCEPT\n");
2024 let extra_uci = format!("{uci}\nconfig rule 'http'\n option src 'wan'\n option dest_port '80'\n option target 'ACCEPT'\n");
2025 let after = run(
2026 &[
2027 ("nft-json", &extra_nft),
2028 ("ufw-numbered", extra_ufw),
2029 ("firewalld-runtime", &extra_zone),
2030 ("iptables", &extra_ipt),
2031 ("uci-firewall", &extra_uci),
2032 ],
2033 None,
2034 );
2035 for field in ["rules", "nat"] {
2036 for r in before[field].as_array().unwrap() {
2037 assert!(
2038 after[field]
2039 .as_array()
2040 .unwrap()
2041 .iter()
2042 .any(|new| new["id"] == r["id"]),
2043 "id changed: {}",
2044 r["id"]
2045 );
2048 assert_eq!(before["rules"][0]["id"], "nft:ip:filter:input:12");
2049 let duplicate = run(
2050 &[(
2051 "iptables",
2052 &format!("{ipt}-A INPUT -p tcp --dport 22 -j ACCEPT\n"),
2053 )],
2054 None,
2055 );
2056 assert_ne!(duplicate["rules"][0]["id"], duplicate["rules"][1]["id"]);
SHA-256663bf1ccab2cfc2cb5deeb1288264b5030f85a36dfe65e1b7f71923088f7a089

CH-J Proprietary Software License 1.14 · Bron wordt geleverd onder de CH-J Proprietary Software License 1.14. De beschikbaarheid verandert de licentievoorwaarden niet of verleent geen extra rechten.

Archief SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Bestanden