Go is the default for module code. One binary, fail2ban-tools, serving the node-intrusion-prevention seat's four verbs and fail2ban_settings over the SDK, with the same parsing and the same tests; read back against the control node's live daemon.
408 lines
12 KiB
Go
408 lines
12 KiB
Go
// 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 <jail> 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
|
|
}
|