Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
- Allowlist: **Persist to jail.local** option (ignoreip mode) — writes changes to
the `[DEFAULT] ignoreip` line so they survive a fail2ban restart.
- Notifications: alert on **new attacker IPs** and on **open-port changes**, each
independently toggleable in Settings (in addition to ban-spike alerts).

## [1.0.0] - 2026-07-10

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ English · [한국어](#-한국어)

**Alerting**
- **ntfy push notifications** on a ban spike (configurable threshold + window),
with a one-click *Send test* button
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)
Expand Down
4 changes: 4 additions & 0 deletions bastion/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"lbl_ban_spike_threshold": "Ban-spike threshold",
"lbl_ban_spike_window": "Window (minutes)",
"hint_ban_spike": "Alert when at least this many IPs are banned within the window.",
"lbl_notify_new_attacker": "Alert on new attacker IPs",
"lbl_notify_port_change": "Alert on open-port changes",
"sec_allowlist": "Allowlist (nftables)",
"sec_allowlist_hint": "The nftables set that holds trusted IPs, managed from the Allowlist page.",
"lbl_family": "Family",
Expand Down Expand Up @@ -200,6 +202,8 @@
"lbl_ban_spike_threshold": "차단 급증 임계값",
"lbl_ban_spike_window": "기간 (분)",
"hint_ban_spike": "해당 기간 안에 이 개수 이상 차단되면 알립니다.",
"lbl_notify_new_attacker": "새 공격자 IP 알림",
"lbl_notify_port_change": "열린 포트 변경 알림",
"sec_allowlist": "허용 목록 (nftables)",
"sec_allowlist_hint": "신뢰하는 IP를 담는 nftables 셋으로, 허용 목록 페이지에서 관리합니다.",
"lbl_family": "패밀리",
Expand Down
3 changes: 3 additions & 0 deletions bastion/prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"priority": "default",
"ban_spike_threshold": 5,
"ban_spike_window_min": 10,
# Additional event alerts (each independently toggleable).
"notify_new_attacker": False,
"notify_port_change": False,
},
"allowlist": {
# "ignoreip" -> manage fail2ban ignoreip (works with any firewall);
Expand Down
84 changes: 78 additions & 6 deletions bastion/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,26 @@ async def _lifespan(app: FastAPI):
log.warning("BASTION_AUTH_PASSWORD is not set — the dashboard is OPEN (no login).")


# ---------- Ban-spike alerting (background poller) ----------
# Poll fail2ban periodically; when a burst of new bans crosses the configured
# threshold within the window, push one ntfy alert (rate-limited by the window).
# ---------- Intrusion alerting (background poller) ----------
# Poll periodically and push ntfy alerts for: a burst of new bans (rate-limited
# by the window), newly-seen attacker IPs, and open-port changes. Each event
# type is independently toggleable in Settings.
POLL_INTERVAL_SEC = 60

_seen_bans: set[tuple[str, str]] = set()
_ban_events: list[float] = []
_last_alert_at = 0.0
_primed = False

_seen_attackers: set[str] = set()
_attackers_primed = False

_seen_ports: set[tuple[int, str]] = set()
_ports_primed = False


def _poll_bans_once(now: float) -> None:
"""One alerting pass. Pure-ish: time is injected so it can be tested."""
"""Ban-spike pass. Pure-ish: time is injected so it can be tested."""
global _seen_bans, _ban_events, _last_alert_at, _primed
n = prefs.get("notifications")
if not n["enabled"]:
Expand Down Expand Up @@ -108,13 +115,78 @@ def _poll_bans_once(now: float) -> None:
_ban_events = []


def _poll_new_attackers_once() -> None:
"""Alert once for attacker IPs that are new since the last pass."""
global _seen_attackers, _attackers_primed
n = prefs.get("notifications")
if not n["enabled"] or not n.get("notify_new_attacker"):
return
data, error = dashboard.top_attackers()
if error:
return
current = {a.ip for a in (data or [])}
if not _attackers_primed:
_seen_attackers = current
_attackers_primed = True
return
new = current - _seen_attackers
_seen_attackers = current
if new:
sample = ", ".join(sorted(new)[:5])
notify.send(
f"Bastion: new attacker(s) ({len(new)})",
f"New attacker IP(s): {sample}",
tags="warning",
priority=n["priority"],
n=n,
)


def _poll_port_changes_once() -> None:
"""Alert when the set of listening ports changes (opened / closed)."""
global _seen_ports, _ports_primed
n = prefs.get("notifications")
if not n["enabled"] or not n.get("notify_port_change"):
return
data, error = dashboard.open_ports()
if error:
return
current = {(p.port, p.proto) for p in (data or [])}
if not _ports_primed:
_seen_ports = current
_ports_primed = True
return
opened = current - _seen_ports
closed = _seen_ports - current
_seen_ports = current
if opened or closed:
parts = []
if opened:
parts.append("opened: " + ", ".join(f"{p}/{pr}" for p, pr in sorted(opened)))
if closed:
parts.append("closed: " + ", ".join(f"{p}/{pr}" for p, pr in sorted(closed)))
notify.send(
"Bastion: port change",
"; ".join(parts),
tags="electric_plug",
priority=n["priority"],
n=n,
)


def _poll_once(now: float) -> None:
_poll_bans_once(now)
_poll_new_attackers_once()
_poll_port_changes_once()


async def _alert_loop() -> None:
while True:
await asyncio.sleep(POLL_INTERVAL_SEC)
try:
_poll_bans_once(time.time())
_poll_once(time.time())
except Exception as e: # never let the loop die
log.warning("ban-spike poll failed: %s", e)
log.warning("alert poll failed: %s", e)


@app.middleware("http")
Expand Down
8 changes: 8 additions & 0 deletions bastion/web/templates/views/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ <h2>{{ t("sec_notifications") }}</h2>
</div>
</div>
<p class="hint">{{ t("hint_ban_spike") }}</p>
<div class="field check">
<input type="checkbox" id="n_new_attacker" name="notify_new_attacker" {% if n.notify_new_attacker %}checked{% endif %}>
<label for="n_new_attacker">{{ t("lbl_notify_new_attacker") }}</label>
</div>
<div class="field check">
<input type="checkbox" id="n_port_change" name="notify_port_change" {% if n.notify_port_change %}checked{% endif %}>
<label for="n_port_change">{{ t("lbl_notify_port_change") }}</label>
</div>
<div class="formactions">
<button type="submit" class="btn btn-primary">{{ t("btn_save") }}</button>
<button type="submit" class="btn" formaction="/settings/notifications/test">{{ t("btn_test_notify") }}</button>
Expand Down
76 changes: 76 additions & 0 deletions tests/test_alerting.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def reset_poller(monkeypatch):
monkeypatch.setattr(webapp, "_ban_events", [])
monkeypatch.setattr(webapp, "_last_alert_at", 0.0)
monkeypatch.setattr(webapp, "_primed", False)
monkeypatch.setattr(webapp, "_seen_attackers", set())
monkeypatch.setattr(webapp, "_attackers_primed", False)
monkeypatch.setattr(webapp, "_seen_ports", set())
monkeypatch.setattr(webapp, "_ports_primed", False)
sent = []
monkeypatch.setattr(webapp.notify, "send", lambda *a, **k: sent.append((a, k)) or True)
return sent
Expand All @@ -30,6 +34,8 @@ def _prefs(monkeypatch, **over):
"priority": "default",
"ban_spike_threshold": 2,
"ban_spike_window_min": 10,
"notify_new_attacker": False,
"notify_port_change": False,
}
n.update(over)
monkeypatch.setattr(webapp.prefs, "get", lambda section: n)
Expand All @@ -39,6 +45,20 @@ def _banned(monkeypatch, bans):
monkeypatch.setattr(webapp.dashboard, "banned_ips", lambda: (bans, None))


def _attackers(monkeypatch, ips):
monkeypatch.setattr(
webapp.dashboard, "top_attackers",
lambda: ([SimpleNamespace(ip=ip) for ip in ips], None),
)


def _ports(monkeypatch, entries):
monkeypatch.setattr(
webapp.dashboard, "open_ports",
lambda: ([SimpleNamespace(port=p, proto=pr) for p, pr in entries], None),
)


def test_disabled_never_alerts(reset_poller, monkeypatch):
_prefs(monkeypatch, enabled=False)
_banned(monkeypatch, [_ban("sshd", "1.1.1.1")] * 5)
Expand Down Expand Up @@ -71,3 +91,59 @@ def test_below_threshold_does_not_alert(reset_poller, monkeypatch):
_banned(monkeypatch, [_ban("sshd", "9.9.9.9")]) # only 1 new
webapp._poll_bans_once(now=1001.0)
assert reset_poller == []


# ---------- new-attacker alerts ----------
def test_new_attacker_alert_disabled_by_default(reset_poller, monkeypatch):
_prefs(monkeypatch) # notify_new_attacker False
_attackers(monkeypatch, ["1.1.1.1"])
webapp._poll_new_attackers_once()
_attackers(monkeypatch, ["2.2.2.2"])
webapp._poll_new_attackers_once()
assert reset_poller == []


def test_new_attacker_alerts_after_prime(reset_poller, monkeypatch):
_prefs(monkeypatch, notify_new_attacker=True)
_attackers(monkeypatch, ["1.1.1.1"])
webapp._poll_new_attackers_once() # prime
assert reset_poller == []
_attackers(monkeypatch, ["1.1.1.1", "2.2.2.2"]) # 2.2.2.2 is new
webapp._poll_new_attackers_once()
assert len(reset_poller) == 1


def test_new_attacker_no_alert_when_unchanged(reset_poller, monkeypatch):
_prefs(monkeypatch, notify_new_attacker=True)
_attackers(monkeypatch, ["1.1.1.1"])
webapp._poll_new_attackers_once() # prime
webapp._poll_new_attackers_once() # same set
assert reset_poller == []


# ---------- port-change alerts ----------
def test_port_change_alerts_on_open_and_close(reset_poller, monkeypatch):
_prefs(monkeypatch, notify_port_change=True)
_ports(monkeypatch, [(22, "tcp"), (80, "tcp")])
webapp._poll_port_changes_once() # prime
assert reset_poller == []
_ports(monkeypatch, [(22, "tcp"), (443, "tcp")]) # 80 closed, 443 opened
webapp._poll_port_changes_once()
assert len(reset_poller) == 1


def test_port_change_no_alert_when_stable(reset_poller, monkeypatch):
_prefs(monkeypatch, notify_port_change=True)
_ports(monkeypatch, [(22, "tcp")])
webapp._poll_port_changes_once() # prime
webapp._poll_port_changes_once() # unchanged
assert reset_poller == []


def test_poll_once_runs_all_detectors(reset_poller, monkeypatch):
_prefs(monkeypatch, notify_new_attacker=True, notify_port_change=True)
_banned(monkeypatch, [])
_attackers(monkeypatch, [])
_ports(monkeypatch, [])
webapp._poll_once(now=1000.0) # primes all three, no alerts
assert reset_poller == []
Loading