Init
This commit is contained in:
23 files changed
+1216
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user