From 2e3f79236c8fc006d0892c99bff7dfb35da91b41 Mon Sep 17 00:00:00 2001 From: SchiWaGoA Date: Sun, 25 Oct 2026 15:38:17 +0100 Subject: [PATCH] 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 --- .dockerignore | 9 + .gitattributes | 2 + .gitignore | 7 + CHANGELOG.md | 26 +++ LICENSE | 21 +++ README.md | 88 +++++++++ SECURITY.md | 30 +++ agent/f2b-webctl | 226 +++++++++++++++++++++++ agent/install.sh | 314 ++++++++++++++++++++++++++++++++ agent/uninstall.sh | 125 +++++++++++++ app/webui/__init__.py | 1 + app/webui/__main__.py | 3 + app/webui/app.py | 215 ++++++++++++++++++++++ app/webui/auth.py | 226 +++++++++++++++++++++++ app/webui/backend.py | 219 ++++++++++++++++++++++ app/webui/cli.py | 180 ++++++++++++++++++ app/webui/config.py | 180 ++++++++++++++++++ app/webui/i18n.py | 45 +++++ app/webui/locales/de.json | 101 ++++++++++ app/webui/locales/en.json | 101 ++++++++++ app/webui/static/app.js | 259 ++++++++++++++++++++++++++ app/webui/static/common.js | 11 ++ app/webui/static/style.css | 143 +++++++++++++++ app/webui/static/world.svg | 1 + app/webui/templates/base.html | 42 +++++ app/webui/templates/error.html | 12 ++ app/webui/templates/index.html | 79 ++++++++ app/webui/templates/login.html | 26 +++ dev/testbed/agent/Dockerfile | 36 ++++ dev/testbed/docker-compose.yaml | 64 +++++++ dev/testbed/hosts.yaml | 6 + docker/Dockerfile | 34 ++++ docs/agent.md | 269 +++++++++++++++++++++++++++ docs/authentication.md | 119 ++++++++++++ docs/configuration.md | 124 +++++++++++++ docs/development.md | 106 +++++++++++ docs/installation.md | 241 ++++++++++++++++++++++++ docs/operations.md | 150 +++++++++++++++ docs/reverse-proxy.md | 121 ++++++++++++ examples/.env.example | 82 +++++++++ examples/docker-compose.yaml | 54 ++++++ examples/hosts.yaml.example | 22 +++ requirements.txt | 6 + tests/smoke_test.py | 154 ++++++++++++++++ 44 files changed, 4280 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100755 agent/f2b-webctl create mode 100755 agent/install.sh create mode 100755 agent/uninstall.sh create mode 100644 app/webui/__init__.py create mode 100644 app/webui/__main__.py create mode 100644 app/webui/app.py create mode 100644 app/webui/auth.py create mode 100644 app/webui/backend.py create mode 100644 app/webui/cli.py create mode 100644 app/webui/config.py create mode 100644 app/webui/i18n.py create mode 100644 app/webui/locales/de.json create mode 100644 app/webui/locales/en.json create mode 100644 app/webui/static/app.js create mode 100644 app/webui/static/common.js create mode 100644 app/webui/static/style.css create mode 100644 app/webui/static/world.svg create mode 100644 app/webui/templates/base.html create mode 100644 app/webui/templates/error.html create mode 100644 app/webui/templates/index.html create mode 100644 app/webui/templates/login.html create mode 100644 dev/testbed/agent/Dockerfile create mode 100644 dev/testbed/docker-compose.yaml create mode 100644 dev/testbed/hosts.yaml create mode 100644 docker/Dockerfile create mode 100644 docs/agent.md create mode 100644 docs/authentication.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/installation.md create mode 100644 docs/operations.md create mode 100644 docs/reverse-proxy.md create mode 100644 examples/.env.example create mode 100644 examples/docker-compose.yaml create mode 100644 examples/hosts.yaml.example create mode 100644 requirements.txt create mode 100644 tests/smoke_test.py 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")