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
@@ -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))
|
||||
Reference in new issue
Block a user