A lightweight fail2ban + nftables security dashboard for a single self-hosted server. See your firewall state, ban and unban IPs, manage an allowlist, and get push alerts on attack spikes โ all from the browser, in one small container.
English ยท ํ๊ตญ์ด
Observability
- Banned IPs โ per-jail fail2ban ban list with country flags (GeoIP)
- Top attackers โ failed-login aggregation from
auth.log/ journald - Open ports โ listening sockets from
ss -tlnpwith the owning process - Firewall rules โ chains, rules and sets from
nft -j list ruleset - Client-side search/filter and click-to-sort on every table; 30s live refresh (HTMX)
- Trend sparklines on the home page for banned IPs and attackers (recorded
over time; also available as JSON at
GET /api/history)
Management
- Ban / unban any IP through
fail2ban-client, with SweetAlert2 confirmations - Bulk ban selected or all Top Attackers in one action
- Allowlist โ add/remove trusted IPs (or CIDRs) in an nftables set
Alerting
- ntfy push notifications on a ban spike (configurable threshold + window), on new attacker IPs, and on open-port changes โ each independently toggleable, with a one-click Send test button
Security & UX
- Single-password login gate (HMAC-signed session cookie)
- Login rate limiting per client IP (brute-force protection)
- Settings page to configure notifications, allowlist target, and rate limits at runtime โ no redeploy needed
- English / Korean UI, switchable from the header (persisted via cookie)
Every input is strictly validated (ipaddress + name allowlists), commands run as
argv lists (never a shell string), and all write actions are audit-logged. Write
actions require authentication.
runner.py run one command -> stdout (fail2ban-client, nft, ss)
services/ raw text -> parse -> dataclass (pure functions, fully tested)
prefs.py runtime user preferences (JSON on disk, layered on env)
web/ FastAPI + HTMX pages + JSON API
All logic lives in services/ / prefs.py, so the HTMX pages and the JSON API
reuse the same code. Parsers are pure functions tested against real-server fixtures.
cp .env.example .env # set BASTION_AUTH_PASSWORD (recommended)
docker compose up -d --buildReading the host firewall requires network_mode: host + cap_add: NET_ADMIN,
plus mounts for the fail2ban socket and /var/log (all in the compose file).
A named volume bastion_data persists your Settings-page preferences across
restarts. The app listens on :8009.
Security: this is an admin tool โ never expose it to the internet. Put it behind Cloudflare Access or a VPN (e.g. wg-easy).
The same code runs without a container.
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
BASTION_SUDO=true .venv/bin/uvicorn bastion.web.app:app --host 127.0.0.1 --port 8009BASTION_SUDO=true prefixes commands with sudo. Add a NOPASSWD allowlist in
sudoers for the exact commands Bastion runs (nft, fail2ban-client).
Open โ Settings in the sidebar. Changes are written to data/prefs.json and
take effect immediately.
- Notifications (ntfy) โ enable alerts, set the ntfy server URL, topic, and an optional access token, and tune the ban-spike threshold and window. Use Send test to verify delivery. A background poller checks fail2ban every 60s and pushes one alert when the threshold is crossed within the window.
- Allowlist โ two modes:
- fail2ban ignoreip (default) โ trusted IPs fail2ban will never ban, managed
per jail over the fail2ban socket. Works with any firewall (UFW/iptables/nftables).
Changes apply live; enable Persist to jail.local in Settings to also write
them to
jail.localso they survive a fail2ban restart (mount it read-write). - nftables set โ manage IPs in a dedicated nftables set (
family/table/set). The set must exist in your ruleset (a first-run guide + Create set button helps). Manage entries on the โ Allowlist page either way.
- fail2ban ignoreip (default) โ trusted IPs fail2ban will never ban, managed
per jail over the fail2ban socket. Works with any firewall (UFW/iptables/nftables).
Changes apply live; enable Persist to jail.local in Settings to also write
them to
- Login security โ max failed attempts and the lockout window for per-IP login rate limiting.
Three modes, in order of precedence:
- Multi-user โ add accounts under Settings โ Users; login is then
username + password (PBKDF2-hashed, stored in
data/users.json; sessions are signed with a key atdata/.secretorBASTION_SECRET_KEY). - Single password โ with no users but
BASTION_AUTH_PASSWORDset, the whole dashboard is gated behind one shared password (HMAC-signed cookie). - Open โ neither configured; a warning is logged on startup.
Repeated failures from one IP are locked out (see Login security above).
/healthz, /login and static assets stay public. Header-only clients can use
HTTP Basic auth (username + password in multi-user mode, or any username + the
shared password otherwise). Keep the app behind HTTPS regardless.
Docker builds automatically download the free DB-IP IP-to-Country Lite database
(CC BY 4.0, no account) and set BASTION_GEOIP_DB, so country flags work out of the
box. To refresh, rebuild the image. For bare metal:
scripts/download-geoip.sh /path/to/dbip-country-lite.mmdb
export BASTION_GEOIP_DB=/path/to/dbip-country-lite.mmdbMaxMind GeoLite2-Country works too (same .mmdb format). IP geolocation data
ยฉ DB-IP (CC BY 4.0).
Bastion exposes a small JSON API (also behind auth). GET /api/stats returns flat
counters handy for dashboards:
{ "total_banned": 12, "jail_count": 2, "open_ports": 9, "attackers": 34 }Also available: /api/banned, /api/attackers, /api/ports (each {data, error})
and /api/history?limit=N (recorded counter time-series).
Header-only clients can't do the cookie login, so the API also accepts HTTP
Basic auth โ any username, the password being BASTION_AUTH_PASSWORD.
Example Homepage customapi widget showing three numbers:
widget:
type: customapi
url: http://<bastion-host>:8009/api/stats
refreshInterval: 30000
username: homepage # ignored; any value
password: <BASTION_AUTH_PASSWORD> # omit both if auth is disabled
mappings:
- field: total_banned
label: Banned
format: number
- field: attackers
label: Attackers
format: number
- field: open_ports
label: Open ports
format: numberSet BASTION_DEMO=true to serve sample data instead of running commands โ handy
for previewing the UI on a machine without the Linux CLIs (e.g. macOS).
Infrastructure knobs only โ everything else lives in the Settings page.
| Variable | Default | Description |
|---|---|---|
BASTION_AUTH_PASSWORD |
(empty) | Login password; empty = open |
BASTION_SUDO |
false |
Prefix commands with sudo (bare metal) |
BASTION_DEMO |
false |
Serve sample data |
BASTION_JAILS |
(auto) | Jails to query, comma-separated |
BASTION_GEOIP_DB |
(auto in Docker) | Path to a DB-IP/GeoLite2 .mmdb |
BASTION_AUTH_LOG |
/var/log/auth.log |
Auth log path |
BASTION_DATA_DIR |
data |
Where prefs.json is stored |
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest httpx
.venv/bin/python -m pytest # pure logic + fixtures, no live server neededTests are isolated from any local .env (see tests/conftest.py).
MIT
๋จ์ผ ์์ฒด ํธ์คํ ์๋ฒ๋ฅผ ์ํ ๊ฐ๋ฒผ์ด fail2ban + nftables ๋ณด์ ๋์๋ณด๋์ ๋๋ค. ๋ฐฉํ๋ฒฝ ์ํ ํ์ธ, IP ์ฐจ๋จ/ํด์ , ํ์ฉ ๋ชฉ๋ก ๊ด๋ฆฌ, ๊ณต๊ฒฉ ๊ธ์ฆ ์ ํธ์ ์๋ฆผ๊น์ง โ ๋ชจ๋ ๋ธ๋ผ์ฐ์ ์์, ํ๋์ ์์ ์ปจํ ์ด๋๋ก ์ฒ๋ฆฌํฉ๋๋ค.
- ๊ด์ธก โ ์ฐจ๋จ๋ IP(๊ตญ๊ฐ ํ๋๊ทธ ํฌํจ), ์์ฃผ ๊ณต๊ฒฉํ๋ IP, ์ด๋ฆฐ ํฌํธ, ๋ฐฉํ๋ฒฝ ๊ท์น
- ๊ด๋ฆฌ โ fail2ban ๊ธฐ๋ฐ IP ์ฐจ๋จ/ํด์ , ์ ํยท์ ์ฒด ์ผ๊ด ์ฐจ๋จ, nftables ํ์ฉ ๋ชฉ๋ก ์ถ๊ฐ/์ญ์
- ์๋ฆผ โ ์ฐจ๋จ ๊ธ์ฆ ์ ntfy ํธ์(์๊ณ๊ฐยท๊ธฐ๊ฐ ์ค์ ๊ฐ๋ฅ, ํ ์คํธ ์ ์ก ๋ฒํผ ์ ๊ณต)
- ๋ณด์/UX โ ๋จ์ผ ๋น๋ฐ๋ฒํธ ๋ก๊ทธ์ธ, IP๋ณ ๋ก๊ทธ์ธ ์๋ ์ ํ(๋ฌด์ฐจ๋ณ ๋์ ๋ฐฉ์ด), ๋ฐํ์ ์ค์ ํ์ด์ง, ์์ด/ํ๊ตญ์ด UI
๋ชจ๋ ์
๋ ฅ์ ์๊ฒฉํ ๊ฒ์ฆ๋๊ณ (ipaddress + ์ด๋ฆ ํ์ฉ ๋ชฉ๋ก), ๋ช
๋ น์ ์
ธ ๋ฌธ์์ด์ด ์๋
argv ๋ฆฌ์คํธ๋ก ์คํ๋๋ฉฐ, ์ฐ๊ธฐ ์์
์ ๊ฐ์ฌ ๋ก๊ทธ์ ๋จ์ต๋๋ค. ์ฐ๊ธฐ ์์
์ ์ธ์ฆ์ด ํ์ํฉ๋๋ค.
cp .env.example .env # BASTION_AUTH_PASSWORD ์ค์ ๊ถ์ฅ
docker compose up -d --buildํธ์คํธ ๋ฐฉํ๋ฒฝ์ ์ฝ์ผ๋ ค๋ฉด network_mode: host + cap_add: NET_ADMIN, ๊ทธ๋ฆฌ๊ณ
fail2ban ์์ผ๊ณผ /var/log ๋ง์ดํธ๊ฐ ํ์ํฉ๋๋ค(compose ํ์ผ์ ํฌํจ). ์ค์ ํ์ด์ง์
ํ๊ฒฝ์ค์ ์ bastion_data ๋ณผ๋ฅจ์ ์ ์ฅ๋์ด ์ฌ์์ ํ์๋ ์ ์ง๋ฉ๋๋ค. ์ฑ์ :8009
ํฌํธ์์ ๋์ํฉ๋๋ค.
๋ณด์: ๊ด๋ฆฌ ๋๊ตฌ์ด๋ฏ๋ก ์ ๋ ์ธํฐ๋ท์ ์ง์ ๋ ธ์ถํ์ง ๋ง์ธ์. Cloudflare Access๋ VPN(wg-easy ๋ฑ) ๋ค์ ๋์ธ์.
์ฌ์ด๋๋ฐ์ โ ์ค์ ์์ ์๋ฆผ(ntfy), ํ์ฉ ๋ชฉ๋ก ๋์ ์
, ๋ก๊ทธ์ธ ์๋ ์ ํ์ ๋ฐํ์์
๋ฐ๋ก ๋ณ๊ฒฝํ ์ ์์ต๋๋ค. ๋ณ๊ฒฝ ์ฌํญ์ data/prefs.json์ ์ ์ฅ๋๋ฉฐ ์ฆ์ ์ ์ฉ๋ฉ๋๋ค.
- ์๋ฆผ(ntfy): ntfy ์๋ฒ ์ฃผ์ยทํ ํฝยทํ ํฐ๊ณผ ์ฐจ๋จ ๊ธ์ฆ ์๊ณ๊ฐ/๊ธฐ๊ฐ์ ์ค์ ํฉ๋๋ค. ๋ฐฑ๊ทธ๋ผ์ด๋ ํด๋ฌ๊ฐ 60์ด๋ง๋ค fail2ban์ ํ์ธํด ์๊ณ๊ฐ ์ด๊ณผ ์ ํ ๋ฒ ์๋ฆฝ๋๋ค.
- ํ์ฉ ๋ชฉ๋ก: ๋ ๊ฐ์ง ๋ฐฉ์
- fail2ban ignoreip(๊ธฐ๋ณธ): fail2ban์ด ์ ๋ ์ฐจ๋จํ์ง ์๋ ์ ๋ขฐ IP๋ฅผ jail๋ณ๋ก ๊ด๋ฆฌ.
UFW/iptables/nftables ์ด๋ค ๋ฐฉํ๋ฒฝ์ด๋ ๋์. ๋ณ๊ฒฝ์ ์ฆ์ ์ ์ฉ๋์ง๋ง
jail.local์๋ ์ ์ฅ๋์ง ์์ผ๋ ์๊ตฌ ํญ๋ชฉ์ ๊ฑฐ๊ธฐ์ ์ถ๊ฐํ์ธ์. - nftables ์
: ์ ์ฉ nftables ์
(
family/table/set)์ IP๋ฅผ ๊ด๋ฆฌ. ์ ์ ruleset์ ์ด๋ฏธ ์กด์ฌํด์ผ ํ๋ฉฐ(์์ผ๋ฉด ์๋ด + ์ ์์ฑ ๋ฒํผ ์ ๊ณต).
- fail2ban ignoreip(๊ธฐ๋ณธ): fail2ban์ด ์ ๋ ์ฐจ๋จํ์ง ์๋ ์ ๋ขฐ IP๋ฅผ jail๋ณ๋ก ๊ด๋ฆฌ.
UFW/iptables/nftables ์ด๋ค ๋ฐฉํ๋ฒฝ์ด๋ ๋์. ๋ณ๊ฒฝ์ ์ฆ์ ์ ์ฉ๋์ง๋ง
- ๋ก๊ทธ์ธ ๋ณด์: ์ต๋ ์คํจ ํ์์ ์ ๊ธ ๊ธฐ๊ฐ์ ์ค์ ํฉ๋๋ค.
BASTION_AUTH_PASSWORD๋ฅผ ์ค์ ํ๋ฉด ์ ์ฒด ๋์๋ณด๋๊ฐ ๋จ์ผ ๋น๋ฐ๋ฒํธ ๋ก๊ทธ์ธ์ผ๋ก ๋ณดํธ๋ฉ๋๋ค
(HMAC ์๋ช
์ฟ ํค ์ธ์
). ํ IP์์ ์คํจ๊ฐ ๋ฐ๋ณต๋๋ฉด ์ ๊น๋๋ค. ๋น์๋๋ฉด ๊ฐ๋ฐฉ ๋ชจ๋๋ก
์คํ๋๋ฉฐ ์์ ์ ๊ฒฝ๊ณ ๊ฐ ๋ก๊ทธ์ ๋จ์ต๋๋ค. /healthz, /login, ์ ์ ์์ฐ์ ๊ณต๊ฐ์
๋๋ค.
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest httpx
.venv/bin/python -m pytest๋ผ์ด์ ์ค: MIT