// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the // modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by // one. This code exists only to read and steer the *live* state the daemon owns: who is banned now // and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's // four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the // jails and never writes the ban list. // // Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one // package this module declares on the machine, and the socket is root's: root is the module's // concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be // 38 WP4), so the client is run through sudo without a prompt where the account is not root. package main import ( "bytes" "context" "errors" "fmt" "net" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "time" ) // Runner runs one command and answers what it printed, so the verbs can be tested without a daemon. type Runner func(ctx context.Context, name string, args ...string) (string, error) // escalated is the command as it is run: as given when this process is root, else through sudo // without a prompt. The daemon's socket answers only to root. func escalated(uid int, name string, args []string) (string, []string) { if uid == 0 { return name, args } return "sudo", append([]string{"-n", name}, args...) } // installed is whether a tool is on this machine: an executable of that name on the path, or where // the system keeps its administration. func installed(tool, path string) bool { dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin") for _, dir := range dirs { if dir == "" { continue } if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { return true } } return false } var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`) func execRunner(ctx context.Context, name string, args ...string) (string, error) { if !installed(name, os.Getenv("PATH")) { return "", fmt.Errorf("%s is not installed on this machine", name) } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() program, argv := escalated(os.Getuid(), name, args) var stdout, stderr bytes.Buffer cmd := exec.CommandContext(ctx, program, argv...) cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() if err == nil { return stdout.String(), nil } said := strings.TrimSpace(stdout.String() + stderr.String()) // What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on // its own stderr line, and the rest is the client's own answer. if program == "sudo" { if errors.Is(err, exec.ErrNotFound) { return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name) } if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) { return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said) } } if socketTrouble.MatchString(said) { return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account") } // fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist"). var lines []string for _, l := range strings.Split(said, "\n") { if l = strings.TrimSpace(l); l != "" { lines = append(lines, l) } } if len(lines) > 0 { return "", errors.New(lines[len(lines)-1]) } return "", fmt.Errorf("%s failed: %v", name, err) } // Counted is a jail's count now and since it started. type Counted struct { Now int `json:"now"` Total int `json:"total"` } // Held is what a jail holds: the count now and since it started, and the addresses. type Held struct { Now int `json:"now"` Total int `json:"total"` Addresses []string `json:"addresses"` } // JailStatus is one jail as the daemon reports it. type JailStatus struct { Jail string `json:"jail"` // Watching is what the jail is reading: files or journal matches, as fail2ban names them. Watching []string `json:"watching"` // Failing is the addresses with failures counted against them now, and all failures since the // jail started. Failing Counted `json:"failing"` // Banned is the addresses held right now, and all bans since the jail started. Banned Held `json:"banned"` } // Ban is one ban as the daemon holds it. type Ban struct { IP string `json:"ip"` Jail string `json:"jail"` // Since is when the ban was placed, in the machine's local time as fail2ban prints it. Since string `json:"since"` // Until is when the ban ends; "never" for a permanent ban. Until string `json:"until"` } // JailSettings is one jail's effective settings. type JailSettings struct { Jail string `json:"jail"` Bantime string `json:"bantime"` Findtime string `json:"findtime"` Maxretry int `json:"maxretry"` Ignoreip []string `json:"ignoreip"` Actions []string `json:"actions"` Logpath []string `json:"logpath"` Journal string `json:"journalmatch"` } // Fail2ban is the daemon as this machine has it, through its own client. type Fail2ban struct { Run Runner } func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) { return f.Run(ctx, "fail2ban-client", args...) } var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`) // Jails is the jails the daemon runs, by name. func (f Fail2ban) Jails(ctx context.Context) ([]string, error) { out, err := f.client(ctx, "status") if err != nil { return nil, err } m := jailList.FindStringSubmatch(out) if m == nil { return []string{}, nil } var jails []string for _, j := range strings.Split(m[1], ",") { if j = strings.TrimSpace(j); j != "" { jails = append(jails, j) } } return jails, nil } func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) { if jail != "" { return []string{jail}, nil } return f.Jails(ctx) } // Status is every jail with what it watches and holds, or one jail's detail. func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) { names, err := f.named(ctx, jail) if err != nil { return nil, err } jails := []JailStatus{} for _, name := range names { out, err := f.client(ctx, "status", name) if err != nil { return nil, err } jails = append(jails, parseJailStatus(name, out)) } return map[string][]JailStatus{"jails": jails}, nil } // Banned is every address banned now, with the jail holding it and when the ban ends, soonest to // end first. func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) { names, err := f.named(ctx, jail) if err != nil { return nil, err } banned := []Ban{} for _, name := range names { out, err := f.client(ctx, "get", name, "banip", "--with-time") if err != nil { return nil, err } banned = append(banned, parseBans(name, out)...) } sort.SliceStable(banned, func(a, b int) bool { if banned[a].Until != banned[b].Until { return banned[a].Until < banned[b].Until } return banned[a].IP < banned[b].IP }) return map[string][]Ban{"banned": banned}, nil } // BanOutcome is a ban as held, and how many addresses the daemon said it added. type BanOutcome struct { Banned *Ban `json:"banned"` Added int `json:"added"` } // Ban bans one address in one jail now. The daemon's own answer is how many addresses it added. func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) { if err := address(ip); err != nil { return nil, err } if err := jailName(jail); err != nil { return nil, err } out, err := f.client(ctx, "set", jail, "banip", ip) if err != nil { return nil, err } added, _ := strconv.Atoi(strings.TrimSpace(out)) held, err := f.Banned(ctx, jail) if err != nil { return nil, err } outcome := &BanOutcome{Added: added} for _, b := range held["banned"] { if b.IP == ip { b := b outcome.Banned = &b } } return outcome, nil } // Released is how many bans the daemon let go, of which address, from where. type Released struct { Released int `json:"released"` IP string `json:"ip"` Jail string `json:"jail"` } // Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it // released. func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) { if err := address(ip); err != nil { return nil, err } var out string var err error if jail != "" { if err := jailName(jail); err != nil { return nil, err } out, err = f.client(ctx, "set", jail, "unbanip", ip) } else { out, err = f.client(ctx, "unban", ip) jail = "every jail" } if err != nil { return nil, err } released, _ := strconv.Atoi(strings.TrimSpace(out)) return &Released{Released: released, IP: ip, Jail: jail}, nil } // Settings is one jail's effective settings — the module's own tool, beside the seat's verbs. func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) { if err := jailName(jail); err != nil { return nil, err } got := map[string]string{} for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} { out, err := f.client(ctx, "get", jail, key) if err != nil { return nil, err } got[key] = out } maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"])) s := &JailSettings{ Jail: jail, Bantime: strings.TrimSpace(got["bantime"]), Findtime: strings.TrimSpace(got["findtime"]), Maxretry: maxretry, Ignoreip: listed(got["ignoreip"]), Actions: afterHeading(got["actions"]), Logpath: []string{}, Journal: strings.Join(afterHeading(got["journalmatch"]), " "), } if !strings.Contains(got["logpath"], "No file is currently monitored") { s.Logpath = listed(got["logpath"]) } return s, nil } var treeMarks = regexp.MustCompile("^[\\s|`-]+") // listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. func listed(out string) []string { items := []string{} for i, l := range strings.Split(out, "\n") { l = strings.TrimSpace(treeMarks.ReplaceAllString(l, "")) if i > 0 && l != "" { items = append(items, l) } } return items } // afterHeading is every non-empty line after the first, trimmed. func afterHeading(out string) []string { items := []string{} for i, l := range strings.Split(out, "\n") { if l = strings.TrimSpace(l); i > 0 && l != "" { items = append(items, l) } } return items } func parseJailStatus(jail, out string) JailStatus { field := func(label string) string { m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out) if m == nil { return "" } return strings.TrimSpace(m[1]) } num := func(label string) int { n, _ := strconv.Atoi(field(label)) return n } watching := []string{} for _, w := range []string{field("File list"), field("Journal matches")} { if w != "" { watching = append(watching, w) } } addresses := strings.Fields(field("Banned IP list")) if addresses == nil { addresses = []string{} } return JailStatus{ Jail: jail, Watching: watching, Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")}, Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses}, } } var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`) // parseBans reads `get banip --with-time`, one ban per line: "IP \tsince + seconds = until". func parseBans(jail, out string) []Ban { bans := []Ban{} for _, line := range strings.Split(out, "\n") { m := banLine.FindStringSubmatch(line) if m == nil { continue } until := m[4] if seconds, _ := strconv.Atoi(m[3]); seconds < 0 { until = "never" } bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until}) } return bans } func address(ip string) error { if net.ParseIP(ip) == nil { return fmt.Errorf("%q is not an address", ip) } return nil } var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) func jailName(jail string) error { if !jailNamed.MatchString(jail) { return fmt.Errorf("%q is not a jail's name", jail) } return nil }