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:
commit
2e3f79236c
44 files changed
+4280
No files matched your search
+269
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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*.
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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://`.
|
||||
Reference in new issue
Block a user