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 <noreply@anthropic.com>
This commit is contained in:
schiwagoaandClaude Opus 5.5 committed 2026-10-25 15:38:17 +01:00
commit 2e3f79236c
44 files changed
+4280

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.git
dev
examples
docs
**/__pycache__
**/*.pyc
data
config
.env
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
data/
config/hosts.yaml
dev/testbed/data/
dev/testbed/.env
+26
View File
@@ -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
+21
View File
@@ -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.
+88
View File
@@ -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 <ip>`, `unban <jail> <ip>`, `unban-all <ip>` 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.
---
<p align="center">
🏰 <a href="https://retronerd.at">Website</a> ·
🐘 <a href="https://social.retronerd.at/@schiwagoa">Mastodon</a> ·
🔒 <a href="https://retronerd.at/datenschutzerklaerung/">Datenschutz</a> ·
💬 <a href="https://helpdesk.retronerd.at/open.php">Support</a>
</p>
<p align="center">
<img src="https://dl.retronerd.at/Bilder/cat-footer.png" alt="" width="100%">
</p>
<p align="center">© Thomas Werner Schwaighofer · retronerd.AT · <a href="LICENSE">MIT Lizenz</a></p>
+30
View File
@@ -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 <address of the web UI>`.
- 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.
+226
View File
@@ -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 <ip> ban an IP permanently in the manual jail
# unban <jail> <ip> unban an IP in one jail
# unban-all <ip> 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()
+314
View File
@@ -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=… <key of the web UI>
# 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 <<EOF
fail2ban-webui agent $AGENT_VERSION – fail2ban $F2B_VERSION – $(hostname -f 2>/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" <<EOF
# fail2ban-webui agent (f2b-webctl) – settings
# Jail used for manual bans from the web UI
MANUAL_JAIL=$MANUAL_JAIL
# 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=$PROTECT
# Also protect all addresses of this host and the address of the web UI (yes/no)
PROTECT_OWN_ADDRESSES=yes
PROTECT_CLIENT=yes
EOF
chmod 0644 "$CONF_FILE"
elif [ -n "$PROTECT" ]; then
warn "$CONF_FILE exists – --protect was NOT applied (edit the file or use --force-config)"
fi
if [ "$USER_NAME" != root ]; then
if ! id "$USER_NAME" >/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" <<EOF
# fail2ban-webui agent: $USER_NAME may only run f2b-webctl as root.
# SSH_ORIGINAL_COMMAND carries the requested command, SSH_CLIENT the caller for the log.
Defaults!$AGENT_BIN env_keep += "SSH_ORIGINAL_COMMAND SSH_CLIENT"
$USER_NAME ALL=(root) NOPASSWD: $AGENT_BIN
EOF
visudo -cf "$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" <<EOF
# fail2ban-webui: never matches – the jail is only filled via
# "fail2ban-client set $MANUAL_JAIL banip <ip>"
[Definition]
failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES <HOST>\$
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 <<EOF
Done. Next steps on the web UI host:
1. add this host to config/hosts.yaml (user: $USER_NAME)
2. docker compose run --rm -it fail2ban-webui keyscan
3. docker compose run --rm fail2ban-webui check
EOF
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# =====================================================================
# fail2ban-webui agent – uninstaller
#
# Removes the key entry, the sudo rule, f2b-webctl and its config.
# The user and the manual jail are only removed on request, because
# removing the jail also lifts all manual bans.
# Every removed file is backed up to --backup-dir first.
# =====================================================================
set -euo pipefail
AGENT_BIN=/usr/local/sbin/f2b-webctl
CONF_FILE=/etc/f2b-webctl.conf
SUDOERS_FILE=/etc/sudoers.d/f2b-webctl
USER_NAME=f2bweb
AK_FILE=""
MANUAL_JAIL=""
REMOVE_USER=0
REMOVE_JAIL=0
BACKUP_DIR=/var/backups/fail2ban-webui-agent
DRY_RUN=0
YES=0
usage() {
cat <<'EOF'
Usage: uninstall.sh [options]
--user NAME SSH user of the agent (default: f2bweb)
--authorized-keys-file PATH file with the key entry (default: ~USER/.ssh/authorized_keys)
--remove-user also delete the user (not possible for root)
--remove-jail also delete the manual jail – lifts all its bans!
--manual-jail NAME name of the manual jail (default: from /etc/f2b-webctl.conf)
--backup-dir DIR backups (default: /var/backups/fail2ban-webui-agent)
--dry-run only show what would be done
-y, --yes do not ask for confirmation
EOF
}
die() { echo "ERROR: $*" >&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"
+1
View File
@@ -0,0 +1 @@
"""fail2ban-webui – web interface for fail2ban on several hosts."""
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
main()
+215
View File
@@ -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
+226
View File
@@ -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=<name> ip=<address>
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")
+219
View File
@@ -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
+180
View File
@@ -0,0 +1,180 @@
"""Command line: docker compose run --rm -it fail2ban-webui <command>
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)
+180
View File
@@ -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))
+45
View File
@@ -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()
+101
View File
@@ -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"
}
+101
View File
@@ -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"
}
+259
View File
@@ -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);
+11
View File
@@ -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();
});
}
+143
View File
@@ -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; }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 5 360 145"><!-- Natural Earth 110m land (public domain), equirectangular --><path fill="#8a8f98" fill-opacity=".35" d="M112.2 143.8L113.5 144.5L114.9 144.7L114.5 145.2L113.5 145.2L113 144.9L112.7 145.3L111.9 145.6L110.8 145.5L110 145.2L109 145.1L107.7 144.5L106.7 144L105.3 142.8L106.2 143L107.6 143.7L108.9 144.1L109.4 143.6L109.7 142.9L110.7 142.5L111.4 142.6L111.7 143.1L112.2 143.8ZM121.5 141.1L122.3 141.5L122 141.9L120.6 142.2L120.2 141.8L119.3 142.3L118.8 141.8L120 141.3L120.8 141.5L121.5 141.1ZM250.3 139.7L248.7 139.8L248.7 139.2L248.9 138.8L248.9 138.6L249.6 138.9L250.5 139.1L250.6 139.3L250.3 139.7ZM325.4 130.8L326.4 131.1L326.9 131L327.7 130.8L328.3 130.9L328.4 132.1L328 132.4L327.9 133.2L327.6 132.9L326.9 133.6L326.7 133.6L326 133.5L325.4 132.7L325.3 132L324.7 131.2L324.7 130.7L325.4 130.8ZM353 130.9L353.2 131.3L354 130.9L354.2 131.3L354.2 131.8L353.9 132.2L353.2 133L352.7 133.4L353.1 133.9L352.3 133.9L351.5 134.2L351.2 134.9L350.6 135.9L349.8 136.4L349.3 136.6L348.4 136.6L347.8 136.3L346.7 136.2L346.5 135.9L347 135.1L348.3 134.1L348.9 133.9L349.7 133.6L350.5 133L351.1 132.5L351.6 131.8L352 131.5L352.1 131L352.8 130.5L353 130.9ZM354.6 126.2L355.3 127.2L355.4 126.5L355.8 126.8L356 127.6L356.8 127.9L357.4 128L358 127.6L358.5 127.7L358.3 128.6L358 129.2L357.2 129.1L356.9 129.4L357 129.9L356.9 130.1L356.5 130.6L356 131.3L355.2 131.7L355.1 131.4L354.7 131.3L355.2 130.5L354.9 129.9L353.8 129.5L353.9 129.1L354.6 128.8L354.7 128L354.7 127.4L354.3 126.7L354.3 126.5L353.8 126.1L353.1 125.2L352.6 124.5L353 124.5L353.6 125L354.3 125.3L354.6 126.2ZM347.1 112.2L346.7 112.4L346.2 112.1L345.5 111.7L344.8 111.1L344.2 110.4L344 110.1L344.5 110.1L345 110.5L345.5 110.8L345.8 111.1L346.6 111.7L347.1 112.2ZM358.4 107.3L358.7 107.6L358.6 108.2L357.9 108.3L357.4 108.2L357.3 107.7L357.7 107.4L358.1 107.5L358.4 107.3ZM359.4 106.8L358.7 107L358.6 106.6L359.1 106.4L359.4 106.4ZM0 106.1L0.2 106L0.1 106.5L0 106.6ZM347.8 106.5L347.5 106.6L347.2 106.2L347.2 105.9L347.8 106.5ZM347.1 104.9L347.3 105.7L347 105.6L346.8 105.7L346.7 105.4L346.6 104.6L347.1 104.9ZM230.1 103.6L230.2 104.8L230.5 105.2L230.4 105.7L230.2 106L229.9 105.4L229.7 105.7L229.9 106.5L229.8 106.9L229.5 107.1L229.4 108L229 109.1L228.5 110.5L227.9 112.4L227.5 113.8L227.1 114.9L226.3 115.2L225.4 115.6L224.8 115.3L224 115L223.8 114.5L223.7 113.6L223.3 112.8L223.3 112.1L223.4 111.3L223.9 111.2L223.9 110.8L224.4 110.1L224.5 109.4L224.2 109L224 108.3L224 107.4L224.3 106.9L224.4 106.2L224.9 106.2L225.5 106L225.9 105.8L226.3 105.8L226.9 105.2L227.7 104.6L228 104.1L227.9 103.7L228.3 103.8L228.8 103.1L228.9 102.5L229.2 102L229.5 102.5L229.8 102.9L230.1 103.6ZM323.6 103.8L323.9 104.5L324.6 104.2L324.9 104.6L325.4 105L325.3 105.4L325.5 106.3L325.6 106.8L325.9 106.9L326.2 107.8L326.1 108.3L326.4 109L327.5 109.5L328.2 110L328.8 110.4L328.7 110.6L329.3 111.3L329.7 112.3L330.1 112.1L330.5 112.6L330.7 112.4L330.9 113.5L331.6 114.1L332.1 114.5L332.9 115.3L333.1 116.1L333.2 116.6L333.1 117.3L333.6 118.1L333.5 119L333.3 119.5L333.1 120.4L333.1 120.9L332.9 121.6L332.4 122.6L331.7 123L331.3 123.8L331 124.3L330.7 125.2L330.3 125.7L330.1 126.4L329.9 127.1L330 127.4L329.4 127.8L328.3 127.8L327.4 128.2L326.9 128.6L326.3 129L325.5 128.6L324.9 128.4L325 127.9L324.5 128.1L323.6 128.8L322.7 128.5L322.2 128.4L321.6 128.3L320.6 128L320 127.4L319.8 126.6L319.6 126.1L319.1 125.7L318.1 125.6L318.4 125.1L318.2 124.4L317.7 125.1L316.8 125.3L317.4 124.7L317.5 124.1L317.9 123.6L317.8 122.9L317 123.8L316.4 124.1L316 124.9L315.2 124.5L315.2 123.9L314.6 123.2L314.1 122.8L314.3 122.6L313 122L312.3 122L311.3 121.5L309.5 121.6L308.2 121.9L307.1 122.3L306.1 122.2L305.1 122.7L304.2 123L304 123.5L303.7 123.9L302.8 123.9L302.2 124L301.3 123.8L300.6 123.9L299.9 124L299.3 124.5L299 124.5L298.5 124.7L298 125.1L297.3 125L296.6 125L295.6 124.4L295 124.2L295 123.6L295.5 123.5L295.7 123.3L295.7 122.9L295.8 122.2L295.7 121.6L295.2 120.6L295 120L295 119.5L294.6 118.8L294.6 118.5L294.2 118.1L294 117.3L293.5 116.5L293.3 116.1L293.8 116.5L293.4 115.6L293.9 115.9L294.2 116.3L294.2 115.8L293.7 115L293.6 114.7L293.4 114.4L293.5 113.8L293.7 113.6L293.8 113.1L293.7 112.5L294.1 111.8L294.2 112.5L294.6 111.8L295.5 111.5L295.9 111.1L296.7 110.7L297.2 110.6L297.4 110.7L298.2 110.4L298.8 110.3L299 110L299.3 110L299.8 110L300.9 109.7L301.4 109.2L301.7 108.7L302.2 108.2L302.3 107.8L302.3 107.3L303 106.4L303.4 107.3L303.9 107.1L303.5 106.6L303.8 106.1L304.3 106.3L304.4 105.6L304.9 105.1L305.2 104.7L305.7 104.5L305.7 104.2L306.1 104.3L306.1 104.1L306.6 104L307.1 103.8L307.8 104.3L308.4 104.9L309 104.9L309.6 105L309.4 104.4L309.9 103.6L310.3 103.4L310.2 103.1L310.6 102.5L311.2 102.2L311.7 102.3L312.6 102.1L312.6 101.6L311.8 101.3L312.4 101.1L313 101.4L313.6 101.8L314.4 102L314.7 101.9L315.3 102.2L315.9 102L316.3 102L316.5 101.9L317 102.4L316.7 102.9L316.3 103.3L316 103.3L316.1 103.7L315.8 104.2L315.4 104.7L315.5 105L316.3 105.6L317.1 105.9L317.6 106.2L318.3 106.8L318.6 106.8L319.1 107.1L319Line truncated

After

Width:  |  Height:  |  Size: 46 KiB

+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t('title') }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="csrf-token" content="{{ csrf }}">
</head>
<body class="{% block body_class %}{% endblock %}">
<header>
<h1>{{ t('title') }}</h1>
<div class="meta">
{% block header_meta %}{% endblock %}
<label class="lang-select">
<span class="sr-only">{{ t('language') }}</span>
<select id="lang" aria-label="{{ t('language') }}">
{% for code, name in languages %}<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>{% endfor %}
</select>
</label>
{% if user %}
<span class="user" title="{{ t('signed_in_as', user=user) }}">{{ user }}</span>
{% if logout_local %}
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">{{ t('logout') }}</button>
</form>
{% elif logout_url %}
<a class="btn-link" href="{{ logout_url }}">{{ t('logout') }}</a>
{% endif %}
{% endif %}
</div>
</header>
{% block content %}{% endblock %}
<footer>
{% if geo_enabled %}{{ t('footer_geo') }} <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP Lite</a> (CC BY 4.0) · {% endif %}
<a href="https://git.retronerd.at/schiwagoa/fail2ban-webui" target="_blank" rel="noopener">fail2ban-webui</a> {{ version }}
</footer>
<script src="{{ url_for('static', filename='common.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ t('error_title', code=code) }} – {{ t('title') }}{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('error_title', code=code) }}</h2>
<p class="msg error">{{ message }}</p>
<p><a href="{{ url_for('index') }}">{{ t('error_back') }}</a></p>
</section>
</main>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block header_meta %}
<span id="generated">–</span>
<button id="refresh" type="button">{{ t('refresh') }}</button>
{% if client_config.grafana and client_config.grafanaDashboard %}<a class="btn-link" href="{{ client_config.grafana }}{{ client_config.grafanaDashboard }}" target="_blank" rel="noopener">{{ t('grafana') }}</a>{% endif %}
{% endblock %}
{% block content %}
<main>
<section id="hosts" class="cards"></section>
{% if geo_enabled %}
<section class="panel map-panel">
<div class="toolbar">
<h2>{{ t('map_heading') }}</h2>
<span id="map-summary" class="muted"></span>
<button id="map-reset" type="button" class="small" hidden>{{ t('map_reset') }}</button>
</div>
<div class="map">
<svg id="map" viewBox="0 5 360 145" role="img" aria-label="{{ t('map_heading') }}">
<image href="{{ url_for('static', filename='world.svg') }}" x="0" y="5" width="360" height="145"/>
<g id="map-points"></g>
</svg>
</div>
<p class="hint">{{ t('map_hint') }}</p>
</section>
{% endif %}
<section class="panel">
<h2>{{ t('ban_heading') }}</h2>
<form id="ban-form" autocomplete="off">
<input id="ban-ip" type="text" placeholder="{{ t('ban_ip_placeholder') }}" required>
<input id="ban-note" type="text" placeholder="{{ t('ban_note_placeholder') }}" maxlength="200">
<span class="hosts-select">
{% for h in hosts %}<label><input type="checkbox" name="ban-host" value="{{ h }}" checked> {{ h }}</label>{% endfor %}
</span>
<button type="submit" class="danger">{{ t('ban_button') }}</button>
</form>
<p class="hint">{{ t('ban_hint', jail=manual_jail) }}</p>
</section>
<section class="panel">
<div class="toolbar">
<h2>{{ t('bans_heading') }} <span id="count" class="badge">0</span></h2>
<input id="filter-text" type="search" placeholder="{{ t('filter_search') }}">
<select id="filter-host"><option value="">{{ t('filter_all_hosts') }}</option></select>
<select id="filter-jail"><option value="">{{ t('filter_all_jails') }}</option></select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>{{ t('col_host') }}</th><th>{{ t('col_jail') }}</th><th>{{ t('col_ip') }}</th><th>{{ t('col_origin') }}</th><th>{{ t('col_since') }}</th><th>{{ t('col_until') }}</th><th>{{ t('col_actions') }}</th></tr>
</thead>
<tbody id="bans"><tr><td colspan="7" class="muted">{{ t('loading') }}</td></tr></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>{{ t('audit_heading') }}</h2>
<ul id="audit" class="audit"></ul>
</section>
</main>
<dialog id="history">
<div class="dialog-head">
<h2 id="history-title"></h2>
<button type="button" id="history-close" aria-label="{{ t('history_close') }}">✕</button>
</div>
<div id="history-body"></div>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script id="i18n" type="application/json">{{ i18n | tojson }}</script>
<script id="client-config" type="application/json">{{ client_config | tojson }}</script>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static', filename='app.js') }}"></script>{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ t('login_title') }} – {{ t('title') }}{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('login_title') }}</h2>
{% for category, message in get_flashed_messages(with_categories=true) %}
<p class="msg {{ category }}">{{ message }}</p>
{% endfor %}
{% if local %}
<form method="post" action="{{ url_for('auth.login_post') }}" class="login-form">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>{{ t('login_user') }}<input name="username" autocomplete="username" required autofocus></label>
<label>{{ t('login_password') }}<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" class="primary">{{ t('login_button') }}</button>
</form>
{% endif %}
{% if local and oidc %}<p class="or muted">{{ t('login_or') }}</p>{% endif %}
{% if oidc %}
<a class="btn-link primary sso" href="{{ url_for('auth.oidc_start') }}">{{ t('login_oidc', provider=oidc_label) }}</a>
{% endif %}
</section>
</main>
{% endblock %}
+36
View File
@@ -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"]
+64
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
hosts:
- name: agent1
address: agent1
- name: agent2
address: agent2
user: root
+34
View File
@@ -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"]
+269
View File
@@ -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 <ip>` | ban permanently in the manual jail (`manual-block`) |
| `unban <jail> <ip>` | unban in one jail |
| `unban-all <ip>` | 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 <ip>"
[Definition]
failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES <HOST>$
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
```
+119
View File
@@ -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 **`<PUBLIC_URL>/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/<realm>` | 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://<instance>.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).
+124
View File
@@ -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 `<issuer>/.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).
+106
View File
@@ -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/<code>.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 … <user>@<host> "<command>"`; 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*.
+241
View File
@@ -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: <https://git.retronerd.at/schiwagoa/fail2ban-webui>
- 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 <public IP of the web UI host>
```
## 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)
+150
View File
@@ -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 <new address>`.
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=<HOST>
LOGIN_LOCKED ip=<HOST>
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 `<PUBLIC_URL>/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 |
+121
View File
@@ -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=<address of Traefik in the shared network>/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://`.
+82
View File
@@ -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
+54
View File
@@ -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
+22
View File
@@ -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
+6
View File
@@ -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
+154
View File
@@ -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'<html lang="en">' 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'<html lang="de">' 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")