commit ecbe1fc03d8e9b93400b79fc0de7c4f307f2561f Author: Claude Date: Sun Apr 26 00:26:48 2026 +0000 Initial Authelia + fail2ban stack Self-hosted SSO portal with file-based users, SQLite storage, filesystem notifier, and an iptables-banning fail2ban sidecar. Designed to drop into a DotheEvo-style ~/docker layout next to a dockerized Caddy on the main server, joining the same external caddy_net so Caddy reaches Authelia by container name. fail2ban runs in host network mode with NET_ADMIN/NET_RAW caps so its bans hit DOCKER-USER and actually drop packets at the edge. Includes a Caddy snippet (caddy/snippet.example.caddyfile) to merge into the user's real Caddyfile -- this repo doesn't manage Caddy itself. https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..046f09e --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to .env and fill in. DO NOT commit .env. +# +# Secrets themselves live as files under authelia/secrets/ (also gitignored) +# so they can be mounted into the container without env-var leakage. This +# .env only holds non-secret tunables. + +# Pin your image versions. Bump to current stable when you upgrade -- +# check https://github.com/authelia/authelia/releases and +# https://github.com/crazy-max/docker-fail2ban/releases. +AUTHELIA_VERSION=4.39 +FAIL2BAN_VERSION=latest + +# Used by both containers for log timestamps. Set to your IANA zone. +TZ=America/New_York diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80654c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Secrets — never commit +.env +authelia/secrets/JWT_SECRET +authelia/secrets/SESSION_SECRET +authelia/secrets/STORAGE_ENCRYPTION_KEY +authelia/secrets/SMTP_PASSWORD + +# Authelia runtime state +authelia/users_database.yml +authelia/db.sqlite3 +authelia/db.sqlite3-* +authelia/authelia.log +authelia/notifications/notification.txt + +# fail2ban runtime state +fail2ban/data/db/ +fail2ban/data/fail2ban.sqlite3 +fail2ban/data/*.bak +fail2ban/data/jail.d/*.bak +fail2ban/data/filter.d/*.bak + +# Editor / OS junk +*.swp +*~ +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf6c8c6 --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +# Authelia + fail2ban + +Self-hosted authentication portal (Authelia) with an IP-banning sidecar +(fail2ban). Designed to sit next to a dockerized Caddy on the main server +and gate every public subdomain (`cam.example.com`, `doorbell.example.com`, +etc.) behind a single sign-on portal at `auth.example.com`. + +``` + Internet + | + v + +-------+ caddy_net (docker) +--------------------+ + | Caddy |--- forward_auth -------------->| Authelia | + +---+---+ | /api/authz/... | + | reverse_proxy +--------+-----------+ + | | + v v + Frigate (IoT VLAN), ntfy, etc. ./authelia/db.sqlite3 + ./authelia/authelia.log + ^ + | tail + +------+--------+ + | fail2ban | host net + | DOCKER-USER | +iptables + +---------------+ +``` + +- One docker-compose file, two services, one external network (`caddy_net`). +- File-backed users database, SQLite storage, in-memory sessions (no Redis). +- Filesystem notifier -- password reset / new-device emails get written to + a file you `tail -f`. Swap to SMTP later by editing one block. +- fail2ban runs in host network mode and bans via the `DOCKER-USER` chain + so drops happen at the host edge, before traffic reaches the Caddy + container's published ports. +- Caddy is *not* in this repo. You merge a snippet into your real Caddyfile + (see `caddy/snippet.example.caddyfile`). + +## Repo layout + +``` +authelia/ +|-- docker-compose.yml +|-- .env.example # copy to .env +|-- .gitignore +|-- README.md # this file +| +|-- authelia/ +| |-- configuration.yml # main config -- edit your domain in here +| |-- users_database.yml.example # copy to users_database.yml (gitignored) +| |-- secrets/ # gitignored; secret files mounted as /secrets +| `-- notifications/ # filesystem notifier writes here +| +|-- fail2ban/ +| `-- data/ # mounted as /data in the container +| |-- jail.d/ +| | |-- authelia.local +| | `-- caddy.local +| `-- filter.d/ +| |-- authelia.local +| `-- caddy-4xx.local +| +`-- caddy/ + `-- snippet.example.caddyfile # merge into YOUR Caddyfile +``` + +## Prerequisites + +- Docker + docker compose v2 on the main server. +- Caddy already running on the main server, in Docker, joined to an + external network named `caddy_net`. If your network is named differently, + change `caddy_net` everywhere in this repo. +- A root domain you control (the examples use `example.com`). DNS records + for `auth.` and every protected subdomain should point at the + Caddy host's public IP. +- Caddy v2.5.1 or newer (for `forward_auth` directive). + +## First-run setup + +```bash +# 0) From wherever you keep ~/docker stacks +cd ~/docker +git clone authelia +cd authelia + +# 1) External docker network -- Caddy must already be on this. If it isn't, +# create it now and make sure your Caddy compose joins it. +docker network create caddy_net 2>/dev/null || true + +# 2) Bootstrap the secrets directory +mkdir -p authelia/secrets +openssl rand -hex 32 > authelia/secrets/JWT_SECRET +openssl rand -hex 32 > authelia/secrets/SESSION_SECRET +openssl rand -hex 32 > authelia/secrets/STORAGE_ENCRYPTION_KEY +chmod 600 authelia/secrets/* + +# 3) Copy and edit the .env +cp .env.example .env +$EDITOR .env # set TZ; pin AUTHELIA_VERSION if you want + +# 4) Edit configuration.yml -- replace EVERY `example.com` with your real +# root domain (look for the CHANGE comments) +$EDITOR authelia/configuration.yml + +# 5) Create your first user +cp authelia/users_database.yml.example authelia/users_database.yml +$EDITOR authelia/users_database.yml # set username, email, displayname + +# Generate the password hash: +docker compose run --rm authelia \ + authelia crypto hash generate argon2 --password 'your-real-password' + +# Paste the resulting `$argon2id$v=19$m=...` into the user's `password:`. + +# 6) Validate the config before starting (catches typos / schema issues) +docker compose run --rm authelia \ + authelia validate-config --config /config/configuration.yml + +# 7) Bring it up +docker compose up -d +docker compose logs -f authelia # expect "Authelia is listening on ..." +``` + +## Wire Caddy into Authelia + +Open `caddy/snippet.example.caddyfile`. It defines: + +- `(authelia)` -- a reusable snippet: `import authelia` in any site block to + gate it. +- `(accesslog)` -- writes Caddy's JSON access log to `/var/log/caddy/access.log` + so fail2ban can watch it. +- `auth.example.com` -- the Authelia portal subdomain. +- Example protected blocks for `cam.example.com` and `doorbell.example.com`. + +Copy the relevant blocks into your real Caddyfile, replace `example.com` +with your domain and `192.168.x.x` with real upstream IPs, then reload Caddy: + +```bash +docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \ + caddy reload --config /etc/caddy/Caddyfile +``` + +For each protected domain, also add a rule under `access_control.rules` in +`authelia/configuration.yml` (Authelia's default policy is `deny` -- a +domain with no rule will not authenticate). Restart Authelia after editing: + +```bash +docker compose restart authelia +``` + +### Caddy access log path + +fail2ban mounts `/var/log/caddy` from the host as read-only. Your Caddy +compose needs to mount the same host path read-write so Caddy can write to +it. In your Caddy compose: + +```yaml +services: + caddy: + volumes: + - /var/log/caddy:/var/log/caddy +``` + +Make sure the host directory exists and is writable by Caddy's UID: + +```bash +sudo mkdir -p /var/log/caddy +sudo chown -R 1000:1000 /var/log/caddy # adjust UID to match your Caddy +``` + +## First login + TOTP enrollment + +1. Visit any protected subdomain in a private browser window. +2. Caddy bounces you to `https://auth./` -- log in with the + username and plaintext password you set above. +3. If the access rule is `two_factor`, Authelia asks you to register a + second factor. Pick **TOTP** and scan the QR with Authy / 1Password / + Google Authenticator / Bitwarden / etc. +4. On first registration Authelia tries to email you a confirmation link. + The filesystem notifier writes it to a file -- grab it with: + ```bash + docker compose exec authelia cat /config/notifications/notification.txt + ``` + Click that link to confirm registration. +5. Re-enter the TOTP code -- you're in. + +A successful login sets the `authelia_session` cookie scoped to your root +domain, so it covers every subdomain protected by the same Authelia. + +## User management + +### Add a user + +Append to `authelia/users_database.yml`, generate a hash with +`docker compose run --rm authelia authelia crypto hash generate argon2 --password '...'`, +paste it as `password:`, then `docker compose restart authelia` (or wait +five minutes for the file refresh interval). + +### Change a password + +Same as above -- re-generate the hash and replace the `password:` field. + +### Disable a user + +Set `disabled: true` on their entry and restart Authelia. + +### Reset their TOTP + +```bash +docker compose exec authelia \ + authelia storage user totp delete --username yourname \ + --config /config/configuration.yml +``` + +They will be prompted to re-enroll on next login. + +## fail2ban + +### Verify it's running and watching the right files + +```bash +docker compose exec fail2ban fail2ban-client status +docker compose exec fail2ban fail2ban-client status authelia +docker compose exec fail2ban fail2ban-client status caddy-4xx +``` + +Each `status ` shows the active failures, banned IPs, and the log +file it's tailing. + +### Test a filter against your real logs + +```bash +# Authelia +docker compose exec fail2ban fail2ban-regex \ + /var/log/authelia/authelia.log \ + /data/filter.d/authelia.local + +# Caddy +docker compose exec fail2ban fail2ban-regex \ + /var/log/caddy/access.log \ + /data/filter.d/caddy-4xx.local +``` + +If the failregex doesn't match anything, your log format probably differs +from what the filter expects. For Authelia: confirm `log.format: 'text'` +in `configuration.yml`. For Caddy: confirm the `(accesslog)` snippet is +imported into the site you're testing and that `format json` is set. + +### Manually unban an IP + +```bash +docker compose exec fail2ban fail2ban-client set authelia unbanip 1.2.3.4 +docker compose exec fail2ban fail2ban-client set caddy-4xx unbanip 1.2.3.4 +``` + +### Tune + +Per-jail `maxretry`, `findtime`, `bantime` live in +`fail2ban/data/jail.d/*.local`. Edit and restart fail2ban: + +```bash +docker compose restart fail2ban +``` + +The `caddy-4xx` jail's defaults (30 fails / 2 minutes -> 30 minute ban) are +intentionally loose -- a single failed request shouldn't ban you, but a +scanner spraying `/wp-admin`, `/.env`, `/admin.php` etc. will hit it fast. +The `authelia` jail is tighter (3 fails / 10 minutes -> 1 hour ban) on top +of Authelia's own in-app `regulation` (3 fails / 2 minutes -> 5 minute +account lockout), giving you defense in depth: Authelia locks the *user*, +fail2ban bans the *IP*. + +## Day-to-day operation + +```bash +docker compose ps # everything up? +docker compose logs -f authelia # follow Authelia +docker compose logs -f fail2ban # follow fail2ban +docker compose pull && docker compose up -d # upgrade +``` + +Bump `AUTHELIA_VERSION` in `.env` when you upgrade Authelia. After any +Authelia upgrade, re-run `validate-config` -- the schema does evolve. + +## Troubleshooting + +### Redirect loop between site and `auth.` + +Cookie domain mismatch. The `domain` under `session.cookies[]` must be the +*root* domain (e.g. `example.com`), and every protected site must be a +subdomain of that root, served over HTTPS. Mixed `http://` and `https://` +won't work; the cookie is `Secure`. + +### "Configuration: session: option 'domain' and option 'cookies' can't be specified at the same time" + +Old-style `session.domain: ...` left over from pre-4.38 config. Remove it; +this repo's `configuration.yml` already uses the new `session.cookies[]` +form. + +### "access denied" with no login prompt + +Default policy is `deny`. Add a rule under `access_control.rules` for the +domain you're hitting and restart Authelia. + +### Authelia container restarts forever + +```bash +docker compose logs authelia | head -50 +``` + +Most often: missing/empty secret files, bad YAML in `configuration.yml`, +or a `users_database.yml` with an invalid hash. + +### Caddy can't resolve `authelia` + +Caddy isn't on `caddy_net`. Add `networks: [caddy_net]` to your Caddy +service and `caddy_net: external: true` at the bottom of its compose, +then `docker compose up -d caddy`. + +### fail2ban bans don't actually block + +Almost always: fail2ban isn't writing to the right iptables chain. With +dockerized Caddy, you need `chain = DOCKER-USER` (already set in the +shipped jail files). Verify: +```bash +sudo iptables -L DOCKER-USER -n +``` +You should see jump rules pointing at f2b-* chains. + +### Authelia logs show nothing + +`log.file_path` is `/config/authelia.log` (i.e. `./authelia/authelia.log` +on the host). If the file isn't appearing, Authelia probably isn't +writing logs because it failed to start -- check `docker compose logs +authelia`. + +## Switching the notifier to SMTP + +When you have a transactional sender (Mailgun, Postmark, Amazon SES, your +own postfix), replace the `notifier:` block in `authelia/configuration.yml`: + +```yaml +notifier: + disable_startup_check: false + smtp: + address: 'smtps://smtp.example.com:465' + username: 'authelia@example.com' + sender: 'Authelia ' + subject: '[Authelia] {title}' + # Password loaded via AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE +``` + +Add the password file: +```bash +echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD +chmod 600 authelia/secrets/SMTP_PASSWORD +``` + +And add to `docker-compose.yml` under the authelia service `environment:`: +```yaml +- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/secrets/SMTP_PASSWORD +``` + +Restart and verify with `docker compose logs authelia` -- expect a +"Notifier SMTP startup check successful" line. + +## Security notes + +- `.env`, `authelia/secrets/*`, `authelia/users_database.yml`, + `authelia/db.sqlite3*`, and the notifications file are all gitignored. + Verify with `git status` before every commit. +- Authelia is *not* port-mapped to the host. Only containers on + `caddy_net` can reach it, and only Caddy is configured to forward + unauthenticated traffic to it via `forward_auth`. +- The TOTP secrets in the SQLite DB are encrypted at rest with + `STORAGE_ENCRYPTION_KEY`. Lose that file and you lose every user's TOTP + -- back it up alongside the DB. +- `regulation` is per-user; fail2ban is per-IP. Both are on by default. +- The shipped `caddy-4xx` filter ignores `favicon.ico`, `robots.txt`, and + Apple touch icons so accidentally-missing static assets don't ban your + own browser. Add to `ignoreregex` if other false-positives show up in + `fail2ban-regex` testing. + +## What's next + +Once this is steady-state: + +- Add OIDC clients in Authelia for apps that speak OIDC natively (Grafana, + Gitea, etc.) -- they'll do real SSO without forward-auth headers. +- Switch the filesystem notifier to SMTP (see above). +- Consider a backup job for `authelia/db.sqlite3` and `authelia/secrets/` + -- losing either is a recovery mess. diff --git a/authelia/configuration.yml b/authelia/configuration.yml new file mode 100644 index 0000000..1a4174d --- /dev/null +++ b/authelia/configuration.yml @@ -0,0 +1,117 @@ +--- +############################################################################### +# Authelia configuration +# +# - File-based user database (no LDAP) +# - SQLite local storage (no Redis, no MySQL/Postgres) +# - Filesystem notifier (writes "emails" to /config/notifications/...) +# - Argon2id password hashing +# - Per-domain access policies under access_control.rules +# +# Secrets are NOT in this file. They are loaded from files mounted at +# /secrets via the AUTHELIA_*_FILE env vars in docker-compose.yml. +# +# After editing, validate before restarting: +# docker compose run --rm authelia authelia validate-config --config /config/configuration.yml +############################################################################### + +theme: 'dark' + +server: + address: 'tcp://0.0.0.0:9091' + buffers: + read: 8192 + write: 8192 + +log: + level: 'info' + format: 'text' # fail2ban filter expects text format + file_path: '/config/authelia.log' + keep_stdout: true # also log to stdout for `docker logs` + +identity_validation: + reset_password: + jwt_lifespan: '5 minutes' + jwt_algorithm: 'HS256' + # jwt_secret loaded via AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE + +totp: + disable: false + issuer: 'example.com' # CHANGE: your root domain (shown in authenticator app) + algorithm: 'sha1' + digits: 6 + period: 30 + +authentication_backend: + password_change: + disable: false + password_reset: + disable: false + refresh_interval: '5 minutes' + file: + path: '/config/users_database.yml' + password: + algorithm: 'argon2' + argon2: + variant: 'argon2id' + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +# --------------------------------------------------------------------------- +# Access control +# +# default_policy: deny means every domain Authelia sees must have an +# explicit allow rule. Caddy only sends a domain to Authelia when its site +# block has `import authelia`, so domains you don't proxy through Authelia +# aren't affected. +# +# Policies: +# bypass no auth (Authelia portal itself) +# one_factor password only +# two_factor password + TOTP +# --------------------------------------------------------------------------- +access_control: + default_policy: 'deny' + rules: + - domain: 'auth.example.com' # CHANGE + policy: 'bypass' + + # Examples -- uncomment / change to your subdomains: + # - domain: 'cam.example.com' + # policy: 'one_factor' + # - domain: 'doorbell.example.com' + # policy: 'two_factor' + +session: + # secret loaded via AUTHELIA_SESSION_SECRET_FILE + cookies: + - name: 'authelia_session' + domain: 'example.com' # CHANGE: your root domain + authelia_url: 'https://auth.example.com' # CHANGE + default_redirection_url: 'https://example.com' # CHANGE + expiration: '1 hour' + inactivity: '5 minutes' + remember_me: '1 month' + same_site: 'lax' + +# In-app rate limiting. First line of defense; fail2ban is the second. +regulation: + max_retries: 3 + find_time: '2 minutes' + ban_time: '5 minutes' + +storage: + # encryption_key loaded via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE + local: + path: '/config/db.sqlite3' + +# Filesystem notifier -- password reset / new device emails get written to +# a file you can `tail -f`. Swap to `smtp:` when you wire up a real +# transactional sender. +notifier: + disable_startup_check: false + filesystem: + filename: '/config/notifications/notification.txt' diff --git a/authelia/notifications/.gitkeep b/authelia/notifications/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authelia/secrets/.gitkeep b/authelia/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/authelia/users_database.yml.example b/authelia/users_database.yml.example new file mode 100644 index 0000000..546be7f --- /dev/null +++ b/authelia/users_database.yml.example @@ -0,0 +1,34 @@ +--- +############################################################################### +# Authelia users database +# +# Copy this to users_database.yml (gitignored) and edit. Generate the +# password hash with: +# +# docker compose run --rm authelia \ +# authelia crypto hash generate argon2 --password 'your-plaintext-pass' +# +# Paste the resulting `$argon2id$v=19$m=...` string as the `password:` value. +# Restart Authelia for changes to take effect (or wait refresh_interval). +############################################################################### + +users: + + yourname: + disabled: false + displayname: 'Your Name' + password: '$argon2id$v=19$m=65536,t=3,p=4$REPLACE_WITH_GENERATED_HASH' + email: 'you@example.com' + groups: + - 'admins' + + # Add more users here. `groups` are referenced from access_control rules + # via `subject: 'group:admins'`. + # + # guest: + # disabled: false + # displayname: 'Guest' + # password: '$argon2id$v=19$m=65536,t=3,p=4$...' + # email: 'guest@example.com' + # groups: + # - 'guests' diff --git a/caddy/snippet.example.caddyfile b/caddy/snippet.example.caddyfile new file mode 100644 index 0000000..8710d2e --- /dev/null +++ b/caddy/snippet.example.caddyfile @@ -0,0 +1,87 @@ +# ============================================================================= +# Authelia + Caddy integration snippets +# +# Merge these blocks into your real Caddyfile (typically the one your +# dockerized Caddy mounts from its own ~/docker/caddy/ folder). Reload Caddy +# after editing: +# docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \ +# caddy reload --config /etc/caddy/Caddyfile +# +# Requires: +# - Caddy v2.5.1 or newer +# - Caddy joined to the external `caddy_net` docker network so it can +# resolve `authelia` by container name +# - access_control.rules in authelia/configuration.yml have an entry +# for each protected domain (otherwise Authelia's default_policy of +# `deny` will refuse access) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Reusable forward_auth snippet -- import into any site you want gated. +# ----------------------------------------------------------------------------- +(authelia) { + forward_auth authelia:9091 { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } +} + +# ----------------------------------------------------------------------------- +# Caddy access logging -- fail2ban needs JSON access logs at a host path +# both Caddy and fail2ban can see. Mount /var/log/caddy in BOTH compose +# files (Caddy as rw, fail2ban as ro). The roll directives keep it bounded. +# ----------------------------------------------------------------------------- +(accesslog) { + log { + output file /var/log/caddy/access.log { + roll_size 10MiB + roll_keep 5 + roll_keep_for 720h + } + format json + } +} + +# ----------------------------------------------------------------------------- +# Authelia login portal -- bypass policy in access_control.rules +# ----------------------------------------------------------------------------- +auth.example.com { + import accesslog + reverse_proxy authelia:9091 +} + +# ----------------------------------------------------------------------------- +# Example: gate cam.example.com (Frigate UI on a different VLAN/host) +# Per-domain policy lives in authelia/configuration.yml, NOT here. +# ----------------------------------------------------------------------------- +cam.example.com { + import accesslog + import authelia + reverse_proxy 192.168.x.x:8971 { + transport http { + read_timeout 60s + write_timeout 60s + } + } +} + +# ----------------------------------------------------------------------------- +# Example: gate doorbell.example.com (Pi PTT page + same-origin Frigate proxy) +# ----------------------------------------------------------------------------- +doorbell.example.com { + import accesslog + import authelia + + handle_path /frigate/* { + reverse_proxy 192.168.x.x:8971 { + transport http { + read_timeout 60s + write_timeout 60s + } + } + } + + handle { + reverse_proxy 192.168.x.x:5555 + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51bb0e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +# --------------------------------------------------------------------------- +# Authelia + fail2ban +# +# Self-hosted authentication portal (Authelia) plus an IP-banning sidecar +# (fail2ban). Sits next to your dockerized Caddy on the main server and +# joins the same external `caddy_net` so Caddy reaches Authelia by +# container name (`authelia:9091`). Authelia is NOT port-mapped to the +# host -- there is no reason for anything outside the docker network to +# hit it directly. +# +# fail2ban runs in host network mode so its iptables bans drop packets +# at the host edge, which is the only place the bans actually work for +# traffic destined for docker-published ports. +# +# First-run: see README.md. +# --------------------------------------------------------------------------- + +name: authelia + +services: + + authelia: + container_name: authelia + image: authelia/authelia:${AUTHELIA_VERSION:-4.39} + restart: unless-stopped + networks: + - caddy_net + expose: + - 9091 + environment: + # Secrets are loaded from files mounted at /secrets (see volumes). + # The _FILE suffix is supported for any AUTHELIA_* env var. + - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/JWT_SECRET + - AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET + - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY + - TZ=${TZ:-UTC} + volumes: + - ./authelia:/config + - ./authelia/secrets:/secrets:ro + healthcheck: + test: ['CMD', 'authelia', 'healthcheck'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + fail2ban: + container_name: fail2ban + image: crazymax/fail2ban:${FAIL2BAN_VERSION:-latest} + restart: unless-stopped + # Host networking so iptables bans take effect on the host's edge, + # including DOCKER-USER chain rules that gate traffic to containers. + network_mode: host + cap_add: + - NET_ADMIN + - NET_RAW + environment: + - TZ=${TZ:-UTC} + - F2B_LOG_LEVEL=INFO + - F2B_DB_PURGE_AGE=7d + volumes: + - ./fail2ban/data:/data + # Authelia log -- read-only mount so fail2ban can parse 1FA/TOTP + # failures. Authelia writes this under /config which is ./authelia. + - ./authelia/authelia.log:/var/log/authelia/authelia.log:ro + # Caddy access log -- you must configure your Caddyfile to write + # JSON access logs to this host path. See README.md. + - /var/log/caddy:/var/log/caddy:ro + depends_on: + - authelia + +networks: + caddy_net: + external: true diff --git a/fail2ban/data/filter.d/authelia.local b/fail2ban/data/filter.d/authelia.local new file mode 100644 index 0000000..a2a1392 --- /dev/null +++ b/fail2ban/data/filter.d/authelia.local @@ -0,0 +1,15 @@ +# Matches Authelia's text-format log lines for failed authentication. +# Targets Authelia 4.38+. If you change `log.format` to `json` in +# authelia/configuration.yml, this regex needs updating. +# +# Test against a real log: +# docker compose exec fail2ban fail2ban-regex \ +# /var/log/authelia/authelia.log \ +# /data/filter.d/authelia.local + +[Definition] + +failregex = ^.*Unsuccessful (1FA|TOTP|Duo|U2F) authentication attempt by user.*remote_ip"?(:|=)"?"?.*$ + ^.*user not found.*path=/api/reset-password/identity/start.*remote_ip"?(:|=)"?"?.*$ + +ignoreregex = diff --git a/fail2ban/data/filter.d/caddy-4xx.local b/fail2ban/data/filter.d/caddy-4xx.local new file mode 100644 index 0000000..d93bf41 --- /dev/null +++ b/fail2ban/data/filter.d/caddy-4xx.local @@ -0,0 +1,12 @@ +# Bans IPs that spray 401/403/404/429 across many requests against Caddy. +# Targets Caddy's default JSON access log shape (one JSON object per line). +# Verify against a real log: +# docker compose exec fail2ban fail2ban-regex \ +# /var/log/caddy/access.log \ +# /data/filter.d/caddy-4xx.local + +[Definition] + +failregex = ^.*"remote_ip":"".*"status":(401|403|404|429).*$ + +ignoreregex = ^.*"uri":"/(favicon\.ico|robots\.txt|apple-touch-icon[^"]*)".*$ diff --git a/fail2ban/data/jail.d/authelia.local b/fail2ban/data/jail.d/authelia.local new file mode 100644 index 0000000..9f4add1 --- /dev/null +++ b/fail2ban/data/jail.d/authelia.local @@ -0,0 +1,17 @@ +[authelia] +enabled = true +filter = authelia +logpath = /var/log/authelia/authelia.log +maxretry = 3 +findtime = 10m +bantime = 1h + +# DOCKER-USER is the chain Docker inserts before its own per-container +# rules; banning here drops packets destined for docker-published ports +# (i.e. your Caddy container's 80/443) before iptables routes them in. +chain = DOCKER-USER +banaction = iptables-allports + +# Tuple-form action so we record where it came from. `port=anyport` is +# fine because chain=DOCKER-USER drops at the chain head regardless. +action = iptables-allports[name=authelia, chain=DOCKER-USER] diff --git a/fail2ban/data/jail.d/caddy.local b/fail2ban/data/jail.d/caddy.local new file mode 100644 index 0000000..f67eebd --- /dev/null +++ b/fail2ban/data/jail.d/caddy.local @@ -0,0 +1,14 @@ +[caddy-4xx] +enabled = true +filter = caddy-4xx +# Adjust if your Caddy writes elsewhere -- this must match the host path +# mounted into the fail2ban container in docker-compose.yml. +logpath = /var/log/caddy/access.log +maxretry = 30 +findtime = 2m +bantime = 30m + +chain = DOCKER-USER +banaction = iptables-allports + +action = iptables-allports[name=caddy-4xx, chain=DOCKER-USER]