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

+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://`.