This commit is contained in:
mathiaspalmqvist committed 2026-11-11 23:19:20 +01:00
commit 3ed6eb9d19
23 files changed
+1216

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Mattitja\BadIpBlocker;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Support\ServiceProvider;
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
class BadIpBlockerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/bad-ip-blocker.php', 'bad-ip-blocker');
$this->app->singleton(BadIpStore::class, fn ($app) => new BadIpStore(
$app['config']->get('bad-ip-blocker.storage_path') ?? storage_path('app/bad-ip-blocker.json'),
));
}
public function boot(): void
{
$this->registerMiddleware();
$this->registerSchedule();
if (! $this->app->runningInConsole()) {
return;
}
$this->publishes([
__DIR__.'/../config/bad-ip-blocker.php' => config_path('bad-ip-blocker.php'),
], 'bad-ip-blocker-config');
$this->commands([
Console\AddCommand::class,
Console\RemoveCommand::class,
Console\ListCommand::class,
Console\PruneCommand::class,
Console\ClearCommand::class,
]);
}
/**
* Append the middleware to the global stack. Runs after bootstrap/app.php's
* withMiddleware() has set the global middleware, so it is not overwritten,
* and pushMiddleware() skips it if the app already registered it manually.
*/
protected function registerMiddleware(): void
{
if (! $this->app['config']->get('bad-ip-blocker.auto_register', true)) {
return;
}
$this->callAfterResolving(HttpKernel::class, function ($kernel) {
if (method_exists($kernel, 'pushMiddleware')) {
$kernel->pushMiddleware(CheckBadIps::class);
}
});
}
protected function registerSchedule(): void
{
if (! $this->app['config']->get('bad-ip-blocker.schedule_prune', true)) {
return;
}
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->command('bad-ip-blocker:prune')->daily();
});
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace Mattitja\BadIpBlocker;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use RuntimeException;
class BadIpStore
{
/** @var array<string, array{reason: ?string, blocked_at: string, expires_at: ?string}>|null */
protected ?array $entries = null;
protected ?string $loadedSignature = null;
public function __construct(protected string $path) {}
public function path(): string
{
return $this->path;
}
/**
* All active (non-expired) entries keyed by IP.
*
* @return array<string, array{reason: ?string, blocked_at: string, expires_at: ?string}>
*/
public function all(): array
{
return array_filter($this->load(), fn (array $entry) => ! $this->isExpired($entry));
}
public function isBlocked(string $ip): bool
{
$entries = $this->load();
return isset($entries[$ip]) && ! $this->isExpired($entries[$ip]);
}
public function block(string $ip, ?string $reason = null, ?int $ttl = null): void
{
$this->assertValidIp($ip);
$now = CarbonImmutable::now();
$this->mutate(function (array $entries) use ($ip, $reason, $ttl, $now) {
$entries[$ip] = [
'reason' => $reason,
'blocked_at' => $now->toIso8601String(),
'expires_at' => $ttl !== null ? $now->addSeconds($ttl)->toIso8601String() : null,
];
return $entries;
});
}
public function unblock(string $ip): bool
{
$removed = false;
$this->mutate(function (array $entries) use ($ip, &$removed) {
$removed = isset($entries[$ip]);
unset($entries[$ip]);
return $entries;
});
return $removed;
}
public function clear(): void
{
$this->mutate(fn () => []);
}
/**
* Remove expired entries. Returns the number removed.
*/
public function prune(): int
{
$removed = 0;
$this->mutate(function (array $entries) use (&$removed) {
$active = array_filter($entries, fn (array $entry) => ! $this->isExpired($entry));
$removed = count($entries) - count($active);
return $active;
});
return $removed;
}
protected function assertValidIp(string $ip): void
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
throw new InvalidArgumentException("Invalid IP address [{$ip}].");
}
}
protected function isExpired(array $entry): bool
{
return isset($entry['expires_at'])
&& CarbonImmutable::parse($entry['expires_at'])->isPast();
}
/**
* Load entries, re-reading the file only when it has changed.
*/
protected function load(): array
{
clearstatcache(true, $this->path);
$signature = $this->signature();
if ($this->entries !== null && $signature === $this->loadedSignature) {
return $this->entries;
}
$this->loadedSignature = $signature;
return $this->entries = $signature === null ? [] : $this->decode((string) @file_get_contents($this->path));
}
/**
* Identifies the current file version. rename() gives a new inode, so
* writes from other processes are detected even within the same second.
*/
protected function signature(): ?string
{
$stat = @stat($this->path);
return $stat === false ? null : $stat['ino'].':'.$stat['mtime'].':'.$stat['size'];
}
protected function decode(string $contents): array
{
if (trim($contents) === '') {
return [];
}
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['ips']) || ! is_array($data['ips'])) {
Log::warning('bad-ip-blocker: could not parse IP list, treating it as empty.', ['path' => $this->path]);
return [];
}
return array_filter(
$data['ips'],
fn ($entry, $ip) => is_array($entry) && filter_var($ip, FILTER_VALIDATE_IP) !== false,
ARRAY_FILTER_USE_BOTH,
);
}
/**
* Read-modify-write the file under an exclusive lock with an atomic rename.
*/
protected function mutate(callable $callback): void
{
$directory = dirname($this->path);
if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
throw new RuntimeException("Unable to create directory [{$directory}].");
}
$lock = fopen($this->path.'.lock', 'c');
if ($lock === false) {
throw new RuntimeException("Unable to open lock file for [{$this->path}].");
}
try {
flock($lock, LOCK_EX);
$this->entries = null;
$entries = $callback($this->load());
$temp = $this->path.'.'.bin2hex(random_bytes(6)).'.tmp';
$json = json_encode(['ips' => (object) $entries], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($temp, $json."\n") === false || ! rename($temp, $this->path)) {
@unlink($temp);
throw new RuntimeException("Unable to write [{$this->path}].");
}
clearstatcache(true, $this->path);
$this->entries = $entries;
$this->loadedSignature = $this->signature();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Mattitja\BadIpBlocker\Console;
use Illuminate\Console\Command;
use InvalidArgumentException;
use Mattitja\BadIpBlocker\BadIpStore;
class AddCommand extends Command
{
protected $signature = 'bad-ip-blocker:add
{ip : The IP address to block}
{--reason= : Why the IP is blocked}
{--ttl= : Seconds until the block expires (omit for permanent)}';
protected $description = 'Block an IP address';
public function handle(BadIpStore $store): int
{
$ttl = $this->option('ttl');
if ($ttl !== null && (! ctype_digit((string) $ttl) || (int) $ttl < 1)) {
$this->components->error('The --ttl option must be a positive number of seconds.');
return self::FAILURE;
}
try {
$store->block($this->argument('ip'), $this->option('reason'), $ttl !== null ? (int) $ttl : null);
} catch (InvalidArgumentException $e) {
$this->components->error($e->getMessage());
return self::FAILURE;
}
$this->components->info("Blocked [{$this->argument('ip')}].");
return self::SUCCESS;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Mattitja\BadIpBlocker\Console;
use Illuminate\Console\Command;
use Mattitja\BadIpBlocker\BadIpStore;
class ClearCommand extends Command
{
protected $signature = 'bad-ip-blocker:clear {--force : Skip confirmation}';
protected $description = 'Remove all blocked IP addresses';
public function handle(BadIpStore $store): int
{
if (! $this->option('force') && ! $this->confirm('Remove all blocked IP addresses?')) {
return self::FAILURE;
}
$store->clear();
$this->components->info('All blocks removed.');
return self::SUCCESS;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Mattitja\BadIpBlocker\Console;
use Illuminate\Console\Command;
use Mattitja\BadIpBlocker\BadIpStore;
class ListCommand extends Command
{
protected $signature = 'bad-ip-blocker:list';
protected $description = 'List blocked IP addresses';
public function handle(BadIpStore $store): int
{
$entries = $store->all();
if ($entries === []) {
$this->components->info('No blocked IP addresses.');
return self::SUCCESS;
}
$this->table(
['IP', 'Reason', 'Blocked at', 'Expires at'],
collect($entries)->map(fn (array $entry, string $ip) => [
$ip,
$entry['reason'] ?? '',
$entry['blocked_at'] ?? '',
$entry['expires_at'] ?? 'never',
])->values()->all(),
);
return self::SUCCESS;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Mattitja\BadIpBlocker\Console;
use Illuminate\Console\Command;
use Mattitja\BadIpBlocker\BadIpStore;
class PruneCommand extends Command
{
protected $signature = 'bad-ip-blocker:prune';
protected $description = 'Remove expired IP blocks';
public function handle(BadIpStore $store): int
{
$this->components->info("Removed {$store->prune()} expired block(s).");
return self::SUCCESS;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Mattitja\BadIpBlocker\Console;
use Illuminate\Console\Command;
use Mattitja\BadIpBlocker\BadIpStore;
class RemoveCommand extends Command
{
protected $signature = 'bad-ip-blocker:remove {ip : The IP address to unblock}';
protected $description = 'Unblock an IP address';
public function handle(BadIpStore $store): int
{
$ip = $this->argument('ip');
if (! $store->unblock($ip)) {
$this->components->warn("[{$ip}] was not blocked.");
return self::FAILURE;
}
$this->components->info("Unblocked [{$ip}].");
return self::SUCCESS;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Mattitja\BadIpBlocker\Facades;
use Illuminate\Support\Facades\Facade;
use Mattitja\BadIpBlocker\BadIpStore;
/**
* @method static array all()
* @method static bool isBlocked(string $ip)
* @method static void block(string $ip, ?string $reason = null, ?int $ttl = null)
* @method static bool unblock(string $ip)
* @method static void clear()
* @method static int prune()
* @method static string path()
*
* @see BadIpStore
*/
class BadIpBlocker extends Facade
{
protected static function getFacadeAccessor(): string
{
return BadIpStore::class;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace Mattitja\BadIpBlocker\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Mattitja\BadIpBlocker\BadIpStore;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
class CheckBadIps
{
public function __construct(protected BadIpStore $store) {}
public function handle(Request $request, Closure $next): Response
{
$ip = $request->ip();
if (! config('bad-ip-blocker.enabled') || $ip === null || $this->isWhitelisted($ip)) {
return $next($request);
}
if ($this->store->isBlocked($ip)) {
return $this->blockedResponse();
}
$response = $next($request);
if ($this->shouldAutoBlock($request, $response, $ip)) {
$ttl = config('bad-ip-blocker.auto_block.ttl');
$this->store->block($ip, 'Auto: /'.ltrim($request->path(), '/'), $ttl !== null ? (int) $ttl : null);
return $this->blockedResponse();
}
return $response;
}
protected function isWhitelisted(string $ip): bool
{
return IpUtils::checkIp($ip, (array) config('bad-ip-blocker.whitelist', []));
}
protected function shouldAutoBlock(Request $request, Response $response, string $ip): bool
{
$patterns = (array) config('bad-ip-blocker.auto_block.paths', []);
if (! config('bad-ip-blocker.auto_block.enabled')
|| $response->getStatusCode() !== 404
|| $patterns === []
|| ! $request->is(...$patterns)) {
return false;
}
$key = 'bad-ip-blocker:hits:'.$ip;
RateLimiter::hit($key, (int) config('bad-ip-blocker.auto_block.decay', 3600));
if (RateLimiter::attempts($key) < max(1, (int) config('bad-ip-blocker.auto_block.max_hits', 1))) {
return false;
}
RateLimiter::clear($key);
return true;
}
protected function blockedResponse(): Response
{
return response(
config('bad-ip-blocker.response.message', 'Blocked.'),
(int) config('bad-ip-blocker.response.status', 418),
);
}
}