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

+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))