From 99c4c4ef048d2d124ec178e0073cc5ad338cfbac Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 17:25:34 +0200 Subject: [PATCH] A jail's pattern names once, and is refused by name when it does not (hq ADR 0179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fail2ban expands into a named capture group, so a pattern naming it twice is a duplicate group name: the daemon refuses its whole configuration and exits, and the machine keeps no bans at all — for every jail, not the one at fault. Hit live on the control node the day the jails shipped. A jail with no name, no pattern, or a name another of the module's jails took is refused too. --- internal/catalogue/manifest.go | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 1a532ec..93db44a 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1667,6 +1667,7 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.undeclaredMounts()...) problems = append(problems, m.unknownDirRefs()...) problems = append(problems, m.unknownAccessRefs()...) + problems = append(problems, m.jailProblems()...) for i, r := range m.Resources { id, _ := r["id"].(string) @@ -1773,6 +1774,44 @@ var facilitiesOf = map[string][]string{ "virtualisation": {"/var/lib/incus/unix.socket"}, } +// jailProblems is every jail this module declares that the machine's intrusion prevention would +// refuse (novox/hq ADR 0179). +// +// **Because one bad pattern stops every jail, not its own.** fail2ban expands `` into a named +// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the +// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one +// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern +// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several +// patterns, one per line, as fail2ban's own filters are written. +func (m Manifest) jailProblems() []string { + var problems []string + seen := map[string]bool{} + for _, j := range m.Jails { + switch { + case strings.TrimSpace(j.Name) == "": + problems = append(problems, m.Module+" declares a jail with no name") + case seen[j.Name]: + problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name)) + } + seen[j.Name] = true + if strings.TrimSpace(j.Failregex) == "" { + problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like") + } + for _, line := range strings.Split(j.Failregex, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if n := strings.Count(line, ""); n > 1 { + problems = append(problems, fmt.Sprintf("%s's jail %s names %d times in one pattern; "+ + "fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+ + "keeps no bans at all — write one pattern per shape, each naming once", + m.Module, strconv.Quote(j.Name), n)) + } + } + } + return problems +} + // undeclaredMounts is every bind-mount source no declaration covers — see the check above. func (m Manifest) undeclaredMounts() []string { declared := map[string]bool{}