Sumber yang diterbitkan
Muat turun ZIP sumber
CH-J Tools
Layari direktori dan fail untuk keluaran aplikasi tertentu.
CH-J / Server Manager
1704 fail
0.1.2-beta
Ditambah: · Dibina:
1
use super::inventory::{body, finding, pairs, uci, words, Uci};2
use serde_json::{json, Value};3
use std::collections::{BTreeMap, BTreeSet};5
fn text(v: &Value) -> &str {6
v.as_str().unwrap_or("")7
}8
fn field<'a>(m: &'a BTreeMap<String, String>, k: &str) -> &'a str {9
m.get(k).map(String::as_str).unwrap_or("")10
}11
fn 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
}19
}20
fn 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
None30
}31
}32
fn 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})37
}38
fn policy() -> Value {39
json!({"input": null, "output": null, "forward": null})40
}41
fn 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
}55
}56
fn 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
}71
}72
fn values(v: &Value) -> Vec<String> {73
if let Some(a) = v74
.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
}82
}84
// Retain the actual JSON text for each ruleset entry, including its original spacing.85
fn 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
result130
}132
#[derive(Default)]133
struct 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>,140
}141
fn 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
}156
}157
fn nft_table(v: &Value) -> String {158
format!("{}/{}", text(&v["family"]), text(&v["table"]))159
}160
fn 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 chain197
// 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_owners202
.get(&nft_table(v))203
.map(String::as_str)204
.unwrap_or(base)205
}206
fn chain_key(v: &Value, name: &str) -> String {207
format!("{}/{}/{}", text(&v["family"]), text(&v["table"]), name)208
}210
fn 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
None233
};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) = e280
.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
backend315
},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!(ds343
.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
out378
}380
fn 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
true391
}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
true403
}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
ok421
}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
false440
}441
}443
fn 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
result481
}482
fn 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())484
}485
fn 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
}493
}494
#[derive(Default)]495
struct Ipt {496
rules: Vec<Value>,497
nat: Vec<Value>,498
policy: Value,499
active: bool,500
valid: bool,501
v6_drop: bool,502
}503
fn 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!(dirs581
.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
out649
}651
fn 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
numbered679
} else {680
verbose681
};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) = t703
.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) = w714
.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) = first738
.split_once('/')739
.map_or((*first, None), |(a, b)| (a, Some(b)));740
v["protocol"] = json!(proto);741
if port742
.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 = ai752
+ 1753
+ 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)770
}772
fn 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
zones814
}815
fn 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
}824
}825
fn 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
None843
} 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
v871
}872
fn 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
rules923
}924
fn 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!(runtime937
.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 = rules945
.iter()946
.filter(|r| !r["type"].is_null())947
.cloned()948
.collect();949
(rules, nat)950
}951
fn zone_diff(runtime: &[Value], permanent: &[Value]) -> Vec<Value> {952
let mut changes = Vec::new();953
let names: BTreeSet<_> = runtime954
.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
w974
};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
changes983
.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
changes990
}992
fn 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<_> = network1001
.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);1020
}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::Null1044
} 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());1082
}1083
rules.push(v);1084
}1085
}1086
(rules, zones, nat)1087
}1089
fn 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
);1102
}1103
}1104
(None, None, false)1105
}1106
fn denied(s: &str) -> bool {1107
let s = s.to_ascii_lowercase();1108
[1109
"permission denied",1110
"operation not permitted",1111
"need to be root",1112
"must be root",1113
"not authorized",1114
"access denied",1115
]1116
.iter()1117
.any(|e| s.contains(e))1118
}1119
fn allows_port(v: &Value, port: u16) -> bool {1120
if v["action"] != "accept" || v["direction"] != "in" || v["kind"] == "source-port" {1121
return false;1122
}1123
if !v["interface"].is_null() && (v["interface"] == "lo" || v["interface"] == 1) {1124
return false;1125
}1126
if v["service"] == "ssh" && port == 22 {1127
return true;1128
}1129
if !v["protocol"].is_null() && v["protocol"] != "tcp" && v["protocol"] != "6" {1130
return false;1131
}1132
let Some(ports) = v["ports"].as_array() else {1133
return false;1134
};1135
if ports.is_empty() {1136
return v["understood"] == true1137
&& v["source"].is_null()1138
&& v["destination"].is_null()1139
&& v["interface"].is_null();1140
}1141
ports.iter().any(|p| {1142
let p = text(p);1143
if p == "ssh" {1144
return port == 22;1145
}1146
if let Some((a, b)) = p.split_once(':') {1147
return a1148
.parse::<u16>()1149
.ok()1150
.zip(b.parse::<u16>().ok())1151
.is_some_and(|(a, b)| (a..=b).contains(&port));1152
}1153
p.parse::<u16>().ok() == Some(port)1154
})1155
}1157
struct 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,1165
}1166
fn 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_text1170
.lines()1171
.chain(body(parts, "ufw-verbose").lines())1172
.chain(numbered.lines())1173
.any(|l| l.trim() == "Status: active");1174
let ufw_inactive = ufw_text1175
.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());1184
}1185
if firewalld_active {1186
active.insert("firewalld".into());1187
}1188
if service(parts, "pve-firewall").0 == Some(true) {1189
active.insert("pve-firewall".into());1190
}1191
let ipt_owned = ip41192
.rules1193
.iter()1194
.chain(&ip6.rules)1195
.any(|r| r["backend"] == "ufw" || r["backend"] == "pve-firewall");1196
if (ip4.active || ip6.active)1197
&& !ufw_active1198
&& !firewalld_active1199
&& !ipt_owned1200
&& !active.contains("fw4")1201
&& !active.contains("nftables")1202
{1203
active.insert("iptables".into());1204
}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());1209
}1210
}1211
}1212
if ufw_inactive && !nft.owners.contains("ufw") && !ipt_owned {1213
active.remove("ufw");1214
}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",1226
]1227
.iter()1228
.any(|n| denied(body(parts, n)));1229
let invalid_nft = !body(parts, "nft-json").trim().is_empty()1230
&& !nft.valid1231
&& !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,1241
}1242
}1243
fn 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())1254
{1255
z["active"] = json!(true);1256
}1257
}1258
runtime1259
}1260
fn 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);1273
}1274
}1275
if status.active.contains("iptables")1276
|| status.ipt_owned1277
|| (status.active.is_empty() && (ip4.valid || ip6.valid))1278
{1279
merge_policy(&mut p, &ip4.policy);1280
merge_policy(&mut p, &ip6.policy);1281
}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();1286
}1287
}1288
}1289
if status.firewalld_active {1290
let targets: BTreeSet<_> = runtime1291
.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);1303
}1304
}1305
}1306
}1307
}1308
p1309
}1310
fn 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
));1325
}1326
if !differences.is_empty() {1327
findings.push(finding(1328
"warning",1329
"firewall-runtime-differs",1330
json!({"differences": differences}),1331
));1332
}1333
if status.ufw_inactive {1334
findings.push(finding("recommendation", "ufw-inactive", json!({})));1335
}1336
if status.active.is_empty() && status.readable {1337
findings.push(finding("recommendation", "no-firewall", json!({})));1338
}1339
if status.invalid_nft {1340
findings.push(finding(1341
"warning",1342
"firewall-data-invalid",1343
json!({"section": "nft-json"}),1344
));1345
}1346
let managed: Vec<_> = rules1347
.iter()1348
.filter(|r| {1349
r["managed"] == true1350
&& r["enabled"] != false1351
&& r["runtime"] != false1352
&& (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
));1366
}1367
}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!({})));1394
}1395
}1396
findings1397
}1398
fn 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
_ => {}1418
}1419
}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}}));1422
}1423
backends1424
}1425
fn 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"));1434
}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"])1443
)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
base1450
} else {1451
format!("{base}:duplicate:{count}")1452
});1453
}1454
}1455
pub 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
&& nft1493
.rules1494
.iter()1495
.any(|r| r["backend"] == "fw4" && interfaces.contains(&r["interface"]))1496
);1497
}1498
let p = default_policy(&status, &nft, &ip4, &ip6, &ufw_policy, &runtime);1499
let v6_drop = ip6.v6_drop1500
|| nft.v6_drop.iter().any(|n| status.active.contains(n))1501
|| status.ufw_active1502
|| 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")));1506
}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",1528
]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})1538
}1540
#[cfg(test)]1541
mod tests {1542
use super::*;1543
fn run(parts: &[(&str, &str)], port: Option<u16>) -> Value {1544
let parts = parts1545
.iter()1546
.map(|(a, b)| (a.to_string(), b.to_string()))1547
.collect::<Vec<_>>();1548
parse(&parts, port)1549
}1550
fn code(v: &Value, code: &str) -> bool {1551
v["findings"]1552
.as_array()1553
.unwrap()1554
.iter()1555
.any(|f| f["code"] == code)1556
}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"));1566
}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"));1584
}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)));1600
}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"));1614
}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"));1627
}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"));1637
}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);1673
}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());1693
}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());1703
}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());1725
}1726
}1727
#[test]1728
fn nft_ufw_chains_belong_to_ufw() {1729
let v = run(1730
&[1731
("ufw", "Status: active"),1732
(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"));1748
}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"));1763
}1764
}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);1796
}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
&[1811
(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
rules1827
.iter()1828
.filter(|r| r["managed"] == false && r["plumbing"] == false)1829
.count(),1830
31831
);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"));1850
}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 = runtime1856
.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!(rules1869
.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
rules1876
.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);1892
}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));1920
}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 = runtime1937
.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");1956
}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!(rules1971
.iter()1972
.all(|r| r["managed"] == true && r["plumbing"] == false));1973
assert!(!code(&v, "ssh-may-be-blocked"));1974
}1975
}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);1989
}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"));1999
}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
);2046
}2047
}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"]);2057
}2058
}SHA-256
663bf1ccab2cfc2cb5deeb1288264b5030f85a36dfe65e1b7f71923088f7a089CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.
SHA-256 arkib
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f