Read fail2ban's bans as no firewall, and an iptables-nft reject as a refusal, from rulesets captured on a lab machine (hq ADR 0100)

This commit is contained in:
2026-09-22 18:04:48 +02:00
parent 8e2f75454d
commit da65f84c45
5 changed files with 448 additions and 21 deletions
+168 -21
View File
@@ -109,16 +109,31 @@ func statusActive(out string) bool {
// drop or reject, or a base chain whose policy drops — and that is neither the mesh's own nor the
// container runtime's. With ufw active, the tables iptables-nft manages are ufw's and the runtime's
// and are not counted.
//
// **A ban is not a firewall.** fail2ban refuses the sources it banned and passes everything else;
// captured on a lab machine with both of its backends (testdata/fail2ban-nftables.nft,
// testdata/fail2ban-iptables.nft). The mesh opens nothing through it and it closes nothing the
// mesh needs, so a refusal that names the sources it refuses, in a table or a chain that accepts
// nothing and is entered only from chains whose policy accepts, is not counted.
func Refusing(ruleset string, ufwActive bool) []string {
var refusing []string
type rule struct{ table, chain, line string }
type chainOf struct {
base, dropping, accepts bool
policyLine string
jumpedFrom []string
}
chains := map[string]*chainOf{} // by "table\x00chain"
tableAccepts := map[string]bool{}
var tables []string
var refusals []rule
managed := map[string]bool{}
var table, chain string
counted := map[string]bool{}
note := func() {
if !counted[table] {
counted[table] = true
refusing = append(refusing, "table "+table)
get := func(t, c string) *chainOf {
k := t + "\x00" + c
if chains[k] == nil {
chains[k] = &chainOf{}
}
return chains[k]
}
for _, raw := range strings.Split(ruleset, "\n") {
line := strings.TrimSpace(raw)
@@ -131,34 +146,108 @@ func Refusing(ruleset string, ufwActive bool) []string {
case strings.HasPrefix(line, "table "):
table = strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, "table ")), "{")
table = strings.TrimSpace(table)
tables = append(tables, table)
chain = ""
continue
case strings.HasPrefix(line, "chain "):
chain = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "chain "), "{"))
get(table, chain)
continue
case strings.HasPrefix(line, "set ") || strings.HasPrefix(line, "map ") ||
strings.HasPrefix(line, "flowtable "):
chain = ""
continue
case line == "" || line == "}" || strings.HasPrefix(line, "#") || chain == "":
continue
}
if table == "inet mesh" || table == "inet mesh_guard" {
continue
}
iptables := managed[table] || iptablesTable(table)
if iptables && ufwActive {
continue
}
c := get(table, chain)
if strings.HasPrefix(line, "type ") {
if strings.Contains(line, "policy drop") && !(iptables && runtimes(table, chain, line)) {
note()
c.base = true
c.policyLine = line
c.dropping = strings.Contains(line, "policy drop")
continue
}
for _, verb := range []string{"jump ", "goto "} {
if i := strings.Index(line, verb); i >= 0 {
target := strings.Fields(line[i+len(verb):])
if len(target) > 0 {
get(table, target[0]).jumpedFrom = append(get(table, target[0]).jumpedFrom, chain)
}
}
}
if accepts(line) {
c.accepts = true
tableAccepts[table] = true
}
if verdictRefuses(line) {
refusals = append(refusals, rule{table, chain, line})
}
}
skipped := func(table string) bool {
if table == "inet mesh" || table == "inet mesh_guard" {
return true
}
return (managed[table] || iptablesTable(table)) && ufwActive
}
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts
// nothing and whose base chains all accept by default, or in a chain that accepts nothing and
// is entered only from base chains that accept by default.
onlyBans := func(r rule) bool {
if !bansSources(r.line) {
return false
}
allAccepting := true
for k, c := range chains {
if strings.HasPrefix(k, r.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
allAccepting = false
}
}
if !tableAccepts[r.table] && allAccepting {
return true
}
c := get(r.table, r.chain)
if c.base || c.accepts || len(c.jumpedFrom) == 0 {
return false
}
for _, from := range c.jumpedFrom {
caller := get(r.table, from)
if !caller.base || !strings.Contains(caller.policyLine, "policy accept") {
return false
}
}
return true
}
counted := map[string]bool{}
for k, c := range chains {
t, name, _ := strings.Cut(k, "\x00")
if skipped(t) || !c.dropping {
continue
}
if !verdictRefuses(line) {
if (managed[t] || iptablesTable(t)) && runtimes(t, name, c.policyLine) {
continue
}
if iptables && runtimes(table, chain, line) {
counted[t] = true
}
for _, r := range refusals {
if skipped(r.table) || counted[r.table] {
continue
}
note()
if (managed[r.table] || iptablesTable(r.table)) && runtimes(r.table, r.chain, r.line) {
continue
}
if onlyBans(r) {
continue
}
counted[r.table] = true
}
var refusing []string
for _, t := range tables {
if counted[t] {
counted[t] = false
refusing = append(refusing, "table "+t)
}
}
return refusing
}
@@ -194,15 +283,73 @@ func runtimes(table, chain, line string) bool {
return false
}
var verdict = regexp.MustCompile(`(^|\s)(drop|reject)(\s|$)`)
// iptables-nft prints a REJECT target it cannot translate as `xt target "REJECT"`, measured in
// testdata/fail2ban-iptables.nft; a refusal written that way is a refusal too.
var verdict = regexp.MustCompile(`(^|\s)(drop|reject)(\s|$)|xt target "(DROP|REJECT)"`)
func verdictRefuses(line string) bool {
return verdict.MatchString(line)
}
var acceptVerdict = regexp.MustCompile(`(^|\s)accept(\s|;|$)|xt target "ACCEPT"`)
func accepts(line string) bool {
return acceptVerdict.MatchString(line)
}
// bansSources is whether a refusal names the sources it refuses — a set or an address — rather
// than refusing everyone but some.
func bansSources(line string) bool {
f := strings.Fields(line)
for i, w := range f {
if (w == "saddr" || w == "-s") && i+1 < len(f) && f[i+1] != "!=" && !strings.HasPrefix(f[i+1], "!") {
return i == 0 || f[i-1] != "!"
}
}
return false
}
// RefusingLegacy names the chains of an `iptables-legacy -S` that refuse traffic outside the
// container runtime's own.
// container runtime's own. A ban — a refusal of the sources it names, in a chain that accepts
// nothing and is entered only from built-in chains whose policy accepts — is not counted, as in
// Refusing (testdata/fail2ban-iptables-S.txt).
func RefusingLegacy(rules string) []string {
policy := map[string]string{}
accepting := map[string]bool{}
jumpedFrom := map[string][]string{}
for _, line := range strings.Split(rules, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
switch fields[0] {
case "-P":
policy[fields[1]] = fields[2]
case "-A":
for i, f := range fields {
if (f == "-j" || f == "-g") && i+1 < len(fields) {
switch fields[i+1] {
case "ACCEPT":
accepting[fields[1]] = true
case "DROP", "REJECT", "RETURN", "LOG":
default:
jumpedFrom[fields[i+1]] = append(jumpedFrom[fields[i+1]], fields[1])
}
}
}
}
}
ban := func(chain, line string) bool {
if !bansSources(line) || accepting[chain] || len(jumpedFrom[chain]) == 0 {
return false
}
for _, from := range jumpedFrom[chain] {
if policy[from] != "ACCEPT" {
return false
}
}
return true
}
var refusing []string
seen := map[string]bool{}
for _, line := range strings.Split(rules, "\n") {
@@ -218,7 +365,7 @@ func RefusingLegacy(rules string) []string {
case "-A":
for i, f := range fields {
if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") {
refuses = !strings.HasPrefix(chain, "DOCKER")
refuses = !strings.HasPrefix(chain, "DOCKER") && !ban(chain, line)
}
}
}