Add cloudflare ip refresh

This commit is contained in:
DariusIII
2026-03-30 16:20:52 +02:00
parent f9cd1477e2
commit 9c0bf4a3b7
8 changed files with 767 additions and 10 deletions
+13
View File
@@ -76,6 +76,19 @@ APP_DEBUG=false
APP_TIMEZONE=UTC
APP_URL=
# Trusted proxies / Cloudflare
# Comma-separated list of additional proxies to trust alongside Cloudflare.
TRUSTED_PROXIES=
TRUST_CLOUDFLARE=true
CLOUDFLARE_IPS_V4_URL=https://www.cloudflare.com/ips-v4
CLOUDFLARE_IPS_V6_URL=https://www.cloudflare.com/ips-v6
CLOUDFLARE_IPS_TIMEOUT=10
CLOUDFLARE_IPS_CONNECT_TIMEOUT=5
CLOUDFLARE_IPS_RETRY_TIMES=2
CLOUDFLARE_IPS_RETRY_SLEEP_MS=250
CLOUDFLARE_IPS_STORAGE_PATH=storage/app/cloudflare/trusted-proxies.json
CLOUDFLARE_TRUST_REMOTE_ADDR_FALLBACK=true
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cloudflare\CloudflareIpRangeService;
use Illuminate\Console\Command;
use Throwable;
class CloudflareReload extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloudflare:reload
{--dry-run : Fetch and validate the ranges without updating the stored manifest}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh the Cloudflare IP ranges used by the trusted proxy middleware';
public function __construct(
private readonly CloudflareIpRangeService $cloudflareIpRangeService,
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$this->info($dryRun
? 'Fetching and validating Cloudflare trusted proxy IP ranges (dry run)...'
: 'Refreshing Cloudflare trusted proxy IP ranges...');
try {
$manifest = $this->cloudflareIpRangeService->refresh(! $dryRun);
} catch (Throwable $exception) {
report($exception);
$this->error('Failed to refresh Cloudflare IP ranges: '.$exception->getMessage());
return Command::FAILURE;
}
$manualProxies = $this->cloudflareIpRangeService->manualTrustedProxies();
$totalTrustedProxies = array_values(array_unique([
...$manualProxies,
...($manifest['proxies'] ?? []),
]));
$this->line('Cloudflare IPv4 ranges: '.count($manifest['ipv4'] ?? []));
$this->line('Cloudflare IPv6 ranges: '.count($manifest['ipv6'] ?? []));
$this->line('Combined Cloudflare proxy count: '.count($manifest['proxies'] ?? []));
$this->line('Manual proxy count: '.count($manualProxies));
$this->line('Effective trusted proxy count: '.count($totalTrustedProxies));
if ($dryRun) {
$this->info('Dry run complete; manifest file was not updated.');
return Command::SUCCESS;
}
$this->info('Stored Cloudflare manifest: '.$this->cloudflareIpRangeService->manifestPath());
$this->info('Last updated at: '.($manifest['updated_at'] ?? 'unknown'));
return Command::SUCCESS;
}
}
+14 -10
View File
@@ -4,21 +4,12 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Cloudflare\CloudflareIpRangeService;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Symfony\Component\HttpFoundation\Request;
class TrustProxies extends Middleware
{
/**
* Trust the upstream reverse proxy chain.
*
* This keeps Cloudflare / CDN deployments working without the external
* package while remaining compatible with Laravel 12 and 13.
*
* @var string|array<int, string>|null
*/
protected $proxies = '*';
/**
* The headers used to detect proxy forwarding information.
*/
@@ -28,4 +19,17 @@ class TrustProxies extends Middleware
| Request::HEADER_X_FORWARDED_PROTO
| Request::HEADER_X_FORWARDED_PREFIX
| Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Resolve trusted proxies from the stored Cloudflare manifest and any
* manually configured proxy ranges.
*
* @return array<int, string>|string|null
*/
protected function proxies()
{
$proxies = app(CloudflareIpRangeService::class)->trustedProxies();
return $proxies !== [] ? $proxies : null;
}
}
@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Services\Cloudflare;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use JsonException;
use RuntimeException;
class CloudflareIpRangeService
{
private const MANIFEST_VERSION = 1;
/**
* @var array<int, string>
*/
private const ALLOWED_PROXY_TOKENS = [
'REMOTE_ADDR',
'PRIVATE_SUBNETS',
'private_ranges',
];
public function __construct(
private readonly Filesystem $files,
) {}
/**
* Fetch the latest Cloudflare IP ranges and optionally persist them.
*
* @return array<string, mixed>
*/
public function refresh(bool $persist = true): array
{
$ipv4 = $this->fetchRanges((string) config('trustedproxy.cloudflare.ipv4_url'));
$ipv6 = $this->fetchRanges((string) config('trustedproxy.cloudflare.ipv6_url'));
$proxies = $this->deduplicate(array_merge($ipv4, $ipv6));
if ($proxies === []) {
throw new RuntimeException('Cloudflare did not return any trusted proxy CIDRs.');
}
$manifest = [
'version' => self::MANIFEST_VERSION,
'updated_at' => now()->toIso8601String(),
'sources' => [
'ipv4_url' => (string) config('trustedproxy.cloudflare.ipv4_url'),
'ipv6_url' => (string) config('trustedproxy.cloudflare.ipv6_url'),
],
'ipv4' => $ipv4,
'ipv6' => $ipv6,
'proxies' => $proxies,
];
if ($persist) {
$this->writeManifest($manifest);
}
return $manifest;
}
/**
* Return the currently effective trusted proxies.
*
* @return array<int, string>
*/
public function trustedProxies(): array
{
$proxies = $this->deduplicate([
...$this->manualTrustedProxies(),
...$this->cloudflareTrustedProxies(),
]);
if ($proxies !== []) {
return $proxies;
}
if ((bool) config('trustedproxy.cloudflare.fallback_to_remote_addr', true)) {
return ['REMOTE_ADDR'];
}
return [];
}
/**
* Return manually configured trusted proxies.
*
* @return array<int, string>
*/
public function manualTrustedProxies(): array
{
return $this->normalizeConfiguredProxies(config('trustedproxy.proxies'));
}
/**
* Return persisted Cloudflare ranges when Cloudflare trust is enabled.
*
* @return array<int, string>
*/
public function cloudflareTrustedProxies(): array
{
if (! (bool) config('trustedproxy.cloudflare.enabled', true)) {
return [];
}
return $this->storedTrustedProxies();
}
public function manifestPath(): string
{
return $this->resolveManifestPath(
(string) config('trustedproxy.cloudflare.storage_path', storage_path('app/cloudflare/trusted-proxies.json'))
);
}
private function resolveManifestPath(string $path): string
{
$path = trim($path);
if ($path === '') {
return storage_path('app/cloudflare/trusted-proxies.json');
}
if ($this->isAbsolutePath($path)) {
return $path;
}
return base_path($path);
}
private function isAbsolutePath(string $path): bool
{
return str_starts_with($path, '/')
|| str_starts_with($path, '\\')
|| preg_match('~^[A-Za-z]:[\\/]~', $path) === 1;
}
/**
* @return array<int, string>
*/
private function storedTrustedProxies(): array
{
$manifest = $this->readManifest();
if ($manifest === null) {
return [];
}
$ranges = $manifest['proxies'] ?? array_merge($manifest['ipv4'] ?? [], $manifest['ipv6'] ?? []);
if (! is_array($ranges)) {
Log::warning('Cloudflare trusted proxy manifest is missing proxy arrays.', [
'path' => $this->manifestPath(),
]);
return [];
}
try {
return $this->normalizeRanges($ranges);
} catch (RuntimeException $exception) {
Log::warning('Ignoring invalid Cloudflare trusted proxy manifest.', [
'path' => $this->manifestPath(),
'message' => $exception->getMessage(),
]);
return [];
}
}
/**
* @return array<string, mixed>|null
*/
private function readManifest(): ?array
{
$path = $this->manifestPath();
if (! $this->files->exists($path)) {
return null;
}
try {
$decoded = json_decode($this->files->get($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
Log::warning('Unable to decode Cloudflare trusted proxy manifest.', [
'path' => $path,
'message' => $exception->getMessage(),
]);
return null;
}
return is_array($decoded) ? $decoded : null;
}
/**
* @return array<int, string>
*/
private function fetchRanges(string $url): array
{
if ($url === '') {
throw new RuntimeException('Cloudflare IP list URL is not configured.');
}
$response = Http::accept('text/plain')
->connectTimeout((int) config('trustedproxy.cloudflare.connect_timeout', 5))
->retry(
(int) config('trustedproxy.cloudflare.retry_times', 2),
(int) config('trustedproxy.cloudflare.retry_sleep_ms', 250),
)
->timeout((int) config('trustedproxy.cloudflare.timeout', 10))
->get($url);
if (! $response->successful()) {
throw new RuntimeException("Cloudflare IP refresh failed for [{$url}] with HTTP status {$response->status()}.");
}
$ranges = $this->parseRangesFromBody($response->body());
if ($ranges === []) {
throw new RuntimeException("Cloudflare IP refresh returned no valid CIDRs for [{$url}].");
}
return $ranges;
}
/**
* @return array<int, string>
*/
private function parseRangesFromBody(string $body): array
{
$lines = preg_split('/\r\n|\r|\n/', $body) ?: [];
return $this->normalizeRanges($lines);
}
/**
* @param array<int, mixed>|string|null $proxies
* @return array<int, string>
*/
private function normalizeConfiguredProxies(array|string|null $proxies): array
{
if ($proxies === null) {
return [];
}
if (is_string($proxies)) {
$proxies = preg_split('/[\s,]+/', $proxies) ?: [];
}
try {
return $this->normalizeRanges($proxies, true);
} catch (RuntimeException $exception) {
Log::warning('Ignoring invalid manually configured trusted proxies.', [
'message' => $exception->getMessage(),
]);
return [];
}
}
/**
* @param array<int, mixed> $ranges
* @return array<int, string>
*/
private function normalizeRanges(array $ranges, bool $allowProxyTokens = false): array
{
$normalized = [];
foreach ($ranges as $range) {
if (! is_string($range)) {
continue;
}
$range = trim($range);
if ($range === '' || str_starts_with($range, '#')) {
continue;
}
if ($allowProxyTokens && in_array($range, self::ALLOWED_PROXY_TOKENS, true)) {
$normalized[] = $range === 'private_ranges' ? 'PRIVATE_SUBNETS' : $range;
continue;
}
if (! $this->isValidCidr($range)) {
throw new RuntimeException("Invalid trusted proxy entry [{$range}].");
}
$normalized[] = $range;
}
return $this->deduplicate($normalized);
}
private function isValidCidr(string $cidr): bool
{
if (! str_contains($cidr, '/')) {
return false;
}
[$address, $prefix] = explode('/', $cidr, 2);
if (! ctype_digit($prefix)) {
return false;
}
$isIpv4 = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
$isIpv6 = ! $isIpv4 && filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
if (! $isIpv4 && ! $isIpv6) {
return false;
}
$maxPrefix = $isIpv4 ? 32 : 128;
$prefixLength = (int) $prefix;
return $prefixLength >= 0 && $prefixLength <= $maxPrefix;
}
/**
* @param array<int, string> $ranges
* @return array<int, string>
*/
private function deduplicate(array $ranges): array
{
return array_values(array_unique($ranges));
}
/**
* @param array<string, mixed> $manifest
*/
private function writeManifest(array $manifest): void
{
$path = $this->manifestPath();
$directory = dirname($path);
$temporaryPath = $path.'.tmp';
$this->files->ensureDirectoryExists($directory);
$this->files->put(
$temporaryPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($this->files->exists($path)) {
$this->files->delete($path);
}
if (! @rename($temporaryPath, $path)) {
$this->files->delete($temporaryPath);
throw new RuntimeException("Unable to persist the Cloudflare trusted proxy manifest to [{$path}].");
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Manually Trusted Proxies
|--------------------------------------------------------------------------
|
| Comma-separated list of proxies that should always be trusted in addition
| to the Cloudflare ranges fetched by the cloudflare:reload command.
|
*/
'proxies' => env('TRUSTED_PROXIES'),
/*
|--------------------------------------------------------------------------
| Cloudflare Trusted Proxy Settings
|--------------------------------------------------------------------------
*/
'cloudflare' => [
'enabled' => env('TRUST_CLOUDFLARE', true),
'ipv4_url' => env('CLOUDFLARE_IPS_V4_URL', 'https://www.cloudflare.com/ips-v4'),
'ipv6_url' => env('CLOUDFLARE_IPS_V6_URL', 'https://www.cloudflare.com/ips-v6'),
'timeout' => env('CLOUDFLARE_IPS_TIMEOUT', 10),
'connect_timeout' => env('CLOUDFLARE_IPS_CONNECT_TIMEOUT', 5),
'retry_times' => env('CLOUDFLARE_IPS_RETRY_TIMES', 2),
'retry_sleep_ms' => env('CLOUDFLARE_IPS_RETRY_SLEEP_MS', 250),
'storage_path' => env('CLOUDFLARE_IPS_STORAGE_PATH', storage_path('app/cloudflare/trusted-proxies.json')),
'fallback_to_remote_addr' => env('CLOUDFLARE_TRUST_REMOTE_ADDR_FALLBACK', true),
],
];
+1
View File
@@ -32,6 +32,7 @@ Schedule::command('nntmux:disable-expired-promotions')->daily();
// Automatically mark completed registration periods as done shortly after they end
Schedule::command('nntmux:disable-expired-registration-periods')->everyFiveMinutes()->withoutOverlapping();
Schedule::command('nntmux:remove-bad')->hourly()->withoutOverlapping();
Schedule::command('cloudflare:reload')->daily()->withoutOverlapping();
Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::command('nntmux:collect-stats')->hourly();
Schedule::command('nntmux:populate-steam-apps')->monthly();
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Console;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use PDO;
use Tests\TestCase;
class CloudflareReloadCommandTest extends TestCase
{
private string $databasePath;
private string $manifestPath;
private string $resolvedManifestPath;
/**
* @var array<string, string|false>
*/
private array $originalEnvironment = [];
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-cloudflare-command-test.sqlite';
$this->originalEnvironment = [
'APP_ENV' => getenv('APP_ENV'),
'DB_CONNECTION' => getenv('DB_CONNECTION'),
'DB_DATABASE' => getenv('DB_DATABASE'),
];
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings (name, value) VALUES
('categorizeforeign', '0'),
('catwebdl', '0')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
$this->manifestPath = 'storage/framework/testing/cloudflare-'.Str::uuid()->toString().'.json';
$this->resolvedManifestPath = base_path($this->manifestPath);
config([
'trustedproxy.proxies' => null,
'database.default' => 'sqlite',
'database.connections.sqlite.database' => $this->databasePath,
'trustedproxy.cloudflare.storage_path' => $this->manifestPath,
'trustedproxy.cloudflare.ipv4_url' => 'https://www.cloudflare.com/ips-v4',
'trustedproxy.cloudflare.ipv6_url' => 'https://www.cloudflare.com/ips-v6',
'trustedproxy.cloudflare.fallback_to_remote_addr' => false,
]);
}
protected function tearDown(): void
{
if (is_file($this->resolvedManifestPath)) {
unlink($this->resolvedManifestPath);
}
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$directory = dirname($this->resolvedManifestPath);
if (is_dir($directory)) {
@rmdir($directory);
}
parent::tearDown();
foreach ($this->originalEnvironment as $key => $value) {
$this->setEnvironmentValue($key, $value === false ? null : $value);
}
}
public function test_it_refreshes_and_persists_cloudflare_ip_ranges(): void
{
Http::fake([
'https://www.cloudflare.com/ips-v4' => Http::response("173.245.48.0/20\n104.16.0.0/13\n", 200),
'https://www.cloudflare.com/ips-v6' => Http::response("2400:cb00::/32\n2a06:98c0::/29\n", 200),
]);
$this->artisan('cloudflare:reload')
->expectsOutputToContain('Cloudflare IPv4 ranges: 2')
->expectsOutputToContain('Cloudflare IPv6 ranges: 2')
->expectsOutputToContain('Combined Cloudflare proxy count: 4')
->expectsOutputToContain('Stored Cloudflare manifest: '.base_path($this->manifestPath))
->assertExitCode(0);
$this->assertFileExists($this->resolvedManifestPath);
$manifest = json_decode((string) file_get_contents($this->resolvedManifestPath), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(['173.245.48.0/20', '104.16.0.0/13'], $manifest['ipv4']);
$this->assertSame(['2400:cb00::/32', '2a06:98c0::/29'], $manifest['ipv6']);
$this->assertCount(4, $manifest['proxies']);
Http::assertSentCount(2);
}
public function test_it_preserves_the_existing_manifest_when_refresh_fails(): void
{
$existingManifest = [
'version' => 1,
'updated_at' => '2026-03-30T00:00:00+00:00',
'ipv4' => ['173.245.48.0/20'],
'ipv6' => [],
'proxies' => ['173.245.48.0/20'],
];
if (! is_dir(dirname($this->resolvedManifestPath))) {
mkdir(dirname($this->resolvedManifestPath), 0777, true);
}
file_put_contents(
$this->resolvedManifestPath,
json_encode($existingManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
Http::fake([
'https://www.cloudflare.com/ips-v4' => Http::response('upstream failure', 500),
'https://www.cloudflare.com/ips-v6' => Http::response("2400:cb00::/32\n", 200),
]);
$this->artisan('cloudflare:reload')
->expectsOutputToContain('Failed to refresh Cloudflare IP ranges:')
->assertExitCode(1);
$manifest = json_decode((string) file_get_contents($this->resolvedManifestPath), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame($existingManifest, $manifest);
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Http\Middleware;
use App\Http\Middleware\TrustProxies;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Tests\TestCase;
class TrustProxiesTest extends TestCase
{
private string $manifestPath;
private string $resolvedManifestPath;
protected function setUp(): void
{
parent::setUp();
$this->manifestPath = 'storage/framework/testing/trusted-proxies-test.json';
$this->resolvedManifestPath = base_path($this->manifestPath);
config([
'trustedproxy.proxies' => null,
'trustedproxy.cloudflare.storage_path' => $this->manifestPath,
'trustedproxy.cloudflare.enabled' => true,
'trustedproxy.cloudflare.fallback_to_remote_addr' => true,
]);
}
protected function tearDown(): void
{
TrustProxies::flushState();
SymfonyRequest::setTrustedProxies([], SymfonyRequest::HEADER_X_FORWARDED_FOR);
if (is_file($this->resolvedManifestPath)) {
unlink($this->resolvedManifestPath);
}
$directory = dirname($this->resolvedManifestPath);
if (is_dir($directory)) {
@rmdir($directory);
}
parent::tearDown();
}
public function test_it_trusts_manual_and_cloudflare_proxies(): void
{
config([
'trustedproxy.proxies' => '10.0.0.1/32, REMOTE_ADDR',
'trustedproxy.cloudflare.fallback_to_remote_addr' => false,
]);
$this->writeManifest([
'version' => 1,
'updated_at' => now()->toIso8601String(),
'ipv4' => ['173.245.48.0/20'],
'ipv6' => ['2400:cb00::/32'],
'proxies' => ['173.245.48.0/20', '2400:cb00::/32'],
]);
$middleware = new TrustProxies;
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '203.0.113.9']);
$middleware->handle($request, static fn (Request $request) => response('ok'));
$this->assertSame([
'10.0.0.1/32',
'203.0.113.9',
'173.245.48.0/20',
'2400:cb00::/32',
], SymfonyRequest::getTrustedProxies());
}
public function test_it_falls_back_to_the_calling_ip_when_no_manifest_exists(): void
{
$middleware = new TrustProxies;
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '198.51.100.7']);
$middleware->handle($request, static fn (Request $request) => response('ok'));
$this->assertSame(['198.51.100.7'], SymfonyRequest::getTrustedProxies());
}
/**
* @param array<string, mixed> $manifest
*/
private function writeManifest(array $manifest): void
{
$directory = dirname($this->resolvedManifestPath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents(
$this->resolvedManifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
}
}