mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:01:24 +00:00
Add cloudflare ip refresh
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user