commit 2e3f79236c8fc006d0892c99bff7dfb35da91b41 Author: SchiWaGoA Date: Sun Oct 25 15:38:17 2026 +0100 Initial version of fail2ban-webui Web interface to view, ban and unban fail2ban IPs on several hosts via SSH, generalised from the private f2b-web tool. - Web UI (Flask/Waitress): hosts.yaml, local/OIDC/proxy authentication, English/German UI, origin map, optional Loki/Grafana/GeoIP, CLI helpers - Agent f2b-webctl with install.sh/uninstall.sh (dedicated user + sudo, from= restriction, protected networks, manual jail) - Hardened Docker image, examples, documentation, smoke test, testbed Co-Authored-By: Claude Opus 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0079177 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +dev +examples +docs +**/__pycache__ +**/*.pyc +data +config +.env diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a07e87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +data/ +config/hosts.yaml +dev/testbed/data/ +dev/testbed/.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..096328e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes are listed here. Versions follow [Semantic Versioning](https://semver.org). + +## [Unreleased] – 1.0.0 + +First public version, generalised from a private single-site tool. + +### Web UI +- Hosts in `config/hosts.yaml` (name, address, port, user); `F2B_HOSTS` still accepted +- Authentication: local user (scrypt hash, lockout after failed logins), OpenID Connect, + reverse-proxy mode with optional source-IP check and user header +- English and German UI with a language dropdown; new languages via `locales/xx.json` +- Optional Loki history, Grafana links and GeoIP (DB-IP Lite auto-download) +- Origin map: banned IPs as points on a world map (bundled, no external tiles); click filters the list +- CLI: `hash-password`, `keygen`, `keyscan`, `check` +- Hardened image: non-root, read-only root filesystem, no pip/setuptools at runtime, + SSH client ignores the system ssh_config and never uses GSSAPI +- Log lines (`LOGIN_FAILED user=… ip=…`) are safe for fail2ban: user-controlled values + are sanitised, so no extra log lines can be injected + +### Agent +- `f2b-webctl` reads `/etc/f2b-webctl.conf`; private, own and configured addresses are never banned +- Error codes for translated messages, `version` command, fallback for fail2ban without `--with-time` +- `install.sh` / `uninstall.sh`: dedicated user with sudo rule (or root), `from=` restriction, + custom authorized_keys file, optional manual jail, backups, dry run, self test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32b8a0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thomas Werner Schwaighofer (retronerd.AT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3fc532 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# fail2ban-webui + +A small, self-hosted web interface to **view, ban and unban fail2ban IPs on several hosts** – +bare metal, VMs, LXC containers or Docker hosts. + +- One page for all hosts: current bans per jail, origin (GeoIP) with a world map, search and filters +- Ban an IP permanently on any selection of hosts, unban per jail or everywhere +- Audit log of every action (who, when, from where) +- Optional: log history per IP from **Loki**, links into **Grafana** +- Sign-in with a local user, **OpenID Connect** (e.g. Authentik) or your reverse proxy +- English and German UI, more languages via one JSON file +- Runs as a hardened container (read-only, no capabilities, non-root), `amd64` and `arm64` + +``` + Browser ──HTTPS──▶ reverse proxy ──▶ fail2ban-webui (Docker) + │ SSH, one restricted key + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + host A (bare) LXC container Docker host + f2b-webctl f2b-webctl f2b-webctl + fail2ban fail2ban fail2ban +``` + +The web UI never gets a shell on your hosts. Every host runs a tiny agent, +[`f2b-webctl`](agent/f2b-webctl), as SSH *forced command*. It only knows +`list`, `ban `, `unban `, `unban-all ` and `version`, refuses to ban +private networks, the host's own addresses or anything on its protected list, and logs +every action to syslog. Access to the fail2ban socket would be equivalent to root – +the agent is the security boundary, not the web UI. + +## Quick start + +```bash +mkdir -p /opt/fail2ban-webui/{config,data} && cd /opt/fail2ban-webui +curl -fsSLo docker-compose.yaml https://git.retronerd.at/schiwagoa/fail2ban-webui/raw/branch/main/examples/docker-compose.yaml +curl -fsSLo .env https://git.retronerd.at/schiwagoa/fail2ban-webui/raw/branch/main/examples/.env.example +curl -fsSLo config/hosts.yaml https://git.retronerd.at/schiwagoa/fail2ban-webui/raw/branch/main/examples/hosts.yaml.example +chown -R 10050:10050 data + +docker compose run --rm -it fail2ban-webui hash-password # paste the line into .env +docker compose run --rm fail2ban-webui keygen # public key for the agents +# on every fail2ban host: install the agent (docs/agent.md), then: +docker compose run --rm -it fail2ban-webui keyscan +docker compose run --rm fail2ban-webui check +docker compose up -d +``` + +The UI listens on `127.0.0.1:8080` – put a reverse proxy with TLS in front of it. + +## Documentation + +| | | +|---|---| +| [Installation](docs/installation.md) | web UI step by step, from `mkdir` to the first login | +| [Agent](docs/agent.md) | installing `f2b-webctl` on bare metal, LXC and Docker hosts | +| [Configuration](docs/configuration.md) | every setting in `.env` and `hosts.yaml` | +| [Authentication](docs/authentication.md) | local login, OpenID Connect (Authentik), reverse-proxy auth | +| [Reverse proxy](docs/reverse-proxy.md) | Nginx Proxy Manager, Caddy, Traefik, nginx | +| [Operations](docs/operations.md) | logs, updates, backups, troubleshooting | +| [Development](docs/development.md) | local testbed, tests, adding a language | +| [Security](SECURITY.md) | security model and reporting vulnerabilities | + +## Requirements + +- Web UI: Docker with Compose v2, `amd64` or `arm64` +- Every fail2ban host: fail2ban ≥ 0.11 (start/end times need `--with-time`), `python3`, + `sshd`, `sudo` (or a root key), `bash` + +## License + +[MIT](LICENSE). IP geolocation by [DB-IP Lite](https://db-ip.com) (CC BY 4.0), downloaded +only when `GEOIP_AUTO_DOWNLOAD=1`. World map from [Natural Earth](https://www.naturalearthdata.com) +(public domain), bundled with the app. + +--- + +

+ 🏰 Website · + 🐘 Mastodon · + 🔒 Datenschutz · + 💬 Support +

+ +

+ +

+ +

© Thomas Werner Schwaighofer · retronerd.AT · MIT Lizenz

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..297d289 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security + +## Reporting a vulnerability + +Please do **not** open a public issue. Send a description and, if possible, steps to +reproduce to the maintainer via a private message on +[git.retronerd.at](https://git.retronerd.at/schiwagoa). You will get an answer within a +few days; fixes are released as soon as possible and mentioned in the changelog. + +## Security model + +| Part | Trust | Protection | +|---|---|---| +| Browser → web UI | untrusted | login (local / OIDC / proxy), CSRF token on every change, Origin check, strict CSP, no caching | +| Web UI container | holds the SSH key for all agents | non-root (UID 10050), read-only root FS, no capabilities, `no-new-privileges`, bound to 127.0.0.1 by default | +| Web UI → agent | SSH with a single ed25519 key | host keys pinned (`StrictHostKeyChecking=yes`), key restricted with `restrict`, `from=` and a forced command | +| Agent (`f2b-webctl`) | runs as root via one sudo rule | fixed command whitelist, strict IP/jail validation, never bans private, own or protected addresses, syslog of every action | + +What the web UI **cannot** do on a host: open a shell, forward ports, run other commands, +change fail2ban configuration, or ban protected addresses. + +What an attacker **with access to the web UI** can do: ban and unban public IPs on the +configured hosts. Protect the UI accordingly (strong password or SSO, reverse proxy with +TLS, ideally an IP allow list). + +Recommendations: + +- Always install the agent with `--from
`. +- Keep the `data/` directory (SSH key, session secret) readable for UID 10050 only. +- Rebuild or pull the image regularly – the base image receives Debian security updates. diff --git a/agent/f2b-webctl b/agent/f2b-webctl new file mode 100755 index 0000000..67933b6 --- /dev/null +++ b/agent/f2b-webctl @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# ===================================================================== +# f2b-webctl – restricted fail2ban control for fail2ban-webui +# Path: /usr/local/sbin/f2b-webctl (root:root, 0750) +# Config: /etc/f2b-webctl.conf +# +# Runs only as SSH "forced command" of the fail2ban-webui key, e.g. +# restrict,from="198.51.100.10",command="sudo -n /usr/local/sbin/f2b-webctl" ssh-ed25519 AAAA… +# The requested command comes from $SSH_ORIGINAL_COMMAND +# (when started by hand as root: from the arguments): +# +# list all jails with banned IPs as JSON +# ban ban an IP permanently in the manual jail +# unban unban an IP in one jail +# unban-all unban an IP in all jails +# version agent version +# +# Everything else is rejected. Every action goes to syslog (tag f2b-webctl). +# Errors carry "error_key"/"error_args" so the web UI can translate them. +# ===================================================================== +import ipaddress +import json +import os +import re +import shlex +import socket +import subprocess +import sys +import syslog + +VERSION = "1.0.0" +CONF_FILE = "/etc/f2b-webctl.conf" +JAIL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$") +BAN_RE = re.compile( + r"^(\S+)\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) \+ (-?\d+) = (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)") + +# Never banned, regardless of the configuration: loopback, private, link-local, +# CGNAT, multicast and reserved ranges +BUILTIN_PROTECTED = ( + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", + "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4", + "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", +) + + +# --------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------- +def out(obj, code=0): + print(json.dumps(obj, ensure_ascii=False)) + sys.exit(code) + + +def fail(key, text, code=1, **args): + out({"ok": False, "error_key": key, "error_args": args, "error": text}, code) + + +def client(): + return os.environ.get("SSH_CLIENT", "local").split(" ")[0] + + +def log(msg): + syslog.openlog("f2b-webctl", 0, syslog.LOG_AUTH) + syslog.syslog(syslog.LOG_NOTICE, f"{msg} (from {client()})") + + +def load_conf(): + conf = {"MANUAL_JAIL": "manual-block", "PROTECTED": "", + "PROTECT_OWN_ADDRESSES": "yes", "PROTECT_CLIENT": "yes"} + try: + with open(CONF_FILE, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + value = " ".join(shlex.split(value, comments=True)) if value.strip() else "" + conf[key.strip().upper()] = value + except FileNotFoundError: + pass + return conf + + +def own_addresses(): + addrs = set() + try: + r = subprocess.run(["ip", "-o", "addr", "show"], capture_output=True, text=True, timeout=5) + addrs.update(m.split("/")[0] for m in re.findall(r"\binet6? (\S+)", r.stdout)) + except (OSError, subprocess.SubprocessError): + pass + try: + for info in socket.getaddrinfo(socket.getfqdn(), None): + addrs.add(info[4][0]) + except OSError: + pass + return addrs + + +def protected_networks(conf): + nets = [ipaddress.ip_network(n) for n in BUILTIN_PROTECTED] + for item in re.split(r"[\s,]+", conf["PROTECTED"]): + if item: + try: + nets.append(ipaddress.ip_network(item, strict=False)) + except ValueError: + log(f"invalid entry in PROTECTED ignored: {item!r}") + extra = set() + if conf["PROTECT_OWN_ADDRESSES"].lower() in ("yes", "true", "1"): + extra |= own_addresses() + if conf["PROTECT_CLIENT"].lower() in ("yes", "true", "1") and client() != "local": + extra.add(client()) + for a in extra: + try: + nets.append(ipaddress.ip_network(a.split("%")[0])) + except ValueError: + pass + return nets + + +def f2b(*args): + return subprocess.run(["fail2ban-client", *args], capture_output=True, text=True, timeout=30) + + +def jails(): + r = f2b("status") + if r.returncode != 0: + detail = (r.stderr or r.stdout).strip()[:200] + fail("agent_f2b_down", f"fail2ban is not reachable: {detail}", 1, detail=detail) + m = re.search(r"Jail list:\s*(.*)", r.stdout) + return [j.strip() for j in m.group(1).split(",") if j.strip()] if m else [] + + +def valid_ip(value): + try: + return str(ipaddress.ip_address(value)) + except ValueError: + fail("agent_invalid_ip", f"Invalid IP address: {value!r}", 2, value=value) + + +def result(r): + return (r.stdout or r.stderr).strip()[:300] + + +# --------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------- +def cmd_list(conf): + data = {"host": socket.gethostname(), "agent_version": VERSION, + "manual_jail": conf["MANUAL_JAIL"], "jails": {}} + with_time = True + for jail in jails(): + bans = [] + if with_time: + r = f2b("get", jail, "banip", "--with-time") + if r.returncode == 0: + for line in r.stdout.splitlines(): + m = BAN_RE.match(line.strip()) + if m: + bans.append({"ip": m[1], "start": m[2], "bantime": int(m[3]), "end": m[4]}) + else: + with_time = False # older fail2ban without --with-time + if not with_time: + r = f2b("get", jail, "banip") + for ip in re.split(r"[\s,]+", r.stdout.strip()): + try: + ipaddress.ip_address(ip) + except ValueError: + continue + bans.append({"ip": ip, "start": "", "bantime": None, "end": ""}) + total = re.search(r"Total banned:\s*(\d+)", f2b("status", jail).stdout) + data["jails"][jail] = {"bans": bans, "total": int(total[1]) if total else None} + out({"ok": True, **data}) + + +def cmd_ban(conf, ip): + addr = ipaddress.ip_address(ip) + if any(addr in net for net in protected_networks(conf)): + log(f"REJECTED ban {ip} (protected address)") + fail("agent_protected", f"{ip} is protected (private network, own address or PROTECTED list).", 3, ip=ip) + jail = conf["MANUAL_JAIL"] + if jail not in jails(): + fail("agent_no_manual_jail", f"Jail {jail!r} does not exist on this host.", 2, jail=jail) + r = f2b("set", jail, "banip", ip) + ok = r.returncode == 0 + log(f"ban {ip} -> {jail}: {'OK' if ok else result(r)}") + out({"ok": ok, "jail": jail, "ip": ip, "result": result(r)}, 0 if ok else 1) + + +def cmd_unban(jail, ip): + if jail not in jails(): + fail("agent_unknown_jail", f"Unknown jail: {jail!r}", 2, jail=jail) + r = f2b("set", jail, "unbanip", ip) + ok = r.returncode == 0 + log(f"unban {ip} from {jail}: {'OK' if ok else result(r)}") + out({"ok": ok, "jail": jail, "ip": ip, "result": result(r)}, 0 if ok else 1) + + +def cmd_unban_all(ip): + r = f2b("unban", ip) + ok = r.returncode == 0 + log(f"unban-all {ip}: {'OK' if ok else result(r)}") + out({"ok": ok, "jail": "*", "ip": ip, "result": result(r)}, 0 if ok else 1) + + +def main(): + if os.geteuid() != 0: + fail("agent_not_root", "f2b-webctl must run as root (check the sudo rule).", 4) + original = os.environ.get("SSH_ORIGINAL_COMMAND") + args = original.split() if original is not None else sys.argv[1:] + conf = load_conf() + if args == ["list"]: + cmd_list(conf) + elif args == ["version"]: + out({"ok": True, "agent_version": VERSION}) + elif len(args) == 2 and args[0] == "ban": + cmd_ban(conf, valid_ip(args[1])) + elif len(args) == 3 and args[0] == "unban" and JAIL_RE.fullmatch(args[1]): + cmd_unban(args[1], valid_ip(args[2])) + elif len(args) == 2 and args[0] == "unban-all": + cmd_unban_all(valid_ip(args[1])) + else: + log(f"REJECTED unknown command: {' '.join(args)[:100]!r}") + fail("agent_not_allowed", "Command not allowed.", 2) + + +if __name__ == "__main__": + main() diff --git a/agent/install.sh b/agent/install.sh new file mode 100755 index 0000000..8556fb2 --- /dev/null +++ b/agent/install.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# ===================================================================== +# fail2ban-webui agent – installer +# +# Installs on a host with fail2ban (bare metal, VM, LXC, Docker host): +# /usr/local/sbin/f2b-webctl restricted fail2ban control (forced command) +# /etc/f2b-webctl.conf agent settings (protected networks, manual jail) +# user f2bweb + /etc/sudoers.d/f2b-webctl (not with --user root) +# authorized_keys entry restrict,from=…,command=… +# jail manual-block for manual bans (only if it does not exist yet) +# +# Idempotent: running it again updates the script and replaces the key entry. +# Existing files are backed up to --backup-dir first. +# +# Example: +# ./install.sh --key-file id_ed25519.pub --from 198.51.100.10 +# ===================================================================== +set -euo pipefail + +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT_BIN=/usr/local/sbin/f2b-webctl +CONF_FILE=/etc/f2b-webctl.conf +SUDOERS_FILE=/etc/sudoers.d/f2b-webctl + +USER_NAME=f2bweb +KEY="" +KEY_FILE="" +FROM="" +AK_FILE="" +MANUAL_JAIL=manual-block +BANACTION="" +PROTECT="" +INSTALL_JAIL=1 +FORCE_CONFIG=0 +BACKUP_DIR=/var/backups/fail2ban-webui-agent +DRY_RUN=0 +YES=0 + +usage() { + cat <<'EOF' +Usage: install.sh --key-file FILE | --key "ssh-ed25519 AAAA…" [options] + +Required: + --key-file FILE public key of the web UI (from: fail2ban-webui keygen) + --key "KEY" the same, given directly + +Recommended: + --from ADDRESSES only accept the key from these IPs/networks + (comma separated, e.g. 198.51.100.10,2001:db8::10) + +Options: + --user NAME SSH user of the agent (default: f2bweb, uses sudo); + "root" works without sudo + --authorized-keys-file PATH where the key entry goes + (default: ~USER/.ssh/authorized_keys) + --manual-jail NAME jail for manual bans (default: manual-block) + --banaction ACTION banaction for a newly created manual jail + (default: the banaction of your jail.local) + --protect NETWORKS additional addresses that must never be banned + (comma separated, e.g. your home IP) + --no-jail do not create the manual jail + --force-config overwrite an existing /etc/f2b-webctl.conf + --backup-dir DIR backups of changed files (default: /var/backups/fail2ban-webui-agent) + --dry-run only show what would be done + -y, --yes do not ask for confirmation + -h, --help this help +EOF +} + +die() { echo "ERROR: $*" >&2; exit 1; } +warn() { echo "WARNING: $*" >&2; } +info() { echo "==> $*"; } +need() { [ $# -ge 2 ] && [ -n "$2" ] || die "option $1 needs a value"; } + +while [ $# -gt 0 ]; do + case "$1" in + --key) need "$@"; KEY="$2"; shift 2 ;; + --key-file) need "$@"; KEY_FILE="$2"; shift 2 ;; + --from) need "$@"; FROM="$2"; shift 2 ;; + --user) need "$@"; USER_NAME="$2"; shift 2 ;; + --authorized-keys-file) need "$@"; AK_FILE="$2"; shift 2 ;; + --manual-jail) need "$@"; MANUAL_JAIL="$2"; shift 2 ;; + --banaction) need "$@"; BANACTION="$2"; shift 2 ;; + --protect) need "$@"; PROTECT="$2"; shift 2 ;; + --no-jail) INSTALL_JAIL=0; shift ;; + --force-config) FORCE_CONFIG=1; shift ;; + --backup-dir) need "$@"; BACKUP_DIR="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -y|--yes) YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown option: $1" ;; + esac +done + +# --------------------------------------------------------------------- +# Checks (read only) +# --------------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "please run as root" +[ -f "$SRC_DIR/f2b-webctl" ] || die "$SRC_DIR/f2b-webctl not found – run install.sh from the agent directory" +command -v python3 >/dev/null || die "python3 is missing (apt install python3)" +command -v fail2ban-client >/dev/null || die "fail2ban-client not found – is fail2ban installed?" +fail2ban-client ping >/dev/null 2>&1 || die "fail2ban is not running (fail2ban-client ping failed)" +SSHD=$(command -v sshd || echo /usr/sbin/sshd) +[ -x "$SSHD" ] || die "sshd not found – the agent is reached via SSH" + +[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid user name: $USER_NAME" +[[ "$MANUAL_JAIL" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$ ]] || die "invalid jail name: $MANUAL_JAIL" +[ -z "$FROM" ] || [[ "$FROM" =~ ^[0-9A-Fa-f.:/,*?!]+$ ]] || die "invalid --from: $FROM" +[ -z "$PROTECT" ] || [[ "$PROTECT" =~ ^[0-9A-Fa-f.:/,]+$ ]] || die "invalid --protect: $PROTECT" +[ -z "$BANACTION" ] || [[ "$BANACTION" != *$'\n'* ]] || die "invalid --banaction" + +if [ -n "$KEY_FILE" ]; then + [ -r "$KEY_FILE" ] || die "cannot read $KEY_FILE" + KEY=$(grep -m1 -E '^(ssh-|ecdsa-|sk-)' "$KEY_FILE" || true) +fi +[ -n "$KEY" ] || die "no key given (--key-file or --key)" +read -r KEY_TYPE KEY_BLOB _ <<<"$KEY" +[[ "$KEY_TYPE" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com)$ ]] \ + || die "unsupported key type: $KEY_TYPE" +[[ "$KEY_BLOB" =~ ^[A-Za-z0-9+/]+=*$ ]] || die "the key looks broken" +KEY_FP=$(ssh-keygen -lf /dev/stdin <<<"$KEY_TYPE $KEY_BLOB" 2>/dev/null) || die "ssh-keygen cannot read the key" + +if [ "$USER_NAME" = root ]; then + FORCED_CMD="$AGENT_BIN" + HOME_DIR=/root +else + command -v sudo >/dev/null || die "sudo is missing (apt install sudo) – or use --user root" + command -v visudo >/dev/null || die "visudo is missing" + FORCED_CMD="sudo -n $AGENT_BIN" + HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6 || true) + [ -n "$HOME_DIR" ] || HOME_DIR=/var/lib/$USER_NAME +fi +AK_FILE=${AK_FILE:-$HOME_DIR/.ssh/authorized_keys} + +OPTS="restrict" +[ -z "$FROM" ] || OPTS="$OPTS,from=\"$FROM\"" +OPTS="$OPTS,command=\"$FORCED_CMD\"" +AK_LINE="$OPTS $KEY_TYPE $KEY_BLOB fail2ban-webui" + +JAIL_EXISTS=0 +fail2ban-client status "$MANUAL_JAIL" >/dev/null 2>&1 && JAIL_EXISTS=1 +F2B_VERSION=$(fail2ban-client version 2>/dev/null | tail -n1 || echo "?") +AGENT_VERSION=$(sed -n 's/^VERSION = "\(.*\)"/\1/p' "$SRC_DIR/f2b-webctl") + +# sshd: is the chosen file read at all, and may the user log in? +SSHD_T=$("$SSHD" -T -C "user=$USER_NAME,host=localhost,addr=127.0.0.1" 2>/dev/null || true) +AK_OK=0 +for entry in $(awk '/^authorizedkeysfile /{for(i=2;i<=NF;i++)print $i}' <<<"$SSHD_T"); do + entry=${entry//%h/$HOME_DIR}; entry=${entry//%u/$USER_NAME}; entry=${entry//%%/%} + [[ "$entry" = /* ]] || entry="$HOME_DIR/$entry" + [ "$entry" = "$AK_FILE" ] && AK_OK=1 +done + +# --------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------- +cat </dev/null || hostname) + + script $AGENT_BIN + config $CONF_FILE $( [ -e "$CONF_FILE" ] && [ $FORCE_CONFIG -eq 0 ] && echo "(exists, kept)" ) + SSH user $USER_NAME $( [ "$USER_NAME" != root ] && ! id "$USER_NAME" >/dev/null 2>&1 && echo "(will be created)" ) + sudo rule $( [ "$USER_NAME" = root ] && echo "not needed" || echo "$SUDOERS_FILE" ) + key file $AK_FILE + key $KEY_FP + allowed from ${FROM:-ANY ADDRESS (consider --from)} + manual jail $MANUAL_JAIL $( [ $JAIL_EXISTS -eq 1 ] && echo "(exists, kept)" || { [ $INSTALL_JAIL -eq 1 ] && echo "(will be created)" || echo "(MISSING – bans will fail)"; } ) + backups $BACKUP_DIR +EOF +echo +[ $AK_OK -eq 1 ] || warn "sshd does not read $AK_FILE (AuthorizedKeysFile) – the key would not work" +grep -qE '^(allowusers|allowgroups) ' <<<"$SSHD_T" && warn "sshd has AllowUsers/AllowGroups – make sure $USER_NAME is allowed" +[ "$USER_NAME" = root ] && grep -q '^permitrootlogin no' <<<"$SSHD_T" && warn "PermitRootLogin is 'no' – use the default user f2bweb instead" +[ -n "$FROM" ] || warn "without --from the key works from every address" + +if [ $DRY_RUN -eq 1 ]; then + echo "authorized_keys entry:"; echo " $AK_LINE"; echo + info "dry run – nothing changed" + exit 0 +fi +if [ $YES -eq 0 ]; then + [ -t 0 ] || die "no terminal for the confirmation – use --yes" + read -r -p "Install? [y/N] " answer + [[ "$answer" =~ ^[yYjJ] ]] || die "aborted, nothing changed" +fi + +# --------------------------------------------------------------------- +# Backup +# --------------------------------------------------------------------- +STAMP=$(date +%Y-%m-%d_%H-%M-%S) +BK="$BACKUP_DIR/agent_$STAMP" +for f in "$AGENT_BIN" "$CONF_FILE" "$SUDOERS_FILE" "$AK_FILE" \ + "/etc/fail2ban/jail.d/$MANUAL_JAIL.local" "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf"; do + if [ -e "$f" ]; then + mkdir -p "$BK"; chmod 700 "$BACKUP_DIR" "$BK" + cp -a --parents "$f" "$BK/" + fi +done +[ -d "$BK" ] && info "backup: $BK" + +# --------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------- +info "installing $AGENT_BIN" +install -o root -g root -m 0750 "$SRC_DIR/f2b-webctl" "$AGENT_BIN" + +if [ ! -e "$CONF_FILE" ] || [ $FORCE_CONFIG -eq 1 ]; then + info "writing $CONF_FILE" + umask 022 + cat > "$CONF_FILE" </dev/null 2>&1; then + info "creating user $USER_NAME" + useradd --system --create-home --home-dir "$HOME_DIR" --shell /bin/sh \ + --comment "fail2ban-webui agent" "$USER_NAME" + fi + # "*" = no password, but not locked (a locked account may be refused by sshd) + usermod -p '*' "$USER_NAME" + + info "writing $SUDOERS_FILE" + tmp=$(mktemp) + cat > "$tmp" </dev/null || { rm -f "$tmp"; die "sudoers syntax check failed"; } + install -o root -g root -m 0440 "$tmp" "$SUDOERS_FILE" + rm -f "$tmp" +fi + +info "adding key to $AK_FILE" +AK_DIR=$(dirname "$AK_FILE") +mkdir -p "$AK_DIR" +touch "$AK_FILE" +tmp=$(mktemp) +grep -vF "$KEY_BLOB" "$AK_FILE" > "$tmp" || true +echo "$AK_LINE" >> "$tmp" +cat "$tmp" > "$AK_FILE" +rm -f "$tmp" +if [ "$AK_DIR" = "$HOME_DIR/.ssh" ]; then + chown "$USER_NAME:" "$AK_DIR" + chmod 700 "$AK_DIR" +fi +chown "$USER_NAME:" "$AK_FILE" 2>/dev/null || true +chmod 600 "$AK_FILE" + +if [ $JAIL_EXISTS -eq 0 ] && [ $INSTALL_JAIL -eq 1 ]; then + info "creating jail $MANUAL_JAIL" + cat > "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf" <" +[Definition] +failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES \$ +ignoreregex = +EOF + touch "/var/log/fail2ban-$MANUAL_JAIL.log" + { + echo "# fail2ban-webui: permanent manual bans from the web UI" + echo "[$MANUAL_JAIL]" + echo "enabled = true" + echo "filter = $MANUAL_JAIL" + echo "backend = polling" + echo "logpath = /var/log/fail2ban-$MANUAL_JAIL.log" + echo "maxretry = 1" + echo "bantime = -1" + [ -z "$BANACTION" ] || echo "banaction = $BANACTION" + } > "/etc/fail2ban/jail.d/$MANUAL_JAIL.local" + fail2ban-client reload >/dev/null || die "fail2ban reload failed – check: fail2ban-client -t" + fail2ban-client status "$MANUAL_JAIL" >/dev/null 2>&1 || die "jail $MANUAL_JAIL did not start – check the fail2ban log" +fi + +# --------------------------------------------------------------------- +# Self test +# --------------------------------------------------------------------- +info "self test" +if [ "$USER_NAME" = root ]; then + run_test() { SSH_ORIGINAL_COMMAND=list "$AGENT_BIN"; } +else + # same path as over SSH: as $USER_NAME through the sudo rule + run_test() { runuser -u "$USER_NAME" -- env SSH_ORIGINAL_COMMAND=list sudo -n "$AGENT_BIN"; } +fi +run_test | python3 -c ' +import json, sys +d = json.loads(sys.stdin.read()) +if not d.get("ok"): + sys.exit("self test failed: " + d.get("error", "?")) +print(" %d jails, %d bans: %s" % (len(d["jails"]), sum(len(j["bans"]) for j in d["jails"].values()), ", ".join(d["jails"]))) +' + +cat <&2; exit 1; } +info() { echo "==> $*"; } +need() { [ $# -ge 2 ] && [ -n "$2" ] || die "option $1 needs a value"; } + +while [ $# -gt 0 ]; do + case "$1" in + --user) need "$@"; USER_NAME="$2"; shift 2 ;; + --authorized-keys-file) need "$@"; AK_FILE="$2"; shift 2 ;; + --remove-user) REMOVE_USER=1; shift ;; + --remove-jail) REMOVE_JAIL=1; shift ;; + --manual-jail) need "$@"; MANUAL_JAIL="$2"; shift 2 ;; + --backup-dir) need "$@"; BACKUP_DIR="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -y|--yes) YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown option: $1" ;; + esac +done + +[ "$(id -u)" -eq 0 ] || die "please run as root" +[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid user name: $USER_NAME" +[ "$USER_NAME" != root ] || [ $REMOVE_USER -eq 0 ] || die "root cannot be removed" +if [ -z "$MANUAL_JAIL" ]; then + MANUAL_JAIL=$(sed -n 's/^MANUAL_JAIL=//p' "$CONF_FILE" 2>/dev/null | tail -n1) + MANUAL_JAIL=${MANUAL_JAIL:-manual-block} +fi +[[ "$MANUAL_JAIL" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$ ]] || die "invalid jail name: $MANUAL_JAIL" +HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6 || true) +AK_FILE=${AK_FILE:-${HOME_DIR:-/nonexistent}/.ssh/authorized_keys} +JAIL_FILES=("/etc/fail2ban/jail.d/$MANUAL_JAIL.local" "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf") + +KEY_LINES=0 +[ -f "$AK_FILE" ] && KEY_LINES=$(grep -c 'command="[^"]*f2b-webctl"' "$AK_FILE" || true) + +echo +echo "fail2ban-webui agent – removing from $(hostname -f 2>/dev/null || hostname):" +echo " key entries $KEY_LINES in $AK_FILE" +for f in "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE"; do + [ -e "$f" ] && echo " file $f" +done +[ $REMOVE_JAIL -eq 1 ] && for f in "${JAIL_FILES[@]}"; do [ -e "$f" ] && echo " jail file $f"; done +[ $REMOVE_USER -eq 1 ] && id "$USER_NAME" >/dev/null 2>&1 && echo " user $USER_NAME (with home directory)" +echo " backups $BACKUP_DIR" +echo +if [ $REMOVE_JAIL -eq 1 ]; then + echo "WARNING: removing jail $MANUAL_JAIL lifts all manual bans!" >&2 + if fail2ban-client get "$MANUAL_JAIL" banip >/dev/null 2>&1; then + echo " currently banned: $(fail2ban-client get "$MANUAL_JAIL" banip | tr '\n' ' ')" >&2 + fi +fi +[ $DRY_RUN -eq 0 ] || { info "dry run – nothing changed"; exit 0; } +if [ $YES -eq 0 ]; then + [ -t 0 ] || die "no terminal for the confirmation – use --yes" + read -r -p "Remove? [y/N] " answer + [[ "$answer" =~ ^[yYjJ] ]] || die "aborted, nothing changed" +fi + +STAMP=$(date +%Y-%m-%d_%H-%M-%S) +BK="$BACKUP_DIR/uninstall_$STAMP" +mkdir -p "$BK"; chmod 700 "$BACKUP_DIR" "$BK" +for f in "$AK_FILE" "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE" "${JAIL_FILES[@]}"; do + [ -e "$f" ] && cp -a --parents "$f" "$BK/" +done +info "backup: $BK" + +if [ "$KEY_LINES" -gt 0 ]; then + info "removing key entries from $AK_FILE" + tmp=$(mktemp) + grep -v 'command="[^"]*f2b-webctl"' "$AK_FILE" > "$tmp" || true + cat "$tmp" > "$AK_FILE" + rm -f "$tmp" +fi +for f in "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE"; do + [ -e "$f" ] && { info "removing $f"; rm -f "$f"; } +done +if [ $REMOVE_JAIL -eq 1 ]; then + for f in "${JAIL_FILES[@]}"; do + [ -e "$f" ] && { info "removing $f"; rm -f "$f"; } + done + fail2ban-client reload >/dev/null || echo "WARNING: fail2ban reload failed – check: fail2ban-client -t" >&2 +fi +if [ $REMOVE_USER -eq 1 ] && id "$USER_NAME" >/dev/null 2>&1; then + info "removing user $USER_NAME" + userdel -r "$USER_NAME" 2>/dev/null || userdel "$USER_NAME" +fi +info "done" diff --git a/app/webui/__init__.py b/app/webui/__init__.py new file mode 100644 index 0000000..a1c9a56 --- /dev/null +++ b/app/webui/__init__.py @@ -0,0 +1 @@ +"""fail2ban-webui – web interface for fail2ban on several hosts.""" diff --git a/app/webui/__main__.py b/app/webui/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/app/webui/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/app/webui/app.py b/app/webui/app.py new file mode 100644 index 0000000..8c57a20 --- /dev/null +++ b/app/webui/app.py @@ -0,0 +1,215 @@ +"""fail2ban-webui – view, ban and unban fail2ban IPs on several hosts. + +The hosts are reached exclusively via SSH with a restricted key; on every host +the agent (forced command f2b-webctl) decides what is allowed. +""" +import collections +import copy +import ipaddress +import json +import logging +import os +import re +import secrets +import threading +import urllib.parse +from datetime import datetime, timedelta, timezone + +from flask import Flask, abort, g, jsonify, render_template, request, session +from werkzeug.middleware.proxy_fix import ProxyFix + +from . import auth, i18n +from .backend import Agents, Geo, loki_history +from .config import Settings + +log = logging.getLogger("webui") +JAIL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$") # same rule as the agent +_audit_lock = threading.Lock() + + +def load_secret_key(s): + if s.secret_key: + return s.secret_key + path = os.path.join(s.data_dir, "secret_key") + try: + with open(path, encoding="utf-8") as fh: + return fh.read().strip() + except FileNotFoundError: + key = secrets.token_urlsafe(48) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(key) + return key + + +def create_app(s=None): + s = s or Settings() + if not s.hosts: + s.load_hosts() + s.validate_auth() + + app = Flask(__name__) + app.json.ensure_ascii = False + app.json.sort_keys = False # hosts in the order of hosts.yaml + app.config.update( + SETTINGS=s, + SECRET_KEY=load_secret_key(s), + SESSION_COOKIE_NAME="f2bwebui_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", # Strict would drop the cookie on the OIDC redirect back + SESSION_COOKIE_SECURE=s.cookie_secure, + PERMANENT_SESSION_LIFETIME=timedelta(hours=s.session_hours), + ) + if s.trusted_proxy_hops > 0: + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=s.trusted_proxy_hops, x_proto=s.trusted_proxy_hops) + + geo = Geo(s) + agents = Agents(s, geo) + app.extensions["webui"] = {"geo": geo, "agents": agents} + + # Language first – the auth guard already needs g.t for its messages + @app.before_request + def select_language(): + g.lang = i18n.pick(request.cookies.get("lang"), s.default_lang) + g.t = lambda key, **kw: i18n.translate(g.lang, key, **kw) + + auth.init_app(app, s) + + @app.context_processor + def template_globals(): + return { + "t": g.t, "lang": g.lang, "languages": i18n.languages(), "user": g.get("user"), + "csrf": auth.csrf_token(), "geo_enabled": geo.available or s.geoip_auto_download, + "logout_local": s.auth_modes != ["proxy"], "logout_url": s.proxy_logout_url, + "version": s.version, + } + + # ----------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------- + def localize(obj): + """Turns error_key/error_args from the backend into text in the request language.""" + if isinstance(obj, dict): + if "error_key" in obj: + obj = {k: v for k, v in obj.items() if k not in ("error_key", "error_args")} | { + "error": g.t(obj["error_key"], **obj.get("error_args", {}))} + return {k: localize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [localize(v) for v in obj] + return obj + + def valid_ip(value): + try: + return str(ipaddress.ip_address(str(value).strip())) + except ValueError: + abort(400, description=g.t("err_invalid_ip", value=repr(value))) + + def audit(action, **fields): + entry = { + "ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "action": action, "user": g.user, "client": request.remote_addr, **fields, + } + line = json.dumps(entry, ensure_ascii=False) + log.info("AUDIT %s", line) + with _audit_lock, open(s.audit_log, "a", encoding="utf-8") as fh: + fh.write(line + "\n") + + def check_write_request(): + if not auth.csrf_valid(request.headers.get("X-CSRF-Token")): + abort(403, description=g.t("err_csrf")) + origin = request.headers.get("Origin") + if origin and urllib.parse.urlparse(origin).netloc != request.host: + abort(403, description=g.t("err_origin")) + if not request.is_json: + abort(415, description=g.t("err_json")) + + # ----------------------------------------------------------------- + # Routes + # ----------------------------------------------------------------- + @app.after_request + def security_headers(resp): + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["X-Frame-Options"] = "DENY" + resp.headers["Referrer-Policy"] = "no-referrer" + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + resp.headers["Cache-Control"] = "no-store" + return resp + + for code in (400, 401, 403, 404, 415): + @app.errorhandler(code) + def json_error(err): + if request.path.startswith("/api/") or request.is_json: + return jsonify({"ok": False, "error": err.description}), err.code + return render_template("error.html", code=err.code, message=err.description), err.code + + @app.get("/") + def index(): + return render_template( + "index.html", hosts=list(s.hosts), manual_jail=s.manual_jail, + i18n=i18n.catalog(g.lang), + client_config={ + "grafana": s.grafana_url, "grafanaDashboard": s.grafana_dashboard, + "grafanaLokiUid": s.grafana_loki_uid, "loki": bool(s.loki_url), + "lokiSelector": s.loki_selector, "lokiDays": s.loki_days, "lang": g.lang, + }) + + @app.get("/healthz") + def healthz(): + return "ok" + + @app.get("/api/bans") + def api_bans(): + return jsonify(localize(copy.deepcopy(agents.bans(force=request.args.get("refresh") == "1")))) + + @app.post("/api/ban") + def api_ban(): + check_write_request() + body = request.get_json(silent=True) or {} + ip = valid_ip(body.get("ip")) + targets = [h for h in body.get("hosts", []) if h in s.hosts] + note = str(body.get("note", ""))[:200] + if not targets: + abort(400, description=g.t("err_no_host")) + results = agents.run_many(targets, "ban", ip) + audit("ban", ip=ip, hosts=targets, note=note, + ok={h: r.get("ok", False) for h, r in results.items()}) + agents.bans(force=True) + return jsonify({"ok": all(r.get("ok") for r in results.values()), "results": localize(results)}) + + @app.post("/api/unban") + def api_unban(): + check_write_request() + body = request.get_json(silent=True) or {} + ip = valid_ip(body.get("ip")) + host = body.get("host") + jail = str(body.get("jail", "")) + targets = list(s.hosts) if host == "*" else [host] if host in s.hosts else [] + if not targets: + abort(400, description=g.t("err_unknown_host")) + if jail != "*" and not JAIL_RE.fullmatch(jail): + abort(400, description=g.t("err_invalid_jail")) + args = ("unban-all", ip) if jail == "*" else ("unban", jail, ip) + results = agents.run_many(targets, *args) + audit("unban", ip=ip, hosts=targets, jail=jail, + ok={h: r.get("ok", False) for h, r in results.items()}) + agents.bans(force=True) + return jsonify({"ok": all(r.get("ok") for r in results.values()), "results": localize(results)}) + + @app.get("/api/history") + def api_history(): + ip = valid_ip(request.args.get("ip")) + result, status = loki_history(s, ip) + return jsonify(localize({**result, "ip": ip, "geo": geo.lookup(ip)})), status + + @app.get("/api/audit") + def api_audit(): + try: + with open(s.audit_log, encoding="utf-8") as fh: + entries = [json.loads(line) for line in collections.deque(fh, 50) if line.strip()] + except FileNotFoundError: + entries = [] + return jsonify({"ok": True, "entries": list(reversed(entries))}) + + return app diff --git a/app/webui/auth.py b/app/webui/auth.py new file mode 100644 index 0000000..191f298 --- /dev/null +++ b/app/webui/auth.py @@ -0,0 +1,226 @@ +"""Authentication: local user, OpenID Connect and reverse-proxy mode. + +AUTH_MODE=local own login form (user + password hash) +AUTH_MODE=oidc single sign-on via an OpenID Connect provider (e.g. Authentik) +AUTH_MODE=local,oidc both on the login page +AUTH_MODE=proxy the reverse proxy authenticates (forward auth, basic auth, IP list) + +Failed logins are written to the container log as + LOGIN_FAILED user= ip=
+so fail2ban on the Docker host can protect this UI as well. +""" +import hmac +import ipaddress +import logging +import re +import secrets +import threading +import time + +from flask import (Blueprint, abort, current_app, flash, g, jsonify, redirect, + render_template, request, session, url_for) +from werkzeug.security import check_password_hash + +log = logging.getLogger("webui") +bp = Blueprint("auth", __name__) + +PUBLIC_ENDPOINTS = {"healthz", "static", "auth.login", "auth.login_post", + "auth.oidc_start", "auth.oidc_callback"} +# Used when the user name does not exist, so both cases take the same time +_DUMMY_HASH = "scrypt:32768:8:1$dummydummydummy$" + "0" * 128 + +_failures = {} +_failures_lock = threading.Lock() +oauth = None + + +def settings(): + return current_app.config["SETTINGS"] + + +def csrf_token(): + if "csrf" not in session: + session["csrf"] = secrets.token_urlsafe(32) + return session["csrf"] + + +def csrf_valid(value): + return bool(value) and hmac.compare_digest(str(value), session.get("csrf", "")) + + +def client_ip(): + return clean(request.remote_addr or "?") + + +def clean(value, limit=64): + """User-controlled text for log lines: no spaces or line breaks, so nobody can + forge an extra "LOGIN_FAILED … ip=" line that fail2ban would act on.""" + return re.sub(r"[^\w.@:+-]", "?", str(value))[:limit] or "-" + + +def start_session(user, via): + session.clear() + session.permanent = True + session["user"] = user + session["via"] = via + csrf_token() + log.info("LOGIN_OK user=%s ip=%s via=%s", clean(user), client_ip(), via) + + +# --------------------------------------------------------------------- +# Brute-force protection (in memory, per client IP) +# --------------------------------------------------------------------- +def _locked(ip): + s = settings() + window = s.login_lock_minutes * 60 + with _failures_lock: + recent = [t for t in _failures.get(ip, []) if time.time() - t < window] + _failures[ip] = recent + return len(recent) >= s.login_max_attempts + + +def _record_failure(ip, user): + with _failures_lock: + _failures.setdefault(ip, []).append(time.time()) + log.warning("LOGIN_FAILED user=%s ip=%s", clean(user), ip) + + +# --------------------------------------------------------------------- +# Guard for every request +# --------------------------------------------------------------------- +def require_login(): + s = settings() + g.user = None + if request.endpoint in PUBLIC_ENDPOINTS: + return None + + if s.auth_modes == ["proxy"]: + if s.proxy_allowed_ips: + direct = request.environ.get("werkzeug.proxy_fix.orig", {}).get("REMOTE_ADDR", request.remote_addr) + try: + addr = ipaddress.ip_address(direct) + except ValueError: + addr = None + if addr is None or not any(addr in net for net in s.proxy_allowed_ips): + abort(403, description=g.t("err_forbidden_proxy")) + if s.proxy_user_header: + g.user = request.headers.get(s.proxy_user_header, "").strip()[:128] + if not g.user: + abort(401, description=g.t("err_login_required")) + else: + g.user = "proxy" + csrf_token() + return None + + g.user = session.get("user") + if g.user: + return None + if request.path.startswith("/api/"): + return jsonify({"ok": False, "error": g.t("err_login_required")}), 401 + return redirect(url_for("auth.login")) + + +# --------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------- +@bp.get("/login") +def login(): + s = settings() + if s.auth_modes == ["proxy"] or session.get("user"): + return redirect(url_for("index")) + return render_template("login.html", csrf=csrf_token(), + local="local" in s.auth_modes, oidc="oidc" in s.auth_modes, + oidc_label=s.oidc_button_label) + + +@bp.post("/login") +def login_post(): + s = settings() + if "local" not in s.auth_modes: + abort(404) + ip = client_ip() + user = request.form.get("username", "").strip() + if not csrf_valid(request.form.get("csrf")): + flash(g.t("login_csrf"), "error") + return redirect(url_for("auth.login")) + if _locked(ip): + log.warning("LOGIN_LOCKED ip=%s", ip) + flash(g.t("login_locked", minutes=s.login_lock_minutes), "error") + return redirect(url_for("auth.login")) + password = request.form.get("password", "") + user_ok = hmac.compare_digest(user, s.auth_user) + pass_ok = check_password_hash(s.auth_password_hash if user_ok else _DUMMY_HASH, password) + if not (user_ok and pass_ok): + _record_failure(ip, user) + flash(g.t("login_failed"), "error") + return redirect(url_for("auth.login")) + with _failures_lock: + _failures.pop(ip, None) + start_session(user, "local") + return redirect(url_for("index")) + + +@bp.post("/logout") +def logout(): + if not csrf_valid(request.form.get("csrf")): + abort(403, description=g.t("err_csrf")) + log.info("LOGOUT user=%s ip=%s", clean(session.get("user")), client_ip()) + session.clear() + return redirect(url_for("auth.login")) + + +@bp.get("/auth/oidc") +def oidc_start(): + s = settings() + if "oidc" not in s.auth_modes: + abort(404) + return oauth.oidc.authorize_redirect(f"{s.public_url}/auth/callback") + + +@bp.get("/auth/callback") +def oidc_callback(): + s = settings() + if "oidc" not in s.auth_modes: + abort(404) + try: + token = oauth.oidc.authorize_access_token() + info = token.get("userinfo") or oauth.oidc.userinfo(token=token) + except Exception as exc: + log.warning("OIDC_FAILED ip=%s error=%s", client_ip(), clean(exc, 200)) + flash(g.t("login_oidc_failed", error=str(exc)[:200]), "error") + return redirect(url_for("auth.login")) + + user = str(info.get(s.oidc_username_claim) or info.get("email") or info.get("sub") or "")[:128] + groups = info.get(s.oidc_groups_claim) or [] + if isinstance(groups, str): + groups = [groups] + allowed = True + if s.oidc_allowed_groups and not set(groups) & set(s.oidc_allowed_groups): + allowed = False + if s.oidc_allowed_users and user not in s.oidc_allowed_users: + allowed = False + if not user or not allowed: + log.warning("OIDC_DENIED user=%s ip=%s groups=%s", clean(user), client_ip(), clean(",".join(map(str, groups)), 200)) + flash(g.t("login_denied", user=user or "?"), "error") + return redirect(url_for("auth.login")) + start_session(user, "oidc") + return redirect(url_for("index")) + + +def init_app(app, s): + global oauth + app.register_blueprint(bp) + app.before_request(require_login) + if "oidc" in s.auth_modes: + from authlib.integrations.flask_client import OAuth + oauth = OAuth(app) + oauth.register( + name="oidc", + server_metadata_url=s.oidc_discovery_url, + client_id=s.oidc_client_id, + client_secret=s.oidc_client_secret, + client_kwargs={"scope": s.oidc_scopes, "code_challenge_method": "S256"}, + ) + if not s.oidc_allowed_groups and not s.oidc_allowed_users: + log.warning("OIDC without OIDC_ALLOWED_GROUPS/OIDC_ALLOWED_USERS: every account of the " + "provider that may use this client can sign in") diff --git a/app/webui/backend.py b/app/webui/backend.py new file mode 100644 index 0000000..dfd52ab --- /dev/null +++ b/app/webui/backend.py @@ -0,0 +1,219 @@ +"""Talks to the agents (SSH), GeoIP and Loki. + +Errors are returned as {"ok": False, "error_key": ..., "error_args": {...}} so the +web layer can translate them into the language of each request. Errors coming from +an agent arrive as plain text in "error" and are passed through unchanged. +""" +import base64 +import gzip +import json +import logging +import os +import re +import shutil +import subprocess +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime, timedelta + +import maxminddb + +log = logging.getLogger("webui") + + +def fail(key, **args): + return {"ok": False, "error_key": key, "error_args": args} + + +# --------------------------------------------------------------------- +# GeoIP (optional) +# --------------------------------------------------------------------- +class Geo: + def __init__(self, settings): + self.settings = settings + self._reader = None + self._lock = threading.Lock() + + @property + def available(self): + return os.path.exists(self.settings.geoip_db) + + def reload(self): + with self._lock: + self._reader = None + + def lookup(self, ip): + with self._lock: + if self._reader is None: + try: + self._reader = maxminddb.open_database(self.settings.geoip_db) + except Exception: + self._reader = False + reader = self._reader + if not reader: + return {} + try: + rec = reader.get(ip) or {} + except ValueError: + return {} + country = rec.get("country", {}) + names = country.get("names", {}) + city = rec.get("city", {}).get("names", {}) + location = rec.get("location", {}) + # The browser picks the name matching its UI language, English otherwise + return { + "cc": country.get("iso_code", ""), + "country": names.get("en") or "", + "country_names": names, + "city": city.get("en") or "", + "city_names": city, + "lat": location.get("latitude"), + "lon": location.get("longitude"), + } + + def download_loop(self): + """Keeps the free DB-IP Lite database current (monthly release).""" + while True: + try: + self._download_if_old() + except Exception as exc: + log.warning("GeoIP download failed: %s", exc) + time.sleep(86400) + + def _download_if_old(self): + path = self.settings.geoip_db + if os.path.exists(path) and time.time() - os.path.getmtime(path) < 32 * 86400: + return + os.makedirs(os.path.dirname(path), exist_ok=True) + first = date.today().replace(day=1) + for month in (first, (first - timedelta(days=1)).replace(day=1)): + url = f"https://download.db-ip.com/free/dbip-city-lite-{month:%Y-%m}.mmdb.gz" + try: + with urllib.request.urlopen(url, timeout=300) as resp, \ + tempfile.NamedTemporaryFile(dir=os.path.dirname(path), delete=False) as tmp: + with gzip.GzipFile(fileobj=resp) as gz: + shutil.copyfileobj(gz, tmp) + except urllib.error.HTTPError as exc: + if exc.code == 404: + continue + raise + os.replace(tmp.name, path) + log.info("GeoIP database updated from %s", url) + self.reload() + return + + +# --------------------------------------------------------------------- +# Agents via SSH +# --------------------------------------------------------------------- +class Agents: + def __init__(self, settings, geo): + self.settings = settings + self.geo = geo + self._cache = {"ts": 0.0, "data": None} + self._lock = threading.Lock() + + @property + def hosts(self): + return self.settings.hosts + + def ssh(self, name, *args, timeout=None): + s = self.settings + h = self.hosts[name] + # -F none: ignore /etc/ssh/ssh_config (Debian enables GSSAPI there, which is never needed here) + cmd = [ + "ssh", "-F", "none", "-i", s.ssh_key, "-p", str(h.port), + "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "GSSAPIAuthentication=no", + "-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={s.known_hosts}", + "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", + "-o", "LogLevel=ERROR", + "-l", h.user, "--", h.address, " ".join(args), + ] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout or s.ssh_timeout) + except subprocess.TimeoutExpired: + return fail("err_timeout", host=name) + try: + return json.loads(r.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + detail = (r.stderr or r.stdout).strip()[:300] + if detail: + return fail("err_ssh", host=name, detail=detail) + return fail("err_exit", host=name, code=r.returncode) + + def run_many(self, names, *args): + if not names: + return {} + with ThreadPoolExecutor(max_workers=min(len(names), 16)) as pool: + return dict(zip(names, pool.map(lambda n: self.ssh(n, *args), names))) + + def bans(self, force=False): + with self._lock: + if not force and self._cache["data"] and time.time() - self._cache["ts"] < self.settings.cache_seconds: + return self._cache["data"] + results = self.run_many(list(self.hosts), "list") + + rows, hosts = [], {} + for name, res in results.items(): + if not res.get("ok"): + hosts[name] = {**{k: v for k, v in res.items() if k != "jails"}, "ok": False, "jails": {}} + continue + hosts[name] = {"ok": True, "jails": {}} + for jail, info in res.get("jails", {}).items(): + hosts[name]["jails"][jail] = {"current": len(info["bans"]), "total": info.get("total")} + for b in info["bans"]: + rows.append({"host": name, "jail": jail, **b, **self.geo.lookup(b["ip"])}) + rows.sort(key=lambda r: r["start"], reverse=True) + data = {"generated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "hosts": hosts, "bans": rows} + with self._lock: + self._cache.update(ts=time.time(), data=data) + return data + + +# --------------------------------------------------------------------- +# Loki (optional) +# --------------------------------------------------------------------- +def loki_history(settings, ip): + s = settings + if not s.loki_url: + return fail("err_loki_disabled"), 404 + esc = re.escape(ip).replace("\\", "\\\\") + # |= is a fast pre-filter, the regex then only checks the hits + query = (f'{s.loki_selector} |= "{ip}" ' + f'|~ "(^|[^0-9a-fA-F.:]){esc}([^0-9a-fA-F.:]|$)"') + now_ns = int(time.time() * 1e9) + params = urllib.parse.urlencode({ + "query": query, "limit": s.loki_limit, "direction": "backward", + "start": now_ns - s.loki_days * 86400 * 10**9, "end": now_ns, + }) + req = urllib.request.Request(f"{s.loki_url}/loki/api/v1/query_range?{params}") + if s.loki_user: + token = base64.b64encode(f"{s.loki_user}:{s.loki_password}".encode()).decode() + req.add_header("Authorization", f"Basic {token}") + if s.loki_tenant: + req.add_header("X-Scope-OrgID", s.loki_tenant) + try: + with urllib.request.urlopen(req, timeout=30) as r: + res = json.load(r) + except urllib.error.HTTPError as exc: + detail = exc.read()[:200].decode(errors="replace") + return fail("err_loki_http", code=exc.code, detail=detail), 502 + except TimeoutError: + return fail("err_loki_timeout"), 504 + except Exception as exc: + return fail("err_loki_unreachable", error=str(exc)), 502 + lines = [] + for stream in res.get("data", {}).get("result", []): + labels = stream.get("stream", {}) + for ts, line in stream.get("values", []): + lines.append({ + "ts": datetime.fromtimestamp(int(ts) / 1e9).strftime("%Y-%m-%d %H:%M:%S"), + "host": labels.get("host", ""), "job": labels.get("job", ""), "line": line[:400], + }) + lines.sort(key=lambda x: x["ts"], reverse=True) + return {"ok": True, "lines": lines[:s.loki_limit]}, 200 diff --git a/app/webui/cli.py b/app/webui/cli.py new file mode 100644 index 0000000..7680485 --- /dev/null +++ b/app/webui/cli.py @@ -0,0 +1,180 @@ +"""Command line: docker compose run --rm -it fail2ban-webui + + serve start the web server (default) + hash-password create AUTH_PASSWORD_HASH for the .env file + keygen create the SSH key in /data/ssh and show the public key + keyscan fetch the host keys of all hosts into known_hosts (shows fingerprints) + check test the connection to every agent +""" +import argparse +import getpass +import logging +import os +import subprocess +import sys +import threading + +from .config import ConfigError, Settings + +log = logging.getLogger("webui") + + +def die(msg, code=1): + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(code) + + +def ensure_writable(path): + os.makedirs(path, exist_ok=True) + probe = os.path.join(path, ".write-test") + try: + with open(probe, "w", encoding="utf-8") as fh: + fh.write("ok") + os.remove(probe) + except OSError as exc: + die(f"{path} is not writable for UID {os.getuid()} ({exc}).\n" + f" Fix on the Docker host: chown -R {os.getuid()}:{os.getgid()} ./data") + + +def cmd_serve(s): + from waitress import serve + from .app import create_app + + ensure_writable(s.data_dir) + try: + app = create_app(s) + except ConfigError as exc: + die(str(exc)) + geo = app.extensions["webui"]["geo"] + if s.geoip_auto_download: + threading.Thread(target=geo.download_loop, daemon=True, name="geoip").start() + log.info("fail2ban-webui %s – hosts: %s – auth: %s – loki: %s – geoip: %s", + s.version, ", ".join(s.hosts), ",".join(s.auth_modes), + "on" if s.loki_url else "off", "on" if geo.available or s.geoip_auto_download else "off") + serve(app, host="0.0.0.0", port=8080, threads=8, ident=None) + + +def cmd_hash_password(_s): + from werkzeug.security import generate_password_hash + + if sys.stdin.isatty(): + pw = getpass.getpass("New password: ") + if pw != getpass.getpass("Repeat password: "): + die("Passwords do not match.") + else: + pw = sys.stdin.readline().rstrip("\r\n") # \r: input piped from Windows files + if len(pw) < 12: + die("Please use at least 12 characters.") + # Single quotes: docker compose then leaves the $ signs in the hash alone + print(f"AUTH_PASSWORD_HASH='{generate_password_hash(pw)}'") + + +def cmd_keygen(s, force=False): + key = s.ssh_key + ensure_writable(os.path.dirname(key)) + if os.path.exists(key) and not force: + print(f"{key} already exists (use --force to replace it).", file=sys.stderr) + else: + if force: + for p in (key, key + ".pub"): + if os.path.exists(p): + os.remove(p) + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fail2ban-webui", "-f", key], + check=True) + with open(key + ".pub", encoding="utf-8") as fh: + print(fh.read().strip()) + print("\nInstall this public key on every fail2ban host with the agent installer.", file=sys.stderr) + + +def cmd_keyscan(s, yes=False): + try: + s.load_hosts() + except ConfigError as exc: + die(str(exc)) + lines, failed = [], [] + for h in s.hosts.values(): + r = subprocess.run(["ssh-keyscan", "-T", "10", "-p", str(h.port), "-t", "ed25519,ecdsa,rsa", h.address], + capture_output=True, text=True) + found = [x for x in r.stdout.splitlines() if x and not x.startswith("#")] + if not found: + failed.append(h.name) + print(f"{h.name:20} {h.address}:{h.port} NO HOST KEY ({r.stderr.strip()[:120]})") + continue + fp = subprocess.run(["ssh-keygen", "-l", "-f", "-"], input="\n".join(found) + "\n", + capture_output=True, text=True).stdout.strip() + print(f"{h.name:20} {h.address}:{h.port}") + for f in fp.splitlines(): + print(f" {f}") + lines.extend(found) + if not lines: + die("No host keys received.") + print("\nCompare these fingerprints with the hosts, e.g.: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub") + if not yes: + if not sys.stdin.isatty(): + die("Run with -it to confirm, or add --yes.") + if input(f"Write {len(lines)} keys to {s.known_hosts}? [y/N] ").strip().lower() not in ("y", "yes", "j", "ja"): + die("Aborted, nothing written.", 0) + ensure_writable(os.path.dirname(s.known_hosts)) + with open(s.known_hosts, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + print(f"Written: {s.known_hosts}") + if failed: + die("Missing: " + ", ".join(failed)) + + +def cmd_check(s): + from .backend import Agents, Geo + from . import i18n + + try: + s.load_hosts() + except ConfigError as exc: + die(str(exc)) + results = Agents(s, Geo(s)).run_many(list(s.hosts), "list") + bad = 0 + for name, res in results.items(): + if res.get("ok"): + jails = res.get("jails", {}) + bans = sum(len(j["bans"]) for j in jails.values()) + print(f"OK {name:20} agent {res.get('agent_version', '<1.0')}, {len(jails)} jails, {bans} bans") + else: + bad += 1 + err = res.get("error") or i18n.translate("en", res.get("error_key", ""), **res.get("error_args", {})) + print(f"FAIL {name:20} {err}") + sys.exit(1 if bad else 0) + + +def main(argv=None): + logging.basicConfig(level=logging.INFO, stream=sys.stdout, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + parser = argparse.ArgumentParser(prog="fail2ban-webui", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd") + sub.add_parser("serve") + sub.add_parser("hash-password") + p = sub.add_parser("keygen") + p.add_argument("--force", action="store_true", help="replace an existing key") + p = sub.add_parser("keyscan") + p.add_argument("--yes", action="store_true", help="write without asking") + sub.add_parser("check") + args = parser.parse_args(argv) + + try: + s = Settings() + except ConfigError as exc: + die(str(exc)) + cmd = args.cmd or "serve" + if cmd == "serve": + try: + s.load_hosts() + except ConfigError as exc: + die(str(exc)) + cmd_serve(s) + elif cmd == "hash-password": + cmd_hash_password(s) + elif cmd == "keygen": + cmd_keygen(s, args.force) + elif cmd == "keyscan": + cmd_keyscan(s, args.yes) + elif cmd == "check": + cmd_check(s) diff --git a/app/webui/config.py b/app/webui/config.py new file mode 100644 index 0000000..9e4bb21 --- /dev/null +++ b/app/webui/config.py @@ -0,0 +1,180 @@ +"""Configuration from environment variables and the hosts file (config/hosts.yaml).""" +import ipaddress +import os +import re +from dataclasses import dataclass + +import yaml + +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") +AUTH_MODES = {"local", "oidc", "proxy"} + + +class ConfigError(Exception): + pass + + +def env(name, default=""): + return os.environ.get(name, default).strip() + + +def env_bool(name, default=False): + value = env(name).lower() + return default if value == "" else value in ("1", "true", "yes", "on") + + +def env_int(name, default): + value = env(name) + try: + return int(value) if value else default + except ValueError: + raise ConfigError(f"{name} must be an integer, got {value!r}") from None + + +def env_list(name, default=""): + return [x.strip() for x in env(name, default).split(",") if x.strip()] + + +@dataclass(frozen=True) +class Host: + name: str + address: str + port: int = 22 + user: str = "f2bweb" + + +def load_hosts(path, default_user): + hosts = [] + if os.path.exists(path): + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + for item in data.get("hosts") or []: + if not isinstance(item, dict): + raise ConfigError(f"{path}: every entry below 'hosts' must be a mapping") + try: + port = int(item.get("port", 22)) + except (TypeError, ValueError): + raise ConfigError(f"{path}: invalid port {item.get('port')!r}") from None + hosts.append(Host( + name=str(item.get("name", "")).strip(), + address=str(item.get("address", "")).strip(), + port=port, + user=str(item.get("user") or default_user).strip(), + )) + + # Older single-line form: F2B_HOSTS=name=address,name2=address2 + for item in env("F2B_HOSTS").split(","): + if "=" in item: + name, address = item.split("=", 1) + hosts.append(Host(name.strip(), address.strip(), 22, default_user)) + + seen = set() + for h in hosts: + if not NAME_RE.fullmatch(h.name): + raise ConfigError(f"Invalid host name {h.name!r} (allowed: letters, digits, . _ -)") + # No leading dash or whitespace: the address ends up on the ssh command line + if not h.address or h.address.startswith("-") or any(c.isspace() for c in h.address): + raise ConfigError(f"Invalid address for host {h.name!r}: {h.address!r}") + if not 1 <= h.port <= 65535: + raise ConfigError(f"Invalid port for host {h.name!r}: {h.port}") + if not USER_RE.fullmatch(h.user): + raise ConfigError(f"Invalid SSH user for host {h.name!r}: {h.user!r}") + if h.name in seen: + raise ConfigError(f"Host name {h.name!r} is used twice") + seen.add(h.name) + if not hosts: + raise ConfigError(f"No hosts configured – create {path} (see hosts.yaml.example)") + return {h.name: h for h in hosts} + + +class Settings: + def __init__(self): + data = env("DATA_DIR", "/data") + self.data_dir = data + self.config_file = env("CONFIG_FILE", "/config/hosts.yaml") + self.version = env("APP_VERSION", "dev") + + # SSH / agents + self.ssh_user = env("SSH_USER", "f2bweb") + self.ssh_key = env("SSH_KEY", f"{data}/ssh/id_ed25519") + self.known_hosts = env("KNOWN_HOSTS", f"{data}/ssh/known_hosts") + self.ssh_timeout = env_int("SSH_TIMEOUT", 40) + self.cache_seconds = env_int("CACHE_SECONDS", 15) + self.manual_jail = env("MANUAL_JAIL", "manual-block") + self.audit_log = env("AUDIT_LOG", f"{data}/audit.log") + + # Web / sessions + self.public_url = env("PUBLIC_URL").rstrip("/") + self.trusted_proxy_hops = env_int("TRUSTED_PROXY_HOPS", 0) + self.default_lang = env("DEFAULT_LANG", "en") + self.secret_key = env("SECRET_KEY") + self.session_hours = env_int("SESSION_HOURS", 12) + self.cookie_secure = env_bool("SESSION_COOKIE_SECURE", self.public_url.startswith("https://")) + + # Authentication + self.auth_modes = [m.lower() for m in env_list("AUTH_MODE", "local")] + self.auth_user = env("AUTH_USER", "admin") + self.auth_password_hash = env("AUTH_PASSWORD_HASH") + hash_file = env("AUTH_PASSWORD_HASH_FILE") + if not self.auth_password_hash and hash_file and os.path.exists(hash_file): + with open(hash_file, encoding="utf-8") as fh: + self.auth_password_hash = fh.read().strip() + self.login_max_attempts = env_int("LOGIN_MAX_ATTEMPTS", 5) + self.login_lock_minutes = env_int("LOGIN_LOCK_MINUTES", 15) + self.proxy_user_header = env("AUTH_PROXY_HEADER") + self.proxy_logout_url = env("AUTH_PROXY_LOGOUT_URL") + try: + self.proxy_allowed_ips = [ipaddress.ip_network(n, strict=False) + for n in env_list("AUTH_PROXY_ALLOWED_IPS")] + except ValueError as exc: + raise ConfigError(f"AUTH_PROXY_ALLOWED_IPS: {exc}") from None + + issuer = env("OIDC_ISSUER").rstrip("/") + self.oidc_discovery_url = env("OIDC_DISCOVERY_URL") or ( + f"{issuer}/.well-known/openid-configuration" if issuer else "") + self.oidc_client_id = env("OIDC_CLIENT_ID") + self.oidc_client_secret = env("OIDC_CLIENT_SECRET") + self.oidc_scopes = env("OIDC_SCOPES", "openid email profile") + self.oidc_username_claim = env("OIDC_USERNAME_CLAIM", "preferred_username") + self.oidc_groups_claim = env("OIDC_GROUPS_CLAIM", "groups") + self.oidc_allowed_groups = env_list("OIDC_ALLOWED_GROUPS") + self.oidc_allowed_users = env_list("OIDC_ALLOWED_USERS") + self.oidc_button_label = env("OIDC_BUTTON_LABEL", "SSO") + + # Optional integrations + self.loki_url = env("LOKI_URL").rstrip("/") + self.loki_selector = env("LOKI_SELECTOR", '{job=~".+"}') + self.loki_days = env_int("LOKI_DAYS", 7) + self.loki_limit = env_int("LOKI_LIMIT", 50) + self.loki_user = env("LOKI_USER") + self.loki_password = env("LOKI_PASSWORD") + self.loki_tenant = env("LOKI_TENANT") + self.grafana_url = env("GRAFANA_URL").rstrip("/") + self.grafana_dashboard = env("GRAFANA_DASHBOARD") + self.grafana_loki_uid = env("GRAFANA_LOKI_UID", "loki") + self.geoip_db = env("GEOIP_DB", f"{data}/geoip/dbip-city-lite.mmdb") + self.geoip_auto_download = env_bool("GEOIP_AUTO_DOWNLOAD", False) + + self.hosts = {} + + def load_hosts(self): + self.hosts = load_hosts(self.config_file, self.ssh_user) + return self.hosts + + def validate_auth(self): + modes = set(self.auth_modes) + if not modes or modes - AUTH_MODES: + raise ConfigError(f"AUTH_MODE must be local, oidc, local,oidc or proxy – got {','.join(self.auth_modes)!r}") + if "proxy" in modes and len(modes) > 1: + raise ConfigError("AUTH_MODE=proxy cannot be combined with other modes") + if "local" in modes and not self.auth_password_hash: + raise ConfigError("AUTH_MODE=local needs AUTH_PASSWORD_HASH – create one with: " + "docker compose run --rm -it fail2ban-webui hash-password") + if "oidc" in modes: + missing = [n for n, v in (("OIDC_ISSUER or OIDC_DISCOVERY_URL", self.oidc_discovery_url), + ("OIDC_CLIENT_ID", self.oidc_client_id), + ("OIDC_CLIENT_SECRET", self.oidc_client_secret), + ("PUBLIC_URL", self.public_url)) if not v] + if missing: + raise ConfigError("AUTH_MODE=oidc needs " + ", ".join(missing)) diff --git a/app/webui/i18n.py b/app/webui/i18n.py new file mode 100644 index 0000000..8125482 --- /dev/null +++ b/app/webui/i18n.py @@ -0,0 +1,45 @@ +"""Translations: one JSON file per language in webui/locales/ (e.g. en.json, de.json). + +A new language only needs a new file; "_name" is the label shown in the dropdown. +Missing keys fall back to English. +""" +import json +import os + +DIR = os.path.join(os.path.dirname(__file__), "locales") +FALLBACK = "en" +LANGS = {} + + +def load(): + LANGS.clear() + for fname in sorted(os.listdir(DIR)): + if fname.endswith(".json"): + with open(os.path.join(DIR, fname), encoding="utf-8") as fh: + LANGS[fname[:-5]] = json.load(fh) + + +def pick(cookie_value, default): + if cookie_value in LANGS: + return cookie_value + return default if default in LANGS else FALLBACK + + +def catalog(lang): + """Full dictionary for the browser, English as base.""" + return {**LANGS.get(FALLBACK, {}), **LANGS.get(lang, {})} + + +def translate(lang, key, **params): + text = LANGS.get(lang, {}).get(key) or LANGS.get(FALLBACK, {}).get(key) or key + try: + return text.format(**params) if params else text + except (KeyError, IndexError, ValueError): + return text + + +def languages(): + return [(code, data.get("_name", code)) for code, data in sorted(LANGS.items())] + + +load() diff --git a/app/webui/locales/de.json b/app/webui/locales/de.json new file mode 100644 index 0000000..cf89ddd --- /dev/null +++ b/app/webui/locales/de.json @@ -0,0 +1,101 @@ +{ + "_name": "Deutsch", + "title": "Fail2ban-Steuerung", + "language": "Sprache", + "refresh": "Aktualisieren", + "grafana": "Grafana", + "logout": "Abmelden", + "signed_in_as": "Angemeldet als {user}", + "generated": "Stand: {ts}", + "ban_heading": "IP sperren", + "ban_ip_placeholder": "IP-Adresse, z. B. 203.0.113.7", + "ban_note_placeholder": "Notiz (optional, nur fürs Protokoll)", + "ban_button": "Dauerhaft sperren", + "ban_hint": "Die Sperre landet im Jail {jail} (dauerhaft). Private Netze und auf dem Host geschützte Adressen lehnt der Agent ab.", + "bans_heading": "Aktuelle Sperren", + "filter_search": "Suchen (IP, Land, Stadt)", + "filter_all_hosts": "Alle Hosts", + "filter_all_jails": "Alle Jails", + "col_host": "Host", + "col_jail": "Jail", + "col_ip": "IP", + "col_origin": "Herkunft", + "col_since": "Gesperrt seit", + "col_until": "Bis", + "col_actions": "Aktionen", + "loading": "Lade …", + "no_bans": "Keine Sperren gefunden.", + "more_rows": "… {n} weitere – Filter verwenden.", + "permanent": "dauerhaft", + "btn_unban": "Entsperren", + "btn_unban_everywhere": "Überall", + "btn_history": "Verlauf", + "jail": "Jail", + "jail_current": "aktuell", + "jail_total": "seit Start", + "audit_heading": "Protokoll (letzte 50 Aktionen)", + "audit_empty": "Noch keine Aktionen.", + "audit_banned": "gesperrt", + "audit_unbanned": "entsperrt", + "audit_line": "{ts} – {ip} {what} auf {hosts}", + "audit_where_jail": " (Jail {jail})", + "audit_where_all": " (alle Jails)", + "audit_failed": " – FEHLER", + "audit_by": " · von {user} ({client})", + "history_title": "Verlauf {ip} ({days} Tage)", + "history_close": "Schließen", + "origin_unknown": "Herkunft unbekannt", + "history_none": "Keine Einträge in Loki.", + "confirm_unban_all": "{ip} auf ALLEN Hosts in ALLEN Jails entsperren?", + "confirm_unban": "{ip} auf {host} im Jail {jail} entsperren?", + "confirm_ban": "{ip} auf {hosts} DAUERHAFT sperren?", + "toast_unban": "Entsperren {ip}: {result}", + "toast_ban": "Sperren {ip}: {result}", + "toast_load_failed": "Laden fehlgeschlagen: {error}", + "select_host": "Bitte mindestens einen Host wählen.", + "result_ok": "OK", + "result_error": "Fehler", + "footer_geo": "IP-Geolokalisierung:", + "login_title": "Anmelden", + "login_user": "Benutzername", + "login_password": "Passwort", + "login_button": "Anmelden", + "login_oidc": "Anmelden mit {provider}", + "login_or": "oder", + "login_failed": "Benutzername oder Passwort falsch.", + "login_locked": "Zu viele Fehlversuche. Bitte in {minutes} Minuten erneut versuchen.", + "login_oidc_failed": "Single Sign-on fehlgeschlagen: {error}", + "login_denied": "Das Konto {user} darf diese Anwendung nicht verwenden.", + "login_csrf": "Sitzung abgelaufen – bitte erneut versuchen.", + "error_title": "Fehler {code}", + "error_back": "Zurück zur Startseite", + "err_login_required": "Nicht angemeldet oder Sitzung abgelaufen – bitte Seite neu laden.", + "err_csrf": "CSRF-Token fehlt oder ist ungültig – Seite neu laden.", + "err_origin": "Anfrage fremder Herkunft (Origin) abgelehnt.", + "err_json": "JSON erwartet.", + "err_invalid_ip": "Ungültige IP-Adresse: {value}", + "err_no_host": "Kein gültiger Host ausgewählt.", + "err_unknown_host": "Unbekannter Host.", + "err_invalid_jail": "Ungültiger Jail-Name.", + "err_timeout": "Zeitüberschreitung bei {host}", + "err_ssh": "{host}: {detail}", + "err_exit": "{host}: Exit-Code {code}", + "err_loki_disabled": "Loki ist nicht konfiguriert.", + "err_loki_http": "Loki-Fehler {code}: {detail}", + "err_loki_timeout": "Loki-Abfrage dauerte zu lange (Timeout).", + "err_loki_unreachable": "Loki nicht erreichbar: {error}", + "err_forbidden_proxy": "Zugriff nur über den konfigurierten Reverse Proxy erlaubt.", + "agent_f2b_down": "fail2ban ist auf dem Host nicht erreichbar: {detail}", + "agent_invalid_ip": "Ungültige IP-Adresse: {value}", + "agent_protected": "{ip} ist auf diesem Host geschützt (privates Netz, eigene Adresse oder PROTECTED-Liste).", + "agent_no_manual_jail": "Das Jail {jail} gibt es auf diesem Host nicht.", + "agent_unknown_jail": "Unbekanntes Jail: {jail}", + "agent_not_root": "Der Agent läuft nicht als root – sudo-Regel prüfen.", + "agent_not_allowed": "Der Agent hat den Befehl abgelehnt.", + "map_heading": "Herkunftskarte", + "map_summary": "{ips} Sperren aus {places} Orten", + "map_unknown": " · {n} ohne Standort", + "map_hint": "Punkt anklicken, um die Liste zu filtern.", + "map_point": "{place}: {n} Sperren", + "map_reset": "Alle anzeigen" +} diff --git a/app/webui/locales/en.json b/app/webui/locales/en.json new file mode 100644 index 0000000..c37aaba --- /dev/null +++ b/app/webui/locales/en.json @@ -0,0 +1,101 @@ +{ + "_name": "English", + "title": "Fail2ban Web UI", + "language": "Language", + "refresh": "Refresh", + "grafana": "Grafana", + "logout": "Sign out", + "signed_in_as": "Signed in as {user}", + "generated": "As of: {ts}", + "ban_heading": "Ban IP address", + "ban_ip_placeholder": "IP address, e.g. 203.0.113.7", + "ban_note_placeholder": "Note (optional, audit log only)", + "ban_button": "Ban permanently", + "ban_hint": "The ban goes into the jail {jail} (permanent). Private networks and addresses protected on the host are rejected by the agent.", + "bans_heading": "Current bans", + "filter_search": "Search (IP, country, city)", + "filter_all_hosts": "All hosts", + "filter_all_jails": "All jails", + "col_host": "Host", + "col_jail": "Jail", + "col_ip": "IP", + "col_origin": "Origin", + "col_since": "Banned since", + "col_until": "Until", + "col_actions": "Actions", + "loading": "Loading …", + "no_bans": "No bans found.", + "more_rows": "… {n} more – use the filters.", + "permanent": "permanent", + "btn_unban": "Unban", + "btn_unban_everywhere": "Everywhere", + "btn_history": "History", + "jail": "Jail", + "jail_current": "current", + "jail_total": "since start", + "audit_heading": "Audit log (last 50 actions)", + "audit_empty": "No actions yet.", + "audit_banned": "banned", + "audit_unbanned": "unbanned", + "audit_line": "{ts} – {ip} {what} on {hosts}", + "audit_where_jail": " (jail {jail})", + "audit_where_all": " (all jails)", + "audit_failed": " – FAILED", + "audit_by": " · by {user} ({client})", + "history_title": "History {ip} ({days} days)", + "history_close": "Close", + "origin_unknown": "Origin unknown", + "history_none": "No entries in Loki.", + "confirm_unban_all": "Unban {ip} on ALL hosts in ALL jails?", + "confirm_unban": "Unban {ip} on {host} in jail {jail}?", + "confirm_ban": "Ban {ip} PERMANENTLY on {hosts}?", + "toast_unban": "Unban {ip}: {result}", + "toast_ban": "Ban {ip}: {result}", + "toast_load_failed": "Loading failed: {error}", + "select_host": "Please select at least one host.", + "result_ok": "OK", + "result_error": "Error", + "footer_geo": "IP geolocation:", + "login_title": "Sign in", + "login_user": "Username", + "login_password": "Password", + "login_button": "Sign in", + "login_oidc": "Sign in with {provider}", + "login_or": "or", + "login_failed": "Invalid username or password.", + "login_locked": "Too many failed attempts. Try again in {minutes} minutes.", + "login_oidc_failed": "Single sign-on failed: {error}", + "login_denied": "The account {user} is not allowed to use this application.", + "login_csrf": "Session expired – please try again.", + "error_title": "Error {code}", + "error_back": "Back to the start page", + "err_login_required": "Not signed in or session expired – please reload the page.", + "err_csrf": "CSRF token missing or invalid – reload the page.", + "err_origin": "Request from a foreign origin rejected.", + "err_json": "JSON expected.", + "err_invalid_ip": "Invalid IP address: {value}", + "err_no_host": "No valid host selected.", + "err_unknown_host": "Unknown host.", + "err_invalid_jail": "Invalid jail name.", + "err_timeout": "Timeout talking to {host}", + "err_ssh": "{host}: {detail}", + "err_exit": "{host}: exit code {code}", + "err_loki_disabled": "Loki is not configured.", + "err_loki_http": "Loki error {code}: {detail}", + "err_loki_timeout": "Loki query took too long (timeout).", + "err_loki_unreachable": "Loki not reachable: {error}", + "err_forbidden_proxy": "Access is only allowed via the configured reverse proxy.", + "agent_f2b_down": "fail2ban is not reachable on the host: {detail}", + "agent_invalid_ip": "Invalid IP address: {value}", + "agent_protected": "{ip} is protected on this host (private network, own address or PROTECTED list).", + "agent_no_manual_jail": "The jail {jail} does not exist on this host.", + "agent_unknown_jail": "Unknown jail: {jail}", + "agent_not_root": "The agent does not run as root – check the sudo rule.", + "agent_not_allowed": "The agent rejected the command.", + "map_heading": "Origin map", + "map_summary": "{ips} bans from {places} places", + "map_unknown": " · {n} without location", + "map_hint": "Click a point to filter the list.", + "map_point": "{place}: {n} bans", + "map_reset": "Show all" +} diff --git a/app/webui/static/app.js b/app/webui/static/app.js new file mode 100644 index 0000000..88642d5 --- /dev/null +++ b/app/webui/static/app.js @@ -0,0 +1,259 @@ +"use strict"; + +const $ = (sel) => document.querySelector(sel); +const CSRF = document.querySelector('meta[name="csrf-token"]').content; +const I18N = JSON.parse($("#i18n").textContent); +const CFG = JSON.parse($("#client-config").textContent); +let state = { bans: [], hosts: {} }; + +// ---------- Helpers ---------- +function t(key, params = {}) { + return (I18N[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m)); +} + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + for (const c of children) node.append(c instanceof Node ? c : document.createTextNode(c ?? "")); + return node; +} + +function toast(msg, isError = false) { + const box = $("#toast"); + box.textContent = msg; + box.className = "show" + (isError ? " error" : ""); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { box.className = ""; }, isError ? 8000 : 4000); +} + +async function api(path, body) { + const opts = body + ? { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": CSRF }, body: JSON.stringify(body) } + : {}; + const res = await fetch(path, opts); + let data; + try { data = await res.json(); } catch { data = { ok: false, error: `HTTP ${res.status}` }; } + if (!res.ok && data.ok !== false) data.ok = false; + if (res.status === 401) setTimeout(() => location.reload(), 2500); + return data; +} + +function resultText(data) { + if (data.error) return data.error; + return Object.entries(data.results || {}) + .map(([h, r]) => `${h}: ${r.ok ? t("result_ok") : (r.error || r.result || t("result_error"))}`).join(" · "); +} + +function localName(names, fallback) { + return (names && names[CFG.lang]) || fallback || ""; +} + +function grafanaLink(ip) { + if (!CFG.grafana || !CFG.loki) return null; + const esc = ip.replaceAll(".", "\\\\."); + const panes = { a: { datasource: CFG.grafanaLokiUid, queries: [{ refId: "A", + datasource: { type: "loki", uid: CFG.grafanaLokiUid }, expr: `${CFG.lokiSelector} |~ "${esc}"` }], + range: { from: `now-${CFG.lokiDays}d`, to: "now" } } }; + return `${CFG.grafana}/explore?schemaVersion=1&orgId=1&panes=${encodeURIComponent(JSON.stringify(panes))}`; +} + +// ---------- Rendering ---------- +function renderHosts() { + const box = $("#hosts"); + box.replaceChildren(); + for (const [host, info] of Object.entries(state.hosts)) { + const card = el("div", { class: "card" }); + const sum = Object.values(info.jails || {}).reduce((a, j) => a + j.current, 0); + card.append(el("h3", {}, host, el("span", { class: "badge" }, String(sum)))); + if (!info.ok) { + card.append(el("div", { class: "err" }, info.error)); + } else { + const list = el("div", { class: "jail-list" }, + el("span", { class: "h" }, t("jail")), el("span", { class: "h" }, t("jail_current")), + el("span", { class: "h" }, t("jail_total"))); + for (const [jail, j] of Object.entries(info.jails).sort()) { + list.append(el("span", {}, jail), el("span", {}, String(j.current)), el("span", { class: "muted" }, String(j.total ?? "–"))); + } + card.append(list); + } + box.append(card); + } +} + +// World map: one point per place (rounded to 0.5°), size grows with the number of bans. +// Projection matches static/world.svg: x = lon + 180, y = 90 - lat. +const SVG_NS = "http://www.w3.org/2000/svg"; + +function renderMap(rows) { + const layer = $("#map-points"); + if (!layer) return; + const places = new Map(); + let unknown = 0; + for (const b of rows) { + if (b.lat == null || b.lon == null) { unknown++; continue; } + const key = `${Math.round(b.lat * 2) / 2},${Math.round(b.lon * 2) / 2}`; + const p = places.get(key) || { lat: b.lat, lon: b.lon, n: 0, cc: b.cc, + name: [localName(b.city_names, b.city), localName(b.country_names, b.country)].filter(Boolean).join(", ") || b.cc || "?" }; + p.n++; + places.set(key, p); + } + layer.replaceChildren(); + // big points first, so small ones stay clickable on top + for (const p of [...places.values()].sort((a, b) => b.n - a.n)) { + const c = document.createElementNS(SVG_NS, "circle"); + c.setAttribute("cx", (p.lon + 180).toFixed(2)); + c.setAttribute("cy", (90 - p.lat).toFixed(2)); + c.setAttribute("r", (1.3 + Math.sqrt(p.n) * 0.9).toFixed(2)); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = t("map_point", { place: p.name, n: p.n }); + c.append(title); + c.addEventListener("click", () => { + $("#filter-text").value = p.cc || ""; + renderBans(); + }); + layer.append(c); + } + $("#map-summary").textContent = t("map_summary", { ips: rows.length - unknown, places: places.size }) + + (unknown ? t("map_unknown", { n: unknown }) : ""); + $("#map-reset").hidden = !$("#filter-text").value; +} + +function fillSelect(sel, values) { + const cur = sel.value; + const first = sel.options[0]; + sel.replaceChildren(first, ...values.map((v) => el("option", { value: v }, v))); + sel.value = values.includes(cur) ? cur : ""; +} + +function renderBans() { + const text = $("#filter-text").value.trim().toLowerCase(); + const host = $("#filter-host").value; + const jail = $("#filter-jail").value; + const rows = state.bans.filter((b) => + (!host || b.host === host) && (!jail || b.jail === jail) && + (!text || [b.ip, b.cc, b.country, b.city, localName(b.country_names), localName(b.city_names)] + .join(" ").toLowerCase().includes(text))); + + $("#count").textContent = String(rows.length); + renderMap(rows); + const tbody = $("#bans"); + tbody.replaceChildren(); + if (!rows.length) { + tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("no_bans")))); + return; + } + for (const b of rows.slice(0, 1000)) { + const origin = [b.cc, localName(b.country_names, b.country), localName(b.city_names, b.city)] + .filter(Boolean).join(" · ") || "–"; + // bantime null: older fail2ban without --with-time, start/end unknown + const until = b.bantime === null ? "–" : b.bantime < 0 ? el("span", { class: "perm" }, t("permanent")) : b.end; + const actions = el("td", { class: "actions" }, + el("button", { class: "small", type: "button", onclick: () => unban(b.host, b.jail, b.ip) }, t("btn_unban")), + el("button", { class: "small", type: "button", onclick: () => unban("*", "*", b.ip) }, t("btn_unban_everywhere"))); + if (CFG.loki) { + actions.append(el("button", { class: "small", type: "button", onclick: () => history(b.ip) }, t("btn_history"))); + } + const link = grafanaLink(b.ip); + if (link) actions.append(el("a", { class: "btn-link small", href: link, target: "_blank", rel: "noopener" }, t("grafana"))); + // data-label: column name for the card view on narrow screens + tbody.append(el("tr", {}, + el("td", { "data-label": t("col_host") }, b.host), el("td", { "data-label": t("col_jail") }, b.jail), + el("td", { "data-label": t("col_ip"), class: "ip" }, el("code", {}, b.ip)), + el("td", { "data-label": t("col_origin") }, origin), el("td", { "data-label": t("col_since") }, b.start || "–"), + el("td", { "data-label": t("col_until") }, until), actions)); + } + if (rows.length > 1000) { + tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("more_rows", { n: rows.length - 1000 })))); + } +} + +async function loadAudit() { + const data = await api("/api/audit"); + const ul = $("#audit"); + ul.replaceChildren(); + if (!data.entries || !data.entries.length) { ul.append(el("li", { class: "muted" }, t("audit_empty"))); return; } + for (const e of data.entries) { + const failed = Object.values(e.ok || {}).some((v) => !v); + const what = e.action === "ban" ? t("audit_banned") : t("audit_unbanned"); + const where = e.jail && e.jail !== "*" ? t("audit_where_jail", { jail: e.jail }) + : e.action === "unban" ? t("audit_where_all") : ""; + ul.append(el("li", { class: failed ? "bad" : "" }, + t("audit_line", { ts: e.ts, ip: e.ip, what, hosts: (e.hosts || []).join(", ") }) + where, + e.note ? ` – „${e.note}“` : "", failed ? t("audit_failed") : "", + el("span", { class: "muted" }, t("audit_by", { user: e.user || "?", client: e.client })))); + } +} + +async function load(force = false) { + const btn = $("#refresh"); + btn.disabled = true; + try { + const data = await api("/api/bans" + (force ? "?refresh=1" : "")); + if (!data.hosts) throw new Error(data.error || "?"); + state = data; + $("#generated").textContent = t("generated", { ts: data.generated }); + fillSelect($("#filter-host"), Object.keys(data.hosts)); + fillSelect($("#filter-jail"), [...new Set(data.bans.map((b) => b.jail))].sort()); + renderHosts(); + renderBans(); + } catch (err) { + toast(t("toast_load_failed", { error: err.message || err }), true); + } finally { + btn.disabled = false; + } + loadAudit(); +} + +// ---------- Actions ---------- +async function unban(host, jail, ip) { + const question = host === "*" ? t("confirm_unban_all", { ip }) : t("confirm_unban", { ip, host, jail }); + if (!confirm(question)) return; + const data = await api("/api/unban", { ip, host, jail }); + toast(t("toast_unban", { ip, result: resultText(data) }), !data.ok); + load(); +} + +async function ban(ev) { + ev.preventDefault(); + const ip = $("#ban-ip").value.trim(); + const note = $("#ban-note").value.trim(); + const hosts = [...document.querySelectorAll('input[name="ban-host"]:checked')].map((c) => c.value); + if (!hosts.length) { toast(t("select_host"), true); return; } + if (!confirm(t("confirm_ban", { ip, hosts: hosts.join(", ") }))) return; + const data = await api("/api/ban", { ip, hosts, note }); + toast(t("toast_ban", { ip, result: resultText(data) }), !data.ok); + if (data.ok) { $("#ban-ip").value = ""; $("#ban-note").value = ""; } + load(); +} + +async function history(ip) { + const dlg = $("#history"); + $("#history-title").textContent = t("history_title", { ip, days: CFG.lokiDays }); + const body = $("#history-body"); + body.replaceChildren(el("p", { class: "muted" }, t("loading"))); + dlg.showModal(); + const data = await api(`/api/history?ip=${encodeURIComponent(ip)}`); + body.replaceChildren(); + if (!data.ok) { body.append(el("p", { class: "err" }, data.error)); return; } + const g = data.geo || {}; + body.append(el("p", {}, [g.cc, localName(g.country_names, g.country), localName(g.city_names, g.city)] + .filter(Boolean).join(" · ") || t("origin_unknown"))); + if (!data.lines.length) { body.append(el("p", { class: "muted" }, t("history_none"))); return; } + for (const l of data.lines) { + body.append(el("div", { class: "line" }, + el("span", { class: "muted" }, `${l.ts} · ${l.host} · ${l.job}`), el("pre", {}, l.line))); + } +} + +// ---------- Start ---------- +$("#refresh").addEventListener("click", () => load(true)); +$("#ban-form").addEventListener("submit", ban); +$("#history-close").addEventListener("click", () => $("#history").close()); +if ($("#map-reset")) $("#map-reset").addEventListener("click", () => { $("#filter-text").value = ""; renderBans(); }); +for (const id of ["#filter-text", "#filter-host", "#filter-jail"]) $(id).addEventListener("input", renderBans); +load(); +setInterval(() => load(), 60000); diff --git a/app/webui/static/common.js b/app/webui/static/common.js new file mode 100644 index 0000000..cd14928 --- /dev/null +++ b/app/webui/static/common.js @@ -0,0 +1,11 @@ +"use strict"; + +// Language dropdown: remember the choice for a year and reload +const langSelect = document.getElementById("lang"); +if (langSelect) { + langSelect.addEventListener("change", () => { + const secure = location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `lang=${encodeURIComponent(langSelect.value)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`; + location.reload(); + }); +} diff --git a/app/webui/static/style.css b/app/webui/static/style.css new file mode 100644 index 0000000..e0cd285 --- /dev/null +++ b/app/webui/static/style.css @@ -0,0 +1,143 @@ +:root { + --bg: #f5f6f8; --panel: #ffffff; --text: #1d2330; --muted: #6b7385; --border: #dfe3ea; + --accent: #2f6fdb; --danger: #c93b3b; --ok: #2e8b57; --warn: #c98a1b; --row: #f0f3f8; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171d; --panel: #1c2028; --text: #e3e7ee; --muted: #8f98a8; --border: #2c3240; + --accent: #5b8ff0; --danger: #e05d5d; --ok: #4fbf84; --warn: #e0a73c; --row: #222733; + } +} +* { box-sizing: border-box; } +body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); } +header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); } +h1 { font-size: 18px; margin: 0; } +h2 { font-size: 15px; margin: 0 0 10px; } +.meta { display: flex; gap: 10px; align-items: center; color: var(--muted); } +main { padding: 16px 20px; display: grid; gap: 16px; max-width: 1400px; margin: 0 auto; } +main > * { min-width: 0; } +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 16px; } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; } +.card h3 { margin: 0 0 8px; font-size: 15px; display: flex; justify-content: space-between; } +.card .err { color: var(--danger); } +.jail-list { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 12px; font-variant-numeric: tabular-nums; } +.jail-list .h { color: var(--muted); font-size: 12px; } +button, .btn-link { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; text-decoration: none; } +button:hover, .btn-link:hover { border-color: var(--accent); } +button.danger { background: var(--danger); border-color: var(--danger); color: #fff; } +button.small { padding: 2px 8px; font-size: 12px; } +button:disabled { opacity: .5; cursor: wait; } +input, select { font: inherit; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); } +#ban-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +#ban-ip { width: 260px; } #ban-note { flex: 1; min-width: 200px; } +.hosts-select { display: flex; gap: 10px; } +.hint { color: var(--muted); font-size: 12px; margin: 8px 0 0; } +.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; } +.toolbar h2 { margin: 0 auto 0 0; } +.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 8px; font-size: 12px; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; } +th { color: var(--muted); font-weight: 600; font-size: 12px; } +tbody tr:hover { background: var(--row); } +td.actions { display: flex; gap: 4px; } +.muted { color: var(--muted); } +.perm { color: var(--danger); font-weight: 600; } +code { font-size: 12px; } +.audit { list-style: none; margin: 0; padding: 0; font-size: 13px; } +.audit li { padding: 4px 0; border-bottom: 1px solid var(--border); } +.audit .bad { color: var(--danger); } +dialog { width: min(1000px, 95vw); max-height: 85vh; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; } +dialog::backdrop { background: rgba(0, 0, 0, .5); } +.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); } +#history-body { padding: 12px 16px; overflow: auto; } +#history-body pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; margin: 0; } +#history-body .line { padding: 4px 0; border-bottom: 1px solid var(--border); } +#toast { position: fixed; right: 20px; bottom: 20px; max-width: 420px; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--panel); opacity: 0; transition: opacity .2s; pointer-events: none; } +#toast.show { opacity: .95; } +#toast.error { background: var(--danger); color: #fff; } +footer { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 24px; } +footer a { color: inherit; } +.audit li { overflow-wrap: anywhere; } + +/* ---------- Tablet ---------- */ +@media (max-width: 1000px) { + main { padding: 12px 16px; gap: 12px; } + #ban-ip { flex: 1 1 220px; width: auto; } + .toolbar h2 { flex: 1 1 100%; } + .toolbar input[type="search"] { flex: 1 1 220px; } +} + +/* ---------- Handy ---------- */ +@media (max-width: 820px) { + header { padding: 12px 16px; } + .meta { flex-wrap: wrap; width: 100%; } + #generated { flex: 1 1 100%; font-size: 12px; } + .meta button, .meta .btn-link { flex: 1; text-align: center; } + .panel, .card { padding: 12px; } + + /* 16px verhindert das automatische Zoomen in iOS beim Antippen */ + input, select { font-size: 16px; min-height: 40px; } + button, .btn-link { min-height: 40px; } + .btn-link { display: inline-flex; align-items: center; justify-content: center; } + + #ban-form > input, #ban-form > button { flex: 1 1 100%; width: 100%; min-width: 0; } + .hosts-select { flex-wrap: wrap; gap: 6px 16px; } + .hosts-select label { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; } + + .toolbar input[type="search"] { flex: 1 1 100%; } + .toolbar select { flex: 1 1 0; min-width: 0; } + + /* Tabelle wird zur Kartenliste */ + .table-wrap { overflow: visible; } + table, tbody, tr, td { display: block; width: 100%; } + thead { display: none; } + tbody tr { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; } + tbody tr:hover { background: none; } + td { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; padding: 4px 0; border: 0; white-space: normal; text-align: right; overflow-wrap: anywhere; } + td[data-label]::before { content: attr(data-label); color: var(--muted); font-size: 12px; text-align: left; flex: none; } + td.ip { order: -1; justify-content: flex-start; font-size: 15px; font-weight: 600; padding-bottom: 6px; } + td.ip::before { display: none; } + td.ip code { font-size: 15px; } + td[colspan] { display: block; text-align: left; } + tbody tr:has(td[colspan]) { border: 0; padding: 0; } + td.actions { flex-wrap: wrap; gap: 6px; padding-top: 8px; margin-top: 4px; border-top: 1px solid var(--border); } + td.actions > * { flex: 1 1 calc(50% - 6px); text-align: center; min-height: 36px; font-size: 13px; padding: 6px 8px; } + + dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; border: 0; } + .dialog-head { padding: 10px 12px; } + #history-body { padding: 10px 12px; } + + #toast { left: 12px; right: 12px; bottom: 12px; max-width: none; } +} + +/* ---------- Header: language, user, logout ---------- */ +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.lang-select select { padding: 5px 8px; } +.user { font-weight: 600; color: var(--text); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +form.inline { display: inline; margin: 0; } +button.primary, .btn-link.primary { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* ---------- Login / error page ---------- */ +main.login { max-width: 420px; } +.login-form { display: grid; gap: 12px; } +.login-form label { display: grid; gap: 4px; color: var(--muted); font-size: 13px; } +.login-form input { width: 100%; } +.login .sso { display: block; text-align: center; } +.login .or { text-align: center; margin: 12px 0; } +.msg { padding: 8px 10px; border-radius: 6px; margin: 0 0 12px; overflow-wrap: anywhere; } +.msg.error { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } +@media (max-width: 820px) { + .lang-select, .lang-select select { flex: 1; } + .user { flex: 1 1 100%; max-width: none; } + form.inline { flex: 1; display: flex; } + form.inline button { flex: 1; } +} + +/* ---------- Origin map ---------- */ +.map svg { display: block; width: 100%; height: auto; max-height: 360px; } +.map circle { fill: var(--danger); fill-opacity: .5; stroke: var(--danger); stroke-width: .3; cursor: pointer; transition: fill-opacity .15s; } +.map circle:hover { fill-opacity: .9; } +.map-panel .toolbar h2 { margin-right: 0; } +.map-panel #map-summary { margin-right: auto; font-size: 13px; } diff --git a/app/webui/static/world.svg b/app/webui/static/world.svg new file mode 100644 index 0000000..ec55df4 --- /dev/null +++ b/app/webui/static/world.svg @@ -0,0 +1 @@ + diff --git a/app/webui/templates/base.html b/app/webui/templates/base.html new file mode 100644 index 0000000..af5a49a --- /dev/null +++ b/app/webui/templates/base.html @@ -0,0 +1,42 @@ + + + + + + {% block title %}{{ t('title') }}{% endblock %} + + + + +
+

{{ t('title') }}

+
+ {% block header_meta %}{% endblock %} + + {% if user %} + {{ user }} + {% if logout_local %} +
+ + +
+ {% elif logout_url %} + {{ t('logout') }} + {% endif %} + {% endif %} +
+
+{% block content %}{% endblock %} + + +{% block scripts %}{% endblock %} + + diff --git a/app/webui/templates/error.html b/app/webui/templates/error.html new file mode 100644 index 0000000..7d7259f --- /dev/null +++ b/app/webui/templates/error.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}{{ t('error_title', code=code) }} – {{ t('title') }}{% endblock %} + +{% block content %} +
+
+

{{ t('error_title', code=code) }}

+

{{ message }}

+

{{ t('error_back') }}

+
+
+{% endblock %} diff --git a/app/webui/templates/index.html b/app/webui/templates/index.html new file mode 100644 index 0000000..00e53b1 --- /dev/null +++ b/app/webui/templates/index.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block header_meta %} + – + + {% if client_config.grafana and client_config.grafanaDashboard %}{{ t('grafana') }}{% endif %} +{% endblock %} + +{% block content %} +
+
+ + {% if geo_enabled %} +
+
+

{{ t('map_heading') }}

+ + +
+
+ + + + +
+

{{ t('map_hint') }}

+
+ {% endif %} + +
+

{{ t('ban_heading') }}

+
+ + + + {% for h in hosts %}{% endfor %} + + +
+

{{ t('ban_hint', jail=manual_jail) }}

+
+ +
+
+

{{ t('bans_heading') }} 0

+ + + +
+
+ + + + + +
{{ t('col_host') }}{{ t('col_jail') }}{{ t('col_ip') }}{{ t('col_origin') }}{{ t('col_since') }}{{ t('col_until') }}{{ t('col_actions') }}
{{ t('loading') }}
+
+
+ +
+

{{ t('audit_heading') }}

+
    +
    +
    + + +
    +

    + +
    +
    +
    + +
    + + +{% endblock %} + +{% block scripts %}{% endblock %} diff --git a/app/webui/templates/login.html b/app/webui/templates/login.html new file mode 100644 index 0000000..1f5e987 --- /dev/null +++ b/app/webui/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ t('login_title') }} – {{ t('title') }}{% endblock %} +{% block body_class %}login-page{% endblock %} + +{% block content %} +
    +
    +

    {{ t('login_title') }}

    + {% for category, message in get_flashed_messages(with_categories=true) %} +

    {{ message }}

    + {% endfor %} + {% if local %} + + {% endif %} + {% if local and oidc %}

    {{ t('login_or') }}

    {% endif %} + {% if oidc %} + {{ t('login_oidc', provider=oidc_label) }} + {% endif %} +
    +
    +{% endblock %} diff --git a/dev/testbed/agent/Dockerfile b/dev/testbed/agent/Dockerfile new file mode 100644 index 0000000..036dd01 --- /dev/null +++ b/dev/testbed/agent/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Test host: sshd + fail2ban (dummy actions, no firewall changes). +# The agent is installed at start with agent/install.sh – exactly like on a real host. +# Build context: repository root (see ../docker-compose.yaml) +FROM debian:trixie-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-server openssh-client fail2ban python3 sudo iproute2 \ + && rm -rf /var/lib/apt/lists/* /etc/fail2ban/jail.d/* /etc/ssh/ssh_host_* \ + && mkdir -p /run/sshd && touch /var/log/empty.log + +COPY <<'JAIL' /etc/fail2ban/jail.local +[DEFAULT] +banaction = dummy +banaction_allports = dummy +backend = polling + +[sshd] +enabled = true +logpath = /var/log/empty.log +JAIL + +COPY agent/ /opt/fail2ban-webui-agent/ + +COPY <<'ENTRY' /entrypoint.sh +#!/bin/sh +set -e +ssh-keygen -A >/dev/null +until [ -s /keys/ssh/id_ed25519.pub ]; do echo "waiting for /keys/ssh/id_ed25519.pub"; sleep 2; done +fail2ban-client -x start >/dev/null +# INSTALL_ARGS: options for install.sh, set per agent in docker-compose.yaml +bash /opt/fail2ban-webui-agent/install.sh --yes --key-file /keys/ssh/id_ed25519.pub $INSTALL_ARGS +for ip in $SAMPLE_BANS; do fail2ban-client set sshd banip "$ip" >/dev/null; done +exec /usr/sbin/sshd -D -e +ENTRY +RUN chmod 0755 /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/dev/testbed/docker-compose.yaml b/dev/testbed/docker-compose.yaml new file mode 100644 index 0000000..6aa4735 --- /dev/null +++ b/dev/testbed/docker-compose.yaml @@ -0,0 +1,64 @@ +# Local testbed: web UI + two fail2ban hosts (dummy actions, nothing is really blocked). +# The agents are set up with agent/install.sh at start: +# agent1 user f2bweb + sudo, key only accepted from the testbed network +# agent2 user root, key in authorized_keys2 (like hosts managed by Keywarden) +# +# docker compose build +# docker compose run --rm -it webui hash-password >> .env (test password of your choice) +# docker compose run --rm webui keygen +# docker compose run --rm -T --entrypoint sh webui -c 'cat > /data/hosts.yaml' < hosts.yaml +# docker compose up -d agent1 agent2 +# docker compose run --rm webui keyscan --yes +# docker compose up -d webui -> http://127.0.0.1:18080 (login: see .env) +# docker compose down -v -> remove everything +# +# hosts.yaml lives in the data volume: Docker Desktop cannot bind-mount network drives. +name: f2bwebui-testbed + +services: + webui: + build: { context: ../.., dockerfile: docker/Dockerfile, args: { VERSION: testbed } } + image: fail2ban-webui:testbed + env_file: .env + environment: + CONFIG_FILE: /data/hosts.yaml + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + volumes: + - webui-data:/data + ports: + - "127.0.0.1:18080:8080" + networks: [testbed] + + agent1: + build: { context: ../.., dockerfile: dev/testbed/agent/Dockerfile } + image: f2bwebui-testbed-agent + hostname: agent1 + environment: + INSTALL_ARGS: "--from 172.31.231.0/24 --protect 203.0.113.250" + SAMPLE_BANS: "203.0.113.7 198.51.100.23 2001:db8::42" + volumes: + - webui-data:/keys:ro + networks: [testbed] + + agent2: + image: f2bwebui-testbed-agent + hostname: agent2 + environment: + INSTALL_ARGS: "--user root --authorized-keys-file /root/.ssh/authorized_keys2 --manual-jail blocklist" + SAMPLE_BANS: "8.8.4.4 1.1.1.1" + volumes: + - webui-data:/keys:ro + networks: [testbed] + +volumes: + webui-data: + +networks: + testbed: + name: f2bwebui-testbed.dockernetwork.local + ipam: + config: + - subnet: 172.31.231.0/24 diff --git a/dev/testbed/hosts.yaml b/dev/testbed/hosts.yaml new file mode 100644 index 0000000..a020069 --- /dev/null +++ b/dev/testbed/hosts.yaml @@ -0,0 +1,6 @@ +hosts: + - name: agent1 + address: agent1 + - name: agent2 + address: agent2 + user: root diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f2dd23e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,34 @@ +# fail2ban-webui – web interface for fail2ban on several hosts (Flask + Waitress) +# Build: docker build -f docker/Dockerfile -t fail2ban-webui:dev . +FROM python:3.13.17-slim-trixie + +ARG VERSION=dev +LABEL org.opencontainers.image.title="fail2ban-webui" \ + org.opencontainers.image.description="Web interface to view, ban and unban fail2ban IPs on several hosts via SSH" \ + org.opencontainers.image.source="https://git.retronerd.at/schiwagoa/fail2ban-webui" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${VERSION}" + +# ssh needs a passwd entry for its UID – therefore a fixed user 10050 +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-client tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 10050 --user-group --no-create-home --home-dir /tmp --shell /usr/sbin/nologin f2bweb \ + && mkdir -p /data /config \ + && chown 10050:10050 /data + +WORKDIR /app +COPY requirements.txt . +# pip, setuptools and the bundled ensurepip wheels are not needed at runtime – +# removing them also removes their (vendored) packages from security scans +RUN pip install --no-cache-dir -r requirements.txt && pip uninstall -y pip setuptools wheel 2>/dev/null; rm -rf /usr/local/lib/python3.*/ensurepip /root/.cache + +COPY app/ /app/ + +USER 10050:10050 +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HOME=/tmp APP_VERSION=${VERSION} +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3)" +ENTRYPOINT ["python", "-m", "webui"] +CMD ["serve"] diff --git a/docs/agent.md b/docs/agent.md new file mode 100644 index 0000000..802839f --- /dev/null +++ b/docs/agent.md @@ -0,0 +1,269 @@ +# Agent – f2b-webctl + +Every fail2ban host runs the agent [`f2b-webctl`](../agent/f2b-webctl): a small Python +script (standard library only) started as SSH **forced command** of the web UI's key. +The web UI cannot run anything else on the host. + +| Command (sent by the web UI) | Effect | +|---|---| +| `list` | all jails with their banned IPs (JSON) | +| `ban ` | ban permanently in the manual jail (`manual-block`) | +| `unban ` | unban in one jail | +| `unban-all ` | unban in all jails | +| `version` | agent version | + +Never banned, whatever the web UI sends: + +- private, loopback, link-local, CGNAT, multicast and reserved ranges + (`10/8`, `172.16/12`, `192.168/16`, `100.64/10`, `127/8`, `169.254/16`, `fc00::/7`, `fe80::/10`, …) +- all addresses of the host itself and the address the web UI connects from +- everything listed in `PROTECTED` in `/etc/f2b-webctl.conf` (e.g. your home IP) + +Every action is logged to syslog (facility `auth`, tag `f2b-webctl`), including rejected ones. + +## Requirements + +| | | +|---|---| +| fail2ban | ≥ 0.11, running (`fail2ban-client ping`) – older versions without `--with-time` work, but without start/end times | +| python3 | ≥ 3.8 | +| sshd | reachable from the web UI host | +| sudo | for the default user `f2bweb` (`apt install sudo`); not needed with `--user root` | +| bash, ssh-keygen | for the installer | + +Works on Debian, Ubuntu, Proxmox VE and other systemd or non-systemd Linux systems – +bare metal, VMs, LXC containers (privileged or unprivileged) and Docker hosts. + +## Installation + +You need the public key printed by `fail2ban-webui keygen` and the address the web UI +connects from (the public IP of the Docker host, or its LAN address). + +```bash +# as root on the fail2ban host +git clone https://git.retronerd.at/schiwagoa/fail2ban-webui.git /tmp/fail2ban-webui +cd /tmp/fail2ban-webui/agent + +echo 'ssh-ed25519 AAAA… fail2ban-webui' > /tmp/webui.pub + +# see what would happen +bash install.sh --key-file /tmp/webui.pub --from 198.51.100.10 --dry-run +# install +bash install.sh --key-file /tmp/webui.pub --from 198.51.100.10 +``` + +Without `git`: download the three files `f2b-webctl`, `install.sh` and `uninstall.sh` from +the `agent/` directory into one directory and run `install.sh` from there. + +The installer shows a summary and asks before it changes anything: + +``` +fail2ban-webui agent 1.0.0 – fail2ban 1.1.0 – web01 + + script /usr/local/sbin/f2b-webctl + config /etc/f2b-webctl.conf + SSH user f2bweb (will be created) + sudo rule /etc/sudoers.d/f2b-webctl + key file /var/lib/f2bweb/.ssh/authorized_keys + key 256 SHA256:zfwh… (ED25519) + allowed from 198.51.100.10 + manual jail manual-block (will be created) + backups /var/backups/fail2ban-webui-agent +``` + +What it does: + +1. backs up every file it is going to change to `--backup-dir` +2. installs `/usr/local/sbin/f2b-webctl` (`root:root 0750`) +3. writes `/etc/f2b-webctl.conf` (kept if it already exists) +4. creates the system user `f2bweb` (home `/var/lib/f2bweb`, no password) and the sudo rule + ``` + Defaults!/usr/local/sbin/f2b-webctl env_keep += "SSH_ORIGINAL_COMMAND SSH_CLIENT" + f2bweb ALL=(root) NOPASSWD: /usr/local/sbin/f2b-webctl + ``` +5. adds the key with all restrictions: + ``` + restrict,from="198.51.100.10",command="sudo -n /usr/local/sbin/f2b-webctl" ssh-ed25519 AAAA… fail2ban-webui + ``` +6. creates the jail `manual-block` if it does not exist (see below) +7. runs a self test through the same sudo path the web UI uses + +Running the installer again is safe: it updates the script, replaces the key entry and +keeps your configuration. + +### Options + +| Option | Default | Meaning | +|---|---|---| +| `--key-file FILE` / `--key "KEY"` | – | public key of the web UI (required) | +| `--from ADDRESSES` | any | only accept the key from these IPs/networks (comma separated). **Strongly recommended.** | +| `--user NAME` | `f2bweb` | SSH user; `root` works without sudo | +| `--authorized-keys-file PATH` | `~USER/.ssh/authorized_keys` | where the key entry goes – see [managed keys](#hosts-with-managed-authorized_keys) | +| `--manual-jail NAME` | `manual-block` | jail for manual bans | +| `--banaction ACTION` | from `jail.local` | banaction of a newly created manual jail | +| `--protect NETWORKS` | – | additional protected addresses (only when the config file is written) | +| `--no-jail` | – | do not create the manual jail | +| `--force-config` | – | overwrite an existing `/etc/f2b-webctl.conf` | +| `--backup-dir DIR` | `/var/backups/fail2ban-webui-agent` | backups of changed files | +| `--dry-run` | – | only show what would be done | +| `-y`, `--yes` | – | do not ask | + +The installer warns when sshd would not read the chosen key file (`AuthorizedKeysFile`), +when `AllowUsers`/`AllowGroups` are set, or when `--user root` meets `PermitRootLogin no`. + +## The manual jail + +Bans from the web UI go into one jail, `manual-block` by default, with `bantime = -1` +(permanent). If the jail does not exist, the installer creates: + +`/etc/fail2ban/filter.d/manual-block.conf` + +```ini +# fail2ban-webui: never matches – the jail is only filled via +# "fail2ban-client set manual-block banip " +[Definition] +failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES $ +ignoreregex = +``` + +`/etc/fail2ban/jail.d/manual-block.local` + +```ini +# fail2ban-webui: permanent manual bans from the web UI +[manual-block] +enabled = true +filter = manual-block +backend = polling +logpath = /var/log/fail2ban-manual-block.log +maxretry = 1 +bantime = -1 +``` + +The jail uses your default `banaction` from `jail.local`. Bans survive restarts through +the fail2ban database. + +An existing jail with that name is never touched – if you already have a jail for manual +bans, pass its name with `--manual-jail`. + +### Docker hosts + +fail2ban's default actions block in the `INPUT` chain. Traffic to **published Docker +ports** does not pass `INPUT` but `FORWARD` → `DOCKER-USER`, so those bans would not +affect your containers. For a Docker host, create the manual jail with an action that +uses the `DOCKER-USER` chain, e.g. with iptables: + +```bash +bash install.sh --key-file /tmp/webui.pub --from 198.51.100.10 \ + --banaction "iptables-allports[chain=DOCKER-USER]" +``` + +Check the result after the first ban: + +```bash +iptables -S DOCKER-USER | head +``` + +If the web UI runs on the same Docker host, use `address: host.docker.internal` in +`hosts.yaml` and allow SSH from the Docker network if a firewall (e.g. UFW) blocks it: + +```bash +ufw allow from 172.23.51.0/24 to any port 22 proto tcp comment 'fail2ban-webui' +``` + +and install the agent with `--from 172.23.51.10`. + +### LXC containers + +The agent runs inside the container where fail2ban runs – nothing special is needed. +In **unprivileged** containers fail2ban itself must be able to change the container's +firewall (nftables/iptables inside the CT); test with a manual ban: + +```bash +fail2ban-client set manual-block banip 203.0.113.1 && nft list ruleset | grep 203.0.113.1 +fail2ban-client set manual-block unbanip 203.0.113.1 +``` + +### Hosts with managed authorized_keys + +Some tools (Ansible, Keywarden, cloud-init, …) overwrite `~/.ssh/authorized_keys`. +Put the key into a file they do not manage and make sure sshd reads it: + +```bash +grep -i '^AuthorizedKeysFile' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf +# e.g. AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 + +bash install.sh --key-file /tmp/webui.pub --from 198.51.100.10 \ + --user root --authorized-keys-file /root/.ssh/authorized_keys2 +``` + +## Configuration – /etc/f2b-webctl.conf + +```ini +# fail2ban-webui agent (f2b-webctl) – settings +# Jail used for manual bans from the web UI +MANUAL_JAIL=manual-block +# Addresses/networks that must never be banned (comma separated), e.g. your home IP. +# Private, loopback, link-local and CGNAT ranges are always protected. +PROTECTED=203.0.113.25,2001:db8:1234::/48 +# Also protect all addresses of this host and the address of the web UI (yes/no) +PROTECT_OWN_ADDRESSES=yes +PROTECT_CLIENT=yes +``` + +Changes take effect immediately – the agent reads the file on every call. + +## Testing on the host + +```bash +# as root, directly +f2b-webctl list | python3 -m json.tool | head -30 +f2b-webctl ban 10.0.0.1 # → rejected, protected + +# the same path as the web UI (user f2bweb through sudo) +runuser -u f2bweb -- env SSH_ORIGINAL_COMMAND=version sudo -n /usr/local/sbin/f2b-webctl + +# what the agent did +journalctl -t f2b-webctl --since today # or: grep f2b-webctl /var/log/auth.log +``` + +## Update + +Run the installer of the new version again with the same options. Your configuration +and the jail are kept; the script and the key entry are replaced. + +## Uninstall + +```bash +bash uninstall.sh --dry-run +bash uninstall.sh +``` + +| Option | Meaning | +|---|---| +| `--user NAME` | agent user (default `f2bweb`) | +| `--authorized-keys-file PATH` | file with the key entry | +| `--remove-user` | also delete the user and its home | +| `--remove-jail` | also delete the manual jail – **lifts all manual bans** (they are listed first) | +| `--backup-dir DIR`, `--dry-run`, `--yes` | as for the installer | + +## Manual installation + +If you prefer to do it by hand, these are all the steps the installer performs: + +```bash +install -o root -g root -m 0750 f2b-webctl /usr/local/sbin/f2b-webctl +nano /etc/f2b-webctl.conf # content see above + +useradd --system --create-home --home-dir /var/lib/f2bweb --shell /bin/sh f2bweb +usermod -p '*' f2bweb +visudo -f /etc/sudoers.d/f2b-webctl # content see "What it does", step 4 + +install -d -o f2bweb -g f2bweb -m 700 /var/lib/f2bweb/.ssh +nano /var/lib/f2bweb/.ssh/authorized_keys # line see step 5 +chown f2bweb: /var/lib/f2bweb/.ssh/authorized_keys +chmod 600 /var/lib/f2bweb/.ssh/authorized_keys + +# manual jail: the two files from "The manual jail", then +touch /var/log/fail2ban-manual-block.log +fail2ban-client reload +``` diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..77fdc1d --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,119 @@ +# Authentication + +`AUTH_MODE` selects how users sign in. The container refuses to start without a working +configuration – there is no unauthenticated mode. + +| Mode | Use when | +|---|---| +| `local` | one shared admin account is enough | +| `oidc` | you have an identity provider (Authentik, Keycloak, Authelia, Zitadel, …) | +| `local,oidc` | SSO with a local fallback account | +| `proxy` | your reverse proxy already authenticates (forward auth, basic auth, IP allow list) | + +Every change (ban/unban) additionally needs the per-session CSRF token and a matching +`Origin` header, and every action is written to the audit log with the user name. + +## Local user + +```bash +docker compose run --rm -it fail2ban-webui hash-password +``` + +```ini +AUTH_MODE=local +AUTH_USER=admin +AUTH_PASSWORD_HASH='scrypt:32768:8:1$…' +``` + +- Passwords need at least 12 characters; the hash is scrypt (Werkzeug). +- After `LOGIN_MAX_ATTEMPTS` (5) failures a client IP is locked for `LOGIN_LOCK_MINUTES` (15). +- Failed logins are logged as `LOGIN_FAILED user=… ip=…` – see + [operations.md](operations.md#protecting-the-login-with-fail2ban) to let fail2ban ban + them at the proxy. +- Set `TRUSTED_PROXY_HOPS=1` behind a reverse proxy, otherwise all clients appear with the + proxy's address and share one lockout counter. + +## OpenID Connect + +Requirements: `PUBLIC_URL` is set to the external `https://` URL, and the provider allows +the redirect URI **`/auth/callback`**. + +The UI uses the authorization code flow with PKCE (`S256`) and a confidential client. +User name: `OIDC_USERNAME_CLAIM` (default `preferred_username`); groups: +`OIDC_GROUPS_CLAIM` (default `groups`). + +**Always restrict who may sign in** with `OIDC_ALLOWED_GROUPS` and/or +`OIDC_ALLOWED_USERS` – or with an access policy in the provider. Without either, every +account that the provider lets use this client can ban IPs (a warning is logged at start). + +### Authentik + +1. **Applications → Providers → Create → OAuth2/OpenID Provider** + - Name: `fail2ban-webui` + - Authorization flow: `default-provider-authorization-explicit-consent` (or implicit) + - Client type: **Confidential** – note *Client ID* and *Client Secret* + - Redirect URIs: `strict` · `https://fail2ban.example.org/auth/callback` + - Signing key: any certificate (e.g. `authentik Self-signed Certificate`) + - Advanced protocol settings → Scopes: `openid`, `email`, `profile` + (the default *profile* mapping contains the `groups` claim) +2. **Applications → Applications → Create** + - Name: `fail2ban-webui`, Slug: `fail2ban-webui`, Provider: the one from step 1 +3. **Directory → Groups → Create** `fail2ban-admins` and add the users. +4. Optional, recommended: in the application under **Policy / Group / User Bindings** + bind the group `fail2ban-admins`, so nobody else even reaches the consent screen. +5. `.env`: + + ```ini + PUBLIC_URL=https://fail2ban.example.org + AUTH_MODE=oidc + OIDC_ISSUER=https://auth.example.org/application/o/fail2ban-webui/ + OIDC_CLIENT_ID=… + OIDC_CLIENT_SECRET='…' + OIDC_ALLOWED_GROUPS=fail2ban-admins + OIDC_BUTTON_LABEL=Authentik + ``` + +6. `docker compose up -d`, open `PUBLIC_URL`, click **Sign in with Authentik**. + +The issuer URL is shown in Authentik on the provider page (*OpenID Configuration Issuer*). + +### Other providers + +| Provider | `OIDC_ISSUER` | Groups | +|---|---|---| +| Keycloak | `https://kc.example.org/realms/` | add a *Group Membership* mapper with claim name `groups`, "Full group path" off | +| Authelia | `https://auth.example.org` | `groups` scope; set `OIDC_SCOPES=openid email profile groups` | +| Zitadel | `https://.zitadel.cloud` | roles instead of groups – use `OIDC_ALLOWED_USERS` or a project role check in Zitadel | + +### Sign out + +"Sign out" ends the session in the web UI. The session at the provider stays active, so +the next "Sign in with …" usually logs in again without a password. + +## Reverse-proxy mode + +The proxy does the authentication; the UI only checks where requests come from and, +optionally, which user the proxy reports. + +```ini +AUTH_MODE=proxy +TRUSTED_PROXY_HOPS=1 +# only accept requests from the proxy (address as seen by the container) +AUTH_PROXY_ALLOWED_IPS=172.23.51.1/32 +# optional: user name for the audit log, set by the proxy +AUTH_PROXY_HEADER=X-authentik-username +# optional: logout URL of the proxy/IdP +AUTH_PROXY_LOGOUT_URL=https://fail2ban.example.org/outpost.goauthentik.io/sign_out +``` + +- `AUTH_PROXY_ALLOWED_IPS` is checked against the **direct** peer of the container, not + against `X-Forwarded-For`. If the proxy runs on the same Docker host and reaches the + container via the published port, that is the network gateway (`172.23.51.1` in the + example compose file); check with `docker compose logs` after a request. +- With `AUTH_PROXY_HEADER` set, requests without the header are rejected (401). Make sure + the proxy **removes** that header from client requests – forward-auth integrations + (Authentik, Authelia) do that. +- Without `AUTH_PROXY_HEADER` the audit log shows the user `proxy`. + +Examples for Nginx Proxy Manager, Traefik and Caddy are in +[reverse-proxy.md](reverse-proxy.md). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..47f64fe --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,124 @@ +# Configuration + +The web UI is configured with environment variables (`.env`, loaded via `env_file:`) and +the host list `config/hosts.yaml`. A complete, commented template is +[`examples/.env.example`](../examples/.env.example). Changes need +`docker compose up -d` (which recreates the container). + +Values containing `$` (password hashes, some secrets) must be wrapped in **single quotes** +in `.env`, otherwise Docker Compose expands them. + +## Hosts – config/hosts.yaml + +```yaml +hosts: + - name: web01 # shown in the UI: letters, digits, . _ - + address: web01.example.org # DNS name or IP, reachable from the container + port: 22 # optional, default 22 + user: f2bweb # optional, default SSH_USER +``` + +After adding a host run `keyscan` again, then restart: + +```bash +docker compose run --rm -it fail2ban-webui keyscan +docker compose run --rm fail2ban-webui check +docker compose up -d --force-recreate +``` + +`F2B_HOSTS=name=address,name2=address2` (port 22, user `SSH_USER`) is still accepted for +compatibility; entries from both are combined. + +## General + +| Variable | Default | Meaning | +|---|---|---| +| `PUBLIC_URL` | – | external URL, e.g. `https://fail2ban.example.org`. Required for OIDC; with `https://` session cookies get the `Secure` flag | +| `DEFAULT_LANG` | `en` | language until a user picks one in the dropdown (`en`, `de`, …) | +| `TRUSTED_PROXY_HOPS` | `0` | number of reverse proxies whose `X-Forwarded-For`/`-Proto` headers are trusted. Behind one proxy: `1`. Wrong values make the audit log and the login lockout use the wrong client IP | +| `SESSION_HOURS` | `12` | session lifetime | +| `SESSION_COOKIE_SECURE` | from `PUBLIC_URL` | force the `Secure` cookie flag on/off | +| `SECRET_KEY` | generated | key that signs session cookies; by default created once in `data/secret_key` | +| `TZ` | `Europe/Vienna` (compose) | time zone of the log and audit timestamps | +| `CONFIG_FILE` | `/config/hosts.yaml` | path of the host list inside the container | +| `DATA_DIR` | `/data` | data directory inside the container | + +## Authentication + +See [authentication.md](authentication.md) for the complete setup. + +| Variable | Default | Meaning | +|---|---|---| +| `AUTH_MODE` | `local` | `local`, `oidc`, `local,oidc` or `proxy` | +| `AUTH_USER` | `admin` | local user name | +| `AUTH_PASSWORD_HASH` | – | scrypt hash from `hash-password` (single quotes!) | +| `AUTH_PASSWORD_HASH_FILE` | – | alternative: file containing the hash (e.g. a Docker secret) | +| `LOGIN_MAX_ATTEMPTS` | `5` | failed logins per client IP before the lockout | +| `LOGIN_LOCK_MINUTES` | `15` | duration of the lockout window | +| `OIDC_ISSUER` | – | issuer URL; the discovery document is read from `/.well-known/openid-configuration` | +| `OIDC_DISCOVERY_URL` | – | full discovery URL instead of `OIDC_ISSUER` | +| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | – | client credentials | +| `OIDC_SCOPES` | `openid email profile` | requested scopes | +| `OIDC_USERNAME_CLAIM` | `preferred_username` | claim shown as user name (falls back to `email`, then `sub`) | +| `OIDC_GROUPS_CLAIM` | `groups` | claim with the group list | +| `OIDC_ALLOWED_GROUPS` | – | only members of these groups may sign in (comma separated) | +| `OIDC_ALLOWED_USERS` | – | only these user names may sign in | +| `OIDC_BUTTON_LABEL` | `SSO` | text on the login button ("Sign in with …") | +| `AUTH_PROXY_ALLOWED_IPS` | – | proxy mode: only accept requests from these addresses/networks | +| `AUTH_PROXY_HEADER` | – | proxy mode: header with the user name, e.g. `X-authentik-username` | +| `AUTH_PROXY_LOGOUT_URL` | – | proxy mode: target of the "Sign out" button | + +## Agents (SSH) + +| Variable | Default | Meaning | +|---|---|---| +| `SSH_USER` | `f2bweb` | default user for hosts without `user:` | +| `SSH_KEY` | `/data/ssh/id_ed25519` | private key | +| `KNOWN_HOSTS` | `/data/ssh/known_hosts` | pinned host keys (written by `keyscan`) | +| `SSH_TIMEOUT` | `40` | seconds per agent call | +| `CACHE_SECONDS` | `15` | how long the ban list is cached between page refreshes | +| `MANUAL_JAIL` | `manual-block` | jail name shown in the UI hint – the agent decides the real jail (`/etc/f2b-webctl.conf`) | +| `AUDIT_LOG` | `/data/audit.log` | audit log (JSON lines) | + +The SSH client ignores the system `ssh_config`, uses only the configured key, never +asks questions and refuses unknown host keys. + +## GeoIP + +| Variable | Default | Meaning | +|---|---|---| +| `GEOIP_AUTO_DOWNLOAD` | `0` | `1`: download the free [DB-IP Lite](https://db-ip.com/db/lite.php) city database (CC BY 4.0) and refresh it monthly | +| `GEOIP_DB` | `/data/geoip/dbip-city-lite.mmdb` | path of the database – any MaxMind-format city database works (e.g. GeoLite2 City) | + +With a city database the UI also shows the **origin map** (country-only databases have no +coordinates). The map is bundled with the app (Natural Earth, public domain) – no external +tile server is contacted. Without a database the "Origin" column stays empty and the map +is hidden. + +## Loki and Grafana + +With Loki the UI gets a **History** button per IP showing the matching log lines of the +last days; with Grafana every ban gets a link to Explore. + +| Variable | Default | Meaning | +|---|---|---| +| `LOKI_URL` | – | e.g. `http://loki:3100`; empty = history disabled | +| `LOKI_SELECTOR` | `{job=~".+"}` | LogQL stream selector for the search, e.g. `{job=~"fail2ban\|sshd\|nginx"}` | +| `LOKI_DAYS` | `7` | how far back to search | +| `LOKI_LIMIT` | `50` | maximum lines | +| `LOKI_USER` / `LOKI_PASSWORD` | – | basic auth | +| `LOKI_TENANT` | – | `X-Scope-OrgID` for multi-tenant Loki | +| `GRAFANA_URL` | – | e.g. `https://grafana.example.org` | +| `GRAFANA_DASHBOARD` | – | path of a dashboard for the header link, e.g. `/d/abc123/security` | +| `GRAFANA_LOKI_UID` | `loki` | UID of the Loki data source in Grafana (for Explore links) | + +Example – exclude the UI's own access log from the search: + +```ini +LOKI_SELECTOR={job=~"fail2ban|ufw|nginx|sshd", domain!="fail2ban.example.org"} +``` + +## Agent settings + +Protected addresses and the manual jail are configured **on each host** in +`/etc/f2b-webctl.conf` – see [agent.md](agent.md#configuration--etcf2b-webctlconf). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f4fc63f --- /dev/null +++ b/docs/development.md @@ -0,0 +1,106 @@ +# Development + +## Layout + +``` +app/webui/ Flask application (package "webui") + app.py routes, security headers, audit log + auth.py local / OIDC / proxy authentication, login lockout + backend.py SSH to the agents, GeoIP, Loki + cli.py serve, hash-password, keygen, keyscan, check + config.py settings from the environment, hosts.yaml + i18n.py, locales/ translations (one JSON file per language) + templates/, static/ +agent/ f2b-webctl, install.sh, uninstall.sh +docker/Dockerfile production image +examples/ docker-compose.yaml, .env.example, hosts.yaml.example +dev/testbed/ web UI + two fail2ban hosts in Docker +tests/smoke_test.py web UI tests without Docker/SSH +``` + +## Smoke test (no Docker needed) + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python tests/smoke_test.py +``` + +The agents are simulated. Covered: login, lockout, CSRF/Origin checks, translations, +ban/unban validation, audit log, proxy mode, config validation, SSH command line, log +injection. + +## Testbed (Docker) + +`dev/testbed` starts the web UI and two Debian hosts with sshd and fail2ban (dummy ban +actions – nothing is blocked). The agents are installed with `agent/install.sh` at +container start: + +| Host | Agent setup | +|---|---| +| `agent1` | user `f2bweb` + sudo, `--from` the testbed network, extra protected IP | +| `agent2` | user `root`, key in `authorized_keys2`, manual jail named `blocklist` | + +```bash +cd dev/testbed +printf 'DEFAULT_LANG=en\nAUTH_MODE=local\nAUTH_USER=admin\nCACHE_SECONDS=5\n' > .env +docker compose build +docker compose run --rm -it webui hash-password >> .env +docker compose run --rm webui keygen +docker compose run --rm -T --entrypoint sh webui -c 'cat > /data/hosts.yaml' < hosts.yaml +docker compose up -d agent1 agent2 +docker compose run --rm webui keyscan --yes +docker compose run --rm webui check +docker compose up -d webui # http://127.0.0.1:18080 +docker compose down -v # remove everything +``` + +`hosts.yaml` is written into the data volume because Docker Desktop cannot bind-mount +network drives. After recreating an agent its host key changes – run `keyscan` again. + +On Windows with Git Bash, prefix `docker compose exec … /path` with `MSYS_NO_PATHCONV=1`, +otherwise the path is rewritten to a Windows path. + +## Adding a language + +1. Copy `app/webui/locales/en.json` to `app/webui/locales/.json` (e.g. `fr.json`). +2. Set `"_name"` to the language's own name (`"Français"`) – it appears in the dropdown. +3. Translate the values; keep `{placeholders}` unchanged. Missing keys fall back to English. +4. Run the smoke test and check the UI. + +## Building the image + +```bash +docker build -f docker/Dockerfile --build-arg VERSION=1.0.0 -t fail2ban-webui:1.0.0 . +# multi-arch, as in CI +docker buildx build -f docker/Dockerfile --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=1.0.0 -t git.retronerd.at/schiwagoa/fail2ban-webui:1.0.0 --push . +``` + +Scan before publishing: + +```bash +docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 \ + image --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL fail2ban-webui:1.0.0 +``` + +## Agent protocol + +The web UI runs `ssh … @ ""`; the agent reads the command from +`SSH_ORIGINAL_COMMAND` and prints exactly one JSON line: + +```json +{"ok": true, "host": "web01", "agent_version": "1.0.0", "manual_jail": "manual-block", + "jails": {"sshd": {"bans": [{"ip": "203.0.113.7", "start": "2026-10-25 10:00:00", + "bantime": 600, "end": "2026-10-25 10:10:00"}], "total": 42}}} +``` + +Errors: `{"ok": false, "error_key": "agent_protected", "error_args": {"ip": "…"}, "error": "English text"}`. +The web UI translates `error_key` with `locales/*.json` (keys starting with `agent_`); +unknown keys show the English `error`. + +## Versioning + +- Version numbers follow SemVer and are set at build time (`VERSION` build arg → + `APP_VERSION`); the agent has its own `VERSION` in `agent/f2b-webctl`. +- Every change goes into `CHANGELOG.md` under *Unreleased*. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c99fc39 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,241 @@ +# Installation – web UI + +This guide sets up the web UI on a Docker host, step by step. The agents on your +fail2ban hosts are covered in [agent.md](agent.md); you need both. + +- Source: +- Image: `git.retronerd.at/schiwagoa/fail2ban-webui` (`amd64`, `arm64`) +- Tags: `1.0.0` (exact), `1` (latest 1.x), `latest` + +## 1. Prerequisites + +- Docker Engine with Compose v2 (`docker compose version`) +- Outgoing SSH (TCP 22 or your port) from the Docker host to every fail2ban host +- A DNS name and a reverse proxy with TLS for the UI (see [reverse-proxy.md](reverse-proxy.md)) + +Check that the subnet used in the example (`172.23.51.0/24`) is free: + +```bash +docker network inspect $(docker network ls -q) --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}}' | grep 172.23.51 \ + || echo "172.23.51.0/24 is free" +``` + +If it is taken, pick another `/24` and change it in step 3. + +## 2. Directories + +```bash +mkdir -p /opt/fail2ban-webui/config /opt/fail2ban-webui/data +cd /opt/fail2ban-webui +# the container runs as UID 10050 and needs to write to data/ +chown -R 10050:10050 data +chmod 700 data +``` + +| Path | Content | +|---|---| +| `docker-compose.yaml` | the service | +| `.env` | settings and secrets (password hash, OIDC secret) | +| `config/hosts.yaml` | the fail2ban hosts (mounted read-only) | +| `data/` | SSH key, `known_hosts`, session secret, audit log, GeoIP database | + +## 3. docker-compose.yaml + +```bash +nano docker-compose.yaml +``` + +```yaml +# ===================================================================== +# fail2ban-webui – example docker-compose.yaml +# +# mkdir -p fail2ban-webui/{config,data} && cd fail2ban-webui +# (copy this file, .env.example as .env and hosts.yaml.example as config/hosts.yaml) +# chown -R 10050:10050 data +# +# The UI listens on 127.0.0.1:8080 only – put a reverse proxy with TLS in front of it. +# The network uses its own /24 subnet; change it if it collides with an existing one +# (docker network inspect $(docker network ls -q) | grep Subnet). +# ===================================================================== +name: fail2ban-webui + +services: + fail2ban-webui: + image: git.retronerd.at/schiwagoa/fail2ban-webui:latest + # build: { context: .., dockerfile: docker/Dockerfile } # build from the repository instead + container_name: fail2ban-webui + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Europe/Vienna} + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + volumes: + - ./config:/config:ro + - ./data:/data + ports: + - "127.0.0.1:8080:8080" + extra_hosts: + # lets the container reach an agent on the Docker host itself (address: host.docker.internal) + - "host.docker.internal:host-gateway" + deploy: + resources: + limits: + memory: 256M + networks: + fail2ban-webui.dockernetwork.local: + ipv4_address: 172.23.51.10 + +networks: + fail2ban-webui.dockernetwork.local: + name: fail2ban-webui.dockernetwork.local + driver: bridge + ipam: + config: + - subnet: 172.23.51.0/24 + gateway: 172.23.51.1 + ip_range: 172.23.51.128/25 +``` + +If the reverse proxy runs on another machine, bind the port to the LAN address instead, +e.g. `"192.168.1.10:8080:8080"` – never to `0.0.0.0` without a firewall in front. + +## 4. .env + +Start with this minimal file; every option is described in +[configuration.md](configuration.md) and in [`examples/.env.example`](../examples/.env.example). + +```bash +nano .env +chmod 600 .env +``` + +```ini +PUBLIC_URL=https://fail2ban.example.org +DEFAULT_LANG=en +TRUSTED_PROXY_HOPS=1 + +AUTH_MODE=local +AUTH_USER=admin +AUTH_PASSWORD_HASH='set in the next step' + +SSH_USER=f2bweb +MANUAL_JAIL=manual-block +GEOIP_AUTO_DOWNLOAD=1 +``` + +### Password hash + +```bash +docker compose run --rm -it fail2ban-webui hash-password +``` + +Copy the printed line (`AUTH_PASSWORD_HASH='scrypt:…'`) into `.env` and replace the +placeholder. **Keep the single quotes** – the hash contains `$` signs that Docker Compose +would otherwise try to expand. For single sign-on instead see +[authentication.md](authentication.md). + +## 5. SSH key + +```bash +docker compose run --rm fail2ban-webui keygen +``` + +The command creates `data/ssh/id_ed25519` and prints the public key +(`ssh-ed25519 AAAA… fail2ban-webui`). Copy it – you need it for the agent installer. + +## 6. Agents + +Install the agent on every fail2ban host as described in [agent.md](agent.md). In short: + +```bash +# on each fail2ban host, as root +git clone https://git.retronerd.at/schiwagoa/fail2ban-webui.git /tmp/fail2ban-webui +echo 'ssh-ed25519 AAAA… fail2ban-webui' > /tmp/webui.pub +bash /tmp/fail2ban-webui/agent/install.sh --key-file /tmp/webui.pub --from +``` + +## 7. config/hosts.yaml + +```bash +nano config/hosts.yaml +``` + +```yaml +hosts: + - name: web01 + address: web01.example.org + + - name: lxc-proxy + address: 192.168.1.20 + port: 2222 + + # fail2ban on the Docker host itself + - name: dockerhost + address: host.docker.internal +``` + +`user` defaults to `SSH_USER` (`f2bweb`). Set `user: root` for hosts where the agent was +installed with `--user root`. + +## 8. Host keys and connection test + +```bash +docker compose run --rm -it fail2ban-webui keyscan +``` + +`keyscan` shows the fingerprint of every host and asks before writing +`data/ssh/known_hosts`. Compare the fingerprints with the hosts: + +```bash +# on the fail2ban host +ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub +``` + +Then test every agent: + +```bash +docker compose run --rm fail2ban-webui check +``` + +``` +OK web01 agent 1.0.0, 3 jails, 12 bans +OK lxc-proxy agent 1.0.0, 2 jails, 0 bans +OK dockerhost agent 1.0.0, 4 jails, 7 bans +``` + +Every host must show `OK` – see [operations.md](operations.md#troubleshooting) otherwise. + +## 9. Start + +```bash +docker compose up -d +docker compose ps # STATUS: Up … (healthy) +docker compose logs -f fail2ban-webui # Ctrl+C to leave +``` + +The log shows a summary like +`fail2ban-webui 1.0.0 – hosts: web01, lxc-proxy, dockerhost – auth: local – loki: off – geoip: on`. + +Local test without the proxy: + +```bash +curl -s http://127.0.0.1:8080/healthz # ok +``` + +## 10. Reverse proxy + +Publish `http://127.0.0.1:8080` (or the LAN address from step 3) under your DNS name with +TLS – examples for Nginx Proxy Manager, Caddy, Traefik and nginx are in +[reverse-proxy.md](reverse-proxy.md). Then open `PUBLIC_URL` and sign in. + +## Next steps + +- [Protect the login with fail2ban](operations.md#protecting-the-login-with-fail2ban) +- [Updates and backups](operations.md) +- Optional: [Loki history and Grafana links](configuration.md#loki-and-grafana) diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..14a0391 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,150 @@ +# Operations + +All commands are run in the directory with `docker-compose.yaml` (e.g. `/opt/fail2ban-webui`). + +## Status and logs + +```bash +docker compose ps # Up … (healthy) +docker compose logs --tail 100 fail2ban-webui +docker compose logs -f fail2ban-webui | grep -E 'AUDIT|LOGIN|ERROR|WARNING' +docker compose run --rm fail2ban-webui check # every agent reachable? +``` + +Log lines of interest: + +| Line | Meaning | +|---|---| +| `fail2ban-webui 1.0.0 – hosts: … – auth: … – loki: … – geoip: …` | start summary | +| `AUDIT {…}` | ban/unban with user, client IP, hosts and result | +| `LOGIN_OK` / `LOGIN_FAILED` / `LOGIN_LOCKED` / `LOGOUT` | sign-in events | +| `OIDC_FAILED` / `OIDC_DENIED` | SSO errors / account not allowed | + +The audit log is also kept in `data/audit.log` (one JSON object per line): + +```bash +tail -n 20 data/audit.log | python3 -m json.tool --json-lines +``` + +On the hosts every agent action is in syslog: + +```bash +journalctl -t f2b-webctl --since today +``` + +## Update + +```bash +cp -a .env docker-compose.yaml config /backups/fail2ban-webui_$(date +%F)/ # or your backup location +docker compose pull +docker compose up -d +docker compose ps +docker compose run --rm fail2ban-webui check +``` + +Read the [changelog](../CHANGELOG.md) first – if it mentions a new agent version, update +the agents as well (re-run `install.sh` with the same options, see [agent.md](agent.md#update)). +The `1` tag only moves within the 1.x series; breaking changes get a new major version. + +Rebuild/pull regularly even without new features: the image is rebuilt with current +Debian security updates. + +## Backup + +| What | Why | +|---|---| +| `.env` | settings, password hash, OIDC secret | +| `config/hosts.yaml` | host list | +| `data/ssh/` | private key and pinned host keys – without them every agent needs the new key | +| `data/audit.log` | audit trail | +| `data/secret_key` | only keeps sessions valid; losing it signs everybody out | + +`data/geoip/` is downloaded again automatically. + +```bash +tar -cpzf /backups/fail2ban-webui_$(date +%F_%H-%M).tar.gz .env docker-compose.yaml config data +sha256sum /backups/fail2ban-webui_*.tar.gz | tail -n1 +``` + +The archive contains the private SSH key – store it accordingly. + +## Moving to another host + +1. `docker compose stop` on the old host, create the archive as above. +2. Copy it, unpack with `tar -xpzf` (keeps owner 10050 and permissions). +3. On **every agent** add the new address to `from=` – easiest by re-running + `install.sh … --from `. +4. `docker compose up -d`, then `check`. + +## Rotating the SSH key + +```bash +docker compose run --rm fail2ban-webui keygen --force +# on every host: +bash install.sh --key-file new.pub --from … (same options as before) +``` + +The installer replaces only entries with the same key; remove the old one with +`uninstall.sh` first or delete its line from `authorized_keys` (it contains +`command="…f2b-webctl"`). + +## Protecting the login with fail2ban + +The UI locks a client after 5 failed logins itself. To ban persistent attackers on the +firewall, let fail2ban on the **proxy host** read the container log. + +`/etc/fail2ban/filter.d/fail2ban-webui.conf` + +```ini +[Definition] +# Docker json-file log: {"log":"… WARNING webui: LOGIN_FAILED user=x ip=203.0.113.7\n",…} +# user names never contain spaces (they are sanitised), so only the real " ip=" matches +failregex = LOGIN_FAILED user=\S* ip= + LOGIN_LOCKED ip= +ignoreregex = +``` + +`/etc/fail2ban/jail.d/fail2ban-webui.local` + +```ini +[fail2ban-webui] +enabled = true +filter = fail2ban-webui +backend = polling +logpath = /var/lib/docker/containers/*/*-json.log +maxretry = 5 +findtime = 1h +bantime = 1d +# the proxy runs in Docker? Then ban in the DOCKER-USER chain: +# banaction = iptables-allports[chain=DOCKER-USER] +``` + +```bash +fail2ban-client reload +fail2ban-regex /var/lib/docker/containers/*/*-json.log /etc/fail2ban/filter.d/fail2ban-webui.conf +``` + +This only works when the container sees the real client address (`TRUSTED_PROXY_HOPS` +set correctly). User names are logged with spaces and control characters replaced, so a +crafted user name cannot inject a second log line. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| Container exits: `… is not writable for UID 10050` | `chown -R 10050:10050 data` | +| Container exits: `AUTH_MODE=local needs AUTH_PASSWORD_HASH` | run `hash-password`, paste the line with single quotes | +| Container exits: `No hosts configured` | `config/hosts.yaml` missing or empty; on Windows/Docker Desktop: the drive cannot be bind-mounted | +| Login always fails after pasting the hash | single quotes missing in `.env` – Compose ate the `$` parts | +| Everybody locked out together / audit shows the proxy IP | `TRUSTED_PROXY_HOPS` not set | +| `Host key verification failed` | host not in `known_hosts` or its key changed → `keyscan`, compare fingerprints | +| `Permission denied (publickey)` | key not installed for that user, wrong `user:` in `hosts.yaml`, wrong `from=` (the address the host sees!), or sshd does not read that `authorized_keys` file | +| `sudo: a password is required` | sudo rule missing → re-run `install.sh` | +| `The agent does not run as root` | forced command without `sudo -n` for a non-root user → re-run `install.sh` | +| `Timeout talking to …` | firewall/port, or the host is slow – raise `SSH_TIMEOUT` | +| `The jail manual-block does not exist on this host` | installed with `--no-jail` or with another `--manual-jail`; check `MANUAL_JAIL` in `/etc/f2b-webctl.conf` | +| Bans have no start/end time | fail2ban older than 0.11 (no `--with-time`) | +| Banned IP still reaches a Docker service | banaction blocks only `INPUT` – see [Docker hosts](agent.md#docker-hosts) | +| OIDC: `redirect_uri` mismatch | provider must allow exactly `/auth/callback` | +| OIDC: `mismatching_state` | session cookie lost between redirect and callback: `PUBLIC_URL` host differs from the one in the browser, or `https` without `Secure`/proxy headers | +| OIDC: "account … is not allowed" | user not in `OIDC_ALLOWED_GROUPS`; check that the provider sends the `groups` claim | diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..92374c1 --- /dev/null +++ b/docs/reverse-proxy.md @@ -0,0 +1,121 @@ +# Reverse proxy + +The container serves plain HTTP on port 8080 (published as `127.0.0.1:8080` in the +example). Put a reverse proxy with TLS in front of it and set in `.env`: + +```ini +PUBLIC_URL=https://fail2ban.example.org +TRUSTED_PROXY_HOPS=1 +``` + +The proxy must pass `Host` unchanged and set `X-Forwarded-For` and `X-Forwarded-Proto` – +all examples below do. Restricting access to known IP addresses at the proxy is a cheap +extra layer and strongly recommended. + +## Nginx Proxy Manager + +**Hosts → Proxy Hosts → Add Proxy Host** + +| Field | Value | +|---|---| +| Domain Names | `fail2ban.example.org` | +| Scheme / Forward Hostname / Port | `http` · IP of the Docker host (or `127.0.0.1` if NPM runs with host networking) · `8080` | +| Block Common Exploits | on | +| SSL | request a certificate, *Force SSL*, *HTTP/2*, *HSTS* | +| Access List | optional: an access list with your IP addresses (*Satisfy any* off) | + +If NPM runs in another Docker network on the same host, bind the UI port to the host's +LAN address (e.g. `"192.168.1.10:8080:8080"`) or attach the UI to NPM's network and use +`fail2ban-webui:8080` as target. + +Response headers belong into **Advanced → Custom Nginx Configuration**; NPM's image +includes the *headers-more* module, which also applies them to proxied responses: + +```nginx +more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains"; +``` + +(The app sets CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and +`Cache-Control` itself.) + +## Caddy + +```caddy +fail2ban.example.org { + @allowed remote_ip 203.0.113.0/24 2001:db8::/32 + handle @allowed { + reverse_proxy 127.0.0.1:8080 + } + respond 403 +} +``` + +Caddy sets `X-Forwarded-For`/`-Proto` automatically. + +## Traefik (Docker labels) + +Attach the container to Traefik's network and add labels instead of publishing the port: + +```yaml +services: + fail2ban-webui: + # … as in the example compose file, without "ports:" + labels: + - traefik.enable=true + - traefik.http.routers.f2bwebui.rule=Host(`fail2ban.example.org`) + - traefik.http.routers.f2bwebui.entrypoints=websecure + - traefik.http.routers.f2bwebui.tls.certresolver=letsencrypt + - traefik.http.routers.f2bwebui.middlewares=f2bwebui-ipallow + - traefik.http.middlewares.f2bwebui-ipallow.ipallowlist.sourcerange=203.0.113.0/24 + - traefik.http.services.f2bwebui.loadbalancer.server.port=8080 + networks: + - fail2ban-webui.dockernetwork.local + - traefik +``` + +### Traefik with Authentik forward auth (proxy mode) + +```yaml + - traefik.http.routers.f2bwebui.middlewares=authentik@docker +``` + +```ini +AUTH_MODE=proxy +AUTH_PROXY_HEADER=X-authentik-username +AUTH_PROXY_ALLOWED_IPS=
    /32 +AUTH_PROXY_LOGOUT_URL=https://fail2ban.example.org/outpost.goauthentik.io/sign_out +``` + +## nginx + +```nginx +server { + listen 443 ssl; + http2 on; + server_name fail2ban.example.org; + + ssl_certificate /etc/letsencrypt/live/fail2ban.example.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/fail2ban.example.org/privkey.pem; + + allow 203.0.113.0/24; + deny all; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; # several slow agents can take a while + } +} +``` + +## Check + +```bash +curl -sI https://fail2ban.example.org/login | grep -iE '^(HTTP|content-security|set-cookie)' +``` + +Expect `HTTP/2 200`, the `Content-Security-Policy` header and a session cookie with +`Secure; HttpOnly; SameSite=Lax`. A cookie without `Secure` means `PUBLIC_URL` does not +start with `https://`. diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..d97d612 --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,82 @@ +# ===================================================================== +# fail2ban-webui – settings (copy to .env next to docker-compose.yaml) +# Only the block "Authentication" is required, everything else is optional. +# ===================================================================== + +# --------------------------------------------------------------------- +# General +# --------------------------------------------------------------------- +# External address of the UI (needed for OIDC; also turns on Secure cookies for https) +PUBLIC_URL=https://fail2ban.example.org +# Language when the user has not chosen one in the dropdown (en, de, …) +DEFAULT_LANG=en +# Number of reverse proxies in front of the container whose X-Forwarded-For/-Proto +# headers are trusted (0 = none). Behind one proxy (NPM, Traefik, Caddy): 1 +TRUSTED_PROXY_HOPS=1 +# Session lifetime in hours +SESSION_HOURS=12 + +# --------------------------------------------------------------------- +# Authentication – AUTH_MODE: local | oidc | local,oidc | proxy +# --------------------------------------------------------------------- +AUTH_MODE=local + +# local: create the hash with +# docker compose run --rm -it fail2ban-webui hash-password +# and paste the printed line (keep the single quotes!) +AUTH_USER=admin +AUTH_PASSWORD_HASH='scrypt:...' +# Lock a client IP after this many failed logins for LOGIN_LOCK_MINUTES +LOGIN_MAX_ATTEMPTS=5 +LOGIN_LOCK_MINUTES=15 + +# oidc: e.g. Authentik – Redirect URI in the provider: ${PUBLIC_URL}/auth/callback +#OIDC_ISSUER=https://auth.example.org/application/o/fail2ban-webui/ +#OIDC_CLIENT_ID= +#OIDC_CLIENT_SECRET= +#OIDC_SCOPES=openid email profile +#OIDC_USERNAME_CLAIM=preferred_username +#OIDC_GROUPS_CLAIM=groups +# Strongly recommended: only these groups / users may sign in (comma separated) +#OIDC_ALLOWED_GROUPS=fail2ban-admins +#OIDC_ALLOWED_USERS= +#OIDC_BUTTON_LABEL=Authentik + +# proxy: the reverse proxy does the login (forward auth, basic auth, IP list). +# Only requests from these addresses are accepted (address of the proxy): +#AUTH_PROXY_ALLOWED_IPS=172.23.51.1/32 +# Header with the user name set by the proxy, e.g. X-authentik-username or Remote-User +#AUTH_PROXY_HEADER= +#AUTH_PROXY_LOGOUT_URL= + +# --------------------------------------------------------------------- +# Agents (SSH) +# --------------------------------------------------------------------- +# Default SSH user on the hosts (can be overridden per host in hosts.yaml) +SSH_USER=f2bweb +SSH_TIMEOUT=40 +# Seconds the ban list is cached +CACHE_SECONDS=15 +# Jail used by the agent for manual bans (only shown in the UI) +MANUAL_JAIL=manual-block + +# --------------------------------------------------------------------- +# GeoIP (optional) – country/city per IP +# --------------------------------------------------------------------- +# 1 = download the free DB-IP Lite database (CC BY 4.0) monthly into /data/geoip +GEOIP_AUTO_DOWNLOAD=1 +# or mount your own .mmdb (DB-IP or MaxMind GeoLite2 City) and point to it: +#GEOIP_DB=/data/geoip/dbip-city-lite.mmdb + +# --------------------------------------------------------------------- +# Loki / Grafana (optional) – "History" button and Grafana links +# --------------------------------------------------------------------- +#LOKI_URL=http://loki:3100 +#LOKI_SELECTOR={job=~"fail2ban|sshd|nginx"} +#LOKI_DAYS=7 +#LOKI_USER= +#LOKI_PASSWORD= +#LOKI_TENANT= +#GRAFANA_URL=https://grafana.example.org +#GRAFANA_DASHBOARD=/d/my-security-dashboard +#GRAFANA_LOKI_UID=loki diff --git a/examples/docker-compose.yaml b/examples/docker-compose.yaml new file mode 100644 index 0000000..7b537ba --- /dev/null +++ b/examples/docker-compose.yaml @@ -0,0 +1,54 @@ +# ===================================================================== +# fail2ban-webui – example docker-compose.yaml +# +# mkdir -p fail2ban-webui/{config,data} && cd fail2ban-webui +# (copy this file, .env.example as .env and hosts.yaml.example as config/hosts.yaml) +# chown -R 10050:10050 data +# +# The UI listens on 127.0.0.1:8080 only – put a reverse proxy with TLS in front of it. +# The network uses its own /24 subnet; change it if it collides with an existing one +# (docker network inspect $(docker network ls -q) | grep Subnet). +# ===================================================================== +name: fail2ban-webui + +services: + fail2ban-webui: + image: git.retronerd.at/schiwagoa/fail2ban-webui:latest + # build: { context: .., dockerfile: docker/Dockerfile } # build from the repository instead + container_name: fail2ban-webui + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Europe/Vienna} + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + volumes: + - ./config:/config:ro + - ./data:/data + ports: + - "127.0.0.1:8080:8080" + extra_hosts: + # lets the container reach an agent on the Docker host itself (address: host.docker.internal) + - "host.docker.internal:host-gateway" + deploy: + resources: + limits: + memory: 256M + networks: + fail2ban-webui.dockernetwork.local: + ipv4_address: 172.23.51.10 + +networks: + fail2ban-webui.dockernetwork.local: + name: fail2ban-webui.dockernetwork.local + driver: bridge + ipam: + config: + - subnet: 172.23.51.0/24 + gateway: 172.23.51.1 + ip_range: 172.23.51.128/25 diff --git a/examples/hosts.yaml.example b/examples/hosts.yaml.example new file mode 100644 index 0000000..6f92761 --- /dev/null +++ b/examples/hosts.yaml.example @@ -0,0 +1,22 @@ +# ===================================================================== +# fail2ban-webui – hosts with fail2ban and the agent (copy to config/hosts.yaml) +# +# name shown in the UI (letters, digits, . _ -) +# address DNS name or IP reachable from the container +# port SSH port (default 22) +# user SSH user of the agent (default: SSH_USER from .env, normally f2bweb) +# +# After changes: docker compose run --rm -it fail2ban-webui keyscan +# docker compose restart +# ===================================================================== +hosts: + - name: web01 + address: web01.example.org + + - name: lxc-proxy + address: 192.168.1.20 + port: 2222 + + # fail2ban on the Docker host itself + - name: dockerhost + address: host.docker.internal diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ccaeb5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask==3.1.3 +waitress==3.0.2 +maxminddb==3.2.0 +PyYAML==6.0.3 +Authlib==1.6.12 +requests==2.34.2 diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..b82daf4 --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,154 @@ +"""Smoke test for the web UI without Docker or SSH – the agents are simulated. + + python -m venv .venv && .venv/bin/pip install -r requirements.txt + .venv/bin/python tests/smoke_test.py +""" +import os +import re +import sys +import tempfile + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app") +sys.path.insert(0, ROOT) +tmp = tempfile.mkdtemp() +with open(os.path.join(tmp, "hosts.yaml"), "w", encoding="utf-8") as fh: + fh.write("hosts:\n - name: a1\n address: a1.example\n - name: a2\n address: 192.0.2.5\n port: 2222\n user: root\n") + +from werkzeug.security import generate_password_hash # noqa: E402 + +os.environ.update(DATA_DIR=tmp, CONFIG_FILE=os.path.join(tmp, "hosts.yaml"), AUTH_MODE="local", + AUTH_USER="admin", AUTH_PASSWORD_HASH=generate_password_hash("correct-horse-battery"), + LOGIN_MAX_ATTEMPTS="3") + +from webui import backend, config # noqa: E402 +from webui.app import create_app # noqa: E402 + +calls = [] + + +def fake_ssh(self, name, *args, timeout=None): + calls.append((name, args)) + if name == "a2" and args == ("list",): + return backend.fail("err_timeout", host=name) + if args == ("list",): + return {"ok": True, "jails": {"sshd": {"bans": [ + {"ip": "203.0.113.7", "start": "2026-10-25 10:00:00", "bantime": 600, "end": "2026-10-25 10:10:00"}], + "total": 3}}} + return {"ok": True, "result": "1"} + + +REAL_SSH = backend.Agents.ssh +backend.Agents.ssh = fake_ssh +ok = 0 + + +def check(cond, label): + global ok + print(("PASS " if cond else "FAIL ") + label) + if not cond: + sys.exit(1) + ok += 1 + + +app = create_app() +c = app.test_client() + +r = c.get("/healthz"); check(r.status_code == 200, "healthz public") +r = c.get("/"); check(r.status_code == 302 and "/login" in r.location, "index redirects to login") +r = c.get("/api/bans"); check(r.status_code == 401 and r.json["ok"] is False, "api 401 without login") +r = c.get("/login"); check(b"Sign in" in r.data and b'' in r.data, "login page english default") +csrf = re.search(rb'name="csrf" value="([^"]+)"', r.data).group(1).decode() + +r = c.post("/login", data={"username": "admin", "password": "x", "csrf": "bad"}, follow_redirects=True) +check(b"Session expired" in r.data, "login csrf rejected") +for _ in range(3): + r = c.post("/login", data={"username": "admin", "password": "wrong", "csrf": csrf}, follow_redirects=True) +check(b"Invalid username or password" in r.data, "wrong password") +r = c.post("/login", data={"username": "admin", "password": "correct-horse-battery", "csrf": csrf}, follow_redirects=True) +check(b"Too many failed attempts" in r.data, "lockout after 3 failures") + +import logging # noqa: E402 +records = [] +handler = logging.Handler() +handler.emit = lambda rec: records.append(rec.getMessage()) +logging.getLogger("webui").addHandler(handler) +sys.modules["webui.auth"]._failures.clear() +c.post("/login", data={"username": "x\nLOGIN_FAILED user=y ip=8.8.8.8", "password": "w", "csrf": csrf}) +logging.getLogger("webui").removeHandler(handler) +check(len(records) == 1 and chr(10) not in records[0] and records[0].endswith("ip=127.0.0.1"), "no log injection via user name") + +from webui import auth # noqa: E402 +auth._failures.clear() +r = c.post("/login", data={"username": "admin", "password": "correct-horse-battery", "csrf": csrf}) +check(r.status_code == 302 and r.location.endswith("/"), "login ok") + +r = c.get("/"); check(r.status_code == 200 and b"Current bans" in r.data, "index english") +page_csrf = re.search(rb'name="csrf-token" content="([^"]+)"', r.data).group(1).decode() +check(page_csrf != csrf, "session/csrf rotated on login") +check(b"Content-Security-Policy" not in r.data and "form-action 'self'" in r.headers["Content-Security-Policy"], "csp") + +r = c.get("/api/bans"); d = r.json +check(d["hosts"]["a1"]["ok"] and d["hosts"]["a2"]["error"] == "Timeout talking to a2", "bans + localized error en") +check(len(d["bans"]) == 1 and d["bans"][0]["host"] == "a1", "ban rows") + +c.set_cookie("lang", "de") +r = c.get("/"); check(b"Aktuelle Sperren" in r.data and b'' in r.data and "Schließen".encode() in r.data, "german via cookie") +r = c.get("/api/bans"); check(r.json["hosts"]["a2"]["error"] == "Zeitüberschreitung bei a2", "localized error de (cached data)") + +r = c.post("/api/ban", json={"ip": "203.0.113.9", "hosts": ["a1"]}); check(r.status_code == 403, "ban without csrf -> 403") +r = c.post("/api/ban", json={"ip": "203.0.113.9", "hosts": ["a1"]}, headers={"X-CSRF-Token": page_csrf, "Origin": "https://evil.example"}) +check(r.status_code == 403, "foreign origin -> 403") +r = c.post("/api/ban", json={"ip": "nope", "hosts": ["a1"]}, headers={"X-CSRF-Token": page_csrf}) +check(r.status_code == 400 and "Ungültige IP" in r.json["error"], "invalid ip de") +calls.clear() +r = c.post("/api/ban", json={"ip": "203.0.113.9", "hosts": ["a1", "a2", "evil"], "note": "test"}, headers={"X-CSRF-Token": page_csrf}) +check(r.json["ok"] and sorted(n for n, a in calls if a[0] == "ban") == ["a1", "a2"], "ban on valid hosts only") +r = c.post("/api/unban", json={"ip": "203.0.113.9", "host": "*", "jail": "*"}, headers={"X-CSRF-Token": page_csrf}) +check(r.json["ok"], "unban everywhere") +r = c.post("/api/unban", json={"ip": "203.0.113.9", "host": "a1", "jail": "x;rm"}, headers={"X-CSRF-Token": page_csrf}) +check(r.status_code == 400, "bad jail rejected") +r = c.get("/api/audit"); e = r.json["entries"] +check(len(e) == 2 and e[0]["action"] == "unban" and e[1]["user"] == "admin", "audit log with user") +r = c.get("/api/history?ip=203.0.113.9"); check(r.status_code == 404 and "Loki" in r.json["error"], "history without loki") + +r = c.post("/logout", data={"csrf": page_csrf}); check(r.status_code == 302, "logout") +r = c.get("/api/bans"); check(r.status_code == 401, "after logout 401") + +# ssh command line built correctly +import subprocess # noqa: E402 +captured = {} +def fake_run(cmd, **kw): + captured["cmd"] = cmd + return subprocess.CompletedProcess(cmd, 0, '{"ok": true, "x": 1}' + chr(10), "") +backend.subprocess.run = fake_run +res = REAL_SSH(app.extensions["webui"]["agents"], "a2", "ban", "203.0.113.9") +cmd = captured["cmd"] +check(res == {"ok": True, "x": 1} and cmd[-4:] == ["root", "--", "192.0.2.5", "ban 203.0.113.9"] and "2222" in cmd, "ssh argv") + +# config validation +os.environ["AUTH_MODE"] = "proxy,local" +try: + s = config.Settings(); s.validate_auth(); check(False, "proxy,local rejected") +except config.ConfigError: + check(True, "proxy,local rejected") +os.environ["AUTH_MODE"] = "oidc" +try: + s = config.Settings(); s.validate_auth(); check(False, "oidc incomplete rejected") +except config.ConfigError as exc: + check("OIDC_CLIENT_ID" in str(exc), "oidc incomplete rejected") +with open(os.path.join(tmp, "bad.yaml"), "w") as fh: + fh.write("hosts:\n - name: x\n address: -oProxyCommand=evil\n") +try: + config.load_hosts(os.path.join(tmp, "bad.yaml"), "f2bweb"); check(False, "option injection in address rejected") +except config.ConfigError: + check(True, "option injection in address rejected") + +# proxy mode +os.environ.update(AUTH_MODE="proxy", AUTH_PROXY_HEADER="X-Remote-User", AUTH_PROXY_ALLOWED_IPS="10.9.9.9/32") +p = create_app().test_client() +r = p.get("/", environ_base={"REMOTE_ADDR": "10.1.1.1"}); check(r.status_code == 403, "proxy: foreign source ip 403") +r = p.get("/", environ_base={"REMOTE_ADDR": "10.9.9.9"}); check(r.status_code == 401, "proxy: missing user header 401") +r = p.get("/", environ_base={"REMOTE_ADDR": "10.9.9.9"}, headers={"X-Remote-User": "thomas"}) +check(r.status_code == 200 and b"thomas" in r.data and b"action=\"/logout\"" not in r.data, "proxy: user header accepted, no logout form") + +print(f"\n{ok} checks passed")