51 KiB
Control-plane brute-force IP ban (Anmeldeschutz) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add an application-level persistent IP ban that blocks unauthenticated requests from IPs with too many failed Clusev logins, configurable from a new "Anmeldeschutz" settings tab.
Architecture: A BannedIp table + BruteforceGuard service (cache-counter → DB ban, inet_pton CIDR exemptions, cached ban check) feeds an appended BlockBannedIp web-group middleware that 403s guest requests from banned IPs (authenticated operators pass, so they're never self-locked-out and can unban from the tab). Login + 2FA failure hooks drive the guard and write audit events. A clusev:unban CLI is the host escape hatch.
Tech Stack: Laravel 13, Livewire v3 (class-based), Tailwind v4, PHPUnit, Pint, wire-elements/modal (R5 confirm via ConfirmToken). Spec: docs/superpowers/specs/2026-06-20-control-plane-bruteforce-ban-design.md.
Pre-flight & corrections
- Dev stack up (
docker compose up -d); run all tooling in the container (R8). Branchfeat/v1-foundation. - Spec erratum (this plan supersedes it): the middleware is appended to the web group, not prepended. Guests-only enforcement needs
Auth::guest(), which requires the session middleware (StartSession) to have already run — a prepended middleware runs before it and would see every request as a guest. Appended (afterAuthenticateSession), the auth guard resolves correctly.request()->ip()is still correct becausetrustProxiesis a global middleware that runs before the whole web group. - Test isolation (memory: view-cache race): if Blade-compile flakiness appears, re-run with
-e VIEW_COMPILED_PATH=/tmp/views-test.
File structure
| File | Responsibility |
|---|---|
app/Models/BannedIp.php + migration |
active-ban storage |
app/Services/BruteforceGuard.php |
record/ban/isBanned/isExempt/unban + CIDR + config casts |
app/Rules/ValidIpOrCidr.php |
whitelist field validation |
app/Http/Middleware/BlockBannedIp.php |
guests-only 403 enforcement |
resources/views/errors/blocked.blade.php + lang/{de,en}/errors.php |
the 403 page |
app/Console/Commands/UnbanIp.php + docker/clusev/clusev |
host escape |
app/Livewire/Settings/LoginProtection.php + view |
the settings tab |
app/Support/Confirm/ConfirmToken.php (modify) |
allowlist the unban confirm events |
bootstrap/app.php, Login.php, CompletesTwoFactorChallenge.php, settings/index.blade.php, lang/{de,en}/settings.php (modify) |
wiring + hooks + tab + i18n |
Task 1: BannedIp model + migration
Files: Create app/Models/BannedIp.php, database/migrations/2026_06_20_000001_create_banned_ips_table.php; Test tests/Feature/BruteforceBannedIpTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Models\BannedIp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BruteforceBannedIpTest extends TestCase
{
use RefreshDatabase;
public function test_active_scope_excludes_expired_bans(): void
{
BannedIp::create(['ip' => '1.2.3.4', 'banned_until' => now()->addHour(), 'attempts' => 10]);
BannedIp::create(['ip' => '5.6.7.8', 'banned_until' => now()->subHour(), 'attempts' => 10]);
$active = BannedIp::active()->pluck('ip')->all();
$this->assertSame(['1.2.3.4'], $active);
}
}
-
Step 2: Run → fails (
docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest) — "Class BannedIp not found". -
Step 3: Migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('banned_ips', function (Blueprint $table) {
$table->id();
$table->string('ip', 45)->unique();
$table->timestamp('banned_until');
$table->string('reason')->nullable();
$table->unsignedInteger('attempts')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('banned_ips');
}
};
- Step 4: Model (
app/Models/BannedIp.php)
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class BannedIp extends Model
{
protected $guarded = [];
protected $casts = ['banned_until' => 'datetime'];
/** Non-expired bans only. */
public function scopeActive(Builder $query): Builder
{
return $query->where('banned_until', '>', now());
}
}
- Step 5: Migrate + run → passes
docker compose exec -T app php artisan migrate
docker compose exec -T app php artisan test --filter=BruteforceBannedIpTest
- Step 6: Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Models/BannedIp.php database/migrations tests/Feature/BruteforceBannedIpTest.php
git add app/Models/BannedIp.php database/migrations/*_create_banned_ips_table.php tests/Feature/BruteforceBannedIpTest.php
git commit -m "feat(auth): BannedIp model + migration"
Task 2: BruteforceGuard — CIDR exemptions
The safety core: which IPs are never counted/banned. matchesCidr mirrors Fail2banService::sameIp() (inet_pton).
Files: Create app/Services/BruteforceGuard.php; Test tests/Feature/BruteforceGuardExemptTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Models\Setting;
use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BruteforceGuardExemptTest extends TestCase
{
use RefreshDatabase;
private function guard(): BruteforceGuard
{
return app(BruteforceGuard::class);
}
public function test_loopback_and_ipv6_loopback_are_exempt(): void
{
$g = $this->guard();
$this->assertTrue($g->isExempt('127.0.0.1'));
$this->assertTrue($g->isExempt('::1'));
$this->assertTrue($g->isExempt('0:0:0:0:0:0:0:1')); // alternate spelling
}
public function test_default_private_whitelist_is_exempt_public_is_not(): void
{
$g = $this->guard();
$this->assertTrue($g->isExempt('192.168.4.20')); // default whitelist
$this->assertTrue($g->isExempt('10.9.9.9'));
$this->assertFalse($g->isExempt('203.0.113.7')); // public
}
public function test_invalid_ip_is_treated_as_exempt(): void
{
$this->assertTrue($this->guard()->isExempt('not-an-ip'));
}
public function test_custom_whitelist_cidr_matches(): void
{
Setting::put('bruteforce_whitelist', "203.0.113.0/24");
$this->assertTrue($this->guard()->isExempt('203.0.113.50'));
$this->assertFalse($this->guard()->isExempt('203.0.114.50'));
}
}
-
Step 2: Run → fails.
-
Step 3: Implement (
app/Services/BruteforceGuard.php)
<?php
namespace App\Services;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
/**
* Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
* the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
* not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
*/
class BruteforceGuard
{
/** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.1'];
/** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";
private const CHECK_TTL = 30;
public function enabled(): bool
{
return Setting::get('bruteforce_enabled', '1') === '1';
}
public function maxretry(): int
{
return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
}
/** findtime in MINUTES. */
public function findtime(): int
{
return max(1, (int) Setting::get('bruteforce_findtime', '10'));
}
/** bantime in MINUTES. */
public function bantime(): int
{
return max(1, (int) Setting::get('bruteforce_bantime', '60'));
}
/** @return string[] */
public function whitelist(): array
{
$raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
public function isExempt(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return true; // unknown/malformed IP → never count or ban
}
foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) {
if ($this->matchesCidr($ip, $cidr)) {
return true;
}
}
return false;
}
public function record(?string $ip, string $reason): void
{
if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
return;
}
$canonical = $this->canonical($ip);
$key = 'bruteforce:'.md5($canonical);
RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds
$hits = RateLimiter::attempts($key);
if ($hits < $this->maxretry()) {
return;
}
// Atomic upsert: two concurrent requests at the threshold must not collide on the
// unique ip index. Eloquent upsert does not auto-manage timestamps for bulk ops.
BannedIp::upsert(
[[
'ip' => $canonical,
'banned_until' => now()->addMinutes($this->bantime()),
'reason' => $reason,
'attempts' => $hits,
'created_at' => now(),
'updated_at' => now(),
]],
['ip'],
['banned_until', 'reason', 'attempts', 'updated_at'],
);
RateLimiter::clear($key);
Cache::forget('bruteforce:banned:'.$canonical);
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason, 'attempts' => $hits],
]);
}
public function isBanned(string $ip): bool
{
if ($this->isExempt($ip)) {
return false; // exempt short-circuits — whitelisting unblocks immediately
}
$canonical = $this->canonical($ip);
return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query()
->where('ip', $canonical)
->where('banned_until', '>', now())
->exists());
}
public function unban(string $ip): void
{
$canonical = $this->canonical($ip);
BannedIp::query()->where('ip', $canonical)->delete();
Cache::forget('bruteforce:banned:'.$canonical);
}
/** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */
public function matchesCidr(string $ip, string $cidr): bool
{
if (! str_contains($cidr, '/')) {
$a = @inet_pton($ip);
$b = @inet_pton($cidr);
return $a !== false && $b !== false && $a === $b;
}
[$subnet, $maskStr] = explode('/', $cidr, 2);
$pip = @inet_pton($ip);
$psub = @inet_pton($subnet);
if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) {
return false; // v4-vs-v6 mismatch or junk mask
}
$mask = (int) $maskStr;
$bytes = intdiv($mask, 8);
$rem = $mask % 8;
if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) {
return false;
}
if ($rem === 0) {
return true;
}
$b = 0xFF & (0xFF << (8 - $rem));
return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b);
}
private function canonical(string $ip): string
{
$packed = @inet_pton($ip);
return $packed === false ? $ip : (string) inet_ntop($packed);
}
}
-
Step 4: Run → passes.
-
Step 5: Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php
git add app/Services/BruteforceGuard.php tests/Feature/BruteforceGuardExemptTest.php
git commit -m "feat(auth): BruteforceGuard with inet_pton CIDR exemptions"
Task 3: BruteforceGuard — record, ban, isBanned, cache
Files: Test tests/Feature/BruteforceGuardBanTest.php (the service already exists from Task 2)
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Services\BruteforceGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class BruteforceGuardBanTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
private function guard(): BruteforceGuard
{
return app(BruteforceGuard::class);
}
public function test_records_until_threshold_then_bans(): void
{
$g = $this->guard();
Setting::put('bruteforce_maxretry', '3');
$g->record('203.0.113.5', 'login');
$g->record('203.0.113.5', 'login');
$this->assertFalse($g->isBanned('203.0.113.5'));
$g->record('203.0.113.5', 'login'); // 3rd → ban
$this->assertTrue($g->isBanned('203.0.113.5'));
$this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']);
$this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.5']);
}
public function test_exempt_ip_is_never_banned(): void
{
$g = $this->guard();
Setting::put('bruteforce_maxretry', '1');
for ($i = 0; $i < 5; $i++) {
$g->record('192.168.1.50', 'login'); // private = default whitelist
}
$this->assertFalse($g->isBanned('192.168.1.50'));
$this->assertDatabaseCount('banned_ips', 0);
}
public function test_disabled_records_nothing(): void
{
Setting::put('bruteforce_enabled', '0');
Setting::put('bruteforce_maxretry', '1');
$this->guard()->record('203.0.113.9', 'login');
$this->assertDatabaseCount('banned_ips', 0);
}
public function test_unban_clears_row_and_cache(): void
{
$g = $this->guard();
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$this->assertTrue($g->isBanned('203.0.113.5')); // populates cache
$g->unban('203.0.113.5');
$this->assertFalse($g->isBanned('203.0.113.5')); // no stale positive
$this->assertDatabaseCount('banned_ips', 0);
}
public function test_expired_ban_is_not_active(): void
{
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->subMinute(), 'attempts' => 10]);
$this->assertFalse($this->guard()->isBanned('203.0.113.5'));
}
}
-
Step 2: Run → passes (the Task 2 implementation already covers this; if any assertion fails, fix
BruteforceGuardand re-run — do not commit red). -
Step 3: Commit
git add tests/Feature/BruteforceGuardBanTest.php
git commit -m "test(auth): BruteforceGuard record/ban/unban/cache"
Task 4: ValidIpOrCidr rule
Files: Create app/Rules/ValidIpOrCidr.php; Test tests/Feature/ValidIpOrCidrTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Rules\ValidIpOrCidr;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
class ValidIpOrCidrTest extends TestCase
{
private function passes(string $value): bool
{
return Validator::make(['v' => $value], ['v' => [new ValidIpOrCidr]])->passes();
}
public function test_accepts_ips_and_cidrs(): void
{
$this->assertTrue($this->passes('203.0.113.4'));
$this->assertTrue($this->passes('10.0.0.0/8'));
$this->assertTrue($this->passes('::1'));
$this->assertTrue($this->passes('2001:db8::/32'));
}
public function test_rejects_junk(): void
{
$this->assertFalse($this->passes('nope'));
$this->assertFalse($this->passes('10.0.0.0/99'));
$this->assertFalse($this->passes('1.2.3.4/'));
}
}
-
Step 2: Run → fails.
-
Step 3: Implement (
app/Rules/ValidIpOrCidr.php)
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidIpOrCidr implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$v = is_string($value) ? trim($value) : '';
if (filter_var($v, FILTER_VALIDATE_IP) !== false) {
return;
}
if (! str_contains($v, '/')) {
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
return;
}
[$ip, $mask] = explode('/', $v, 2);
$max = str_contains($ip, ':') ? 128 : 32;
if (filter_var($ip, FILTER_VALIDATE_IP) === false || ! ctype_digit($mask) || (int) $mask < 0 || (int) $mask > $max) {
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
}
}
}
-
Step 4: Add the
lp_whitelist_invalidkey to bothlang/de/settings.phpandlang/en/settings.php(the test resolves__()— a missing key returns the key string, which still "passes" the boolean test, but add it now so the message is real):- de:
'lp_whitelist_invalid' => ':value ist keine gültige IP/CIDR.', - en:
'lp_whitelist_invalid' => ':value is not a valid IP/CIDR.',
- de:
-
Step 5: Run → passes. Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php
git add app/Rules/ValidIpOrCidr.php tests/Feature/ValidIpOrCidrTest.php lang/de/settings.php lang/en/settings.php
git commit -m "feat(auth): ValidIpOrCidr validation rule"
Task 5: BlockBannedIp middleware + 403 page
Files: Create app/Http/Middleware/BlockBannedIp.php, resources/views/errors/blocked.blade.php; Modify bootstrap/app.php, lang/de/errors.php, lang/en/errors.php; Test tests/Feature/BlockBannedIpMiddlewareTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class BlockBannedIpMiddlewareTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
private function banned(string $ip = '203.0.113.5'): void
{
BannedIp::create(['ip' => $ip, 'banned_until' => now()->addHour(), 'attempts' => 10]);
}
public function test_guest_from_banned_ip_is_blocked_with_403(): void
{
$this->banned();
$this->get('/login', ['REMOTE_ADDR' => '203.0.113.5'])
->assertStatus(403)
->assertSee(__('errors.blocked_title'));
}
public function test_guest_from_clean_ip_passes(): void
{
$this->banned();
$this->get('/login', ['REMOTE_ADDR' => '198.51.100.9'])->assertStatus(200);
}
public function test_authenticated_user_from_banned_ip_passes(): void
{
$this->banned();
$user = User::factory()->create(['must_change_password' => false]);
$this->actingAs($user)
->withServerVariables(['REMOTE_ADDR' => '203.0.113.5'])
->get('/')
->assertSuccessful();
}
public function test_feature_disabled_lets_banned_ip_through(): void
{
Setting::put('bruteforce_enabled', '0');
$this->banned();
$this->get('/login', ['REMOTE_ADDR' => '203.0.113.5'])->assertStatus(200);
}
}
Note:
$this->get($uri, $headers)sets request headers; for the client IP in tests usewithServerVariables(['REMOTE_ADDR' => ...])(shown in the authed test) — adjust the guest tests to the same form ifREMOTE_ADDRvia the headers arg does not driverequest()->ip()in this Laravel version. Verify at Step 4 and use whichever reachesrequest()->ip().
-
Step 2: Run → fails (middleware not registered).
-
Step 3: Middleware (
app/Http/Middleware/BlockBannedIp.php)
<?php
namespace App\Http\Middleware;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Hard-blocks GUEST requests from a banned IP (403). Authenticated requests pass, so an
* operator already logged in is never cut off mid-session and can unban their own IP from
* the Anmeldeschutz tab. Appended to the web group so Auth::guest() resolves (the session
* middleware has run). request()->ip() is correct via the global trustProxies (prod).
*/
class BlockBannedIp
{
public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response
{
if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) {
return response()->view('errors.blocked', [], 403);
}
return $next($request);
}
}
- Step 4: Register — in
bootstrap/app.php, adduse App\Http\Middleware\BlockBannedIp;and append it to the web group (afterAuthenticateSession):
$middleware->web(
prepend: [PanelScheme::class],
append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class],
);
- Step 5: 403 view (
resources/views/errors/blocked.blade.php)
@extends('errors.layout', ['title' => __('errors.blocked_title')])
@section('code', '403')
@section('heading', __('errors.blocked_title'))
@section('message', __('errors.blocked_body'))
-
Step 6: errors i18n — add to
lang/de/errors.phpandlang/en/errors.php:- de:
'blocked_title' => 'Zugriff vorübergehend gesperrt',/'blocked_body' => 'Zu viele fehlgeschlagene Anmeldeversuche aus deinem Netzwerk. Bitte später erneut versuchen.', - en:
'blocked_title' => 'Access temporarily blocked',/'blocked_body' => 'Too many failed sign-in attempts from your network. Please try again later.',
- de:
-
Step 7: Run → passes (fix the IP-injection form per the Step 1 note if needed). Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/BlockBannedIp.php bootstrap/app.php tests/Feature/BlockBannedIpMiddlewareTest.php
git add app/Http/Middleware/BlockBannedIp.php bootstrap/app.php resources/views/errors/blocked.blade.php lang/de/errors.php lang/en/errors.php tests/Feature/BlockBannedIpMiddlewareTest.php
git commit -m "feat(auth): guests-only BlockBannedIp middleware + 403 page"
Task 6: Failure hooks — Login + 2FA
Files: Modify app/Livewire/Auth/Login.php, app/Livewire/Concerns/CompletesTwoFactorChallenge.php, app/Livewire/Auth/TwoFactorChallenge.php, app/Livewire/Auth/TwoFactorBackup.php; Test tests/Feature/BruteforceHooksTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class BruteforceHooksTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
Setting::put('bruteforce_maxretry', '3');
// The test client IP is 127.0.0.1 (exempt by default) — point the whitelist elsewhere.
Setting::put('bruteforce_whitelist', '198.51.100.0/24');
}
public function test_failed_logins_ban_the_ip_at_threshold(): void
{
User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make('correct-horse')]);
for ($i = 0; $i < 3; $i++) {
Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.5'])
->test(Login::class)->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate');
}
$this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']);
$this->assertDatabaseHas('audit_events', ['action' => 'auth.login_failed']);
}
public function test_failed_2fa_codes_ban_the_ip(): void
{
$user = User::factory()->create(['two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now()]);
for ($i = 0; $i < 3; $i++) {
session()->put('2fa.user', $user->id);
Livewire::withServerVariables(['REMOTE_ADDR' => '203.0.113.6'])
->test(TwoFactorChallenge::class)->set('code', '000000')->call('verify');
}
$this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.6', 'reason' => '2fa']);
}
}
If
Livewire::withServerVariablesis unavailable, set the IP via the test request the same way Task 5 settled on; the 2FA per-(user+IP) rate-limit (5/60s) is above 3, so it won't trip first.
-
Step 2: Run → fails (no ban created — hooks absent).
-
Step 3: Login hook — in
app/Livewire/Auth/Login.php, add importsuse App\Models\AuditEvent;anduse App\Services\BruteforceGuard;, then inside theif (! $user || ! $validPassword) {block, before thethrow, add (after the threeRateLimiter::hitlines):
app(BruteforceGuard::class)->record(request()->ip(), 'login');
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.login_failed',
'target' => $this->email,
'ip' => request()->ip(),
]);
- Step 4: 2FA hook — in
app/Livewire/Concerns/CompletesTwoFactorChallenge.php, add importsuse App\Models\AuditEvent;anduse App\Services\BruteforceGuard;, then add a method:
/** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */
protected function recordFailedAttempt(string $reason): void
{
$this->hitRateLimit();
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, $reason);
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.2fa_failed',
'target' => (string) session('2fa.user'),
'ip' => $ip,
]);
}
Then replace the three $this->hitRateLimit(); call sites with $this->recordFailedAttempt('2fa');:
-
app/Livewire/Auth/TwoFactorChallenge.phpverify()(the line insideif (! $valid)) -
app/Livewire/Auth/TwoFactorChallenge.phpverifyWebauthn()(the line inside the failureif) -
app/Livewire/Auth/TwoFactorBackup.phpverify()(the line insideif (! $user->useRecoveryCode(...))) -
Step 5: Run → passes, then the full auth suite stays green:
docker compose exec -T app php artisan test --filter='Bruteforce|TwoFactor|Challenge|Webauthn|BruteForce|Login'
- Step 6: Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php
git add app/Livewire/Auth/Login.php app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/BruteforceHooksTest.php
git commit -m "feat(auth): feed BruteforceGuard from login + 2FA failures, audit them"
Task 7: clusev:unban CLI + host wrapper
Files: Create app/Console/Commands/UnbanIp.php; Modify docker/clusev/clusev; Test tests/Feature/UnbanCommandTest.php
- Step 1: Failing test
<?php
namespace Tests\Feature;
use App\Models\BannedIp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UnbanCommandTest extends TestCase
{
use RefreshDatabase;
public function test_unban_single_ip(): void
{
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$this->artisan('clusev:unban', ['ip' => '203.0.113.5'])->assertSuccessful();
$this->assertDatabaseCount('banned_ips', 0);
}
public function test_unban_all(): void
{
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
BannedIp::create(['ip' => '203.0.113.6', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$this->artisan('clusev:unban', ['--all' => true])->assertSuccessful();
$this->assertDatabaseCount('banned_ips', 0);
}
public function test_unban_without_args_fails(): void
{
$this->artisan('clusev:unban')->assertFailed();
}
}
-
Step 2: Run → fails.
-
Step 3: Command (
app/Console/Commands/UnbanIp.php)
<?php
namespace App\Console\Commands;
use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class UnbanIp extends Command
{
protected $signature = 'clusev:unban {ip? : Zu entsperrende IP-Adresse} {--all : Alle aktiven Bänne entfernen}';
protected $description = 'Entfernt App-Level-IP-Bänne des Anmeldeschutzes per Shell (Aussperr-Rettung)';
public function handle(BruteforceGuard $guard): int
{
if ($this->option('all')) {
$count = BannedIp::query()->count();
foreach (BannedIp::query()->pluck('ip') as $ip) {
Cache::forget('bruteforce:banned:'.$ip);
}
BannedIp::query()->delete();
$this->info("{$count} Bann/Bänne entfernt.");
return self::SUCCESS;
}
$ip = (string) $this->argument('ip');
if ($ip === '') {
$this->error('Bitte eine IP angeben oder --all verwenden.');
return self::FAILURE;
}
$guard->unban($ip);
$this->info("Bann für {$ip} entfernt (falls vorhanden).");
return self::SUCCESS;
}
}
- Step 4: Host wrapper — in
docker/clusev/clusev, add a usage line under thereset-adminline (around line 21):clusev unban <ip> Gebannte IP entsperren (--all für alle)and add a case after thereset-admin)case:
unban) compose exec app php artisan clusev:unban "$@" ;;
- Step 5: Run → passes. Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Console/Commands/UnbanIp.php tests/Feature/UnbanCommandTest.php
git add app/Console/Commands/UnbanIp.php docker/clusev/clusev tests/Feature/UnbanCommandTest.php
git commit -m "feat(auth): clusev:unban CLI + host wrapper escape hatch"
Task 8: Settings tab "Anmeldeschutz"
Files: Modify app/Support/Confirm/ConfirmToken.php, resources/views/livewire/settings/index.blade.php, lang/de/settings.php, lang/en/settings.php; Create app/Livewire/Settings/LoginProtection.php, resources/views/livewire/settings/login-protection.blade.php; Test tests/Feature/Settings/LoginProtectionTabTest.php
- Step 1: Allowlist the confirm events — in
app/Support/Confirm/ConfirmToken.php, add two entries to theACTIONSconst array:
'banCleared',
'bansClearedAll',
- Step 2: Failing test
<?php
namespace Tests\Feature\Settings;
use App\Livewire\Settings\LoginProtection;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Models\User;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
class LoginProtectionTabTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_saving_persists_cast_settings(): void
{
$user = User::factory()->create();
Livewire::actingAs($user)->test(LoginProtection::class)
->set('enabled', false)->set('maxretry', 7)->set('findtime', 15)->set('bantime', 120)
->set('whitelist', "203.0.113.0/24")
->call('save');
$this->assertSame('0', Setting::get('bruteforce_enabled'));
$this->assertSame('7', Setting::get('bruteforce_maxretry'));
$this->assertSame('203.0.113.0/24', Setting::get('bruteforce_whitelist'));
}
public function test_invalid_whitelist_is_rejected(): void
{
$user = User::factory()->create();
Livewire::actingAs($user)->test(LoginProtection::class)
->set('whitelist', "junk-not-a-cidr")->call('save')
->assertHasErrors('whitelist');
$this->assertNull(Setting::get('bruteforce_maxretry'));
}
public function test_table_lists_active_bans_and_unban_removes_one(): void
{
$user = User::factory()->create();
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
$token = ConfirmToken::issue('banCleared', ['ip' => '203.0.113.5']);
ConfirmToken::confirm($token);
Livewire::actingAs($user)->test(LoginProtection::class)
->assertSee('203.0.113.5')
->call('unban', $token);
$this->assertDatabaseCount('banned_ips', 0);
$this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_unbanned', 'target' => '203.0.113.5']);
}
public function test_widening_whitelist_clears_now_exempt_bans(): void
{
$user = User::factory()->create();
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
Livewire::actingAs($user)->test(LoginProtection::class)
->set('whitelist', "203.0.113.0/24")->call('save');
$this->assertDatabaseCount('banned_ips', 0); // 203.0.113.5 now exempt → purged
}
public function test_whitelist_my_ip_adds_current_ip(): void
{
$user = User::factory()->create();
Livewire::actingAs($user)->withServerVariables(['REMOTE_ADDR' => '198.51.100.7'])
->test(LoginProtection::class)->call('whitelistMyIp')
->assertSet('whitelist', fn ($w) => str_contains($w, '198.51.100.7'));
}
}
-
Step 3: Run → fails.
-
Step 4: Component (
app/Livewire/Settings/LoginProtection.php)
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Rules\ValidIpOrCidr;
use App\Services\BruteforceGuard;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\On;
use Livewire\Component;
class LoginProtection extends Component
{
public bool $enabled = true;
public int $maxretry = 10;
public int $findtime = 10;
public int $bantime = 60;
public string $whitelist = '';
public function mount(BruteforceGuard $guard): void
{
$this->enabled = $guard->enabled();
$this->maxretry = $guard->maxretry();
$this->findtime = $guard->findtime();
$this->bantime = $guard->bantime();
$this->whitelist = implode("\n", $guard->whitelist());
}
public function save(BruteforceGuard $guard): void
{
$this->validate([
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
'bantime' => ['required', 'integer', 'min:1', 'max:43200'],
'whitelist' => ['nullable', 'string'],
]);
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
foreach ($lines as $line) {
if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) {
$this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line]));
return;
}
}
Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0');
Setting::put('bruteforce_maxretry', (string) $this->maxretry);
Setting::put('bruteforce_findtime', (string) $this->findtime);
Setting::put('bruteforce_bantime', (string) $this->bantime);
Setting::put('bruteforce_whitelist', implode("\n", $lines));
// Widening the whitelist immediately clears any now-exempt bans.
foreach (BannedIp::all() as $ban) {
if ($guard->isExempt($ban->ip)) {
$guard->unban($ban->ip);
}
}
$this->audit('auth.protection_updated', null);
$this->dispatch('notify', message: __('settings.lp_saved'));
}
public function whitelistMyIp(): void
{
$ip = (string) request()->ip();
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
if (! in_array($ip, $lines, true)) {
$lines[] = $ip;
}
$this->whitelist = implode("\n", $lines);
}
public function confirmUnban(string $ip): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_heading'),
'body' => __('settings.lp_unban_body', ['ip' => $ip]),
'confirmLabel' => __('settings.lp_unban'),
'danger' => false,
'icon' => 'shield',
'notify' => __('settings.lp_unban_notify'),
'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip),
],
);
}
#[On('banCleared')]
public function unban(string $confirmToken, BruteforceGuard $guard): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
} catch (InvalidConfirmToken) {
return;
}
$ip = (string) $payload['params']['ip'];
$guard->unban($ip);
$this->audit('auth.ip_unbanned', $ip);
}
public function confirmUnbanAll(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_all_heading'),
'body' => __('settings.lp_unban_all_body'),
'confirmLabel' => __('settings.lp_unban_all'),
'danger' => true,
'icon' => 'shield',
'notify' => __('settings.lp_unban_all_notify'),
'token' => ConfirmToken::issue('bansClearedAll'),
],
);
}
#[On('bansClearedAll')]
public function unbanAll(string $confirmToken): void
{
try {
ConfirmToken::consume($confirmToken, 'bansClearedAll');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
foreach (BannedIp::query()->pluck('ip') as $ip) {
Cache::forget('bruteforce:banned:'.$ip);
}
BannedIp::query()->delete();
$this->audit('auth.ip_unbanned', 'all');
}
private function audit(string $action, ?string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(BruteforceGuard $guard)
{
return view('livewire.settings.login-protection', [
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->get(),
'currentIp' => (string) request()->ip(),
'currentIpExempt' => $guard->isExempt((string) request()->ip()),
]);
}
}
- Step 5: View (
resources/views/livewire/settings/login-protection.blade.php) — mirror the profile form + sessions table patterns:
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5">
<x-panel :title="__('settings.lp_title')" :subtitle="__('settings.lp_subtitle')">
<form wire:submit="save" class="space-y-4">
<label class="flex items-center gap-3">
<input type="checkbox" wire:model="enabled" class="h-4 w-4 rounded border-line bg-inset text-accent focus:ring-0" />
<span class="text-sm text-ink">{{ __('settings.lp_enabled') }}</span>
</label>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label class="{{ $label }}">{{ __('settings.lp_maxretry') }}</label>
<input wire:model="maxretry" type="text" inputmode="numeric" class="{{ $field }}" />
@error('maxretry') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_findtime') }}</label>
<input wire:model="findtime" type="text" inputmode="numeric" class="{{ $field }}" />
@error('findtime') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_bantime') }}</label>
<input wire:model="bantime" type="text" inputmode="numeric" class="{{ $field }}" />
@error('bantime') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="{{ $label }}">{{ __('settings.lp_whitelist') }}</label>
<textarea wire:model="whitelist" rows="4" class="{{ $field }} h-auto py-2 font-mono"></textarea>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('settings.lp_whitelist_hint') }}</p>
@error('whitelist') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-line pt-4">
<p class="font-mono text-[11px] text-ink-4">
{{ __('settings.lp_current_ip') }}: <span class="text-ink-2">{{ $currentIp }}</span>
@if ($currentIpExempt) <span class="text-online">· {{ __('settings.lp_current_ip_exempt') }}</span> @endif
<button type="button" wire:click="whitelistMyIp" class="ml-2 text-accent-text hover:underline">{{ __('settings.lp_whitelist_my_ip') }}</button>
</p>
<x-btn variant="primary" size="lg" type="submit">{{ __('settings.lp_save') }}</x-btn>
</div>
</form>
</x-panel>
<x-panel :title="__('settings.lp_bans_title')">
<div class="divide-y divide-line">
@forelse ($bans as $ban)
<div class="flex flex-wrap items-center gap-3 py-3">
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
{{ __('settings.lp_ban_reason') }}: {{ $ban->reason ?? '—' }}
· {{ __('settings.lp_ban_attempts') }}: {{ $ban->attempts }}
· {{ __('settings.lp_ban_until', ['time' => $ban->banned_until->diffForHumans()]) }}
</p>
</div>
<x-modal-trigger variant="secondary" class="shrink-0" action="confirmUnban('{{ $ban->ip }}')">
{{ __('settings.lp_unban') }}
</x-modal-trigger>
</div>
@empty
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('settings.lp_no_bans') }}</p>
@endforelse
</div>
@if ($bans->isNotEmpty())
<div class="flex justify-end border-t border-line pt-4">
<x-modal-trigger variant="danger-soft" action="confirmUnbanAll">{{ __('settings.lp_unban_all') }}</x-modal-trigger>
</div>
@endif
</x-panel>
<p class="flex items-start gap-2 px-1 font-mono text-[11px] text-ink-4">
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ __('settings.lp_lockout_note') }}</span>
</p>
</div>
- Step 6: Register the tab — in
resources/views/livewire/settings/index.blade.php, add to the$tabsarray right after thesecurityentry:
['key' => 'login-protection', 'label' => __('settings.tab_login_protection'), 'icon' => 'shield'],
and in the content switch, after the security arm and before the closing @endif, add the arm + a safe default:
@elseif ($tab === 'login-protection') <livewire:settings.login-protection />
@elseif ($tab === 'users') <livewire:settings.users />
@elseif ($tab === 'sessions') <livewire:settings.sessions />
@elseif ($tab === 'email') <livewire:settings.email />
@else <livewire:settings.profile />
@endif
- Step 7: settings i18n — add to BOTH
lang/de/settings.phpandlang/en/settings.php(identical keys; DE values shown — provide the EN equivalents in the same terse register):
'tab_login_protection' => 'Anmeldeschutz',
'lp_title' => 'Anmeldeschutz',
'lp_subtitle' => 'Sperrt IP-Adressen nach zu vielen fehlgeschlagenen Anmeldeversuchen.',
'lp_enabled' => 'Aktiv',
'lp_maxretry' => 'Max. Versuche',
'lp_findtime' => 'Zeitfenster (Min.)',
'lp_bantime' => 'Bann-Dauer (Min.)',
'lp_whitelist' => 'Whitelist (IP/CIDR, eine pro Zeile)',
'lp_whitelist_hint' => 'Diese Adressen werden nie gezählt oder gesperrt. Loopback ist immer ausgenommen.',
'lp_current_ip' => 'Deine aktuelle IP',
'lp_current_ip_exempt' => 'ausgenommen',
'lp_whitelist_my_ip' => 'Meine IP zur Whitelist hinzufügen',
'lp_save' => 'Speichern',
'lp_saved' => 'Anmeldeschutz gespeichert.',
'lp_bans_title' => 'Aktive Sperren',
'lp_ban_reason' => 'Grund',
'lp_ban_attempts' => 'Versuche',
'lp_ban_until' => 'gesperrt bis :time',
'lp_no_bans' => 'Keine aktiven Sperren.',
'lp_unban' => 'Entsperren',
'lp_unban_heading' => 'IP entsperren?',
'lp_unban_body' => ':ip wird wieder zugelassen.',
'lp_unban_notify' => 'IP entsperrt.',
'lp_unban_all' => 'Alle entsperren',
'lp_unban_all_heading' => 'Alle Sperren aufheben?',
'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.',
'lp_unban_all_notify' => 'Alle Sperren aufgehoben.',
'lp_lockout_note' => 'Eingeloggte Operatoren werden nie geblockt und können hier ihre eigene IP entsperren. Notfall: clusev unban <ip> auf der Host-Shell.',
(lp_whitelist_invalid was already added in Task 4.)
- Step 8: Run → passes
docker compose exec -T app php artisan test --filter=LoginProtectionTabTest
- Step 9: Pint + commit
docker compose exec -T app ./vendor/bin/pint app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php tests/Feature/Settings/LoginProtectionTabTest.php
git add app/Support/Confirm/ConfirmToken.php app/Livewire/Settings/LoginProtection.php resources/views/livewire/settings/login-protection.blade.php resources/views/livewire/settings/index.blade.php lang/de/settings.php lang/en/settings.php tests/Feature/Settings/LoginProtectionTabTest.php
git commit -m "feat(auth): Anmeldeschutz settings tab + unban confirm flow"
Task 9: Full verification (R12 + R15)
- Step 1: Full suite
docker compose exec -T app php artisan test
Expected: PASS, 0 failures.
- Step 2: Build + view:clear (Blade changed)
docker compose exec -T app php artisan view:clear
docker compose exec -T app npm run build
-
Step 3: R12 browser-verify (DE+EN), via the temp-password puppeteer flow (admin@clusev.local): log in, open Settings → Anmeldeschutz tab — HTTP 200, zero console/network errors, DOM leak scan scoped to the tab (no
{{ }}/@/settings.lp_*literals), 3 breakpoints (375/768/1280), touch targets ≥44px. Confirm the config form, the empty/active bans table, and the current-IP/self-whitelist row render. The 403errors.blockedpage is covered by the feature test (it returns 403, not 200) — not via the R12 200-path. -
Step 4: R15 Codex — run
/codex:reviewover the branch; fix until no errors / no security issues. -
Step 5: Final Pint
docker compose exec -T app ./vendor/bin/pint --dirty
Self-review notes
- Spec coverage: §2 BannedIp → Task 1; §3 Guard (config/record/ban/exempt/CIDR) → Tasks 2–3; §4 middleware (guests-only, appended — correcting the spec's "prepend") + 403 → Task 5; §5 hooks + audit → Task 6; §6 tab (+ ConfirmToken allowlist, self-whitelist, whitelist-purge, R5 modals) → Task 8; §7 CLI escape → Task 7; §8 i18n split across Tasks 4/5/8. §0 trust model = no code (documented).
- Casting: every
Setting::getis cast (=== '1'/(int)); everySetting::putstringifies. Units: findtime/bantime are minutes;record()multiplies findtime ×60 for the RateLimiter decay. - Cache:
bruteforce:banned:{canonical}(30s), busted on ban + unban; test covers the no-stale-positive case. - R5: both unban actions use
modals.confirm-action+ConfirmToken(allowlistedbanCleared/bansClearedAll). - Type consistency: service methods (
enabled/maxretry/findtime/bantime/whitelist/isExempt/record/isBanned/unban/matchesCidr) are referenced identically across middleware, hooks, command, and tab. - Out of scope (per spec): host-fail2ban logfile; remote Fail2banService; trustProxies changes.