Jul 22 - Update protect admin page with fail2ban
This commit is contained in:
@@ -18,3 +18,5 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_PASSWORD_HASH=
|
ADMIN_PASSWORD_HASH=
|
||||||
# Set to 1 once served over HTTPS (required for the login cookie to send). Use 0 for plain-HTTP local testing.
|
# Set to 1 once served over HTTPS (required for the login cookie to send). Use 0 for plain-HTTP local testing.
|
||||||
SESSION_COOKIE_SECURE=1
|
SESSION_COOKIE_SECURE=1
|
||||||
|
# Where the auth log fail2ban watches is written. Blank -> <appdir>/logs/auth.log
|
||||||
|
AUTH_LOG_PATH=
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ instance/
|
|||||||
*.db
|
*.db
|
||||||
static/img/
|
static/img/
|
||||||
static/vid/
|
static/vid/
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# CLAUDE.md — JQC Features Site
|
||||||
|
|
||||||
|
Canonical rules and context for the **jqc-features** project. Update at the end
|
||||||
|
of every session.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Public marketing page for LT Services' JQC program, plus a small admin panel to
|
||||||
|
edit its content. Separate codebase from the main JQC app (`janitorial_qc`).
|
||||||
|
Content = numbered sections, each with expandable topics (text / link / photo /
|
||||||
|
video). Served at `jqcfeatures.ltservicesinc.com`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Flask · SQLAlchemy · MySQL 8 · Flask-WTF (CSRF) · Gunicorn · systemd · Nginx ·
|
||||||
|
Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
|
||||||
|
|
||||||
|
## Layout (deployment)
|
||||||
|
|
||||||
|
- App dir: `/home/jqc/jqc_features` (user `jqc`)
|
||||||
|
- venv: `/home/jqc/jqc_features/venv`
|
||||||
|
- `.env` beside `config.py` (app loads it itself — see learnings)
|
||||||
|
- systemd service: `jqc-features`
|
||||||
|
- Auth log: `/home/jqc/jqc_features/logs/auth.log`
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`,
|
||||||
|
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
|
||||||
|
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD.
|
||||||
|
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
|
||||||
|
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
|
||||||
|
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
|
||||||
|
- `schema.sql` — DDL + idempotent seed. `add_admin.sql` — additive audit_log.
|
||||||
|
- `deploy/` — `jqc-features.service`, `nginx.conf`, `fail2ban/`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
- `section(id, num UNIQUE, title, subtitle, sort_order)`
|
||||||
|
- `topic(id, section_id FK cascade, slug UNIQUE, title, body_html, link_url,
|
||||||
|
link_label, media_type[none|image|video|embed], media_url, media_caption,
|
||||||
|
sort_order)`
|
||||||
|
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
|
||||||
|
|
||||||
|
Public page orders sections by `sort_order, num`; topics by `sort_order`.
|
||||||
|
|
||||||
|
## Conventions (follow every change)
|
||||||
|
|
||||||
|
- Surgical, additive patches. Preserve route/function/variable names.
|
||||||
|
- `log_action()` is called ONLY AFTER `db.session.commit()` of the change.
|
||||||
|
- Migrations are idempotent: `CREATE TABLE IF NOT EXISTS`; seed uses
|
||||||
|
`INSERT ... ON DUPLICATE KEY UPDATE`. Safe to re-run.
|
||||||
|
- Every POST form includes `{{ csrf_token() }}`; Flask-WTF `CSRFProtect` is global.
|
||||||
|
- Content `body_html` is admin-authored and trusted → rendered via `Markup`.
|
||||||
|
- Read files on disk before editing; verify the patch after applying.
|
||||||
|
|
||||||
|
## Auth / admin design
|
||||||
|
|
||||||
|
- Single admin account. Password stored ONLY as a Werkzeug hash in
|
||||||
|
`ADMIN_PASSWORD_HASH`; empty hash rejects all logins by design.
|
||||||
|
- Session flag `session['admin']`; `login_required` decorator gates all CRUD.
|
||||||
|
- `next` redirect is restricted to paths starting `/admin` (open-redirect guard).
|
||||||
|
- Username compared with `hmac.compare_digest`; password with
|
||||||
|
`check_password_hash` (constant-time).
|
||||||
|
|
||||||
|
## Security learnings (this session)
|
||||||
|
|
||||||
|
- **`.env` is not read automatically.** The app only sees process env. systemd
|
||||||
|
`EnvironmentFile=` OR the in-app `_load_dotenv()` must supply vars. We added
|
||||||
|
`_load_dotenv()` so the `.env` beside `config.py` is authoritative regardless
|
||||||
|
of systemd. systemd-injected vars still win (`os.environ.setdefault`).
|
||||||
|
- **No variable expansion when loading `.env`.** Werkzeug hashes contain `$`;
|
||||||
|
shell/dotenv interpolation corrupts them. `_load_dotenv()` does a plain split,
|
||||||
|
strips matched surrounding quotes, no expansion.
|
||||||
|
- **`SESSION_COOKIE_SECURE=1` breaks login over plain HTTP.** The session cookie
|
||||||
|
(which holds the CSRF token) is marked `Secure`, so the browser drops it on
|
||||||
|
HTTP → "CSRF session token is missing." Serve HTTPS in prod; only set `0` for
|
||||||
|
local HTTP testing.
|
||||||
|
- **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set,
|
||||||
|
stable, secret. Changing it logs everyone out.
|
||||||
|
- **systemd reads `EnvironmentFile` only at start** → `systemctl restart` after
|
||||||
|
any `.env` edit; `daemon-reload` after unit edits.
|
||||||
|
- **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without
|
||||||
|
`ProxyFix`. We wrap `app.wsgi_app = ProxyFix(..., x_for=1, x_proto=1, x_host=1)`
|
||||||
|
and nginx sets `X-Forwarded-For`/`-Proto`. gunicorn binds 127.0.0.1, so headers
|
||||||
|
can't be spoofed externally. `x_proto=1` also fixes https redirects.
|
||||||
|
|
||||||
|
## fail2ban
|
||||||
|
|
||||||
|
- App writes `logs/auth.log` via the `jqc.auth` logger (RotatingFileHandler,
|
||||||
|
1 MB × 5, own handler, `propagate=False`).
|
||||||
|
- Line format: `<ts> jqc.auth WARNING FAILED LOGIN user=<u> from <ip>`.
|
||||||
|
- Username is sanitized before logging (`\s+`→space, cap 64) to kill CR/LF
|
||||||
|
log-injection; the real IP is the LAST token and the filter regex anchors
|
||||||
|
`from <HOST>\s*$`, so a crafted username can't spoof the ban target.
|
||||||
|
- Filter `deploy/fail2ban/filter.d/jqc-admin.conf` (datepattern uses `%%` —
|
||||||
|
ConfigParser escaping). Jail `deploy/fail2ban/jail.d/jqc-admin.local`:
|
||||||
|
5 fails / 10 min → 1 h ban.
|
||||||
|
- CSRF-less POST floods get HTTP 400 before the view, so they don't reach the
|
||||||
|
auth log — add nginx `limit_req` on `/admin/login` if that traffic matters.
|
||||||
|
|
||||||
|
## Deploy delta cheatsheet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql < add_admin.sql # audit_log (existing DBs)
|
||||||
|
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
||||||
|
sudo ./venv/bin/pip install -r requirements.txt
|
||||||
|
sudo systemctl restart jqc-features
|
||||||
|
# fail2ban:
|
||||||
|
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
|
||||||
|
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
|
||||||
|
sudo systemctl restart fail2ban
|
||||||
|
```
|
||||||
|
|
||||||
|
Password hash: `./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('PW'))"`
|
||||||
@@ -179,3 +179,52 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
|
|||||||
- `SECRET_KEY` must be stable and secret; changing it logs everyone out.
|
- `SECRET_KEY` must be stable and secret; changing it logs everyone out.
|
||||||
- The admin routes live under `/admin`; the public page and `schema.sql` SQL
|
- The admin routes live under `/admin`; the public page and `schema.sql` SQL
|
||||||
workflow above still work unchanged.
|
workflow above still work unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brute-force protection (fail2ban)
|
||||||
|
|
||||||
|
Every login attempt is written to `logs/auth.log` (rotating, 1 MB × 5) with the
|
||||||
|
real client IP:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-07-22 12:00:00,123 jqc.auth WARNING FAILED LOGIN user=admin from 203.0.113.5
|
||||||
|
2026-07-22 12:00:05,456 jqc.auth INFO LOGIN OK user=admin from 203.0.113.5
|
||||||
|
```
|
||||||
|
|
||||||
|
The real IP comes from `ProxyFix` reading nginx's `X-Forwarded-For` (gunicorn
|
||||||
|
binds 127.0.0.1, so the header can't be spoofed from outside). Config ships in
|
||||||
|
`deploy/fail2ban/`.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y fail2ban
|
||||||
|
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
|
||||||
|
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
|
||||||
|
# edit logpath in the jail file if your app dir differs from /home/jqc/jqc_features
|
||||||
|
sudo systemctl enable --now fail2ban
|
||||||
|
sudo systemctl restart fail2ban
|
||||||
|
```
|
||||||
|
|
||||||
|
Default policy: **5 failures in 10 min → 1 h ban** (`maxretry`/`findtime`/`bantime`
|
||||||
|
in the jail file).
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# regex matches the log lines:
|
||||||
|
sudo fail2ban-regex logs/auth.log deploy/fail2ban/filter.d/jqc-admin.conf
|
||||||
|
# jail is live:
|
||||||
|
sudo fail2ban-client status jqc-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
`fail2ban-regex` should report matches equal to the number of `FAILED LOGIN`
|
||||||
|
lines. `status` shows currently banned IPs.
|
||||||
|
|
||||||
|
### Scope note
|
||||||
|
|
||||||
|
This jail bans credential-guessing that reaches the password check (a real
|
||||||
|
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
|
||||||
|
token get an HTTP 400 and never reach the check — they can't guess a password
|
||||||
|
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ from app import db, log_action, Section, Topic
|
|||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
auth_log = logging.getLogger("jqc.auth")
|
||||||
|
|
||||||
MEDIA_TYPES = ("none", "image", "video", "embed")
|
MEDIA_TYPES = ("none", "image", "video", "embed")
|
||||||
|
|
||||||
|
|
||||||
@@ -39,14 +42,24 @@ def login():
|
|||||||
|
|
||||||
user_ok = hmac.compare_digest(username, expected_user)
|
user_ok = hmac.compare_digest(username, expected_user)
|
||||||
pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password)
|
pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password)
|
||||||
|
|
||||||
|
# Sanitize the attacker-controlled username before logging: collapse all
|
||||||
|
# whitespace (kills CR/LF log-injection) and cap length. The real client
|
||||||
|
# IP is logged LAST so a crafted username can't spoof the '... from <ip>'
|
||||||
|
# token the fail2ban filter anchors on at end-of-line.
|
||||||
|
safe_user = re.sub(r"\s+", " ", username).strip()[:64] or "-"
|
||||||
|
client_ip = request.remote_addr or "-"
|
||||||
|
|
||||||
if user_ok and pass_ok:
|
if user_ok and pass_ok:
|
||||||
session.clear()
|
session.clear()
|
||||||
session["admin"] = username
|
session["admin"] = username
|
||||||
|
auth_log.info("LOGIN OK user=%s from %s", safe_user, client_ip)
|
||||||
dest = request.args.get("next", "")
|
dest = request.args.get("next", "")
|
||||||
# only allow local admin redirects
|
# only allow local admin redirects
|
||||||
if not dest.startswith("/admin"):
|
if not dest.startswith("/admin"):
|
||||||
dest = url_for("admin.dashboard")
|
dest = url_for("admin.dashboard")
|
||||||
return redirect(dest)
|
return redirect(dest)
|
||||||
|
auth_log.warning("FAILED LOGIN user=%s from %s", safe_user, client_ip)
|
||||||
flash("Incorrect username or password.", "error")
|
flash("Incorrect username or password.", "error")
|
||||||
|
|
||||||
return render_template("admin/login.html")
|
return render_template("admin/login.html")
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
@@ -75,11 +79,42 @@ def log_action(actor, action, entity, entity_id=None, detail=None):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_auth_logger(app):
|
||||||
|
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
|
||||||
|
file fail2ban watches. Kept separate from the app log so the filter regex
|
||||||
|
stays tight and rotation is self-contained (no logrotate needed)."""
|
||||||
|
log_path = app.config.get("AUTH_LOG_PATH") or os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "logs", "auth.log"
|
||||||
|
)
|
||||||
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
|
|
||||||
|
auth_log = logging.getLogger("jqc.auth")
|
||||||
|
auth_log.setLevel(logging.INFO)
|
||||||
|
auth_log.propagate = False
|
||||||
|
# Guard against duplicate handlers if create_app runs more than once.
|
||||||
|
if not any(isinstance(h, RotatingFileHandler) for h in auth_log.handlers):
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8"
|
||||||
|
)
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
)
|
||||||
|
auth_log.addHandler(handler)
|
||||||
|
return auth_log
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# Behind nginx: trust ONE proxy hop so request.remote_addr / scheme reflect
|
||||||
|
# the real client (nginx sets X-Forwarded-For / -Proto). gunicorn binds
|
||||||
|
# 127.0.0.1 only, so these headers can't be spoofed from outside.
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
|
_configure_auth_logger(app)
|
||||||
|
|
||||||
# Deferred import avoids a circular import: admin.py imports the models and
|
# Deferred import avoids a circular import: admin.py imports the models and
|
||||||
# log_action defined above, which are ready by the time create_app() runs.
|
# log_action defined above, which are ready by the time create_app() runs.
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv():
|
|
||||||
"""Load KEY=value lines from a .env beside this file into the environment,
|
|
||||||
with NO variable expansion. Werkzeug password hashes contain '$', which
|
|
||||||
shell-style interpolation corrupts. Vars already set (e.g. by systemd) win."""
|
|
||||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = raw.strip()
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
key, val = line.split("=", 1)
|
|
||||||
key, val = key.strip(), val.strip()
|
|
||||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
||||||
val = val[1:-1]
|
|
||||||
os.environ.setdefault(key, val)
|
|
||||||
|
|
||||||
|
|
||||||
_load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL.
|
# Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL.
|
||||||
DB_USER = os.environ.get("DB_USER", "jqc_features")
|
DB_USER = os.environ.get("DB_USER", "jqc_features")
|
||||||
@@ -42,11 +20,18 @@ class Config:
|
|||||||
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
|
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
|
||||||
|
|
||||||
# --- Admin / session ---
|
# --- Admin / session ---
|
||||||
|
# SECRET_KEY signs session cookies and CSRF tokens. MUST be set in production.
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
|
||||||
|
|
||||||
|
# Single admin account. Password is stored ONLY as a Werkzeug hash — never plaintext.
|
||||||
|
# Generate a hash: python3 -c "from werkzeug.security import generate_password_hash as g; print(g('yourpassword'))"
|
||||||
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
|
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
|
||||||
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
|
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
|
||||||
|
|
||||||
|
# Cookie hardening. SESSION_COOKIE_SECURE must be true once served over HTTPS.
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1"
|
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1"
|
||||||
|
|
||||||
|
# Path to the auth log fail2ban watches. Empty -> <appdir>/logs/auth.log
|
||||||
|
AUTH_LOG_PATH = os.environ.get("AUTH_LOG_PATH", "")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# fail2ban filter for JQC Features admin login failures.
|
||||||
|
# Install to: /etc/fail2ban/filter.d/jqc-admin.conf
|
||||||
|
#
|
||||||
|
# Matches lines written by the app's "jqc.auth" logger, e.g.:
|
||||||
|
# 2026-07-22 12:00:00,123 jqc.auth WARNING FAILED LOGIN user=admin from 203.0.113.5
|
||||||
|
#
|
||||||
|
# The client IP is the LAST token on the line and the regex is anchored to
|
||||||
|
# end-of-line, so a crafted username cannot spoof the <HOST> capture.
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
failregex = ^.*\bFAILED LOGIN\b.* from <HOST>\s*$
|
||||||
|
ignoreregex =
|
||||||
|
|
||||||
|
# Timestamp the app writes ("YYYY-MM-DD HH:MM:SS"); trailing ,millis is ignored.
|
||||||
|
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# fail2ban jail for JQC Features admin.
|
||||||
|
# Install to: /etc/fail2ban/jail.d/jqc-admin.local
|
||||||
|
#
|
||||||
|
# Bans an IP after `maxretry` failed admin logins within `findtime` seconds.
|
||||||
|
# Adjust logpath to your deployment directory.
|
||||||
|
|
||||||
|
[jqc-admin]
|
||||||
|
enabled = true
|
||||||
|
filter = jqc-admin
|
||||||
|
port = http,https
|
||||||
|
logpath = /home/jqc/jqc_features/logs/auth.log
|
||||||
|
maxretry = 5
|
||||||
|
findtime = 600
|
||||||
|
bantime = 3600
|
||||||
|
# Optional: escalate repeat offenders (uncomment if using fail2ban >= 0.11)
|
||||||
|
# bantime.increment = true
|
||||||
Reference in New Issue
Block a user