From b878f51ae900163370f95f0b97a611ad91a82c56 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 17:24:08 +0000 Subject: [PATCH] root-ubuntu: exempt only SSH peers that actually logged in _fail2ban_ssh_peers took every established connection to sshd, and a brute-forcer mid-attempt holds one too, so it could be written into ignoreip for good. A peer now counts only if sshd logged 'Accepted ... from' it (auth.log, or the journal without one). Also wait up to 10s for the sshd jail after a restart before warning it did not load: the first rollout warned while fail2ban was still starting. Co-Authored-By: Claude Opus 5.5 (1M context) --- root-ubuntu.sh | 28 +++++++++++++++++++++------- test/root-ubuntu.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/root-ubuntu.sh b/root-ubuntu.sh index 2d742f4..bfc2f98 100755 --- a/root-ubuntu.sh +++ b/root-ubuntu.sh @@ -4464,16 +4464,26 @@ FAIL2BAN_MAXRETRY="${FAIL2BAN_MAXRETRY:-5}" # boxes you administer from here. FAIL2BAN_IGNOREIP="${FAIL2BAN_IGNOREIP:-}" -# Addresses connected to sshd right now: the operator running this script. -# Banning them is a lockout, so they go in ignoreip. +# Addresses logged in over SSH right now: the operator running this script. +# Banning them is a lockout, so they go in ignoreip. "Connected" is not enough: +# a brute-forcer mid-attempt holds an established connection too, and would be +# exempted for good. So a peer counts only if sshd also logged a successful +# login from it (auth.log, or the journal on a box without one). _fail2ban_ssh_peers() { - local peer + local peer accepted [[ -n "${SSH_CLIENT:-}" ]] && printf '%s\n' "${SSH_CLIENT%% *}" command -v ss >/dev/null 2>&1 || return 0 + if [[ -f /var/log/auth.log ]]; then + accepted="$(tail -n 20000 /var/log/auth.log 2>/dev/null)" + else + accepted="$(journalctl -q --no-pager --since -2d -t sshd -t sshd-session 2>/dev/null | tail -n 20000)" + fi + accepted="$(printf '%s\n' "$accepted" | sed -n 's/.*Accepted [^ ]* for [^ ]* from \([^ ]*\) port.*/\1/p' | sort -u)" + [[ -n "$accepted" ]] || return 0 ss -tnH state established "( sport = :${SSH_PORT:-22} )" 2>/dev/null \ | awk '{print $4}' | while read -r peer; do peer="${peer%:*}"; peer="${peer#[}"; peer="${peer%]}" - [[ -n "$peer" ]] && printf '%s\n' "$peer" + [[ -n "$peer" ]] && grep -qxF -- "$peer" <<<"$accepted" && printf '%s\n' "$peer" done return 0 } @@ -4538,9 +4548,13 @@ EOF fi # A jail that failed to load leaves the service running and nothing banned. - if ! fail2ban-client status sshd >/dev/null 2>&1; then - warn "fail2ban is running but the sshd jail is not loaded: fail2ban-client status sshd" - fi + # The socket takes a moment after a restart, so wait before calling it. + local i + for i in 1 2 3 4 5 6 7 8 9 10; do + fail2ban-client status sshd >/dev/null 2>&1 && return 0 + sleep "${FAIL2BAN_WAIT:-1}" + done + warn "fail2ban is running but the sshd jail is not loaded: fail2ban-client status sshd" } # ---------------------------------------------------- networkd-dispatcher --- diff --git a/test/root-ubuntu.test.ts b/test/root-ubuntu.test.ts index 9d0d7c4..810fe2a 100644 --- a/test/root-ubuntu.test.ts +++ b/test/root-ubuntu.test.ts @@ -1446,20 +1446,39 @@ describe('configure_fail2ban', () => { expect(conf).toMatch(/\[sshd\]\nenabled = true\nport = 22\nbackend = auto/); }); - it('never bans loopback, the tailnet, the admin list or the operator connected now', () => { + it('never bans loopback, the tailnet, the admin list or the operator logged in now', () => { const dir = fakeRoot(); - writeFileSync(join(dir, 'var/log/auth.log'), ''); + writeFileSync( + join(dir, 'var/log/auth.log'), + '2026-09-25T12:31:52+00:00 box sshd-session[1]: Accepted publickey for anthony from 198.51.100.7 port 40000 ssh2: ED25519 SHA256:x\n' + + '2026-09-25T12:32:00+00:00 box sshd-session[2]: Accepted keyboard-interactive/pam for ops from 198.51.100.8 port 40001 ssh2\n', + ); run( dir, "FAIL2BAN_IGNOREIP='67.205.189.229 10.0.0.0/8' SSH_CLIENT='203.0.113.5 51234 22' " + - "FAKE_SS=$'0 0 23.95.228.174:22 198.51.100.7:40000\\n0 0 23.95.228.174:22 203.0.113.5:51234\\n'", + "FAKE_SS=$'0 0 23.95.228.174:22 198.51.100.7:40000\\n0 0 23.95.228.174:22 198.51.100.8:40001\\n0 0 23.95.228.174:22 203.0.113.5:51234\\n'", ); const line = /^ignoreip = (.*)$/m.exec(jail(dir))?.[1].split(' ') ?? []; expect(line).toEqual([ - '127.0.0.1/8', '::1', '100.64.0.0/10', '67.205.189.229', '10.0.0.0/8', '203.0.113.5', '198.51.100.7', + '127.0.0.1/8', '::1', '100.64.0.0/10', '67.205.189.229', '10.0.0.0/8', + '203.0.113.5', '198.51.100.7', '198.51.100.8', ]); }); + it('does not exempt a peer that is connected but never logged in', () => { + // A brute-forcer mid-attempt holds an established connection too. + const dir = fakeRoot(); + writeFileSync( + join(dir, 'var/log/auth.log'), + '2026-09-25T12:31:52+00:00 box sshd-session[1]: Failed password for root from 192.0.2.99 port 5 ssh2\n' + + '2026-09-25T12:31:53+00:00 box sshd-session[2]: Accepted publickey for anthony from 198.51.100.7 port 40000 ssh2\n', + ); + run(dir, "FAKE_SS=$'0 0 23.95.228.174:22 192.0.2.99:5\\n0 0 23.95.228.174:22 198.51.100.7:40000\\n'"); + const ignore = /^ignoreip = (.*)$/m.exec(jail(dir))?.[1] ?? ''; + expect(ignore).toContain('198.51.100.7'); + expect(ignore).not.toContain('192.0.2.99'); + }); + it('reads the journal on a box with no auth.log', () => { const dir = fakeRoot(); run(dir);