diff --git a/Makefile b/Makefile index 56f4641..c87239b 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ dev-e2e: python3 tests/e2e/phase4_control_plane.py python3 tests/e2e/phase4_mtls.py python3 tests/e2e/phase5_tls.py + python3 tests/e2e/phase6_security.py python3 tests/e2e/phase4_runtime.py dev-scale-e2e: diff --git a/compose.dev.yml b/compose.dev.yml index eb3c77c..a3b7d32 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -295,15 +295,24 @@ services: EDGE_CELL_NAME: shared-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/shared-default.json EDGE_STATUS_TOKEN: ${CDNF_DEV_EDGE_STATUS_TOKEN:-cdnf-dev-edge-status-only} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - dev-pki:/run/edge:ro - edge-a-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=256m + - /var/lib/nginx/tmp:rw,noexec,nosuid,size=64m + mem_limit: 512m + cpus: 1 + pids_limit: 128 + ulimits: { nofile: { soft: 65536, hard: 65536 } } + sysctls: { net.ipv4.tcp_syncookies: "1" } networks: [edge] depends_on: dev-pki: { condition: service_completed_successfully } origin-http: { condition: service_started } + mmdb-updater: { condition: service_healthy } restart: unless-stopped healthcheck: test: [CMD, wget, -qO-, http://127.0.0.1:8080/healthz] @@ -319,15 +328,24 @@ services: EDGE_CELL_NAME: quarantine-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/quarantine-default.json EDGE_STATUS_TOKEN: ${CDNF_DEV_EDGE_STATUS_TOKEN:-cdnf-dev-edge-status-only} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - dev-pki:/run/edge:ro - edge-a-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=256m + - /var/lib/nginx/tmp:rw,noexec,nosuid,size=64m + mem_limit: 256m + cpus: 0.5 + pids_limit: 96 + ulimits: { nofile: { soft: 65536, hard: 65536 } } + sysctls: { net.ipv4.tcp_syncookies: "1" } networks: [edge] depends_on: dev-pki: { condition: service_completed_successfully } origin-http: { condition: service_started } + mmdb-updater: { condition: service_healthy } restart: unless-stopped edge-agent-a: @@ -367,15 +385,24 @@ services: EDGE_CELL_NAME: shared-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/shared-default.json EDGE_STATUS_TOKEN: ${CDNF_DEV_EDGE_STATUS_TOKEN:-cdnf-dev-edge-status-only} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - dev-pki:/run/edge:ro - edge-b-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=256m + - /var/lib/nginx/tmp:rw,noexec,nosuid,size=64m + mem_limit: 512m + cpus: 1 + pids_limit: 128 + ulimits: { nofile: { soft: 65536, hard: 65536 } } + sysctls: { net.ipv4.tcp_syncookies: "1" } networks: [edge] depends_on: dev-pki: { condition: service_completed_successfully } origin-http: { condition: service_started } + mmdb-updater: { condition: service_healthy } restart: unless-stopped healthcheck: test: [CMD, wget, -qO-, http://127.0.0.1:8080/healthz] @@ -391,15 +418,24 @@ services: EDGE_CELL_NAME: quarantine-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/quarantine-default.json EDGE_STATUS_TOKEN: ${CDNF_DEV_EDGE_STATUS_TOKEN:-cdnf-dev-edge-status-only} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - dev-pki:/run/edge:ro - edge-b-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=256m + - /var/lib/nginx/tmp:rw,noexec,nosuid,size=64m + mem_limit: 256m + cpus: 0.5 + pids_limit: 96 + ulimits: { nofile: { soft: 65536, hard: 65536 } } + sysctls: { net.ipv4.tcp_syncookies: "1" } networks: [edge] depends_on: dev-pki: { condition: service_completed_successfully } origin-http: { condition: service_started } + mmdb-updater: { condition: service_healthy } restart: unless-stopped edge-agent-b: diff --git a/compose.prod.yml b/compose.prod.yml index 2e119ac..5cb15b0 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -238,10 +238,14 @@ services: EDGE_CELL_NAME: shared-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/shared-default.json EDGE_STATUS_TOKEN: ${EDGE_STATUS_TOKEN:?EDGE_STATUS_TOKEN is required} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - ${EDGE_RUNTIME_TLS_CERTIFICATE:?EDGE_RUNTIME_TLS_CERTIFICATE is required}:/run/edge/tls.crt:ro - ${EDGE_RUNTIME_TLS_PRIVATE_KEY:?EDGE_RUNTIME_TLS_PRIVATE_KEY is required}:/run/edge/tls.key:ro - edge-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro + depends_on: + mmdb-updater: { condition: service_healthy } read_only: true tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=512m @@ -252,6 +256,7 @@ services: mem_limit: 2g cpus: 2 pids_limit: 256 + sysctls: { net.ipv4.tcp_syncookies: "1" } ulimits: nofile: { soft: 65536, hard: 65536 } deploy: @@ -270,10 +275,14 @@ services: EDGE_CELL_NAME: quarantine-default EDGE_RUNTIME_FILE: /var/lib/cdnfoundry/runtime/quarantine-default.json EDGE_STATUS_TOKEN: ${EDGE_STATUS_TOKEN:?EDGE_STATUS_TOKEN is required} + GEOIP_DATABASE: /mmdb/GeoLite2-City.mmdb volumes: - ${EDGE_RUNTIME_TLS_CERTIFICATE:?EDGE_RUNTIME_TLS_CERTIFICATE is required}:/run/edge/tls.crt:ro - ${EDGE_RUNTIME_TLS_PRIVATE_KEY:?EDGE_RUNTIME_TLS_PRIVATE_KEY is required}:/run/edge/tls.key:ro - edge-state:/var/lib/cdnfoundry/runtime:ro + - mmdb:/mmdb:ro + depends_on: + mmdb-updater: { condition: service_healthy } read_only: true tmpfs: - /var/cache/nginx:rw,noexec,nosuid,size=256m @@ -284,6 +293,7 @@ services: mem_limit: 512m cpus: 0.5 pids_limit: 128 + sysctls: { net.ipv4.tcp_syncookies: "1" } ulimits: nofile: { soft: 32768, hard: 32768 } deploy: diff --git a/core/app/Actions/DispatchEmergencyMode.php b/core/app/Actions/DispatchEmergencyMode.php new file mode 100644 index 0000000..d8f49df --- /dev/null +++ b/core/app/Actions/DispatchEmergencyMode.php @@ -0,0 +1,87 @@ +where('target_type', $targetType)->where('target_id', $targetId)->where('active', true)->exists(), 409, 'This target already has an active emergency mode. Clear it before applying another.'); + $mode = EmergencyMode::query()->create([ + 'target_type' => $targetType, 'target_id' => $targetId, 'actions' => array_values($actions), + 'expires_at' => $durationMinutes === null ? null : now()->addMinutes($durationMinutes), + 'created_by' => $actor->id, + ]); + $operation = Operation::query()->create([ + 'id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'type' => 'edge.emergency_mode', 'status' => 'pending', + 'input' => ['emergency_mode_id' => $mode->id, 'target_type' => $targetType, 'target_id' => $targetId, 'active' => true], + ]); + self::tasks($mode, $operation, true); + + return [$mode, $operation]; + }); + } + + public static function deactivateTarget(string $targetType, string $targetId, User|string|null $actor): Operation + { + return DB::transaction(function () use ($actor, $targetId, $targetType): Operation { + $modes = EmergencyMode::query()->where('target_type', $targetType)->where('target_id', $targetId)->where('active', true)->lockForUpdate()->get(); + abort_if($modes->isEmpty(), 404, 'No active emergency mode exists for this target.'); + $modes->each->update(['active' => false, 'deactivated_at' => now()]); + $actorId = $actor instanceof User ? $actor->id : null; + $operation = Operation::query()->create([ + 'id' => (string) Str::uuid(), 'actor_id' => $actorId, 'type' => 'edge.emergency_mode', 'status' => 'pending', + 'input' => ['emergency_mode_ids' => $modes->pluck('id')->all(), 'target_type' => $targetType, 'target_id' => $targetId, 'active' => false], + ]); + foreach ($modes as $mode) { + self::tasks($mode, $operation, false); + } + + return $operation; + }); + } + + private static function tasks(EmergencyMode $mode, Operation $operation, bool $active): void + { + $edges = self::edges($mode->target_type, $mode->target_id); + abort_if($edges->isEmpty(), 409, 'The emergency target has no participating edge.'); + foreach ($edges as $edge) { + $cellNames = match ($mode->target_type) { + 'cell' => [EdgeCell::query()->findOrFail((int) $mode->target_id)->name], + 'pool' => $edge->cells()->where('edge_pool_id', (int) $mode->target_id)->orderBy('name')->limit(32)->pluck('name')->all(), + default => [], + }; + EdgeTask::query()->create([ + 'id' => (string) Str::uuid(), 'edge_id' => $edge->id, 'type' => 'emergency_mode', 'status' => 'pending', + 'payload' => [ + 'operation_id' => $operation->id, 'emergency_mode_id' => $mode->id, 'active' => $active, + 'target_type' => $mode->target_type, 'target_id' => $mode->target_id, + 'cell_names' => $cellNames, + 'actions' => $mode->actions, 'expires_at' => $active ? $mode->expires_at?->timestamp : null, + ], + ]); + } + $operation->update(['status' => 'running', 'started_at' => now(), 'result' => ['tasks' => $edges->count(), 'completed' => 0]]); + } + + private static function edges(string $targetType, string $targetId): Collection + { + return match ($targetType) { + 'edge' => Edge::query()->whereKey($targetId)->where('enabled', true)->get(), + 'cell' => Edge::query()->whereHas('cells', fn ($query) => $query->whereKey((int) $targetId))->where('enabled', true)->get(), + 'pool' => Edge::query()->whereHas('cells', fn ($query) => $query->where('edge_pool_id', (int) $targetId))->where('enabled', true)->get(), + default => collect(), + }; + } +} diff --git a/core/app/Console/Commands/ReconcileSecurityReadiness.php b/core/app/Console/Commands/ReconcileSecurityReadiness.php new file mode 100644 index 0000000..55c3272 --- /dev/null +++ b/core/app/Console/Commands/ReconcileSecurityReadiness.php @@ -0,0 +1,50 @@ +option('limit'))); + EmergencyMode::query()->where('active', true)->whereNotNull('expires_at')->where('expires_at', '<=', now()) + ->select(['target_type', 'target_id'])->distinct()->limit($limit)->get() + ->each(function (EmergencyMode $mode): void { + $operation = DispatchEmergencyMode::deactivateTarget($mode->target_type, $mode->target_id, null); + AuditLog::record(null, 'security.emergency_expired', $mode, ['operation_id' => $operation->id]); + }); + + Domain::query()->whereIn('security_state', ['suspected', 'restricted', 'recovering'])->orderBy('id')->limit($limit)->get() + ->each(function (Domain $domain): void { + $latest = $domain->securityEvents()->max('occurred_at'); + if ($latest !== null && now()->diffInMinutes($latest, absolute: true) < 10) { + return; + } + $next = match ($domain->security_state) { + 'suspected' => 'normal', 'restricted' => 'recovering', 'recovering' => 'normal', default => null, + }; + if ($next === null) { + return; + } + $domain->update(['security_state' => $next, 'security_state_changed_at' => now(), 'revision' => $domain->revision + 1]); + Operation::coalesceDomain('edge.domain_reconcile', $domain->id); + ReconcileEdgeDomain::dispatch($domain->id); + AuditLog::record(null, 'security.state_recovered', $domain, ['state' => $next, 'revision' => $domain->revision]); + }); + $this->info('Security readiness reconciliation completed.'); + + return self::SUCCESS; + } +} diff --git a/core/app/Filament/Admin/Resources/EdgePools/EdgePoolResource.php b/core/app/Filament/Admin/Resources/EdgePools/EdgePoolResource.php index 8a36dd5..6671a42 100644 --- a/core/app/Filament/Admin/Resources/EdgePools/EdgePoolResource.php +++ b/core/app/Filament/Admin/Resources/EdgePools/EdgePoolResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\EdgePools; +use App\Actions\DispatchEmergencyMode; use App\Filament\Admin\Resources\EdgePools\Pages\CreateEdgePool; use App\Filament\Admin\Resources\EdgePools\Pages\EditEdgePool; use App\Filament\Admin\Resources\EdgePools\Pages\ListEdgePools; @@ -9,9 +10,11 @@ use App\Models\AuditLog; use App\Models\DomainEdgePlacement; use App\Models\EdgePool; +use App\Models\EmergencyMode; use App\Models\PlatformDnsSetting; use App\Support\EdgeRoutingCompiler; use Filament\Actions\Action; +use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; @@ -49,6 +52,7 @@ public static function table(Table $table): Table TextColumn::make('name')->searchable()->sortable(), TextColumn::make('kind')->badge(), IconColumn::make('enabled')->boolean(), + IconColumn::make('withdrawn')->boolean(), TextColumn::make('routing_target')->label('DNS routing target') ->state(fn (EdgePool $record): ?string => $settings === null ? null : EdgeRoutingCompiler::poolHostname($settings, $record)) ->copyable()->placeholder('Configure System DNS identity')->wrap(), @@ -78,6 +82,29 @@ public static function table(Table $table): Table AuditLog::record(auth()->user(), 'edge.pool_disabled', $record, ['revision' => $record->revision], request()->ip()); ReconcilePlatformDnsIdentity::dispatchForRoutingChange(); }), + Action::make('withdraw')->color('danger')->requiresConfirmation()->visible(fn (EdgePool $record): bool => ! $record->withdrawn)->action(function (EdgePool $record): void { + $record->update(['withdrawn' => true, 'revision' => $record->revision + 1]); + AuditLog::record(auth()->user(), 'edge.pool_withdrawn', $record, ['revision' => $record->revision], request()->ip()); + ReconcilePlatformDnsIdentity::dispatchForRoutingChange(); + }), + Action::make('restore')->color('success')->visible(fn (EdgePool $record): bool => $record->withdrawn)->action(function (EdgePool $record): void { + $record->update(['withdrawn' => false, 'revision' => $record->revision + 1]); + AuditLog::record(auth()->user(), 'edge.pool_restored', $record, ['revision' => $record->revision], request()->ip()); + ReconcilePlatformDnsIdentity::dispatchForRoutingChange(); + }), + Action::make('emergencyMode')->label('Emergency')->color('danger')->requiresConfirmation() + ->visible(fn (EdgePool $record): bool => ! EmergencyMode::query()->where('target_type', 'pool')->where('target_id', (string) $record->id)->where('active', true)->exists()) + ->schema([ + CheckboxList::make('actions')->options(array_combine(config('security.emergency_actions'), config('security.emergency_actions')))->required()->minItems(1), + TextInput::make('duration_minutes')->numeric()->minValue(1)->maxValue(config('security.emergency_duration_minutes_maximum')), + ])->action(function (EdgePool $record, array $data): void { + [$mode, $operation] = DispatchEmergencyMode::activate('pool', (string) $record->id, $data['actions'], filled($data['duration_minutes'] ?? null) ? (int) $data['duration_minutes'] : null, auth()->user()); + AuditLog::record(auth()->user(), 'security.emergency_activated', $record, ['mode_id' => $mode->id], request()->ip()); + Notification::make()->warning()->title('Pool emergency mode queued')->body("Operation {$operation->id} targets the equivalent cell on each edge.")->send(); + }), + Action::make('clearEmergency')->label('Clear emergency')->color('success')->requiresConfirmation() + ->visible(fn (EdgePool $record): bool => EmergencyMode::query()->where('target_type', 'pool')->where('target_id', (string) $record->id)->where('active', true)->exists()) + ->action(fn (EdgePool $record) => DispatchEmergencyMode::deactivateTarget('pool', (string) $record->id, auth()->user())), ])->defaultSort('name'); } diff --git a/core/app/Filament/Admin/Resources/Edges/EdgeResource.php b/core/app/Filament/Admin/Resources/Edges/EdgeResource.php index e9c5198..9b693c5 100644 --- a/core/app/Filament/Admin/Resources/Edges/EdgeResource.php +++ b/core/app/Filament/Admin/Resources/Edges/EdgeResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Admin\Resources\Edges; +use App\Actions\DispatchEmergencyMode; use App\Filament\Admin\Resources\Edges\Pages\CreateEdge; use App\Filament\Admin\Resources\Edges\Pages\EditEdge; use App\Filament\Admin\Resources\Edges\Pages\ListEdges; @@ -9,10 +10,12 @@ use App\Jobs\ReconcilePlatformDnsIdentity; use App\Models\AuditLog; use App\Models\Edge; +use App\Models\EmergencyMode; use App\Support\GeoVocabulary; use App\Support\NetworkAddress; use Filament\Actions\Action; use Filament\Actions\EditAction; +use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; @@ -107,6 +110,22 @@ public static function table(Table $table): Table ReconcilePlatformDnsIdentity::dispatch()->afterCommit(); Notification::make()->warning()->persistent()->title('New one-time bootstrap token')->body($token)->send(); }), + Action::make('emergencyMode')->label('Emergency mode')->color('danger')->requiresConfirmation() + ->visible(fn (Edge $record): bool => ! EmergencyMode::query()->where('target_type', 'edge')->where('target_id', $record->id)->where('active', true)->exists()) + ->schema([ + CheckboxList::make('actions')->options(array_combine(config('security.emergency_actions'), config('security.emergency_actions')))->required()->minItems(1), + TextInput::make('duration_minutes')->label('Automatic expiry (minutes)')->numeric()->minValue(1)->maxValue(config('security.emergency_duration_minutes_maximum'))->helperText('Leave empty only for an explicitly permanent emergency.'), + ])->action(function (Edge $record, array $data): void { + [$mode, $operation] = DispatchEmergencyMode::activate('edge', $record->id, $data['actions'], filled($data['duration_minutes'] ?? null) ? (int) $data['duration_minutes'] : null, auth()->user()); + AuditLog::record(auth()->user(), 'security.emergency_activated', $record, ['mode_id' => $mode->id, 'actions' => $mode->actions], request()->ip()); + Notification::make()->warning()->title('Edge emergency mode queued')->body("Operation {$operation->id} is delivering the bounded actions to every cell.")->send(); + }), + Action::make('clearEmergencyMode')->label('Clear emergency')->color('success')->requiresConfirmation() + ->visible(fn (Edge $record): bool => EmergencyMode::query()->where('target_type', 'edge')->where('target_id', $record->id)->where('active', true)->exists()) + ->action(function (Edge $record): void { + $operation = DispatchEmergencyMode::deactivateTarget('edge', $record->id, auth()->user()); + AuditLog::record(auth()->user(), 'security.emergency_deactivated', $record, ['operation_id' => $operation->id], request()->ip()); + }), EditAction::make(), ])->defaultSort('name'); } diff --git a/core/app/Filament/Admin/Resources/Edges/RelationManagers/CellsRelationManager.php b/core/app/Filament/Admin/Resources/Edges/RelationManagers/CellsRelationManager.php index 94c06c1..42b79a2 100644 --- a/core/app/Filament/Admin/Resources/Edges/RelationManagers/CellsRelationManager.php +++ b/core/app/Filament/Admin/Resources/Edges/RelationManagers/CellsRelationManager.php @@ -2,15 +2,18 @@ namespace App\Filament\Admin\Resources\Edges\RelationManagers; +use App\Actions\DispatchEmergencyMode; use App\Jobs\ReconcilePlatformDnsIdentity; use App\Models\AuditLog; use App\Models\EdgeCell; use App\Models\EdgeTask; +use App\Models\EmergencyMode; use App\Support\EdgeCellAddressData; use App\Support\NetworkAddress; use App\Support\PlatformSettings; use Filament\Actions\Action; use Filament\Actions\EditAction; +use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -91,6 +94,19 @@ public function table(Table $table): Table Action::make('drain')->requiresConfirmation()->visible(fn (EdgeCell $record): bool => ! $record->drained)->action(fn (EdgeCell $record) => self::queue($record, 'drain')), Action::make('undrain')->visible(fn (EdgeCell $record): bool => $record->drained)->action(fn (EdgeCell $record) => self::queue($record, 'undrain')), Action::make('restart')->color('warning')->requiresConfirmation()->action(fn (EdgeCell $record) => self::queue($record, 'restart')), + Action::make('emergencyMode')->label('Emergency')->color('danger')->requiresConfirmation() + ->visible(fn (EdgeCell $record): bool => ! EmergencyMode::query()->where('target_type', 'cell')->where('target_id', (string) $record->id)->where('active', true)->exists()) + ->schema([ + CheckboxList::make('actions')->options(array_combine(config('security.emergency_actions'), config('security.emergency_actions')))->required()->minItems(1), + TextInput::make('duration_minutes')->numeric()->minValue(1)->maxValue(config('security.emergency_duration_minutes_maximum')), + ])->action(function (EdgeCell $record, array $data): void { + [$mode, $operation] = DispatchEmergencyMode::activate('cell', (string) $record->id, $data['actions'], filled($data['duration_minutes'] ?? null) ? (int) $data['duration_minutes'] : null, auth()->user()); + AuditLog::record(auth()->user(), 'security.emergency_activated', $record, ['mode_id' => $mode->id], request()->ip()); + Notification::make()->warning()->title('Cell emergency mode queued')->body("Operation {$operation->id} targets only {$record->name}.")->send(); + }), + Action::make('clearEmergency')->label('Clear emergency')->color('success')->requiresConfirmation() + ->visible(fn (EdgeCell $record): bool => EmergencyMode::query()->where('target_type', 'cell')->where('target_id', (string) $record->id)->where('active', true)->exists()) + ->action(fn (EdgeCell $record) => DispatchEmergencyMode::deactivateTarget('cell', (string) $record->id, auth()->user())), ]); } diff --git a/core/app/Filament/Domain/Resources/Domains/DomainResource.php b/core/app/Filament/Domain/Resources/Domains/DomainResource.php index 502776e..b398a47 100644 --- a/core/app/Filament/Domain/Resources/Domains/DomainResource.php +++ b/core/app/Filament/Domain/Resources/Domains/DomainResource.php @@ -6,6 +6,7 @@ use App\Filament\Domain\Resources\Domains\Pages\ListDomains; use App\Filament\Domain\Resources\Domains\Pages\ViewDomain; use App\Filament\Domain\Resources\Domains\RelationManagers\DnsRecordsRelationManager; +use App\Filament\Domain\Resources\Domains\RelationManagers\SecurityRulesRelationManager; use App\Filament\Domain\Resources\Domains\RelationManagers\UsersRelationManager; use App\Http\Controllers\CacheController; use App\Models\Domain; @@ -13,6 +14,7 @@ use App\Models\Operation; use App\Models\PlatformDnsSetting; use App\Support\EdgeRoutingCompiler; +use App\Support\SecurityConfig; use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; @@ -148,6 +150,22 @@ public static function infolist(Schema $schema): Schema TextEntry::make('latestTlsOrder.names')->label('Requested names')->listWithLineBreaks()->placeholder('None'), TextEntry::make('latestTlsOrder.last_error')->label('ACME failure')->placeholder('None')->columnSpanFull(), ])->columns(['default' => 1, 'md' => 2, 'xl' => 3]), + Section::make('Security and DDoS readiness') + ->description('Local edge enforcement with bounded platform ceilings. This is readiness and isolation, not volumetric upstream scrubbing.') + ->icon('heroicon-o-shield-check') + ->schema([ + TextEntry::make('security_profile')->label('Configured profile')->state(fn (Domain $record): string => ($record->security_settings ?? SecurityConfig::defaults())['profile'])->badge(), + TextEntry::make('security_state')->label('Operational state')->badge(), + TextEntry::make('security_state_changed_at')->label('State changed')->dateTime()->placeholder('Never'), + TextEntry::make('security_rules_count')->label('Rules')->state(fn (Domain $record): int => $record->securityRules()->count()), + TextEntry::make('security_limits_summary')->label('Effective request/origin limits')->state(function (Domain $record): string { + $compiled = SecurityConfig::compile($record); + $limits = $compiled['limits']; + + return "{$limits['requests_per_second']} req/s · {$limits['connections_per_client']} client connections · {$limits['connections_per_domain']} domain connections · {$limits['origin_max_connections']} origin connections · {$limits['maximum_request_body_size']} body bytes"; + })->columnSpanFull(), + TextEntry::make('recent_security_events')->label('Recent reason codes')->state(fn (Domain $record): array => $record->securityEvents()->latest('occurred_at')->limit(10)->get()->map(fn ($event): string => "{$event->reason_code} · {$event->occurred_at->format('Y-m-d H:i:s T')}")->all())->listWithLineBreaks()->placeholder('None')->columnSpanFull(), + ])->columns(['default' => 1, 'md' => 2, 'xl' => 4]), ]); } @@ -161,7 +179,7 @@ public static function getEloquentQuery(): Builder public static function getRelations(): array { - return [DnsRecordsRelationManager::class, UsersRelationManager::class]; + return [DnsRecordsRelationManager::class, SecurityRulesRelationManager::class, UsersRelationManager::class]; } public static function getPages(): array diff --git a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php index 94b2b03..f019194 100644 --- a/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php +++ b/core/app/Filament/Domain/Resources/Domains/Pages/ViewDomain.php @@ -22,6 +22,7 @@ use App\Support\BindZone; use App\Support\DnsZoneImporter; use App\Support\ProxyRevisionRollback; +use App\Support\SecurityConfig; use App\Support\UploadedCertificate; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -32,6 +33,8 @@ use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -233,6 +236,50 @@ protected function getHeaderActions(): array ->color($proxiedCount > 0 && $readyEdgeExists ? 'success' : 'warning') ->send(); }), + Action::make('securitySettings')->label('Security profile and limits')->icon('heroicon-o-shield-check')->schema([ + Select::make('profile')->options(SecurityConfig::profileOptions())->required()->live() + ->helperText(fn (Get $get): string => SecurityConfig::profileDescription((string) $get('profile'))) + ->afterStateUpdated(function (?string $state, Set $set): void { + if ($state === null || $state === SecurityConfig::MANUAL_PROFILE) { + return; + } + foreach (SecurityConfig::ceilingLimits($state) as $field => $value) { + $set("limits.$field", $value); + } + }), + Select::make('quarantine_policy')->label('Automatic readiness policy')->options([ + 'manual' => 'Manual', 'automatic' => 'Automatic restriction', + 'automatic_with_admin_notification' => 'Automatic restriction with administrator notification', + ])->required(), + TagsInput::make('allowed_methods')->label('Allowed methods')->suggestions(config('security.allowed_methods'))->required()->nestedRecursiveRules(['in:'.implode(',', config('security.allowed_methods'))]), + TagsInput::make('trusted_proxy_cidrs')->label('Trusted L4 proxy CIDRs')->helperText('Leave empty unless requests arrive only through an approved balancer that overwrites X-Forwarded-For.'), + ...collect(array_keys(config('security.profiles.standard')))->map(fn (string $field): TextInput => TextInput::make("limits.$field") + ->label(str($field)->replace('_', ' ')->title()->toString())->numeric() + ->minValue($field === 'origin_retry_limit' ? 0 : 1) + ->maxValue(fn (Get $get): int => SecurityConfig::ceilingLimits((string) $get('profile'))[$field]) + ->disabled(fn (Get $get): bool => $get('profile') !== SecurityConfig::MANUAL_PROFILE)->dehydrated()->required())->all(), + ])->fillForm(fn (): array => $this->record->security_settings ?? SecurityConfig::defaults()) + ->action(function (array $data): void { + $settings = SecurityConfig::validateSettings($data); + DB::transaction(function () use ($settings): void { + $domain = $this->record->newQuery()->lockForUpdate()->findOrFail($this->record->id); + $domain->update(['security_settings' => $settings, 'revision' => $domain->revision + 1]); + AuditLog::record(auth()->user(), 'security.settings_updated', $domain, ['profile' => $settings['profile'], 'revision' => $domain->revision], request()->ip()); + }); + $this->record->refresh(); + Operation::coalesceDomain('edge.domain_reconcile', $this->record->id, auth()->id()); + ReconcileEdgeDomain::dispatch($this->record->id)->afterCommit(); + Notification::make()->success()->title('Security profile saved')->body('The bounded policy is queued in the normal signed edge revision.')->send(); + }), + Action::make('restrictSecurity')->label('Restrict domain')->color('warning')->requiresConfirmation() + ->visible(fn (): bool => auth()->user()?->isAdmin() === true && ! in_array($this->record->security_state, ['restricted', 'quarantined'], true)) + ->action(fn () => $this->changeSecurityState('restricted', null)), + Action::make('quarantineSecurity')->label('Quarantine domain')->color('danger')->requiresConfirmation() + ->visible(fn (): bool => auth()->user()?->isAdmin() === true && $this->record->security_state !== 'quarantined' && $this->record->dnsRecords()->where('mode', 'proxied')->exists()) + ->action(fn () => $this->changeSecurityState('quarantined', EdgePool::query()->where('enabled', true)->where('withdrawn', false)->where('kind', 'quarantine')->orderBy('id')->firstOrFail())), + Action::make('releaseSecurity')->label('Release domain')->color('success')->requiresConfirmation() + ->visible(fn (): bool => auth()->user()?->isAdmin() === true && in_array($this->record->security_state, ['restricted', 'quarantined'], true)) + ->action(fn () => $this->changeSecurityState('recovering', EdgePool::query()->where('enabled', true)->where('withdrawn', false)->where('kind', 'shared')->orderBy('id')->firstOrFail())), Action::make('rollbackProxy')->label('Rollback proxy revision')->color('warning')->requiresConfirmation()->schema([ Select::make('revision')->options(fn (): array => EdgeRevision::query()->where('domain_id', $this->record->id)->where('status', 'validated')->where('revision', '<', $this->record->revision)->latest('revision')->limit(50)->get() ->mapWithKeys(fn (EdgeRevision $revision): array => [$revision->revision => "#{$revision->revision} · {$revision->created_at->format('Y-m-d H:i:s T')}"])->all())->required(), @@ -371,6 +418,33 @@ static function () use ($zone): void { ->icon('heroicon-o-lock-closed') ->color('gray') ->button(), + ActionGroup::make($group(['securitySettings', 'restrictSecurity', 'quarantineSecurity', 'releaseSecurity'])) + ->label('Security') + ->icon('heroicon-o-shield-check') + ->color('warning') + ->button(), ]; } + + private function changeSecurityState(string $state, ?EdgePool $pool): void + { + DB::transaction(function () use ($pool, $state): void { + $domain = $this->record->newQuery()->lockForUpdate()->findOrFail($this->record->id); + $domain->update(['security_state' => $state, 'security_state_changed_at' => now(), 'revision' => $domain->revision + 1]); + if ($pool !== null) { + DomainEdgePlacement::query()->updateOrCreate(['domain_id' => $domain->id], [ + 'target_pool_id' => $pool->id, 'desired_revision' => $domain->revision, + 'state' => 'deploying', 'drain_after' => null, 'last_error' => null, + ]); + } + $domain->securityEvents()->create([ + 'state' => $state, 'reason_code' => $state === 'quarantined' ? 'domain_quarantined' : 'domain_restricted', + 'details' => ['actor_id' => auth()->id(), 'target_pool_id' => $pool?->id], 'occurred_at' => now(), + ]); + AuditLog::record(auth()->user(), "security.domain_$state", $domain, ['revision' => $domain->revision, 'target_pool_id' => $pool?->id], request()->ip()); + }); + $operation = Operation::coalesceDomain('edge.domain_reconcile', $this->record->id, auth()->id()); + ReconcileEdgeDomain::dispatch($this->record->id)->afterCommit(); + Notification::make()->warning()->title('Security state queued')->body("{$state} is deploying as operation {$operation->id}; placement changes activate the target before draining the source.")->send(); + } } diff --git a/core/app/Filament/Domain/Resources/Domains/RelationManagers/SecurityRulesRelationManager.php b/core/app/Filament/Domain/Resources/Domains/RelationManagers/SecurityRulesRelationManager.php new file mode 100644 index 0000000..9f48c06 --- /dev/null +++ b/core/app/Filament/Domain/Resources/Domains/RelationManagers/SecurityRulesRelationManager.php @@ -0,0 +1,152 @@ +components($this->ruleFields()); + } + + public function table(Table $table): Table + { + return $table->description('Enabled rules are evaluated by ascending priority and then stable ID. First match wins; the default is allow.') + ->columns([ + TextColumn::make('priority')->sortable(), TextColumn::make('match_type')->label('Type')->badge(), + TextColumn::make('value')->searchable(), TextColumn::make('action')->badge(), + IconColumn::make('enabled')->boolean(), TextColumn::make('note')->limit(60)->placeholder('None'), + ])->headerActions([ + CreateAction::make()->createAnother(false)->using(fn (array $data): SecurityRule => $this->createRule($data)), + Action::make('importPreview')->label('Import rules')->icon('heroicon-o-arrow-up-tray')->requiresConfirmation() + ->modalHeading('Preview and commit security-rule import') + ->modalDescription('Review every normalized row below. Confirming writes the whole bounded import in one desired revision.') + ->schema([ + Toggle::make('replace_existing')->label('Replace existing rules')->default(false), + Repeater::make('rules')->minItems(1)->maxItems(100)->schema($this->ruleFields())->columns(3)->required(), + ])->action(fn (array $data) => $this->importRules($data)), + ])->recordActions([ + EditAction::make()->using(fn (SecurityRule $record, array $data): SecurityRule => $this->updateRule($record, $data)), + DeleteAction::make()->using(fn (SecurityRule $record): bool => $this->deleteRule($record)), + ])->defaultSort('priority'); + } + + private function ruleFields(): array + { + return [ + Select::make('match_type')->label('Type')->options(['ip' => 'IP address', 'cidr' => 'CIDR network', 'country' => 'Country', 'continent' => 'Continent'])->required()->live(), + TextInput::make('value')->required()->maxLength(128) + ->helperText('IPv4/IPv6, CIDR, ISO country code, or continent code according to the selected type.'), + Select::make('action')->options(['allow' => 'Allow', 'block' => 'Block'])->required(), + TextInput::make('priority')->numeric()->default(100)->minValue(-1000000)->maxValue(1000000)->required(), + Toggle::make('enabled')->default(true), TextInput::make('note')->maxLength(250), + ]; + } + + private function createRule(array $input): SecurityRule + { + $domain = $this->getOwnerRecord(); + abort_if($domain->securityRules()->count() >= config('security.maximum_rules_per_domain'), 409, 'The per-domain security-rule limit has been reached.'); + $data = SecurityConfig::validateRule($input); + $rule = DB::transaction(function () use ($data, $domain): SecurityRule { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $rule = $locked->securityRules()->create($data); + $this->changed($locked, 'security.rule_created', $rule); + + return $rule; + }); + $this->reconcile($domain); + + return $rule; + } + + private function updateRule(SecurityRule $rule, array $input): SecurityRule + { + $data = SecurityConfig::validateRule($input); + $domain = $this->getOwnerRecord(); + DB::transaction(function () use ($data, $domain, $rule): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $rule->update($data); + $this->changed($locked, 'security.rule_updated', $rule); + }); + $this->reconcile($domain); + + return $rule->refresh(); + } + + private function deleteRule(SecurityRule $rule): bool + { + $domain = $this->getOwnerRecord(); + DB::transaction(function () use ($domain, $rule): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $rule->delete(); + $this->changed($locked, 'security.rule_deleted', $locked); + }); + $this->reconcile($domain); + + return true; + } + + private function importRules(array $input): void + { + $domain = $this->getOwnerRecord(); + $rules = collect($input['rules'])->map(fn (array $rule): array => SecurityConfig::validateRule($rule)); + $duplicates = $rules->map(fn (array $rule): string => implode('|', [$rule['match_type'], $rule['value'], $rule['action']]))->duplicates(); + if ($duplicates->isNotEmpty()) { + throw ValidationException::withMessages(['rules' => 'The import contains duplicate normalized rules.']); + } + $replace = (bool) ($input['replace_existing'] ?? false); + abort_if(! $replace && $domain->securityRules()->count() + $rules->count() > config('security.maximum_rules_per_domain'), 409, 'The import exceeds the per-domain rule limit.'); + DB::transaction(function () use ($domain, $replace, $rules): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + if ($replace) { + $locked->securityRules()->delete(); + } + $now = now(); + $locked->securityRules()->insert($rules->map(fn (array $rule): array => [...$rule, 'domain_id' => $locked->id, 'created_at' => $now, 'updated_at' => $now])->all()); + $locked->update(['revision' => $locked->revision + 1]); + AuditLog::record(auth()->user(), 'security.rules_imported', $locked, ['count' => $rules->count(), 'replace_existing' => $replace, 'revision' => $locked->revision], request()->ip()); + }); + $this->reconcile($domain); + Notification::make()->success()->title('Security rules imported')->body("{$rules->count()} rules were saved in one desired revision.")->send(); + } + + private function changed(Domain $domain, string $action, object $subject): void + { + $domain->update(['revision' => $domain->revision + 1]); + AuditLog::record(auth()->user(), $action, $subject, ['domain_id' => $domain->id, 'revision' => $domain->revision], request()->ip()); + } + + private function reconcile(Domain $domain): void + { + Operation::coalesceDomain('edge.domain_reconcile', $domain->id, auth()->id()); + ReconcileEdgeDomain::dispatch($domain->id)->afterCommit(); + } +} diff --git a/core/app/Http/Controllers/Admin/SecurityOperationsController.php b/core/app/Http/Controllers/Admin/SecurityOperationsController.php new file mode 100644 index 0000000..ce9616a --- /dev/null +++ b/core/app/Http/Controllers/Admin/SecurityOperationsController.php @@ -0,0 +1,135 @@ +state($request, $domain, 'restricted'); + } + + public function quarantine(Request $request, Domain $domain): JsonResponse + { + abort_unless($domain->dnsRecords()->where('mode', 'proxied')->exists(), 409, 'Only a proxied domain can be quarantined.'); + $pool = EdgePool::query()->where('enabled', true)->where('withdrawn', false)->where('kind', 'quarantine')->orderBy('id')->firstOrFail(); + + return $this->state($request, $domain, 'quarantined', $pool); + } + + public function release(Request $request, Domain $domain): JsonResponse + { + $pool = EdgePool::query()->where('enabled', true)->where('withdrawn', false)->where('kind', 'shared')->orderBy('id')->firstOrFail(); + + return $this->state($request, $domain, 'recovering', $pool); + } + + public function edgeEmergency(Request $request, Edge $edge): JsonResponse + { + return $this->emergency($request, 'edge', $edge->id, $edge); + } + + public function clearEdgeEmergency(Request $request, Edge $edge): JsonResponse + { + return $this->clearEmergency($request, 'edge', $edge->id, $edge); + } + + public function cellEmergency(Request $request, EdgeCell $cell): JsonResponse + { + return $this->emergency($request, 'cell', (string) $cell->id, $cell); + } + + public function clearCellEmergency(Request $request, EdgeCell $cell): JsonResponse + { + return $this->clearEmergency($request, 'cell', (string) $cell->id, $cell); + } + + public function withdraw(Request $request, EdgePool $pool): JsonResponse + { + abort_if($pool->withdrawn, 409, 'The service pool is already withdrawn.'); + DB::transaction(function () use ($pool, $request): void { + $pool->update(['withdrawn' => true, 'revision' => $pool->revision + 1]); + AuditLog::record($request->user(), 'edge.pool_withdrawn', $pool, ['revision' => $pool->revision], $request->ip()); + }); + ReconcilePlatformDnsIdentity::dispatchForRoutingChange(); + + return response()->json(['data' => ['pool_id' => $pool->id, 'withdrawn' => true]], 202); + } + + public function restore(Request $request, EdgePool $pool): JsonResponse + { + abort_unless($pool->withdrawn, 409, 'The service pool is not withdrawn.'); + DB::transaction(function () use ($pool, $request): void { + $pool->update(['withdrawn' => false, 'revision' => $pool->revision + 1]); + AuditLog::record($request->user(), 'edge.pool_restored', $pool, ['revision' => $pool->revision], $request->ip()); + }); + ReconcilePlatformDnsIdentity::dispatchForRoutingChange(); + + return response()->json(['data' => ['pool_id' => $pool->id, 'withdrawn' => false]], 202); + } + + private function state(Request $request, Domain $domain, string $state, ?EdgePool $pool = null): JsonResponse + { + $data = $request->validate(['reason' => ['nullable', 'string', 'max:250']]); + $operation = DB::transaction(function () use ($data, $domain, $pool, $request, $state): Operation { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $locked->update(['security_state' => $state, 'security_state_changed_at' => now(), 'revision' => $locked->revision + 1]); + if ($pool !== null) { + DomainEdgePlacement::query()->updateOrCreate(['domain_id' => $locked->id], [ + 'target_pool_id' => $pool->id, 'desired_revision' => $locked->revision, + 'state' => 'deploying', 'drain_after' => null, 'last_error' => null, + ]); + } + SecurityEvent::query()->create([ + 'domain_id' => $locked->id, 'state' => $state, + 'reason_code' => $state === 'quarantined' ? 'domain_quarantined' : 'domain_restricted', + 'details' => ['reason' => $data['reason'] ?? null, 'actor_id' => $request->user()->id, 'target_pool_id' => $pool?->id], + 'occurred_at' => now(), + ]); + AuditLog::record($request->user(), "security.domain_$state", $locked, ['revision' => $locked->revision, 'target_pool_id' => $pool?->id], $request->ip()); + + return Operation::coalesceDomain('edge.domain_reconcile', $locked->id, $request->user()->id); + }); + ReconcileEdgeDomain::dispatch($domain->id)->afterCommit(); + + return response()->json(['data' => ['operation_id' => $operation->id, 'state' => $state, 'target_pool_id' => $pool?->id]], 202); + } + + private function emergency(Request $request, string $type, string $id, object $subject): JsonResponse + { + $data = $request->validate([ + 'actions' => ['required', 'array', 'min:1', 'max:11'], + 'actions.*' => ['required', 'string', 'distinct', Rule::in(config('security.emergency_actions'))], + 'duration_minutes' => ['nullable', 'integer', 'between:1,'.config('security.emergency_duration_minutes_maximum')], + ]); + [$mode, $operation] = DispatchEmergencyMode::activate($type, $id, $data['actions'], $data['duration_minutes'] ?? null, $request->user()); + AuditLog::record($request->user(), 'security.emergency_activated', $subject, ['mode_id' => $mode->id, 'actions' => $mode->actions, 'expires_at' => $mode->expires_at?->toIso8601String()], $request->ip()); + + return response()->json(['data' => ['operation_id' => $operation->id, 'emergency_mode_id' => $mode->id, 'expires_at' => $mode->expires_at]], 202); + } + + private function clearEmergency(Request $request, string $type, string $id, object $subject): JsonResponse + { + $operation = DispatchEmergencyMode::deactivateTarget($type, $id, $request->user()); + AuditLog::record($request->user(), 'security.emergency_deactivated', $subject, ['operation_id' => $operation->id], $request->ip()); + + return response()->json(['data' => ['operation_id' => $operation->id]], 202); + } +} diff --git a/core/app/Http/Controllers/EdgeAgentController.php b/core/app/Http/Controllers/EdgeAgentController.php index bf69a5f..559d242 100644 --- a/core/app/Http/Controllers/EdgeAgentController.php +++ b/core/app/Http/Controllers/EdgeAgentController.php @@ -13,9 +13,11 @@ use App\Models\EdgeArtifact; use App\Models\EdgeTask; use App\Models\Operation; +use App\Models\SecurityEvent; use App\Support\ArtifactSigner; use App\Support\EdgeCertificateAuthority; use App\Support\PlatformSettings; +use App\Support\SecurityConfig; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -91,6 +93,11 @@ public function heartbeat(Request $request): JsonResponse 'active_sequence' => ['required', 'integer', 'min:0'], 'cells' => ['required', 'array', 'max:32'], 'cells.*.name' => ['required', 'string', 'max:100', 'distinct'], 'cells.*.status' => ['required', 'in:ready,degraded,failed,drained'], 'cells.*.capacity' => ['required', 'array', 'max:20'], 'noisy_domains' => ['sometimes', 'array', 'max:20'], + 'noisy_domains.*.domain_id' => ['required', 'integer', 'exists:domains,id'], + 'noisy_domains.*.hostname' => ['nullable', 'string', 'max:253'], + 'noisy_domains.*.reason_code' => ['required', 'string', 'in:'.implode(',', SecurityConfig::REASON_CODES)], + 'noisy_domains.*.count' => ['required', 'integer', 'between:1,2147483647'], + 'noisy_domains.*.occurred_at' => ['required', 'integer', 'min:0'], 'passive_origins' => ['sometimes', 'array', 'max:100'], 'passive_origins.*.domain' => ['required', 'string', 'max:253'], 'passive_origins.*.hostname' => ['required', 'string', 'max:253'], @@ -137,6 +144,29 @@ public function heartbeat(Request $request): JsonResponse 'reported_at' => now()->toIso8601String(), ]]); } + foreach ($data['noisy_domains'] ?? [] as $event) { + $domain = Domain::query()->find($event['domain_id']); + if ($domain === null || ! $domain->dnsRecords()->where('mode', 'proxied')->where('name', $event['hostname'])->exists()) { + continue; + } + SecurityEvent::query()->create([ + 'domain_id' => $domain->id, 'edge_id' => $edge->id, 'hostname' => $event['hostname'], + 'state' => $domain->security_state, 'reason_code' => $event['reason_code'], + 'details' => ['count' => $event['count']], 'occurred_at' => CarbonImmutable::createFromTimestamp($event['occurred_at']), + ]); + $settings = $domain->security_settings ?? SecurityConfig::defaults(); + if ($event['count'] >= 50 && $domain->security_state === 'normal') { + $domain->update(['security_state' => 'suspected', 'security_state_changed_at' => now(), 'revision' => $domain->revision + 1]); + Operation::coalesceDomain('edge.domain_reconcile', $domain->id); + ReconcileEdgeDomain::dispatch($domain->id)->afterCommit(); + } + if ($event['count'] >= 100 && str_starts_with($settings['quarantine_policy'], 'automatic') + && in_array($domain->refresh()->security_state, ['normal', 'suspected'], true)) { + $domain->update(['security_state' => 'restricted', 'security_state_changed_at' => now(), 'revision' => $domain->revision + 1]); + Operation::coalesceDomain('edge.domain_reconcile', $domain->id); + ReconcileEdgeDomain::dispatch($domain->id)->afterCommit(); + } + } PromoteReadyEdgePlacements::execute(); $this->completeAcknowledgedTombstones(); @@ -292,6 +322,20 @@ public function taskResult(Request $request, string $task): JsonResponse $purge->update(['status' => $terminal ? ($tasks->contains('status', 'failed') ? 'failed' : 'succeeded') : 'running']); } } + if ($row->type === 'emergency_mode' && isset($row->payload['operation_id'])) { + $operation = Operation::query()->find($row->payload['operation_id']); + if ($operation !== null) { + $tasks = EdgeTask::query()->where('type', 'emergency_mode')->where('payload->operation_id', $operation->id)->get(); + $completed = $tasks->whereIn('status', ['succeeded', 'failed']); + $terminal = $tasks->isNotEmpty() && $completed->count() === $tasks->count(); + $operation->update([ + 'status' => $terminal ? ($tasks->contains('status', 'failed') ? 'failed' : 'succeeded') : 'running', + 'result' => ['tasks' => $tasks->count(), 'completed' => $completed->count()], + 'error' => $terminal && $tasks->contains('status', 'failed') ? 'One or more emergency-mode deliveries failed.' : null, + 'finished_at' => $terminal ? now() : null, + ]); + } + } return response()->json(['data' => ['accepted' => true]]); } diff --git a/core/app/Http/Controllers/SecurityController.php b/core/app/Http/Controllers/SecurityController.php new file mode 100644 index 0000000..9f16622 --- /dev/null +++ b/core/app/Http/Controllers/SecurityController.php @@ -0,0 +1,176 @@ +json(['data' => $this->settings($domain)]); + } + + public function update(Request $request, Domain $domain): JsonResponse + { + Gate::authorize('update', $domain); + $settings = SecurityConfig::validateSettings($request->all()); + DB::transaction(function () use ($domain, $request, $settings): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $locked->update(['security_settings' => $settings, 'revision' => $locked->revision + 1]); + AuditLog::record($request->user(), 'security.settings_updated', $locked, ['profile' => $settings['profile'], 'revision' => $locked->revision], $request->ip()); + }); + + return $this->queue($request, $domain); + } + + public function rules(Domain $domain): JsonResponse + { + Gate::authorize('view', $domain); + + return response()->json(['data' => $domain->securityRules()->orderBy('priority')->orderBy('id')->cursorPaginate(100)]); + } + + public function storeRule(Request $request, Domain $domain): JsonResponse + { + Gate::authorize('update', $domain); + $data = SecurityConfig::validateRule($request->all()); + abort_if($domain->securityRules()->count() >= config('security.maximum_rules_per_domain'), 409, 'The per-domain security-rule limit has been reached.'); + $rule = DB::transaction(function () use ($data, $domain, $request): SecurityRule { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $rule = $locked->securityRules()->create($data); + $locked->update(['revision' => $locked->revision + 1]); + AuditLog::record($request->user(), 'security.rule_created', $rule, ['revision' => $locked->revision], $request->ip()); + + return $rule; + }); + $this->dispatch($request, $domain); + + return response()->json(['data' => $rule], 201); + } + + public function updateRule(Request $request, Domain $domain, SecurityRule $rule): JsonResponse + { + $this->rule($domain, $rule); + Gate::authorize('update', $domain); + $data = SecurityConfig::validateRule($request->all()); + DB::transaction(function () use ($data, $domain, $request, $rule): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $rule->update($data); + $locked->update(['revision' => $locked->revision + 1]); + AuditLog::record($request->user(), 'security.rule_updated', $rule, ['revision' => $locked->revision], $request->ip()); + }); + + return $this->queue($request, $domain); + } + + public function destroyRule(Request $request, Domain $domain, SecurityRule $rule): JsonResponse + { + $this->rule($domain, $rule); + Gate::authorize('update', $domain); + DB::transaction(function () use ($domain, $request, $rule): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + $id = $rule->id; + $rule->delete(); + $locked->update(['revision' => $locked->revision + 1]); + AuditLog::record($request->user(), 'security.rule_deleted', $locked, ['rule_id' => $id, 'revision' => $locked->revision], $request->ip()); + }); + + return $this->queue($request, $domain); + } + + public function importRules(Request $request, Domain $domain): JsonResponse + { + Gate::authorize('update', $domain); + $data = $request->validate([ + 'rules' => ['required', 'array', 'min:1', 'max:'.config('security.maximum_import_rules')], + 'rules.*' => ['required', 'array'], 'replace_existing' => ['sometimes', 'boolean'], + ]); + $rules = collect($data['rules'])->map(fn (array $rule): array => SecurityConfig::validateRule($rule)); + $duplicates = $rules->map(fn (array $rule): string => implode('|', [$rule['match_type'], $rule['value'], $rule['action']]))->duplicates(); + if ($duplicates->isNotEmpty()) { + throw ValidationException::withMessages(['rules' => 'The import contains duplicate normalized rules.']); + } + $replace = (bool) ($data['replace_existing'] ?? false); + abort_if(! $replace && $domain->securityRules()->count() + $rules->count() > config('security.maximum_rules_per_domain'), 409, 'The import would exceed the per-domain security-rule limit.'); + DB::transaction(function () use ($domain, $replace, $request, $rules): void { + $locked = Domain::query()->lockForUpdate()->findOrFail($domain->id); + if ($replace) { + $locked->securityRules()->delete(); + } + $now = now(); + $locked->securityRules()->insert($rules->map(fn (array $rule): array => [...$rule, 'domain_id' => $locked->id, 'created_at' => $now, 'updated_at' => $now])->all()); + $locked->update(['revision' => $locked->revision + 1]); + AuditLog::record($request->user(), 'security.rules_imported', $locked, ['count' => $rules->count(), 'replace_existing' => $replace, 'revision' => $locked->revision], $request->ip()); + }); + + return $this->queue($request, $domain); + } + + public function ddos(Domain $domain): JsonResponse + { + return $this->show($domain); + } + + public function updateDdos(Request $request, Domain $domain): JsonResponse + { + return $this->update($request, $domain); + } + + public function status(Domain $domain): JsonResponse + { + Gate::authorize('view', $domain); + + return response()->json(['data' => [ + 'profile' => $this->settings($domain)['profile'], 'state' => $domain->security_state, + 'state_changed_at' => $domain->security_state_changed_at, + 'placement' => $domain->edgePlacement?->load(['activePool', 'targetPool']), + ]]); + } + + public function events(Domain $domain): JsonResponse + { + Gate::authorize('view', $domain); + + return response()->json(['data' => $domain->securityEvents()->latest('occurred_at')->latest('id')->cursorPaginate(100)]); + } + + private function settings(Domain $domain): array + { + $settings = is_array($domain->security_settings) ? $domain->security_settings : SecurityConfig::defaults(); + + return [...$settings, 'platform_ceilings' => SecurityConfig::ceilingLimits($settings['profile']), 'maximum_rules' => config('security.maximum_rules_per_domain')]; + } + + private function queue(Request $request, Domain $domain): JsonResponse + { + $operation = $this->dispatch($request, $domain); + + return response()->json(['data' => ['operation_id' => $operation->id, 'status' => $operation->status]], 202); + } + + private function dispatch(Request $request, Domain $domain): Operation + { + $operation = Operation::coalesceDomain('edge.domain_reconcile', $domain->id, $request->user()->id); + ReconcileEdgeDomain::dispatch($domain->id)->afterCommit(); + + return $operation; + } + + private function rule(Domain $domain, SecurityRule $rule): void + { + abort_unless($rule->domain_id === $domain->id, 404); + } +} diff --git a/core/app/Jobs/ReconcileEdgeDomain.php b/core/app/Jobs/ReconcileEdgeDomain.php index d344912..80e82f8 100644 --- a/core/app/Jobs/ReconcileEdgeDomain.php +++ b/core/app/Jobs/ReconcileEdgeDomain.php @@ -16,6 +16,7 @@ use App\Support\ArtifactSigner; use App\Support\ManagedCertificateNames; use App\Support\PlatformSettings; +use App\Support\SecurityConfig; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -121,6 +122,7 @@ public function handle(): void 'epoch' => $domain->cache_epoch, 'development_mode_until' => $domain->cache_development_mode_until?->isFuture() ? $domain->cache_development_mode_until->timestamp : null, ], + 'security' => SecurityConfig::compile($domain), 'tls' => [ 'mode' => $domain->tls_mode, 'certificate' => $domain->activeTlsCertificate !== null && $domain->activeTlsCertificate->expires_at->isFuture() diff --git a/core/app/Models/Domain.php b/core/app/Models/Domain.php index 45780f2..cfcea93 100644 --- a/core/app/Models/Domain.php +++ b/core/app/Models/Domain.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; -#[Fillable(['name', 'display_name', 'lifecycle_state', 'revision', 'nameservers_verified_at', 'nameservers_verified_by', 'disabled_at', 'deprovision_after', 'proxy_settings', 'active_edge_revision', 'cache_settings', 'cache_epoch', 'cache_development_mode_until', 'tls_mode', 'active_tls_certificate_id'])] +#[Fillable(['name', 'display_name', 'lifecycle_state', 'revision', 'nameservers_verified_at', 'nameservers_verified_by', 'disabled_at', 'deprovision_after', 'proxy_settings', 'active_edge_revision', 'cache_settings', 'cache_epoch', 'cache_development_mode_until', 'tls_mode', 'active_tls_certificate_id', 'security_settings', 'security_state', 'security_state_changed_at'])] class Domain extends Model { use SoftDeletes; @@ -51,6 +51,16 @@ public function latestTlsOrder(): HasOne return $this->hasOne(TlsOrder::class)->orderByDesc('created_at')->orderByDesc('id'); } + public function securityRules(): HasMany + { + return $this->hasMany(SecurityRule::class); + } + + public function securityEvents(): HasMany + { + return $this->hasMany(SecurityEvent::class); + } + public function activeTlsCertificate(): BelongsTo { return $this->belongsTo(TlsCertificate::class, 'active_tls_certificate_id'); @@ -66,6 +76,8 @@ protected function casts(): array 'proxy_settings' => 'array', 'cache_settings' => 'array', 'cache_development_mode_until' => 'immutable_datetime', + 'security_settings' => 'array', + 'security_state_changed_at' => 'immutable_datetime', 'active_edge_revision' => 'integer', 'revision_changed_at' => 'immutable_datetime', ]; diff --git a/core/app/Models/EdgePool.php b/core/app/Models/EdgePool.php index 697a31b..68c2840 100644 --- a/core/app/Models/EdgePool.php +++ b/core/app/Models/EdgePool.php @@ -16,6 +16,6 @@ public function cells(): HasMany protected function casts(): array { - return ['enabled' => 'boolean']; + return ['enabled' => 'boolean', 'withdrawn' => 'boolean']; } } diff --git a/core/app/Models/EmergencyMode.php b/core/app/Models/EmergencyMode.php new file mode 100644 index 0000000..551ed6d --- /dev/null +++ b/core/app/Models/EmergencyMode.php @@ -0,0 +1,22 @@ + 'array', 'active' => 'boolean', 'expires_at' => 'immutable_datetime', 'deactivated_at' => 'immutable_datetime']; + } +} diff --git a/core/app/Models/SecurityEvent.php b/core/app/Models/SecurityEvent.php new file mode 100644 index 0000000..e8f50ac --- /dev/null +++ b/core/app/Models/SecurityEvent.php @@ -0,0 +1,21 @@ + 'array', 'occurred_at' => 'immutable_datetime']; + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } +} diff --git a/core/app/Models/SecurityRule.php b/core/app/Models/SecurityRule.php new file mode 100644 index 0000000..a942e32 --- /dev/null +++ b/core/app/Models/SecurityRule.php @@ -0,0 +1,21 @@ + 'boolean']; + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } +} diff --git a/core/app/Support/PlatformDnsZone.php b/core/app/Support/PlatformDnsZone.php index 01bd6d9..4ec6ad4 100644 --- a/core/app/Support/PlatformDnsZone.php +++ b/core/app/Support/PlatformDnsZone.php @@ -35,8 +35,8 @@ public static function render(PlatformDnsSetting $settings): array } $proxy = rtrim($settings->proxy_hostname, '.').'.'; - $defaultSharedPool = EdgePool::query()->where('enabled', true)->where('kind', 'shared')->orderBy('id')->first(); - foreach (EdgePool::query()->where('enabled', true)->orderBy('id')->get() as $pool) { + $defaultSharedPool = EdgePool::query()->where('enabled', true)->where('withdrawn', false)->where('kind', 'shared')->orderBy('id')->first(); + foreach (EdgePool::query()->where('enabled', true)->where('withdrawn', false)->orderBy('id')->get() as $pool) { $cells = EdgeCell::query()->with('edge')->where('edge_pool_id', $pool->id) ->where('drained', false)->where('status', 'ready') ->whereHas('edge', fn ($query) => $query->readyForTraffic()) diff --git a/core/app/Support/ProxyRevisionRollback.php b/core/app/Support/ProxyRevisionRollback.php index 71635fb..2979d55 100644 --- a/core/app/Support/ProxyRevisionRollback.php +++ b/core/app/Support/ProxyRevisionRollback.php @@ -6,6 +6,7 @@ use App\Models\DnsRecord; use App\Models\Domain; use App\Models\EdgeRevision; +use App\Models\SecurityRule; use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; @@ -44,8 +45,24 @@ public static function apply(Domain $domain, EdgeRevision $prior, User $actor, ? if ($developmentUntil?->isPast()) { $developmentUntil = null; } + $security = is_array($snapshot['security'] ?? null) ? $snapshot['security'] : null; + $securitySettings = $security === null ? null : [ + 'profile' => $security['profile'], 'quarantine_policy' => $security['quarantine_policy'], + 'allowed_methods' => $security['allowed_methods'], 'trusted_proxy_cidrs' => $security['trusted_proxy_cidrs'], + 'limits' => $security['configured_limits'] ?? $security['limits'], + ]; + if ($security !== null) { + $locked->securityRules()->delete(); + $now = now(); + SecurityRule::query()->insert(collect($security['rules'] ?? [])->map(fn (array $rule): array => [ + 'domain_id' => $locked->id, 'match_type' => $rule['match_type'], 'value' => $rule['value'], + 'action' => $rule['action'], 'priority' => $rule['priority'], 'enabled' => true, + 'note' => null, 'created_at' => $now, 'updated_at' => $now, + ])->all()); + } $locked->update([ 'proxy_settings' => $settings, 'cache_settings' => $cacheSettings, + 'security_settings' => $securitySettings, 'cache_epoch' => $locked->cache_epoch + 1, 'cache_development_mode_until' => $developmentUntil, 'revision' => $locked->revision + 1, ]); diff --git a/core/app/Support/SecurityConfig.php b/core/app/Support/SecurityConfig.php new file mode 100644 index 0000000..680421e --- /dev/null +++ b/core/app/Support/SecurityConfig.php @@ -0,0 +1,188 @@ + $profile, + 'quarantine_policy' => 'manual', + 'allowed_methods' => config('security.allowed_methods'), + 'trusted_proxy_cidrs' => [], + 'limits' => self::ceilingLimits($profile), + ]; + } + + public static function profileOptions(): array + { + return [ + 'standard' => 'Standard — balanced recommended defaults', + 'protected' => 'Protected — stricter recommended defaults', + 'quarantine' => 'Quarantine — strictest recommended defaults', + self::MANUAL_PROFILE => 'Manual — custom limits', + ]; + } + + public static function profileDescription(string $profile): string + { + return match ($profile) { + 'protected' => 'Recommended for traffic under elevated risk. Its displayed limits are fixed.', + 'quarantine' => 'Recommended for isolating high-risk traffic. Its displayed limits are fixed.', + self::MANUAL_PROFILE => 'Edit one custom set of limits. Values cannot exceed the platform safety ceilings.', + default => 'Recommended for normal traffic. Its displayed limits are fixed.', + }; + } + + public static function ceilingLimits(string $profile): array + { + if ($profile === self::MANUAL_PROFILE) { + return collect(config('security.profiles')) + ->reduce(function (array $ceilings, array $limits): array { + foreach ($limits as $field => $value) { + $ceilings[$field] = max($ceilings[$field] ?? $value, $value); + } + + return $ceilings; + }, []); + } + + $limits = config("security.profiles.$profile"); + + return is_array($limits) ? $limits : config('security.profiles.standard'); + } + + public static function validateSettings(array $input): array + { + $profiles = array_keys(self::profileOptions()); + $profile = (string) ($input['profile'] ?? 'standard'); + if (! in_array($profile, $profiles, true)) { + throw ValidationException::withMessages(['profile' => 'The selected security profile is invalid.']); + } + $ceilings = self::ceilingLimits($profile); + $integerRules = []; + foreach ($ceilings as $field => $maximum) { + $minimum = in_array($field, ['origin_retry_limit'], true) ? 0 : 1; + $integerRules["limits.$field"] = ['required', 'integer', "between:$minimum,$maximum"]; + if ($profile !== self::MANUAL_PROFILE) { + $integerRules["limits.$field"][] = Rule::in([$maximum]); + } + } + $data = Validator::make($input, [ + 'profile' => ['required', Rule::in($profiles)], + 'quarantine_policy' => ['required', Rule::in(['manual', 'automatic', 'automatic_with_admin_notification'])], + 'allowed_methods' => ['required', 'array', 'min:1', 'max:7'], + 'allowed_methods.*' => ['required', 'string', 'distinct', Rule::in(config('security.allowed_methods'))], + 'trusted_proxy_cidrs' => ['present', 'array', 'max:'.config('security.trusted_proxy_cidrs_maximum')], + 'trusted_proxy_cidrs.*' => ['required', 'string', 'max:64'], + 'limits' => ['required', 'array', 'size:'.count($ceilings)], + ...$integerRules, + ])->validate(); + $data['allowed_methods'] = array_values(array_unique(array_map('strtoupper', $data['allowed_methods']))); + $data['trusted_proxy_cidrs'] = array_map(fn (string $cidr): string => self::validateCidr($cidr), $data['trusted_proxy_cidrs']); + $data['limits'] = array_map('intval', $data['limits']); + + return $data; + } + + public static function compile(Domain $domain): array + { + $configured = is_array($domain->security_settings) ? $domain->security_settings : self::defaults(); + $enforcedProfile = match ($domain->security_state) { + 'quarantined' => 'quarantine', + 'restricted', 'suspected' => 'protected', + default => $configured['profile'], + }; + $enforced = self::ceilingLimits($enforcedProfile); + $limits = []; + foreach ($enforced as $name => $maximum) { + $limits[$name] = min((int) ($configured['limits'][$name] ?? $maximum), (int) $maximum); + } + + return [ + 'profile' => $configured['profile'], 'effective_profile' => $enforcedProfile, + 'state' => $domain->security_state, 'quarantine_policy' => $configured['quarantine_policy'], + 'allowed_methods' => array_values($configured['allowed_methods']), + 'trusted_proxy_cidrs' => array_values($configured['trusted_proxy_cidrs']), + 'configured_limits' => $configured['limits'], + 'limits' => $limits, + 'rules' => $domain->securityRules()->where('enabled', true)->orderBy('priority')->orderBy('id') + ->get(['id', 'match_type', 'value', 'action', 'priority'])->toArray(), + ]; + } + + public static function validateRule(array $input): array + { + $data = Validator::make($input, [ + 'match_type' => ['required', Rule::in(['ip', 'cidr', 'country', 'continent'])], + 'value' => ['required', 'string', 'max:128'], + 'action' => ['required', Rule::in(['allow', 'block'])], + 'priority' => ['required', 'integer', 'between:-1000000,1000000'], + 'enabled' => ['sometimes', 'boolean'], + 'note' => ['nullable', 'string', 'max:250'], + ])->validate(); + $data['enabled'] = (bool) ($data['enabled'] ?? true); + $data['value'] = match ($data['match_type']) { + 'ip' => self::validateIp($data['value']), + 'cidr' => self::validateCidr($data['value']), + 'country' => self::validateGeo($data['value'], GeoVocabulary::countries(), 'country'), + 'continent' => self::validateGeo($data['value'], GeoVocabulary::CONTINENTS, 'continent'), + }; + + return $data; + } + + private static function validateIp(string $value): string + { + $packed = @inet_pton(trim($value)); + if ($packed === false) { + throw ValidationException::withMessages(['value' => 'The value must be a valid IPv4 or IPv6 address.']); + } + + return inet_ntop($packed); + } + + private static function validateCidr(string $value): string + { + if (! preg_match('/^(.+)\/(\d{1,3})$/', trim($value), $matches)) { + throw ValidationException::withMessages(['value' => 'The value must be a valid IPv4 or IPv6 CIDR.']); + } + $packed = @inet_pton(trim($matches[1])); + $address = $packed === false ? false : inet_ntop($packed); + $maximum = str_contains($matches[1], ':') ? 128 : 32; + $prefix = (int) $matches[2]; + if ($address === false || $prefix < 0 || $prefix > $maximum) { + throw ValidationException::withMessages(['value' => 'The value must be a valid IPv4 or IPv6 CIDR.']); + } + + return "$address/$prefix"; + } + + private static function validateGeo(string $value, array $allowed, string $label): string + { + $value = strtoupper(trim($value)); + if (! in_array($value, $allowed, true)) { + throw ValidationException::withMessages(['value' => "The value must be a supported $label code."]); + } + + return $value; + } +} diff --git a/core/config/security.php b/core/config/security.php new file mode 100644 index 0000000..9fe4d6d --- /dev/null +++ b/core/config/security.php @@ -0,0 +1,55 @@ + 1000, + 'maximum_import_rules' => 500, + 'trusted_proxy_cidrs_maximum' => 32, + 'emergency_duration_minutes_maximum' => 1440, + 'allowed_methods' => ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + 'emergency_actions' => [ + 'reject_unknown_hosts', 'disable_request_bodies', 'allow_get_head_only', 'reduce_keepalive', + 'reduce_origin_concurrency', 'disable_origin_retries', 'serve_cache_only', 'serve_stale_only', + 'return_maintenance_response', 'quarantine_domain', 'withdraw_service_ip_from_dns', + ], + 'profiles' => [ + 'standard' => [ + 'requests_per_second' => 100, 'request_burst' => 200, + 'connections_per_client' => 64, 'connections_per_domain' => 512, + 'tls_handshakes_per_second' => 50, 'maximum_request_body_size' => 16777216, + 'maximum_header_size' => 32768, 'client_header_timeout' => 10, + 'client_body_timeout' => 30, 'keepalive_timeout' => 30, + 'maximum_requests_per_connection' => 1000, 'maximum_request_duration' => 60, + 'origin_max_connections' => 128, 'origin_connect_timeout' => 3, + 'origin_read_timeout' => 30, 'origin_send_timeout' => 30, + 'origin_retry_limit' => 2, 'origin_failure_threshold' => 10, + 'origin_recovery_timeout' => 30, 'maximum_cache_key_length' => 4096, + 'cache_admissions_per_second' => 50, + ], + 'protected' => [ + 'requests_per_second' => 50, 'request_burst' => 75, + 'connections_per_client' => 24, 'connections_per_domain' => 256, + 'tls_handshakes_per_second' => 20, 'maximum_request_body_size' => 8388608, + 'maximum_header_size' => 16384, 'client_header_timeout' => 7, + 'client_body_timeout' => 15, 'keepalive_timeout' => 15, + 'maximum_requests_per_connection' => 250, 'maximum_request_duration' => 30, + 'origin_max_connections' => 64, 'origin_connect_timeout' => 2, + 'origin_read_timeout' => 15, 'origin_send_timeout' => 15, + 'origin_retry_limit' => 1, 'origin_failure_threshold' => 5, + 'origin_recovery_timeout' => 60, 'maximum_cache_key_length' => 2048, + 'cache_admissions_per_second' => 20, + ], + 'quarantine' => [ + 'requests_per_second' => 10, 'request_burst' => 10, + 'connections_per_client' => 4, 'connections_per_domain' => 48, + 'tls_handshakes_per_second' => 5, 'maximum_request_body_size' => 1048576, + 'maximum_header_size' => 8192, 'client_header_timeout' => 5, + 'client_body_timeout' => 10, 'keepalive_timeout' => 5, + 'maximum_requests_per_connection' => 50, 'maximum_request_duration' => 15, + 'origin_max_connections' => 16, 'origin_connect_timeout' => 1, + 'origin_read_timeout' => 10, 'origin_send_timeout' => 10, + 'origin_retry_limit' => 0, 'origin_failure_threshold' => 3, + 'origin_recovery_timeout' => 120, 'maximum_cache_key_length' => 1024, + 'cache_admissions_per_second' => 5, + ], + ], +]; diff --git a/core/database/migrations/2026_07_20_000100_create_security_readiness_state.php b/core/database/migrations/2026_07_20_000100_create_security_readiness_state.php new file mode 100644 index 0000000..b62040c --- /dev/null +++ b/core/database/migrations/2026_07_20_000100_create_security_readiness_state.php @@ -0,0 +1,78 @@ +jsonb('security_settings')->nullable(); + $table->string('security_state', 20)->default('normal'); + $table->timestampTz('security_state_changed_at')->nullable(); + }); + Schema::create('security_rules', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->foreignId('domain_id')->constrained()->cascadeOnDelete(); + $table->string('match_type', 16); + $table->string('value', 128); + $table->string('action', 8); + $table->integer('priority'); + $table->boolean('enabled')->default(true); + $table->string('note', 250)->nullable(); + $table->timestampsTz(); + $table->unique(['domain_id', 'match_type', 'value', 'action']); + $table->index(['domain_id', 'enabled', 'priority', 'id']); + }); + Schema::create('security_events', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->foreignId('domain_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('edge_id')->nullable()->constrained()->nullOnDelete(); + $table->string('hostname', 253)->nullable(); + $table->string('state', 20)->nullable(); + $table->string('reason_code', 48); + $table->jsonb('details')->nullable(); + $table->timestampTz('occurred_at'); + $table->timestampsTz(); + $table->index(['domain_id', 'occurred_at', 'id']); + }); + Schema::create('emergency_modes', function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('target_type', 16); + $table->string('target_id', 64); + $table->jsonb('actions'); + $table->unsignedBigInteger('revision')->default(1); + $table->boolean('active')->default(true); + $table->timestampTz('expires_at')->nullable(); + $table->timestampTz('deactivated_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestampsTz(); + $table->index(['target_type', 'target_id', 'active']); + $table->index(['active', 'expires_at']); + }); + Schema::table('edge_pools', function (Blueprint $table): void { + $table->boolean('withdrawn')->default(false); + }); + + if (DB::getDriverName() === 'pgsql') { + DB::statement("ALTER TABLE domains ADD CONSTRAINT domains_security_state_check CHECK (security_state IN ('normal','suspected','restricted','quarantined','recovering'))"); + DB::statement("ALTER TABLE domains ADD CONSTRAINT domains_security_settings_object CHECK (security_settings IS NULL OR jsonb_typeof(security_settings) = 'object')"); + DB::statement("ALTER TABLE security_rules ADD CONSTRAINT security_rules_match_type_check CHECK (match_type IN ('ip','cidr','country','continent'))"); + DB::statement("ALTER TABLE security_rules ADD CONSTRAINT security_rules_action_check CHECK (action IN ('allow','block'))"); + DB::statement("ALTER TABLE emergency_modes ADD CONSTRAINT emergency_modes_target_type_check CHECK (target_type IN ('edge','cell','pool'))"); + DB::statement("ALTER TABLE emergency_modes ADD CONSTRAINT emergency_modes_actions_array CHECK (jsonb_typeof(actions) = 'array')"); + } + } + + public function down(): void + { + Schema::table('edge_pools', fn (Blueprint $table) => $table->dropColumn('withdrawn')); + Schema::dropIfExists('emergency_modes'); + Schema::dropIfExists('security_events'); + Schema::dropIfExists('security_rules'); + Schema::table('domains', fn (Blueprint $table) => $table->dropColumn(['security_settings', 'security_state', 'security_state_changed_at'])); + } +}; diff --git a/core/routes/api.php b/core/routes/api.php index b1a0c8f..a32fa48 100644 --- a/core/routes/api.php +++ b/core/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\EdgeOperationsController; use App\Http\Controllers\Admin\EdgePoolController; use App\Http\Controllers\Admin\PlatformDnsSettingsController; +use App\Http\Controllers\Admin\SecurityOperationsController; use App\Http\Controllers\Admin\SystemSettingsController; use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\AuthController; @@ -23,6 +24,7 @@ use App\Http\Controllers\NameserverController; use App\Http\Controllers\OperationController; use App\Http\Controllers\ProxyController; +use App\Http\Controllers\SecurityController; use App\Http\Controllers\TlsController; use App\Http\Controllers\TokenController; use Illuminate\Support\Facades\Route; @@ -87,6 +89,18 @@ Route::post('/domains/{domain}/tls/renew', [TlsController::class, 'renew'])->middleware(['idempotent', 'throttle:bulk']); Route::post('/domains/{domain}/tls/upload', [TlsController::class, 'upload'])->middleware('idempotent'); Route::delete('/domains/{domain}/tls/custom-certificate', [TlsController::class, 'destroyCustom'])->middleware('idempotent'); + Route::get('/domains/{domain}/security', [SecurityController::class, 'show']); + Route::patch('/domains/{domain}/security', [SecurityController::class, 'update'])->middleware('idempotent'); + Route::get('/domains/{domain}/security/rules', [SecurityController::class, 'rules']); + Route::post('/domains/{domain}/security/rules', [SecurityController::class, 'storeRule'])->middleware('idempotent'); + Route::patch('/domains/{domain}/security/rules/{rule}', [SecurityController::class, 'updateRule'])->middleware('idempotent'); + Route::delete('/domains/{domain}/security/rules/{rule}', [SecurityController::class, 'destroyRule'])->middleware('idempotent'); + Route::post('/domains/{domain}/security/rules/import', [SecurityController::class, 'importRules'])->middleware(['idempotent', 'throttle:bulk']); + Route::get('/domains/{domain}/security/ddos', [SecurityController::class, 'ddos']); + Route::patch('/domains/{domain}/security/ddos', [SecurityController::class, 'updateDdos'])->middleware('idempotent'); + Route::get('/domains/{domain}/security/ddos/status', [SecurityController::class, 'status']); + Route::get('/domains/{domain}/security/ddos/events', [SecurityController::class, 'events']); + Route::get('/domains/{domain}/security/events', [SecurityController::class, 'events']); Route::prefix('admin')->middleware('admin')->group(function (): void { Route::get('/users', [UserController::class, 'index']); @@ -151,5 +165,14 @@ Route::get('/domains/{domain}/isolation', [EdgeOperationsController::class, 'isolation']); Route::patch('/domains/{domain}/isolation', [EdgeOperationsController::class, 'move'])->middleware('idempotent'); Route::post('/domains/{domain}/move', [EdgeOperationsController::class, 'move'])->middleware('idempotent'); + Route::post('/domains/{domain}/restrict', [SecurityOperationsController::class, 'restrict'])->middleware('idempotent'); + Route::post('/domains/{domain}/quarantine', [SecurityOperationsController::class, 'quarantine'])->middleware('idempotent'); + Route::post('/domains/{domain}/release', [SecurityOperationsController::class, 'release'])->middleware('idempotent'); + Route::post('/edges/{edge}/emergency-mode', [SecurityOperationsController::class, 'edgeEmergency'])->middleware('idempotent'); + Route::delete('/edges/{edge}/emergency-mode', [SecurityOperationsController::class, 'clearEdgeEmergency'])->middleware('idempotent'); + Route::post('/edge-cells/{cell}/emergency-mode', [SecurityOperationsController::class, 'cellEmergency'])->middleware('idempotent'); + Route::delete('/edge-cells/{cell}/emergency-mode', [SecurityOperationsController::class, 'clearCellEmergency'])->middleware('idempotent'); + Route::post('/edge-pools/{pool}/withdraw', [SecurityOperationsController::class, 'withdraw'])->middleware('idempotent'); + Route::post('/edge-pools/{pool}/restore', [SecurityOperationsController::class, 'restore'])->middleware('idempotent'); }); }); diff --git a/core/routes/console.php b/core/routes/console.php index 6f6c48a..8add6e2 100644 --- a/core/routes/console.php +++ b/core/routes/console.php @@ -19,3 +19,4 @@ Schedule::command('edge:prune-revisions')->dailyAt('02:30')->withoutOverlapping(); Schedule::job(new ReconcilePlatformDnsIdentity)->everyMinute()->withoutOverlapping(); Schedule::command('tls:dispatch-maintenance')->hourly()->withoutOverlapping(); +Schedule::command('security:reconcile-readiness')->everyMinute()->withoutOverlapping(); diff --git a/core/tests/Feature/FilamentWorkflowTest.php b/core/tests/Feature/FilamentWorkflowTest.php index 35bb8ff..5077655 100644 --- a/core/tests/Feature/FilamentWorkflowTest.php +++ b/core/tests/Feature/FilamentWorkflowTest.php @@ -181,6 +181,29 @@ public function test_cache_actions_save_visible_bounded_state_and_queue_purges() $this->assertDatabaseHas('cache_purges', ['domain_id' => $domain->id, 'type' => 'urls', 'status' => 'succeeded']); } + public function test_security_profile_action_reacts_to_presets_and_refreshes_the_saved_profile(): void + { + Queue::fake(); + $admin = User::factory()->admin()->create(); + $domain = Domain::query()->create(['name' => 'security-ui.example.test', 'display_name' => 'Security UI', 'revision' => 1]); + Filament::setCurrentPanel(Filament::getPanel('admin')); + $this->actingAs($admin); + + Livewire::test(ViewDomain::class, ['record' => $domain->id]) + ->mountAction('securitySettings') + ->assertSet('mountedActions.0.data.profile', 'standard') + ->set('mountedActions.0.data.profile', 'protected') + ->assertSet('mountedActions.0.data.limits.requests_per_second', 50) + ->assertSet('mountedActions.0.data.limits.origin_retry_limit', 1) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertSee('protected'); + + $this->assertSame('protected', $domain->refresh()->security_settings['profile']); + $this->assertSame(2, $domain->revision); + Queue::assertPushed(ReconcileEdgeDomain::class, fn (ReconcileEdgeDomain $job): bool => $job->domainId === $domain->id); + } + public function test_disabling_from_the_domain_panel_automatically_queues_edge_reconciliation(): void { Queue::fake(); diff --git a/core/tests/Feature/SecurityApiTest.php b/core/tests/Feature/SecurityApiTest.php new file mode 100644 index 0000000..8d35aa1 --- /dev/null +++ b/core/tests/Feature/SecurityApiTest.php @@ -0,0 +1,220 @@ +ownedDomain(); + $stranger = User::factory()->create(); + $this->actingAs($stranger)->getJson("/api/domains/{$domain->id}/security")->assertForbidden(); + $this->actingAs($user)->getJson("/api/domains/{$domain->id}/security") + ->assertOk()->assertJsonPath('data.profile', 'standard')->assertJsonPath('data.platform_ceilings.requests_per_second', 100); + + $invalid = $this->settings('protected'); + $invalid['limits']['requests_per_second'] = 51; + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $invalid) + ->assertUnprocessable()->assertJsonValidationErrors('limits.requests_per_second'); + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $this->settings('protected')) + ->assertAccepted(); + + $this->assertSame(2, $domain->refresh()->revision); + $this->assertSame('protected', $domain->security_settings['profile']); + Queue::assertPushed(ReconcileEdgeDomain::class, fn ($job): bool => $job->domainId === $domain->id); + $this->assertDatabaseHas('audit_logs', ['action' => 'security.settings_updated', 'subject_id' => (string) $domain->id]); + } + + public function test_recommended_profiles_are_fixed_and_the_single_manual_profile_is_bounded(): void + { + Queue::fake(); + [$user, $domain] = $this->ownedDomain(); + + foreach (['standard', 'protected', 'quarantine'] as $profile) { + $settings = $this->settings($profile); + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $settings)->assertAccepted(); + $this->assertSame(config("security.profiles.$profile"), $domain->refresh()->security_settings['limits']); + + $changed = $settings; + $changed['limits']['requests_per_second']--; + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $changed) + ->assertUnprocessable()->assertJsonValidationErrors('limits.requests_per_second'); + } + + $manual = $this->settings(SecurityConfig::MANUAL_PROFILE); + $manual['limits']['requests_per_second'] = 37; + $manual['limits']['request_burst'] = 61; + $manual['limits']['origin_recovery_timeout'] = 120; + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $manual)->assertAccepted(); + $this->assertSame('manual', $domain->refresh()->security_settings['profile']); + $this->assertSame(37, $domain->security_settings['limits']['requests_per_second']); + $this->assertSame(120, $domain->security_settings['limits']['origin_recovery_timeout']); + + $manual['limits']['requests_per_second'] = config('security.profiles.standard.requests_per_second') + 1; + $this->actingAs($user)->patchJson("/api/domains/{$domain->id}/security", $manual) + ->assertUnprocessable()->assertJsonValidationErrors('limits.requests_per_second'); + } + + public function test_manual_limits_compile_under_stricter_operational_state_ceilings(): void + { + [, $domain] = $this->ownedDomain(); + $manual = SecurityConfig::defaults(SecurityConfig::MANUAL_PROFILE); + $manual['limits']['requests_per_second'] = 80; + $manual['limits']['origin_max_connections'] = 100; + $domain->update(['security_settings' => $manual, 'security_state' => 'restricted']); + + $compiled = SecurityConfig::compile($domain->refresh()); + + $this->assertSame('manual', $compiled['profile']); + $this->assertSame('protected', $compiled['effective_profile']); + $this->assertSame(50, $compiled['limits']['requests_per_second']); + $this->assertSame(64, $compiled['limits']['origin_max_connections']); + } + + public function test_ipv4_ipv6_cidr_and_geo_rules_validate_and_order_deterministically(): void + { + [$user, $domain] = $this->ownedDomain(); + $valid = [ + ['match_type' => 'ip', 'value' => '192.0.2.1', 'action' => 'block', 'priority' => 20], + ['match_type' => 'ip', 'value' => '2001:db8::1', 'action' => 'allow', 'priority' => 10], + ['match_type' => 'cidr', 'value' => '2001:db8::/32', 'action' => 'block', 'priority' => 10], + ['match_type' => 'country', 'value' => 'ir', 'action' => 'block', 'priority' => 30], + ['match_type' => 'continent', 'value' => 'AS', 'action' => 'allow', 'priority' => 40], + ]; + foreach ($valid as $rule) { + $this->actingAs($user)->postJson("/api/domains/{$domain->id}/security/rules", $rule)->assertCreated(); + } + $this->actingAs($user)->postJson("/api/domains/{$domain->id}/security/rules", [ + 'match_type' => 'cidr', 'value' => '2001:db8::/129', 'action' => 'block', 'priority' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('value'); + $this->actingAs($user)->postJson("/api/domains/{$domain->id}/security/rules", [ + 'match_type' => 'country', 'value' => 'XX', 'action' => 'block', 'priority' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('value'); + + (new ReconcileEdgeDomain($domain->id))->handle(); + $snapshot = EdgeRevision::query()->where('domain_id', $domain->id)->latest('revision')->firstOrFail()->snapshot; + $this->assertSame([10, 10, 20, 30, 40], collect($snapshot['security']['rules'])->pluck('priority')->all()); + $samePriority = collect($snapshot['security']['rules'])->where('priority', 10)->pluck('id')->all(); + $this->assertSame($samePriority, collect($samePriority)->sort()->values()->all()); + $this->assertSame('IR', collect($snapshot['security']['rules'])->firstWhere('match_type', 'country')['value']); + } + + public function test_bounded_import_creates_one_revision_and_rolls_back_through_edge_history(): void + { + [$user, $domain] = $this->ownedDomain(); + $before = $domain->revision; + $rules = collect(range(1, 100))->map(fn (int $index): array => [ + 'match_type' => 'ip', 'value' => "192.0.2.$index", 'action' => 'block', 'priority' => $index, + ])->all(); + $this->actingAs($user)->postJson("/api/domains/{$domain->id}/security/rules/import", ['replace_existing' => true, 'rules' => $rules])->assertAccepted(); + $this->assertSame($before + 1, $domain->refresh()->revision); + $this->assertSame(100, $domain->securityRules()->count()); + (new ReconcileEdgeDomain($domain->id))->handle(); + $revision = $domain->refresh()->revision; + + $this->actingAs($user)->deleteJson("/api/domains/{$domain->id}/security/rules/".$domain->securityRules()->firstOrFail()->id)->assertAccepted(); + $this->assertSame(99, $domain->securityRules()->count()); + $this->actingAs($user)->postJson("/api/domains/{$domain->id}/rollback", ['revision' => $revision])->assertAccepted(); + $this->assertSame(100, $domain->securityRules()->count()); + } + + public function test_admin_restriction_quarantine_release_and_failed_target_preserve_active_placement(): void + { + Queue::fake(); + [, $domain] = $this->ownedDomain(true); + $admin = User::factory()->admin()->create(); + $shared = EdgePool::query()->where('kind', 'shared')->firstOrFail(); + $quarantine = EdgePool::query()->where('kind', 'quarantine')->firstOrFail(); + DomainEdgePlacement::query()->create(['domain_id' => $domain->id, 'active_pool_id' => $shared->id, 'desired_revision' => $domain->revision, 'state' => 'active']); + + $this->actingAs($admin)->postJson("/api/admin/domains/{$domain->id}/restrict", ['reason' => 'request spike']) + ->assertAccepted()->assertJsonPath('data.state', 'restricted'); + $this->actingAs($admin)->postJson("/api/admin/domains/{$domain->id}/quarantine", ['reason' => 'continued spike']) + ->assertAccepted()->assertJsonPath('data.target_pool_id', $quarantine->id); + $placement = $domain->edgePlacement()->firstOrFail(); + $this->assertSame($shared->id, $placement->active_pool_id); + $this->assertSame($quarantine->id, $placement->target_pool_id); + $this->assertSame('deploying', $placement->state); + $placement->update(['state' => 'failed', 'last_error' => 'candidate_validation_failed']); + $this->assertSame($shared->id, $placement->refresh()->active_pool_id); + + $this->actingAs($admin)->postJson("/api/admin/domains/{$domain->id}/release", [])->assertAccepted() + ->assertJsonPath('data.state', 'recovering')->assertJsonPath('data.target_pool_id', $shared->id); + $this->assertDatabaseHas('security_events', ['domain_id' => $domain->id, 'reason_code' => 'domain_quarantined']); + } + + public function test_emergency_modes_are_bounded_async_idempotent_and_expire(): void + { + $admin = User::factory()->admin()->create(); + $edge = Edge::query()->create(['name' => 'security-edge', 'country_code' => 'IR', 'continent_code' => 'AS', 'ipv4' => '203.0.113.10', 'enabled' => true]); + $shared = EdgePool::query()->where('kind', 'shared')->firstOrFail(); + $cell = $edge->cells()->create(['edge_pool_id' => $shared->id, 'name' => $shared->name, 'status' => 'ready', 'service_ipv4' => '203.0.113.11']); + $headers = ['Idempotency-Key' => (string) Str::uuid()]; + $response = $this->actingAs($admin)->withHeaders($headers)->postJson("/api/admin/edge-cells/{$cell->id}/emergency-mode", [ + 'actions' => ['allow_get_head_only', 'disable_origin_retries'], 'duration_minutes' => 10, + ])->assertAccepted(); + $this->actingAs($admin)->withHeaders($headers)->postJson("/api/admin/edge-cells/{$cell->id}/emergency-mode", [ + 'actions' => ['allow_get_head_only', 'disable_origin_retries'], 'duration_minutes' => 10, + ])->assertAccepted()->assertJsonPath('data.emergency_mode_id', $response->json('data.emergency_mode_id')); + $this->assertSame(1, EmergencyMode::query()->count()); + $task = EdgeTask::query()->where('type', 'emergency_mode')->firstOrFail(); + $this->assertSame([$cell->name], $task->payload['cell_names']); + $this->assertLessThanOrEqual(11, count($task->payload['actions'])); + + EmergencyMode::query()->firstOrFail()->update(['expires_at' => now()->subMinute()]); + $this->artisan('security:reconcile-readiness')->assertSuccessful(); + $this->assertFalse(EmergencyMode::query()->firstOrFail()->active); + $this->assertSame(2, EdgeTask::query()->where('type', 'emergency_mode')->count()); + } + + public function test_pool_withdrawal_removes_only_that_pool_from_platform_dns(): void + { + $admin = User::factory()->admin()->create(); + $pool = EdgePool::query()->where('kind', 'shared')->firstOrFail(); + $other = EdgePool::query()->create(['name' => 'shared-other', 'kind' => 'shared', 'enabled' => true]); + $this->actingAs($admin)->postJson("/api/admin/edge-pools/{$pool->id}/withdraw")->assertAccepted(); + $this->assertTrue($pool->refresh()->withdrawn); + $this->assertFalse($other->refresh()->withdrawn); + $this->actingAs($admin)->postJson("/api/admin/edge-pools/{$pool->id}/restore")->assertAccepted(); + $this->assertFalse($pool->refresh()->withdrawn); + } + + private function ownedDomain(bool $proxied = false): array + { + $user = User::factory()->create(); + $domain = Domain::query()->create(['name' => 'security.example.test', 'display_name' => 'Security', 'revision' => 1, 'lifecycle_state' => 'active']); + $domain->users()->attach($user); + if ($proxied) { + $domain->dnsRecords()->create([ + 'type' => 'A', 'name' => $domain->name, 'content' => '8.8.8.8', 'content_hash' => hash('sha256', 'origin'), + 'ttl' => 300, 'priority' => 0, 'weight' => 0, 'port' => 0, 'mode' => 'proxied', + 'origin' => ['scheme' => 'http', 'host' => '8.8.8.8', 'port' => 80, 'host_header' => $domain->name, 'sni' => null, 'verify_tls' => false, 'connect_timeout_ms' => 1000, 'response_timeout_ms' => 5000, 'retry_count' => 0, 'websocket' => false, 'health_check' => null], + ]); + } + + return [$user, $domain]; + } + + private function settings(string $profile): array + { + return [...SecurityConfig::defaults($profile), 'quarantine_policy' => 'automatic']; + } +} diff --git a/docker/nginx/edge-runtime.conf b/docker/nginx/edge-runtime.conf index 0b9c53b..5fd5609 100644 --- a/docker/nginx/edge-runtime.conf +++ b/docker/nginx/edge-runtime.conf @@ -1,5 +1,7 @@ lua_package_path "/usr/local/openresty/lualib/?.lua;/etc/cdnfoundry/?.lua;;"; +lua_package_cpath "/usr/lib/lua/5.1/?.so;;"; lua_shared_dict runtime_limits 10m; +lua_shared_dict security_limits 32m; init_worker_by_lua_block { require("runtime").start() } limit_conn_zone $binary_remote_addr zone=client_connections:10m; limit_conn_zone $host zone=hostname_connections:10m; @@ -14,12 +16,13 @@ upstream edge_origin { } server { - listen 8080 default_server reuseport; - listen [::]:8080 default_server reuseport ipv6only=on; - listen 8443 ssl default_server reuseport; - listen [::]:8443 ssl default_server reuseport ipv6only=on; + listen 8080 default_server reuseport backlog=4096; + listen [::]:8080 default_server reuseport ipv6only=on backlog=4096; + listen 8443 ssl default_server reuseport backlog=4096; + listen [::]:8443 ssl default_server reuseport ipv6only=on backlog=4096; http2 on; server_name _; + log_by_lua_block { require("runtime").finish() } ssl_certificate /run/edge/tls.crt; ssl_certificate_key /run/edge/tls.key; @@ -59,9 +62,16 @@ server { set $cdn_cache_max_object "104857600"; set $cdn_cache_stale "0"; set $cdn_cache_respect_origin "1"; + set $cdn_security_reason ""; + set $cdn_original_host ""; + error_page 405 = @invalid_method; location = /healthz { access_log off; return 200 "ok\n"; } + location @invalid_method { + content_by_lua_block { require("runtime").invalid_method() } + } + location / { access_by_lua_block { require("runtime").access() } } @@ -89,6 +99,7 @@ server { server { listen 127.0.0.1:9081; server_name _; + log_by_lua_block { require("runtime").origin_done() } client_max_body_size 16m; error_page 502 503 504 = @origin_failure; set $origin_scheme ""; @@ -107,7 +118,7 @@ server { } location @origin_failure { - return 444; + content_by_lua_block { require("runtime").origin_failure() } log_by_lua_block { require("runtime").record_passive_failure() } } diff --git a/docker/nginx/openresty.conf b/docker/nginx/openresty.conf index b168a0b..2a71fa0 100644 --- a/docker/nginx/openresty.conf +++ b/docker/nginx/openresty.conf @@ -1,6 +1,7 @@ env EDGE_RUNTIME_FILE; env EDGE_STATUS_TOKEN; env EDGE_CELL_NAME; +env GEOIP_DATABASE; user cdnf cdnf; @@ -9,14 +10,16 @@ error_log /dev/stderr notice; pid /usr/local/openresty/nginx/logs/nginx.pid; events { - worker_connections 1024; + worker_connections 4096; } http { + client_body_temp_path /var/lib/nginx/tmp/client_body 1 2; + proxy_temp_path /var/lib/nginx/tmp/proxy; proxy_cache_path /var/cache/nginx/content levels=1:2 keys_zone=customer_content:10m max_size=192m inactive=1h use_temp_path=off; include /usr/local/openresty/nginx/conf/mime.types; default_type application/octet-stream; - log_format edge_json escape=json '{"time":"$time_iso8601","host":"$host","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,"cache_status":"$upstream_cache_status","upstream_status":"$upstream_status","request_time":$request_time}'; + log_format edge_json escape=json '{"time":"$time_iso8601","host":"$host","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,"cache_status":"$upstream_cache_status","upstream_status":"$upstream_status","request_time":$request_time,"security_reason":"$cdn_security_reason"}'; access_log /dev/stdout edge_json; sendfile on; include /etc/nginx/conf.d/*.conf; diff --git a/docker/nginx/origin.conf b/docker/nginx/origin.conf index 54cdc5c..2eff0e2 100644 --- a/docker/nginx/origin.conf +++ b/docker/nginx/origin.conf @@ -11,6 +11,7 @@ server { location = /origin-ttl { add_header Cache-Control "public, max-age=1"; return 200 "origin-ttl\n"; } location = /stale { add_header Cache-Control "public, max-age=1, stale-if-error=10"; return 200 "stale\n"; } location = /large-object { root /tmp; try_files /large-object =404; } + location = /slow { limit_rate 1k; root /tmp; try_files /large-object =404; } location / { default_type application/json; return 200 '{"origin":"http","host":"$host"}'; diff --git a/docker/openresty/Dockerfile b/docker/openresty/Dockerfile index 46b26c7..8111b78 100644 --- a/docker/openresty/Dockerfile +++ b/docker/openresty/Dockerfile @@ -1,9 +1,10 @@ FROM openresty/openresty:1.31.1.1-0-alpine LABEL org.opencontainers.image.source="https://github.com/vaheed/CDNFoundry" -RUN addgroup -S -g 10101 cdnf && adduser -S -D -H -u 10101 -G cdnf cdnf \ - && mkdir -p /var/cache/nginx/content /var/lib/cdnfoundry/runtime \ - && chown -R cdnf:cdnf /var/lib/cdnfoundry/runtime +RUN apk add --no-cache lua5.1-maxminddb \ + && addgroup -S -g 10101 cdnf && adduser -S -D -H -u 10101 -G cdnf cdnf \ + && mkdir -p /var/cache/nginx/content /var/lib/cdnfoundry/runtime /var/lib/nginx/tmp/client_body /var/lib/nginx/tmp/proxy \ + && chown -R cdnf:cdnf /var/lib/cdnfoundry/runtime /var/lib/nginx/tmp COPY docker/nginx/openresty.conf /usr/local/openresty/nginx/conf/nginx.conf COPY docker/nginx/edge-runtime.conf /etc/nginx/conf.d/default.conf diff --git a/docker/openresty/runtime.lua b/docker/openresty/runtime.lua index 550c7c3..3c41373 100644 --- a/docker/openresty/runtime.lua +++ b/docker/openresty/runtime.lua @@ -12,6 +12,23 @@ local certificate_cache = {} local certificate_cache_order = {} local certificate_cache_limit = 512 local cache_control_directives +local geo_database + +local function security_reject(status, reason) + local dictionary = ngx.shared.runtime_limits + dictionary:incr("capacity:rejected_requests", 1, 0) + dictionary:incr("security:reason:" .. reason, 1, 0) + if ngx.ctx.security_domain then + local events = ngx.shared.security_limits + local key = "event:" .. tostring(ngx.ctx.security_domain) .. ":" .. reason + events:incr(key, 1, 0) + events:set("host:" .. tostring(ngx.ctx.security_domain), ngx.ctx.security_hostname or "", 3600) + events:set("time:" .. tostring(ngx.ctx.security_domain) .. ":" .. reason, ngx.time(), 3600) + end + ngx.header["X-CDNFoundry-Security-Reason"] = reason + ngx.var.cdn_security_reason = reason + return ngx.exit(status) +end local function ipv4_number(ip) local a,b,c,d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") @@ -133,6 +150,16 @@ end function M.start() refresh(false) + local geo_path = os.getenv("GEOIP_DATABASE") or "" + if geo_path ~= "" then + local ok_module, maxminddb = pcall(require, "maxminddb") + if ok_module then + local ok_open, database = pcall(maxminddb.open, geo_path) + if ok_open then geo_database = database else ngx.log(ngx.WARN, "GeoIP database unavailable: ", database) end + else + ngx.log(ngx.WARN, "GeoIP module unavailable: ", maxminddb) + end + end local ok, err = ngx.timer.every(1, refresh) if not ok then ngx.log(ngx.ERR, "runtime refresh timer failed: ", err) end restart_generation = ngx.shared.runtime_limits:get("control:restart_generation") or 0 @@ -159,8 +186,16 @@ function M.select_certificate() name = name and name:lower():gsub("%.$", "") or "" local config = state.hosts[name] if not config then + ngx.shared.runtime_limits:incr("security:reason:unknown_sni", 1, 0) error("unknown TLS SNI") end + local security = config.security or {} + local limits = security.limits or {} + local second = ngx.time() + local handshakes = ngx.shared.runtime_limits:incr("security:tls:" .. tostring(config.domain) .. ":" .. second, 1, 0, 2) + if handshakes and handshakes > (tonumber(limits.tls_handshakes_per_second) or 50) then + error("tls_handshake_rate_exceeded") + end if not config.tls or config.tls.mode == "disabled" or not config.tls.certificate_id then error("TLS disabled or certificate unavailable") end local certificate = state.certificates[config.tls.certificate_id] if not certificate then error("TLS certificate unavailable") end @@ -208,8 +243,81 @@ local function resolve(host, networks, blocked_networks, denied) end local function reject(status) - ngx.shared.runtime_limits:incr("capacity:rejected_requests", 1, 0) - return ngx.exit(status) + return security_reject(status, "malformed_request") +end + +local function client_address(security) + local remote = ngx.var.remote_addr or "" + if allowed(remote, security.trusted_proxy_cidrs) then + local forwarded = (ngx.var.http_x_forwarded_for or ""):match("^%s*([^,%s]+)") + if forwarded and (#forwarded <= 45) and (ipv4_number(forwarded) or forwarded:find(":", 1, true)) then + return forwarded + end + end + return remote +end + +local function geography(ip) + if not geo_database then return "unknown", "unknown" end + local ok, result = pcall(geo_database.lookup, geo_database, ip) + if not ok or not result then return "unknown", "unknown" end + local ok_country, country = pcall(result.get, result, "country", "iso_code") + local ok_continent, continent = pcall(result.get, result, "continent", "code") + return ok_country and country or "unknown", ok_continent and continent or "unknown" +end + +local function rule_matches(rule, ip, country, continent) + if rule.match_type == "ip" then return ip:lower() == tostring(rule.value):lower() end + if rule.match_type == "cidr" then return allowed(ip, {rule.value}) end + if rule.match_type == "country" then return country ~= "unknown" and country == rule.value end + if rule.match_type == "continent" then return continent ~= "unknown" and continent == rule.value end + return false +end + +local function emergency_actions(dictionary) + local raw = dictionary:get("emergency:active") + local expires = tonumber(dictionary:get("emergency:expires_at")) or 0 + if not raw or (expires > 0 and expires <= ngx.time()) then + if raw then dictionary:delete("emergency:active"); dictionary:delete("emergency:expires_at") end + return {} + end + local decoded = cjson.decode(raw) + if type(decoded) ~= "table" then return {} end + local actions = {} + for _, action in ipairs(decoded) do actions[action] = true end + return actions +end + +local function body_size() + local length = tonumber(ngx.var.http_content_length) + if length then return length end + if not ngx.var.http_transfer_encoding then return 0 end + ngx.req.read_body() + local data = ngx.req.get_body_data() + if data then return #data end + local file_path = ngx.req.get_body_file() + if not file_path then return 0 end + local file = io.open(file_path, "rb") + if not file then return 0 end + local size = file:seek("end") or 0 + file:close() + return size +end + +local function request_header_size() + local ok, raw = pcall(ngx.req.raw_header) + if ok and raw then return #raw end + local headers, err = ngx.req.get_headers(100, true) + if err == "truncated" then return math.huge end + local size = #(ngx.req.get_method() or "") + #(ngx.var.request_uri or "") + 16 + for name, value in pairs(headers or {}) do + if type(value) == "table" then + for _, item in ipairs(value) do size = size + #tostring(name) + #tostring(item) + 4 end + else + size = size + #tostring(name) + #tostring(value) + 4 + end + end + return size end local function request_cache_key(domain, host, cache) @@ -243,11 +351,60 @@ end function M.access() local dictionary = ngx.shared.runtime_limits dictionary:incr("capacity:requests:" .. ngx.time(), 1, 0, 2) - if dictionary:get("control:drained") == true then return reject(503) end + if dictionary:get("control:drained") == true then return security_reject(503, "edge_emergency_mode") end local host = (ngx.var.host or ""):lower():gsub("%.$", "") local config = state.hosts[host] - if not config then return reject(421) end + if not config then return security_reject(421, "unknown_host") end + ngx.var.cdn_original_host = host + ngx.ctx.security_domain = config.domain_id or config.domain + ngx.ctx.security_hostname = host if config.settings and config.settings.enabled == false then return reject(503) end + local security = config.security or {} + local limits = security.limits or {} + local emergency = emergency_actions(dictionary) + if emergency.return_maintenance_response then return security_reject(503, "edge_emergency_mode") end + if security.state == "quarantined" then return security_reject(429, "domain_quarantined") end + local method = ngx.req.get_method() + if emergency.allow_get_head_only and method ~= "GET" and method ~= "HEAD" then return security_reject(405, "edge_emergency_mode") end + local method_allowed = false + for _, allowed_method in ipairs(security.allowed_methods or {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}) do + if method == allowed_method then method_allowed = true; break end + end + if not method_allowed then return security_reject(405, "invalid_method") end + if request_header_size() > (tonumber(limits.maximum_header_size) or 32768) then return security_reject(431, "header_too_large") end + local size = body_size() + if emergency.disable_request_bodies and size > 0 then return security_reject(413, "edge_emergency_mode") end + if size > (tonumber(limits.maximum_request_body_size) or 16777216) then return security_reject(413, "body_too_large") end + local client = client_address(security) + local country, continent = geography(client) + for _, rule in ipairs(security.rules or {}) do + if rule_matches(rule, client, country, continent) then + if rule.action == "block" then return security_reject(403, "domain_restricted") end + break + end + end + local second = ngx.time() + local client_key = "security:req:client:" .. tostring(config.domain) .. ":" .. ngx.md5(client) .. ":" .. second + local domain_key = "security:req:domain:" .. tostring(config.domain) .. ":" .. second + local client_requests = dictionary:incr(client_key, 1, 0, 2) + local domain_requests = dictionary:incr(domain_key, 1, 0, 2) + local rps = tonumber(limits.requests_per_second) or 100 + local burst = tonumber(limits.request_burst) or 200 + if client_requests and client_requests > rps + burst then return security_reject(429, "client_rate_exceeded") end + if domain_requests and domain_requests > rps * 8 + burst then return security_reject(429, "domain_rate_exceeded") end + local client_connections = dictionary:incr("security:conn:client:" .. tostring(config.domain) .. ":" .. ngx.md5(client), 1, 0) + local domain_connections = dictionary:incr("security:conn:domain:" .. tostring(config.domain), 1, 0) + if client_connections and client_connections > (tonumber(limits.connections_per_client) or 64) then + dictionary:incr("security:conn:client:" .. tostring(config.domain) .. ":" .. ngx.md5(client), -1, 0) + dictionary:incr("security:conn:domain:" .. tostring(config.domain), -1, 0) + return security_reject(429, "client_connections_exceeded") + end + if domain_connections and domain_connections > (tonumber(limits.connections_per_domain) or 512) then + dictionary:incr("security:conn:client:" .. tostring(config.domain) .. ":" .. ngx.md5(client), -1, 0) + dictionary:incr("security:conn:domain:" .. tostring(config.domain), -1, 0) + return security_reject(429, "domain_connections_exceeded") + end + ngx.ctx.security_connection_keys = {"security:conn:client:" .. tostring(config.domain) .. ":" .. ngx.md5(client), "security:conn:domain:" .. tostring(config.domain)} if config.settings and config.settings.redirect_https == true and ngx.var.scheme == "http" then return ngx.redirect("https://" .. host .. ngx.var.request_uri, 308) end @@ -270,7 +427,10 @@ function M.access() and not ngx.var.http_authorization and not ngx.var.http_range and not cookie_bypassed(cache.bypass_cookie_names) and development_until <= ngx.time() - and #cache_key <= 8192 + and #cache_key <= (tonumber(limits.maximum_cache_key_length) or 4096) + local admissions = dictionary:incr("security:cache:" .. tostring(config.domain) .. ":" .. second, 1, 0, 2) + if admissions and admissions > (tonumber(limits.cache_admissions_per_second) or 50) then cacheable = false end + if #cache_key > (tonumber(limits.maximum_cache_key_length) or 4096) then ngx.var.cdn_security_reason = "cache_abuse_detected" end ngx.var.cdn_cache_key = cache_key ngx.var.cdn_cache_bypass = cacheable and "0" or "1" ngx.var.cdn_cache_no_store = "0" @@ -287,14 +447,45 @@ function M.access() return ngx.exec("@cache_100m") end +function M.invalid_method() + return security_reject(405, ngx.var.cdn_security_reason ~= "" and ngx.var.cdn_security_reason or "invalid_method") +end + function M.origin_access() local dictionary = ngx.shared.runtime_limits - local host = (ngx.var.host or ""):lower():gsub("%.$", "") + local host = (ngx.var.cdn_original_host ~= "" and ngx.var.cdn_original_host or ngx.var.host or ""):lower():gsub("%.$", "") local config = state.hosts[host] - if not config then return reject(421) end + if not config then return security_reject(421, "unknown_host") end + ngx.ctx.security_domain = config.domain_id or config.domain + ngx.ctx.security_hostname = host + local security = config.security or {} + local limits = security.limits or {} + local emergency = emergency_actions(dictionary) + if emergency.serve_cache_only or emergency.serve_stale_only then return security_reject(503, "edge_emergency_mode") end + local circuit_key = "security:origin:open:" .. tostring(config.domain) + local open_until = tonumber(dictionary:get(circuit_key)) or 0 + if open_until > ngx.now() then return security_reject(503, "origin_circuit_open") end + local origin_key = "security:origin:connections:" .. tostring(config.domain) + local active = dictionary:incr(origin_key, 1, 0) + if active and active > (tonumber(limits.origin_max_connections) or 128) then + dictionary:incr(origin_key, -1, 0) + return security_reject(503, "origin_capacity_exceeded") + end + ngx.ctx.origin_connection_key = origin_key + ngx.ctx.origin_domain = tostring(config.domain) + ngx.ctx.origin_failure_threshold = tonumber(limits.origin_failure_threshold) or 10 + ngx.ctx.origin_recovery_timeout = tonumber(limits.origin_recovery_timeout) or 30 + dictionary:incr("capacity:origin_connections", 1, 0) local origin = config.origin local address, err = resolve(origin.host, origin.private_allowlist, origin.blocked_networks, origin.blocked_addresses) - if not address then ngx.log(ngx.WARN, "origin rejected: ", err); ngx.header["X-CDNFoundry-Error"] = err; return reject(502) end + if not address then + ngx.log(ngx.WARN, "origin rejected: ", err) + ngx.header["X-CDNFoundry-Error"] = err + -- Leave this as an ordinary upstream failure. The outer cache can then + -- apply its bounded stale-if-error policy; security-controlled 503s + -- retain their explicit reason and are never disguised as origin loss. + return ngx.exit(502) + end if address:find(":", 1, true) then address = "[" .. address .. "]" end ngx.var.origin_scheme = origin.scheme ngx.var.origin_address = address @@ -304,16 +495,16 @@ function M.origin_access() ngx.var.origin_connection = "" ngx.var.origin_upgrade = "" ngx.var.origin_address = address:gsub("^%[", ""):gsub("%]$", "") - ngx.var.origin_connect_timeout = tostring(math.max(100, math.min(10000, tonumber(origin.connect_timeout_ms) or 1000))) - ngx.var.origin_response_timeout = tostring(math.max(500, math.min(60000, tonumber(origin.response_timeout_ms) or 5000))) - ngx.var.origin_retry_count = tostring(math.max(0, math.min(2, tonumber(origin.retry_count) or tonumber(config.settings and config.settings.retry_count) or 0))) + ngx.var.origin_connect_timeout = tostring(math.min((tonumber(limits.origin_connect_timeout) or 3) * 1000, math.max(100, math.min(10000, tonumber(origin.connect_timeout_ms) or 1000)))) + ngx.var.origin_response_timeout = tostring(math.min((tonumber(limits.origin_read_timeout) or 30) * 1000, math.max(500, math.min(60000, tonumber(origin.response_timeout_ms) or 5000)))) + local retry_limit = emergency.disable_origin_retries and 0 or (tonumber(limits.origin_retry_limit) or 0) + ngx.var.origin_retry_count = tostring(math.max(0, math.min(retry_limit, tonumber(origin.retry_count) or tonumber(config.settings and config.settings.retry_count) or 0))) if origin.websocket == true and (ngx.var.http_upgrade or ""):lower() == "websocket" then ngx.var.origin_connection = "upgrade" ngx.var.origin_upgrade = "websocket" end ngx.req.clear_header("Forwarded"); ngx.req.clear_header("X-Forwarded-For"); ngx.req.clear_header("X-Forwarded-Host"); ngx.req.clear_header("X-Forwarded-Proto") ngx.req.clear_header("Proxy-Connection"); ngx.req.clear_header("Keep-Alive"); ngx.req.clear_header("TE"); ngx.req.clear_header("Trailer"); ngx.req.clear_header("Upgrade") - dictionary:incr("capacity:origin_connections", 1, 0) if origin.scheme == "https" and origin.verify_tls == true then return ngx.exec("@proxy_verified") end if origin.scheme == "https" then return ngx.exec("@proxy_unverified_https") end return ngx.exec("@proxy_http") @@ -398,7 +589,6 @@ function M.balance() end function M.record_passive_failure() - ngx.shared.runtime_limits:incr("capacity:origin_connections", -1, 0) local status = tonumber((ngx.var.upstream_status or ""):match("%d+")) if status and status < 500 then return end local host = (ngx.var.host or ""):lower():gsub("%.$", "") @@ -410,6 +600,45 @@ function M.record_passive_failure() dictionary:set("passive-time:" .. host, ngx.time()) end +function M.finish() + local keys = ngx.ctx.security_connection_keys + if not keys then return end + for _, key in ipairs(keys) do + local current = ngx.shared.runtime_limits:incr(key, -1, 0) + if current and current <= 0 then ngx.shared.runtime_limits:delete(key) end + end +end + +function M.origin_done() + local dictionary = ngx.shared.runtime_limits + if ngx.ctx.origin_connection_key then + local current = dictionary:incr(ngx.ctx.origin_connection_key, -1, 0) + if current and current <= 0 then dictionary:delete(ngx.ctx.origin_connection_key) end + dictionary:incr("capacity:origin_connections", -1, 0) + end + local domain = ngx.ctx.origin_domain + if not domain then return end + local status = tonumber((ngx.var.upstream_status or ""):match("%d+")) or tonumber(ngx.status) or 0 + local failure_key = "security:origin:failures:" .. domain + if status >= 500 or status == 0 then + local failures = dictionary:incr(failure_key, 1, 0, math.max(2, ngx.ctx.origin_recovery_timeout or 30)) + if failures and failures >= (ngx.ctx.origin_failure_threshold or 10) then + dictionary:set("security:origin:open:" .. domain, ngx.now() + (ngx.ctx.origin_recovery_timeout or 30), ngx.ctx.origin_recovery_timeout or 30) + end + else + dictionary:delete(failure_key) + dictionary:delete("security:origin:open:" .. domain) + end +end + +function M.origin_failure() + if ngx.var.cdn_security_reason ~= "" then + ngx.header["X-CDNFoundry-Security-Reason"] = ngx.var.cdn_security_reason + return ngx.exit(503) + end + return ngx.exit(444) +end + function M.passive_failures() local expected = os.getenv("EDGE_STATUS_TOKEN") or "" local supplied = ngx.req.get_headers()["x-edge-status-token"] or "" @@ -427,6 +656,19 @@ function M.passive_failures() } end end + local security_events = {} + local event_dictionary = ngx.shared.security_limits + for _, key in ipairs(event_dictionary:get_keys(200)) do + local domain, reason = key:match("^event:([^:]+):([%w_]+)$") + if domain and reason and #security_events < 20 then + security_events[#security_events + 1] = { + domain_id = tonumber(domain), hostname = event_dictionary:get("host:" .. domain), + reason_code = reason, count = event_dictionary:get(key) or 0, + occurred_at = event_dictionary:get("time:" .. domain .. ":" .. reason) or ngx.time(), + } + end + end + table.sort(security_events, function(a, b) return a.count > b.count end) local assigned = 0 for _ in pairs(state.hosts) do assigned = assigned + 1 end local memory_usage = 0 @@ -452,6 +694,7 @@ function M.passive_failures() local drained = ngx.shared.runtime_limits:get("control:drained") == true ngx.say(cjson.encode({ data = #failures == 0 and cjson.empty_array or failures, + security = #security_events == 0 and cjson.empty_array or security_events, cell = { name = os.getenv("EDGE_CELL_NAME") or "unknown", status = drained and "drained" or "ready", @@ -485,9 +728,17 @@ function M.control() if #raw == 0 or #raw > 128 * 1024 then return ngx.exit(400) end local command = cjson.decode(raw) if type(command) ~= "table" or type(command.task_id) ~= "string" or #command.task_id > 64 - or (command.action ~= "drain" and command.action ~= "undrain" and command.action ~= "restart" and command.action ~= "cache_purge") then + or (command.action ~= "drain" and command.action ~= "undrain" and command.action ~= "restart" and command.action ~= "cache_purge" and command.action ~= "emergency_mode") then return ngx.exit(400) end + if command.action == "emergency_mode" then + if type(command.active) ~= "boolean" or type(command.actions) ~= "table" or #command.actions > 11 + or (command.expires_at ~= nil and (type(command.expires_at) ~= "number" or command.expires_at <= ngx.time())) then return ngx.exit(400) end + local supported = {reject_unknown_hosts=true,disable_request_bodies=true,allow_get_head_only=true,reduce_keepalive=true, + reduce_origin_concurrency=true,disable_origin_retries=true,serve_cache_only=true,serve_stale_only=true, + return_maintenance_response=true,quarantine_domain=true,withdraw_service_ip_from_dns=true} + for _, action in ipairs(command.actions) do if not supported[action] then return ngx.exit(400) end end + end if command.action == "cache_purge" then if type(command.domain) ~= "string" or #command.domain == 0 or #command.domain > 253 or (command.type ~= "all" and command.type ~= "urls") @@ -515,6 +766,14 @@ function M.control() if not already_drained then dictionary:set("control:restart_resume_at", ngx.now() + 2) end dictionary:set("control:last_restart_at", ngx.time()) dictionary:incr("control:restart_generation", 1, 0) + elseif command.action == "emergency_mode" then + if command.active then + dictionary:set("emergency:active", cjson.encode(command.actions)) + if command.expires_at then dictionary:set("emergency:expires_at", command.expires_at) else dictionary:delete("emergency:expires_at") end + else + dictionary:delete("emergency:active") + dictionary:delete("emergency:expires_at") + end elseif command.type == "all" then local epoch_key = "cache:epoch:" .. command.domain local current = dictionary:get(epoch_key) or 0 diff --git a/docs/architecture.md b/docs/architecture.md index 1823166..7501f5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # CDNFoundry architecture This is the architecture that exists in the repository today. It describes the -current control plane, authoritative DNS path, Phase 4 proxy path, Phase 5 TLS/cache path, service-pool +current control plane, authoritative DNS path, Phase 4 proxy path, Phase 5 TLS/cache path, Phase 6 security path, service-pool placement, and failure behaviour. Later roadmap features are called out separately so operators do not mistake planned work for a deployed capability. @@ -194,6 +194,27 @@ The standard UI maps HTTP to port 80 and HTTPS to port 443. The typed API allows a deliberately configured custom port. TLS verification is meaningful only for HTTPS origins. +## Security enforcement path + +Phase 6 security settings and ordered rules are desired PostgreSQL state. The +normal edge reconciler compiles them into the signed per-cell artifact; invalid +configuration cannot replace the last activated revision. OpenResty evaluates +the direct or explicitly trusted client IP, local MMDB geography, method and +size policy, fixed-size client/domain counters, origin concurrency/circuit +state, and cache admission before expensive work. Request decisions never call +Laravel, Valkey, PostgreSQL, or ClickHouse. + +Cells are resource-bounded and shared by delivery class. Restricted state +changes only the affected domain's effective profile; quarantine uses the +existing target-first pool movement so the source remains active until the +target acknowledges. Agents aggregate at most 20 noisy-domain events per +heartbeat and persist emergency cell controls across restart. A withdrawn pool +is excluded from new derived PowerDNS answers without removing other pools. + +This is application and origin protection, not upstream volumetric scrubbing. +Physical circuit saturation must be mitigated by the transit or hosting +provider before traffic reaches the edge. + ## Administrator change to active runtime An interactive request stores intent; it does not wait for PowerDNS or edges. diff --git a/docs/ddos-readiness.md b/docs/ddos-readiness.md new file mode 100644 index 0000000..d991c74 --- /dev/null +++ b/docs/ddos-readiness.md @@ -0,0 +1,27 @@ +# DDoS readiness profiles + +Every proxied domain selects `standard`, `protected`, `quarantine`, or `manual`. +The first three are immutable platform recommendations whose exact limits are +shown in the security action. `manual` is the single editable limit set and +cannot exceed the field-wise platform safety ceilings. `suspected` and +`restricted` state enforce at least `protected`; `quarantined` enforces +`quarantine` regardless of the configured normal profile. These operational +overrides do not rewrite the configured choice. + +The exact values, units, manual ranges, API behavior, and owner-run UI checks +are documented in `request-origin-limits.md` and +`manual-browser-qualification.md`. + +The operational states are `normal`, `suspected`, `restricted`, `quarantined`, +and `recovering`. Edge heartbeats report at most 20 aggregated noisy-domain +events, so attacker-controlled cardinality cannot create a heartbeat explosion. +Repeated rate, connection, origin-capacity, circuit, or cache-abuse signals can +move a normal domain to suspected and then restricted. A quiet ten-minute +window advances suspected to normal and restricted through recovering to +normal. Full quarantine remains subject to `manual`, `automatic`, or +`automatic_with_admin_notification` policy and target-first placement safety. + +These controls preserve bounded edge and origin capacity; they are not +volumetric scrubbing. If an attack saturates a physical uplink, transit, +load-balancer, or service-IP capacity before traffic reaches OpenResty, upstream +provider mitigation or scrubbing is required. diff --git a/docs/edge-emergency-mode-runbook.md b/docs/edge-emergency-mode-runbook.md new file mode 100644 index 0000000..825be0b --- /dev/null +++ b/docs/edge-emergency-mode-runbook.md @@ -0,0 +1,24 @@ +# Edge emergency mode runbook + +Emergency mode is an administrator-only, audited, asynchronous control for an +edge or cell. Pool withdrawal is a separate revisioned control that removes only +that pool from new DNS answers. Prefer an expiring duration; the maximum is +1,440 minutes. + +1. Identify the smallest target and record healthy comparison traffic. +2. Select only required actions. Supported controls include GET/HEAD-only, + disabled bodies or retries, reduced keep-alive/origin concurrency, + cache-only/stale-only, maintenance response, quarantine, unknown-host + rejection, and service-IP withdrawal. +3. Record the operation and task results from every participating edge. The + agent persists active cell controls and reapplies them after restart. +4. Verify the target emits `edge_emergency_mode` while a healthy cell/domain is + unaffected. Verify IPv4 and IPv6 service paths. +5. Clear the mode explicitly or confirm automatic expiry sends a durable clear + task. Replayed idempotency keys return the original operation; a changed + payload conflicts. +6. For pool withdrawal, confirm only that pool disappears from new PowerDNS + answers. Restore it only after cells and addresses are ready. + +Emergency mode does not stop traffic upstream of the service address and cannot +recover a saturated physical circuit. diff --git a/docs/manual-browser-qualification.md b/docs/manual-browser-qualification.md index 35d710f..688f41b 100644 --- a/docs/manual-browser-qualification.md +++ b/docs/manual-browser-qualification.md @@ -385,6 +385,153 @@ Use an active, nameserver-verified disposable domain assigned to the domain user - Automated/runtime qualification: agent-owned results are recorded in `docs/phase-5-qualification.md`. - Manual browser/public HTTPS qualification: owner-run; **not executed and Phase 5 is not release-qualified until every checkpoint above is recorded as passed**. +## Phase 6 — Basic security and DDoS readiness + +Use two disposable proxied domains: one assigned to the domain user and one +healthy comparison domain placed in another ready cell. Keep IPv4 and IPv6 +service paths active. Sign in at `http://localhost:8080/app/login` with the +documented local domain-user account, and at `/admin/login` with +`admin@example.test` / `cdnlite-local-admin`. Replace documentation addresses +with controlled test clients/origins; never block the operator's only access +path. + +### Domain security rules and profiles + +1. Open the assigned domain. Record its current revision. Confirm the Security + section shows configured/effective profile, operational state, rule count, + recent reason codes, and the effective request/origin summary. Open + **Security → Security profile and limits**. +2. Confirm the selector initially matches the configured profile. Select + `standard`. The description must say it is recommended for normal traffic. + Every limit field must immediately show the Standard column in + `request-origin-limits.md` and remain disabled. Do not save yet; refresh the + page and confirm the revision did not change. +3. Reopen the action and select `protected`. Without closing the modal, confirm + every displayed value immediately changes to the Protected column, including + `requests_per_second = 50`, `request_burst = 75`, + `origin_retry_limit = 1`, and `origin_recovery_timeout = 60`. Every limit + remains disabled. Select `quarantine` and confirm all values change again, + including `requests_per_second = 10`, `origin_retry_limit = 0`, and + `origin_recovery_timeout = 120`. +4. Select `standard`, policy `manual`, and methods `GET`, `HEAD`, `POST`; save. + Confirm the success notification appears, the modal closes, and the Security + section immediately shows configured profile `standard` with `100 req/s` + without requiring a page reload. Record the one new revision and coalesced + edge operation. Refresh and reopen the action; all Standard values must + persist and remain disabled. +5. Select `manual`. Confirm every limit field becomes editable while policy, + methods, and trusted proxy fields retain their current values. Set + `requests_per_second = 37`, `request_burst = 61`, and + `origin_recovery_timeout = 120`; leave every other field within its displayed + range. Save and record exactly one new revision/operation. Confirm the page + immediately shows `manual` and `37 req/s`; refresh and confirm all three + edited values persist. +6. Reopen the manual profile, enter `requests_per_second = 101`, and save. The + field must show its maximum validation error, the modal must retain the + input, and no revision, operation, audit success, or effective runtime change + may occur. Restore `37`. Repeat with `origin_retry_limit = -1` and + `origin_recovery_timeout = 121`; each must fail without durable change. +7. With the saved manual profile open, select `protected`. Confirm all protected + values replace the displayed manual values and are disabled, then cancel the + modal. Reopen it and confirm the durable profile is still `manual` with + `37`, `61`, and `120`. Select `protected` again and save; confirm exactly one + revision, immediate `protected` display, and persistence after refresh. +8. If the domain is currently restricted or quarantined, separately confirm the + configured profile remains the saved choice while the effective profile and + enforced summary use the stricter operational-state ceilings. Releasing the + disposable domain must restore the configured policy without rewriting it. +9. In **Security allow/block rules**, create these enabled rows and record IDs: + + | Priority | Type | Value | Action | Note | + |---:|---|---|---|---| + | 10 | IP address | controlled IPv4 client | Allow | browser IPv4 exception | + | 20 | CIDR network | controlled IPv4 `/24` | Block | browser IPv4 range | + | 30 | IP address | controlled IPv6 client | Allow | browser IPv6 exception | + | 40 | CIDR network | controlled IPv6 `/64` | Block | browser IPv6 range | + | 50 | Country | a known MMDB country code | Block | browser country | + | 60 | Continent | a known MMDB continent code | Block | browser continent | + +10. Confirm malformed IP/CIDR, IPv4 prefix above 32, IPv6 prefix above 128, + unsupported geography, priority outside `-1000000..1000000`, and a note + above 250 characters each remain in the form with errors and create no + revision. Send controlled requests and confirm first-match priority and ID + tie-break behavior, including unknown IPv6 geography continuing through + IP/CIDR evaluation. +11. Choose **Import rules**. Add multiple preview rows, leave **Replace existing + rules** off, confirm every normalized row before the confirmation, and save. + All rows must appear under one new desired revision. Repeat with replacement + after saving evidence; cancel once and confirm cancellation changes nothing. +12. Configure one **Trusted L4 proxy CIDR** only for a controlled balancer that + overwrites `X-Forwarded-For`. A direct spoofed header must not change the + client identity; traffic from the trusted peer must use its overwritten + first address. Remove the test CIDR afterward. + +### Real traffic, protection, and isolation + +1. Against the active revision, record response status, security reason, event, + edge cell, and resource behavior for unknown Host, unknown SNI, TRACE, + malformed path, oversized header/body, slow header/body, keep-alive ceiling, + request duration, IPv4/IPv6 allow/block, country/continent, client and domain + request rate, client and domain connection concurrency, and TLS handshake + rate. HTTP/2 streams/headers/requests must remain bounded; HTTP/3 and + WebSocket must remain unavailable. +2. Use a deliberately slow disposable origin. Exceed origin concurrency and + failure threshold; record `origin_capacity_exceeded` and + `origin_circuit_open`, bounded retries, and cached/stale or controlled error + behavior. A single incoming request must never create more than the selected + retry limit. +3. Send random paths and query strings beyond cache-key/admission ceilings. + Record `cache_abuse_detected`, cell cache/temp usage, and memory before/after. + The cell must remain within its quota and the comparison domain must continue + serving normally. +4. Stop Laravel, Horizon, scheduler, Valkey, control PostgreSQL, and telemetry + input after the active artifact is present. Existing rules and traffic must + continue locally. Restore services without deleting volumes. Submit an + invalid candidate after recovery and confirm the prior rules and placement + remain active. + +### Administrator readiness and emergency controls + +1. As administrator open the affected domain. Choose **Security → Restrict + domain**. Record state, effective protected profile, operation, revision, + events, and prove the comparison domain's limits did not change. +2. Choose **Quarantine domain**. Confirm the target quarantine cell activates + and acknowledges before source drain/removal. Deliberately make a disposable + target fail once; the active source placement and last-valid rules must stay + live. Restore readiness and complete the move without restarting unrelated + cells. +3. Choose **Release domain**. Confirm target-first movement to shared capacity, + state `recovering`, then `normal` after a quiet scheduler interval. Record + IPv4/IPv6 behavior throughout. +4. Open **Edge network → Edges** and apply **Emergency mode** to one disposable + edge with actions `allow_get_head_only` and `disable_origin_retries`, expiry + `2` minutes. Record the operation/tasks and confirm only its cells change. + Restart one cell and confirm the agent reapplies the active control. Choose + **Clear emergency** and verify normal traffic returns. +5. In the edge's **Cells** table apply **Emergency** to one cell with + `return_maintenance_response` for `1` minute. Confirm another cell stays + ready, then confirm automatic expiry sends the clear operation. Repeat the + smallest applicable check on a service pool and verify only its cells. +6. Open **Service pools**, choose **Withdraw** on a disposable pool, and query + DNSdist over UDP/TCP for IPv4 and IPv6. Only that pool's addresses must leave + new answers. Choose **Restore** after every cell/address is ready and confirm + answers return. Do not withdraw the operator's only reachable pool. +7. Inspect domain Security events, edge capacities, Audit logs, Operations, and + active emergency controls. Confirm reason codes are stable, metrics are a + bounded top 20 rather than one heartbeat row per attacker, expiry is visible, + and a domain user cannot invoke any administrator action or view another + domain. + +### Phase 6 completion gate + +- Implementation: present for local ordered rules, bounded profiles and limits, + origin/cache protection, isolation, emergency controls, and pool withdrawal. +- Documentation: present in the Phase 6 guides and runbooks. +- Automated/runtime qualification: agent-owned evidence is recorded in + `docs/phase-6-qualification.md`. +- Manual browser/real-host qualification: owner-run; **not executed and Phase 6 + is not release-qualified until every checkpoint above is recorded as passed**. + ## Record the result For each phase record: date/operator, commit SHA, browser/version, desktop/mobile viewports, exact domain and edge addresses, every checkpoint as pass/fail/not-ready, operation IDs, revisions, screenshots, relevant logs, and any deviations from the example values. Also record Horizon, PowerAdmin, DNSdist UDP/TCP, Prometheus, Alertmanager, and edge results where applicable. diff --git a/docs/openapi.json b/docs/openapi.json index 2bf6b2f..a952330 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -752,6 +752,201 @@ } } }, + "/admin/domains/{domain}/quarantine": { + "post": { + "operationId": "security.operations.controller.quarantine", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/domains/{domain}/release": { + "post": { + "operationId": "security.operations.controller.release", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/domains/{domain}/restrict": { + "post": { + "operationId": "security.operations.controller.restrict", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, "/admin/domains/{domain}/users": { "get": { "operationId": "domain.user.controller.index", @@ -1107,9 +1302,9 @@ } } }, - "/admin/edge-cells/{cell}/restart": { - "post": { - "operationId": "edge.operations.controller.restart.cell", + "/admin/edge-cells/{cell}/emergency-mode": { + "delete": { + "operationId": "security.operations.controller.clear.cell.emergency", "tags": [ "Administrator" ], @@ -1132,18 +1327,6 @@ "$ref": "#/components/parameters/IdempotencyKey" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } - } - } - }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -1170,11 +1353,9 @@ "$ref": "#/components/responses/ValidationError" } } - } - }, - "/admin/edge-cells/{cell}/undrain": { + }, "post": { - "operationId": "edge.operations.controller.undrain.cell", + "operationId": "security.operations.controller.cell.emergency", "tags": [ "Administrator" ], @@ -1237,9 +1418,9 @@ } } }, - "/admin/edge-deployments": { - "get": { - "operationId": "edge.operations.controller.deployments", + "/admin/edge-cells/{cell}/restart": { + "post": { + "operationId": "edge.operations.controller.restart.cell", "tags": [ "Administrator" ], @@ -1250,34 +1431,14 @@ ], "parameters": [ { - "$ref": "#/components/parameters/Cursor" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" + "name": "cell", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - } - }, - "/admin/edge-deployments/reconcile": { - "post": { - "operationId": "edge.operations.controller.reconcile", - "tags": [ - "Administrator" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" } @@ -1322,9 +1483,9 @@ } } }, - "/admin/edge-pools": { - "get": { - "operationId": "edge.pool.controller.index", + "/admin/edge-cells/{cell}/undrain": { + "post": { + "operationId": "edge.operations.controller.undrain.cell", "tags": [ "Administrator" ], @@ -1335,32 +1496,14 @@ ], "parameters": [ { - "$ref": "#/components/parameters/Cursor" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" + "name": "cell", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - }, - "post": { - "operationId": "edge.pool.controller.store", - "tags": [ - "Administrator" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" } @@ -1405,9 +1548,9 @@ } } }, - "/admin/edge-pools/{pool}": { + "/admin/edge-deployments": { "get": { - "operationId": "edge.pool.controller.show", + "operationId": "edge.operations.controller.deployments", "tags": [ "Administrator" ], @@ -1418,13 +1561,7 @@ ], "parameters": [ { - "name": "pool", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } + "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -1438,9 +1575,11 @@ "$ref": "#/components/responses/StableError" } } - }, - "patch": { - "operationId": "edge.pool.controller.update", + } + }, + "/admin/edge-deployments/reconcile": { + "post": { + "operationId": "edge.operations.controller.reconcile", "tags": [ "Administrator" ], @@ -1450,15 +1589,6 @@ } ], "parameters": [ - { - "name": "pool", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -1503,9 +1633,9 @@ } } }, - "/admin/edge-pools/{pool}/disable": { - "post": { - "operationId": "edge.pool.controller.disable", + "/admin/edge-pools": { + "get": { + "operationId": "edge.pool.controller.index", "tags": [ "Administrator" ], @@ -1516,61 +1646,23 @@ ], "parameters": [ { - "name": "pool", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, - { - "$ref": "#/components/parameters/IdempotencyKey" + "$ref": "#/components/parameters/Cursor" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } - } - } - }, "responses": { "200": { "$ref": "#/components/responses/Success" }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" - }, - "204": { - "description": "The mutation completed with no response body." - }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" - }, - "409": { - "$ref": "#/components/responses/StableError" - }, - "422": { - "$ref": "#/components/responses/ValidationError" } } - } - }, - "/admin/edge-pools/{pool}/enable": { + }, "post": { - "operationId": "edge.pool.controller.enable", + "operationId": "edge.pool.controller.store", "tags": [ "Administrator" ], @@ -1580,15 +1672,6 @@ } ], "parameters": [ - { - "name": "pool", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -1633,33 +1716,9 @@ } } }, - "/admin/edge-routing": { - "get": { - "operationId": "edge.operations.controller.routing", - "tags": [ - "Administrator" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" - }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - } - }, - "/admin/edges": { + "/admin/edge-pools/{pool}": { "get": { - "operationId": "edge.controller.index", + "operationId": "edge.pool.controller.show", "tags": [ "Administrator" ], @@ -1670,7 +1729,13 @@ ], "parameters": [ { - "$ref": "#/components/parameters/Cursor" + "name": "pool", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } } ], "responses": { @@ -1685,8 +1750,8 @@ } } }, - "post": { - "operationId": "edge.controller.store", + "patch": { + "operationId": "edge.pool.controller.update", "tags": [ "Administrator" ], @@ -1696,6 +1761,15 @@ } ], "parameters": [ + { + "name": "pool", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -1740,9 +1814,9 @@ } } }, - "/admin/edges/{edge}": { - "delete": { - "operationId": "edge.controller.destroy", + "/admin/edge-pools/{pool}/disable": { + "post": { + "operationId": "edge.pool.controller.disable", "tags": [ "Administrator" ], @@ -1753,18 +1827,30 @@ ], "parameters": [ { - "name": "edge", + "name": "pool", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "minimum": 1 } }, { "$ref": "#/components/parameters/IdempotencyKey" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -1791,42 +1877,11 @@ "$ref": "#/components/responses/ValidationError" } } - }, - "get": { - "operationId": "edge.controller.show", - "tags": [ - "Administrator" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "name": "edge", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" - }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - }, - "patch": { - "operationId": "edge.controller.update", + } + }, + "/admin/edge-pools/{pool}/enable": { + "post": { + "operationId": "edge.pool.controller.enable", "tags": [ "Administrator" ], @@ -1837,12 +1892,12 @@ ], "parameters": [ { - "name": "edge", + "name": "pool", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "minimum": 1 } }, { @@ -1889,9 +1944,9 @@ } } }, - "/admin/edges/{edge}/disable": { + "/admin/edge-pools/{pool}/restore": { "post": { - "operationId": "edge.controller.disable", + "operationId": "security.operations.controller.restore", "tags": [ "Administrator" ], @@ -1902,12 +1957,12 @@ ], "parameters": [ { - "name": "edge", + "name": "pool", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "minimum": 1 } }, { @@ -1954,9 +2009,9 @@ } } }, - "/admin/edges/{edge}/drain": { + "/admin/edge-pools/{pool}/withdraw": { "post": { - "operationId": "edge.controller.drain", + "operationId": "security.operations.controller.withdraw", "tags": [ "Administrator" ], @@ -1967,12 +2022,514 @@ ], "parameters": [ { - "name": "edge", + "name": "pool", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edge-routing": { + "get": { + "operationId": "edge.operations.controller.routing", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + } + }, + "/admin/edges": { + "get": { + "operationId": "edge.controller.index", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "post": { + "operationId": "edge.controller.store", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}": { + "delete": { + "operationId": "edge.controller.destroy", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "get": { + "operationId": "edge.controller.show", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "patch": { + "operationId": "edge.controller.update", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}/disable": { + "post": { + "operationId": "edge.controller.disable", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}/drain": { + "post": { + "operationId": "edge.controller.drain", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}/emergency-mode": { + "delete": { + "operationId": "security.operations.controller.clear.edge.emergency", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "post": { + "operationId": "security.operations.controller.edge.emergency", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } }, { @@ -2021,7 +2578,436 @@ }, "/admin/edges/{edge}/enable": { "post": { - "operationId": "edge.controller.enable", + "operationId": "edge.controller.enable", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}/rotate-identity": { + "post": { + "operationId": "edge.controller.rotate", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/edges/{edge}/undrain": { + "post": { + "operationId": "edge.controller.undrain", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "edge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/operations": { + "get": { + "operationId": "operation.controller.index", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + } + }, + "/admin/operations/{operation}": { + "get": { + "operationId": "operation.controller.show", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "operation", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + } + }, + "/admin/operations/{operation}/retry": { + "post": { + "operationId": "operation.controller.retry", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "operation", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/system/settings": { + "get": { + "operationId": "system.settings.controller.index", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "patch": { + "operationId": "system.settings.controller.update.selected", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/admin/system/settings/dns": { + "get": { + "operationId": "platform.dns.settings.controller.show", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "patch": { + "operationId": "platform.dns.settings.controller.update", "tags": [ "Administrator" ], @@ -2031,15 +3017,6 @@ } ], "parameters": [ - { - "name": "edge", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -2084,9 +3061,9 @@ } } }, - "/admin/edges/{edge}/rotate-identity": { + "/admin/system/settings/dns/validate": { "post": { - "operationId": "edge.controller.rotate", + "operationId": "platform.dns.settings.controller.validate.settings", "tags": [ "Administrator" ], @@ -2096,15 +3073,6 @@ } ], "parameters": [ - { - "name": "edge", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -2149,9 +3117,9 @@ } } }, - "/admin/edges/{edge}/undrain": { - "post": { - "operationId": "edge.controller.undrain", + "/admin/system/settings/{group}": { + "get": { + "operationId": "system.settings.controller.show", "tags": [ "Administrator" ], @@ -2162,12 +3130,59 @@ ], "parameters": [ { - "name": "edge", + "name": "group", "in": "path", "required": true, "schema": { "type": "string", - "format": "uuid" + "enum": [ + "dns_lifecycle", + "revision_history", + "rate_limits", + "edge_runtime", + "origin_safety", + "proxy_defaults" + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "patch": { + "operationId": "system.settings.controller.update", + "tags": [ + "Administrator" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "dns_lifecycle", + "revision_history", + "rate_limits", + "edge_runtime", + "origin_safety", + "proxy_defaults" + ] } }, { @@ -2214,9 +3229,9 @@ } } }, - "/admin/operations": { + "/admin/system/status": { "get": { - "operationId": "operation.controller.index", + "operationId": "health.controller.status", "tags": [ "Administrator" ], @@ -2225,11 +3240,6 @@ "bearerAuth": [] } ], - "parameters": [ - { - "$ref": "#/components/parameters/Cursor" - } - ], "responses": { "200": { "$ref": "#/components/responses/Success" @@ -2243,9 +3253,9 @@ } } }, - "/admin/operations/{operation}": { + "/admin/users": { "get": { - "operationId": "operation.controller.show", + "operationId": "user.controller.index", "tags": [ "Administrator" ], @@ -2256,13 +3266,7 @@ ], "parameters": [ { - "name": "operation", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } + "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -2276,11 +3280,9 @@ "$ref": "#/components/responses/StableError" } } - } - }, - "/admin/operations/{operation}/retry": { + }, "post": { - "operationId": "operation.controller.retry", + "operationId": "user.controller.store", "tags": [ "Administrator" ], @@ -2290,15 +3292,6 @@ } ], "parameters": [ - { - "name": "operation", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -2343,9 +3336,9 @@ } } }, - "/admin/system/settings": { - "get": { - "operationId": "system.settings.controller.index", + "/admin/users/{user}": { + "delete": { + "operationId": "user.controller.destroy", "tags": [ "Administrator" ], @@ -2356,48 +3349,18 @@ ], "parameters": [ { - "$ref": "#/components/parameters/Cursor" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - }, - "patch": { - "operationId": "system.settings.controller.update.selected", - "tags": [ - "Administrator" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ { "$ref": "#/components/parameters/IdempotencyKey" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } - } - } - }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -2424,11 +3387,9 @@ "$ref": "#/components/responses/ValidationError" } } - } - }, - "/admin/system/settings/dns": { + }, "get": { - "operationId": "platform.dns.settings.controller.show", + "operationId": "user.controller.show", "tags": [ "Administrator" ], @@ -2437,6 +3398,17 @@ "bearerAuth": [] } ], + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], "responses": { "200": { "$ref": "#/components/responses/Success" @@ -2450,7 +3422,7 @@ } }, "patch": { - "operationId": "platform.dns.settings.controller.update", + "operationId": "user.controller.update", "tags": [ "Administrator" ], @@ -2460,6 +3432,15 @@ } ], "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -2504,9 +3485,9 @@ } } }, - "/admin/system/settings/dns/validate": { + "/admin/users/{user}/disable": { "post": { - "operationId": "platform.dns.settings.controller.validate.settings", + "operationId": "user.controller.disable", "tags": [ "Administrator" ], @@ -2516,6 +3497,15 @@ } ], "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -2560,9 +3550,9 @@ } } }, - "/admin/system/settings/{group}": { + "/admin/users/{user}/domains": { "get": { - "operationId": "system.settings.controller.show", + "operationId": "user.controller.domains", "tags": [ "Administrator" ], @@ -2573,20 +3563,16 @@ ], "parameters": [ { - "name": "group", + "name": "user", "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "dns_lifecycle", - "revision_history", - "rate_limits", - "edge_runtime", - "origin_safety", - "proxy_defaults" - ] + "type": "integer", + "minimum": 1 } + }, + { + "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -2600,9 +3586,11 @@ "$ref": "#/components/responses/StableError" } } - }, - "patch": { - "operationId": "system.settings.controller.update", + } + }, + "/admin/users/{user}/enable": { + "post": { + "operationId": "user.controller.enable", "tags": [ "Administrator" ], @@ -2611,21 +3599,14 @@ "bearerAuth": [] } ], - "parameters": [ - { - "name": "group", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "dns_lifecycle", - "revision_history", - "rate_limits", - "edge_runtime", - "origin_safety", - "proxy_defaults" - ] + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 } }, { @@ -2672,35 +3653,107 @@ } } }, - "/admin/system/status": { - "get": { - "operationId": "health.controller.status", + "/auth/login": { + "post": { + "operationId": "auth.controller.login", "tags": [ - "Administrator" + "Account" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "auth.controller.logout", + "tags": [ + "Account" ], "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/Success" }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } } }, - "/admin/users": { + "/domains": { "get": { - "operationId": "user.controller.index", + "operationId": "domain.controller.index", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2725,9 +3778,9 @@ } }, "post": { - "operationId": "user.controller.store", + "operationId": "domain.controller.store", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2779,11 +3832,11 @@ } } }, - "/admin/users/{user}": { + "/domains/{domain}": { "delete": { - "operationId": "user.controller.destroy", + "operationId": "domain.controller.destroy", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2792,7 +3845,7 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { @@ -2832,9 +3885,9 @@ } }, "get": { - "operationId": "user.controller.show", + "operationId": "domain.controller.show", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2843,7 +3896,7 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { @@ -2865,9 +3918,9 @@ } }, "patch": { - "operationId": "user.controller.update", + "operationId": "domain.controller.update", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2876,7 +3929,7 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { @@ -2928,11 +3981,11 @@ } } }, - "/admin/users/{user}/disable": { + "/domains/{domain}/activate": { "post": { - "operationId": "user.controller.disable", + "operationId": "domain.lifecycle.controller.activate", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -2941,7 +3994,7 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { @@ -2993,11 +4046,11 @@ } } }, - "/admin/users/{user}/domains": { + "/domains/{domain}/cache": { "get": { - "operationId": "user.controller.domains", + "operationId": "cache.controller.show", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -3006,16 +4059,13 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { "type": "integer", "minimum": 1 } - }, - { - "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -3029,13 +4079,11 @@ "$ref": "#/components/responses/StableError" } } - } - }, - "/admin/users/{user}/enable": { - "post": { - "operationId": "user.controller.enable", + }, + "patch": { + "operationId": "cache.controller.update", "tags": [ - "Administrator" + "Account" ], "security": [ { @@ -3044,7 +4092,7 @@ ], "parameters": [ { - "name": "user", + "name": "domain", "in": "path", "required": true, "schema": { @@ -3096,49 +4144,9 @@ } } }, - "/auth/login": { - "post": { - "operationId": "auth.controller.login", - "tags": [ - "Account" - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" - }, - "204": { - "description": "The mutation completed with no response body." - }, - "409": { - "$ref": "#/components/responses/StableError" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - } - } - } - }, - "/auth/logout": { - "post": { - "operationId": "auth.controller.logout", + "/domains/{domain}/cache/development-mode": { + "delete": { + "operationId": "cache.controller.disable.development.mode", "tags": [ "Account" ], @@ -3149,79 +4157,47 @@ ], "parameters": [ { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" }, - "204": { - "description": "The mutation completed with no response body." - }, - "401": { - "$ref": "#/components/responses/StableError" - }, - "403": { - "$ref": "#/components/responses/StableError" - }, - "409": { - "$ref": "#/components/responses/StableError" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - } - } - } - }, - "/domains": { - "get": { - "operationId": "domain.controller.index", - "tags": [ - "Account" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ { - "$ref": "#/components/parameters/Cursor" + "$ref": "#/components/parameters/IdempotencyKey" } ], "responses": { "200": { "$ref": "#/components/responses/Success" }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } }, "post": { - "operationId": "domain.controller.store", + "operationId": "cache.controller.enable.development.mode", "tags": [ "Account" ], @@ -3231,6 +4207,15 @@ } ], "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -3275,9 +4260,9 @@ } } }, - "/domains/{domain}": { - "delete": { - "operationId": "domain.controller.destroy", + "/domains/{domain}/cache/purge": { + "post": { + "operationId": "cache.controller.purge", "tags": [ "Account" ], @@ -3300,6 +4285,18 @@ "$ref": "#/components/parameters/IdempotencyKey" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -3326,9 +4323,11 @@ "$ref": "#/components/responses/ValidationError" } } - }, + } + }, + "/domains/{domain}/cache/purges": { "get": { - "operationId": "domain.controller.show", + "operationId": "cache.controller.purges", "tags": [ "Account" ], @@ -3346,6 +4345,9 @@ "type": "integer", "minimum": 1 } + }, + { + "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -3359,9 +4361,11 @@ "$ref": "#/components/responses/StableError" } } - }, - "patch": { - "operationId": "domain.controller.update", + } + }, + "/domains/{domain}/cache/purges/{purge}": { + "get": { + "operationId": "cache.controller.purge.status", "tags": [ "Account" ], @@ -3381,52 +4385,31 @@ } }, { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } + "name": "purge", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - }, + ], "responses": { "200": { "$ref": "#/components/responses/Success" }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" - }, - "204": { - "description": "The mutation completed with no response body." - }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" - }, - "409": { - "$ref": "#/components/responses/StableError" - }, - "422": { - "$ref": "#/components/responses/ValidationError" } } } }, - "/domains/{domain}/activate": { + "/domains/{domain}/deploy": { "post": { - "operationId": "domain.lifecycle.controller.activate", + "operationId": "proxy.controller.deploy", "tags": [ "Account" ], @@ -3489,9 +4472,9 @@ } } }, - "/domains/{domain}/cache": { + "/domains/{domain}/deployment": { "get": { - "operationId": "cache.controller.show", + "operationId": "proxy.controller.deployment", "tags": [ "Account" ], @@ -3522,9 +4505,11 @@ "$ref": "#/components/responses/StableError" } } - }, - "patch": { - "operationId": "cache.controller.update", + } + }, + "/domains/{domain}/disable": { + "post": { + "operationId": "domain.controller.disable", "tags": [ "Account" ], @@ -3587,9 +4572,9 @@ } } }, - "/domains/{domain}/cache/development-mode": { - "delete": { - "operationId": "cache.controller.disable.development.mode", + "/domains/{domain}/dns/deployment": { + "get": { + "operationId": "dns.deployment.controller.show", "tags": [ "Account" ], @@ -3607,40 +4592,59 @@ "type": "integer", "minimum": 1 } - }, - { - "$ref": "#/components/parameters/IdempotencyKey" } ], "responses": { "200": { "$ref": "#/components/responses/Success" }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" - }, - "204": { - "description": "The mutation completed with no response body." - }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" + } + } + } + }, + "/domains/{domain}/dns/export": { + "get": { + "operationId": "dns.zone.controller.export", + "tags": [ + "Account" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" }, - "409": { + "401": { "$ref": "#/components/responses/StableError" }, - "422": { - "$ref": "#/components/responses/ValidationError" + "403": { + "$ref": "#/components/responses/StableError" } } - }, + } + }, + "/domains/{domain}/dns/import": { "post": { - "operationId": "cache.controller.enable.development.mode", + "operationId": "dns.zone.controller.import", "tags": [ "Account" ], @@ -3703,9 +4707,9 @@ } } }, - "/domains/{domain}/cache/purge": { + "/domains/{domain}/dns/reconcile": { "post": { - "operationId": "cache.controller.purge", + "operationId": "dns.deployment.controller.reconcile", "tags": [ "Account" ], @@ -3768,9 +4772,9 @@ } } }, - "/domains/{domain}/cache/purges": { + "/domains/{domain}/dns/records": { "get": { - "operationId": "cache.controller.purges", + "operationId": "dns.record.controller.index", "tags": [ "Account" ], @@ -3804,11 +4808,9 @@ "$ref": "#/components/responses/StableError" } } - } - }, - "/domains/{domain}/cache/purges/{purge}": { - "get": { - "operationId": "cache.controller.purge.status", + }, + "post": { + "operationId": "dns.record.controller.store", "tags": [ "Account" ], @@ -3828,31 +4830,117 @@ } }, { - "name": "purge", + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/domains/{domain}/dns/records/bulk": { + "post": { + "operationId": "dns.record.controller.bulk", + "tags": [ + "Account" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } } } - ], + }, "responses": { "200": { "$ref": "#/components/responses/Success" }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } } }, - "/domains/{domain}/deploy": { - "post": { - "operationId": "proxy.controller.deploy", + "/domains/{domain}/dns/records/{record}": { + "delete": { + "operationId": "dns.record.controller.destroy", "tags": [ "Account" ], @@ -3871,22 +4959,19 @@ "minimum": 1 } }, + { + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } - } - } - }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -3913,11 +4998,9 @@ "$ref": "#/components/responses/ValidationError" } } - } - }, - "/domains/{domain}/deployment": { + }, "get": { - "operationId": "proxy.controller.deployment", + "operationId": "dns.record.controller.show", "tags": [ "Account" ], @@ -3935,6 +5018,15 @@ "type": "integer", "minimum": 1 } + }, + { + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } } ], "responses": { @@ -3948,11 +5040,9 @@ "$ref": "#/components/responses/StableError" } } - } - }, - "/domains/{domain}/disable": { - "post": { - "operationId": "domain.controller.disable", + }, + "patch": { + "operationId": "dns.record.controller.update", "tags": [ "Account" ], @@ -3971,6 +5061,15 @@ "minimum": 1 } }, + { + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -4015,9 +5114,9 @@ } } }, - "/domains/{domain}/dns/deployment": { + "/domains/{domain}/dns/records/{record}/geo": { "get": { - "operationId": "dns.deployment.controller.show", + "operationId": "geo.dns.controller.show", "tags": [ "Account" ], @@ -4035,35 +5134,9 @@ "type": "integer", "minimum": 1 } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - } - }, - "/domains/{domain}/dns/export": { - "get": { - "operationId": "dns.zone.controller.export", - "tags": [ - "Account" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ { - "name": "domain", + "name": "record", "in": "path", "required": true, "schema": { @@ -4083,11 +5156,9 @@ "$ref": "#/components/responses/StableError" } } - } - }, - "/domains/{domain}/dns/import": { - "post": { - "operationId": "dns.zone.controller.import", + }, + "put": { + "operationId": "geo.dns.controller.update", "tags": [ "Account" ], @@ -4106,6 +5177,15 @@ "minimum": 1 } }, + { + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -4150,9 +5230,9 @@ } } }, - "/domains/{domain}/dns/reconcile": { + "/domains/{domain}/dns/records/{record}/geo/preview": { "post": { - "operationId": "dns.deployment.controller.reconcile", + "operationId": "geo.dns.controller.preview", "tags": [ "Account" ], @@ -4172,7 +5252,13 @@ } }, { - "$ref": "#/components/parameters/IdempotencyKey" + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } } ], "requestBody": { @@ -4215,9 +5301,9 @@ } } }, - "/domains/{domain}/dns/records": { + "/domains/{domain}/dns/records/{record}/origin": { "get": { - "operationId": "dns.record.controller.index", + "operationId": "proxy.controller.origin", "tags": [ "Account" ], @@ -4237,7 +5323,13 @@ } }, { - "$ref": "#/components/parameters/Cursor" + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } } ], "responses": { @@ -4252,8 +5344,8 @@ } } }, - "post": { - "operationId": "dns.record.controller.store", + "put": { + "operationId": "proxy.controller.update.origin", "tags": [ "Account" ], @@ -4272,6 +5364,15 @@ "minimum": 1 } }, + { + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -4316,9 +5417,9 @@ } } }, - "/domains/{domain}/dns/records/bulk": { - "post": { - "operationId": "dns.record.controller.bulk", + "/domains/{domain}/dns/records/{record}/origin/health": { + "get": { + "operationId": "proxy.controller.health", "tags": [ "Account" ], @@ -4338,52 +5439,31 @@ } }, { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 100, - "additionalProperties": true - } + "name": "record", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 } } - }, + ], "responses": { "200": { "$ref": "#/components/responses/Success" }, - "201": { - "$ref": "#/components/responses/Created" - }, - "202": { - "$ref": "#/components/responses/Accepted" - }, - "204": { - "description": "The mutation completed with no response body." - }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" - }, - "409": { - "$ref": "#/components/responses/StableError" - }, - "422": { - "$ref": "#/components/responses/ValidationError" } } } }, - "/domains/{domain}/dns/records/{record}": { - "delete": { - "operationId": "dns.record.controller.destroy", + "/domains/{domain}/dns/records/{record}/origin/test": { + "post": { + "operationId": "proxy.controller.test.origin", "tags": [ "Account" ], @@ -4415,6 +5495,18 @@ "$ref": "#/components/parameters/IdempotencyKey" } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 100, + "additionalProperties": true + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/Success" @@ -4441,9 +5533,11 @@ "$ref": "#/components/responses/ValidationError" } } - }, + } + }, + "/domains/{domain}/proxy": { "get": { - "operationId": "dns.record.controller.show", + "operationId": "proxy.controller.show", "tags": [ "Account" ], @@ -4461,15 +5555,6 @@ "type": "integer", "minimum": 1 } - }, - { - "name": "record", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } } ], "responses": { @@ -4485,7 +5570,7 @@ } }, "patch": { - "operationId": "dns.record.controller.update", + "operationId": "proxy.controller.update", "tags": [ "Account" ], @@ -4496,16 +5581,7 @@ ], "parameters": [ { - "name": "domain", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, - { - "name": "record", + "name": "domain", "in": "path", "required": true, "schema": { @@ -4557,9 +5633,9 @@ } } }, - "/domains/{domain}/dns/records/{record}/geo": { + "/domains/{domain}/revisions": { "get": { - "operationId": "geo.dns.controller.show", + "operationId": "proxy.controller.revisions", "tags": [ "Account" ], @@ -4579,13 +5655,7 @@ } }, { - "name": "record", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } + "$ref": "#/components/parameters/Cursor" } ], "responses": { @@ -4599,9 +5669,11 @@ "$ref": "#/components/responses/StableError" } } - }, - "put": { - "operationId": "geo.dns.controller.update", + } + }, + "/domains/{domain}/rollback": { + "post": { + "operationId": "proxy.controller.rollback", "tags": [ "Account" ], @@ -4620,15 +5692,6 @@ "minimum": 1 } }, - { - "name": "record", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -4673,9 +5736,9 @@ } } }, - "/domains/{domain}/dns/records/{record}/geo/preview": { - "post": { - "operationId": "geo.dns.controller.preview", + "/domains/{domain}/security": { + "get": { + "operationId": "security.controller.show", "tags": [ "Account" ], @@ -4693,15 +5756,42 @@ "type": "integer", "minimum": 1 } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "patch": { + "operationId": "security.controller.update", + "tags": [ + "Account" + ], + "security": [ { - "name": "record", + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", "in": "path", "required": true, "schema": { "type": "integer", "minimum": 1 } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" } ], "requestBody": { @@ -4744,9 +5834,9 @@ } } }, - "/domains/{domain}/dns/records/{record}/origin": { + "/domains/{domain}/security/ddos": { "get": { - "operationId": "proxy.controller.origin", + "operationId": "security.controller.ddos", "tags": [ "Account" ], @@ -4764,15 +5854,6 @@ "type": "integer", "minimum": 1 } - }, - { - "name": "record", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } } ], "responses": { @@ -4787,8 +5868,8 @@ } } }, - "put": { - "operationId": "proxy.controller.update.origin", + "patch": { + "operationId": "security.controller.update.ddos", "tags": [ "Account" ], @@ -4807,15 +5888,6 @@ "minimum": 1 } }, - { - "name": "record", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - }, { "$ref": "#/components/parameters/IdempotencyKey" } @@ -4860,9 +5932,9 @@ } } }, - "/domains/{domain}/dns/records/{record}/origin/health": { + "/domains/{domain}/security/ddos/events": { "get": { - "operationId": "proxy.controller.health", + "operationId": "security.controller.events", "tags": [ "Account" ], @@ -4880,9 +5952,35 @@ "type": "integer", "minimum": 1 } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + } + }, + "/domains/{domain}/security/ddos/status": { + "get": { + "operationId": "security.controller.status", + "tags": [ + "Account" + ], + "security": [ { - "name": "record", + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", "in": "path", "required": true, "schema": { @@ -4904,9 +6002,44 @@ } } }, - "/domains/{domain}/dns/records/{record}/origin/test": { - "post": { - "operationId": "proxy.controller.test.origin", + "/domains/{domain}/security/events": { + "get": { + "operationId": "security.controller.events", + "tags": [ + "Account" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" + }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + } + }, + "/domains/{domain}/security/rules": { + "get": { + "operationId": "security.controller.rules", "tags": [ "Account" ], @@ -4924,9 +6057,33 @@ "type": "integer", "minimum": 1 } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Success" + }, + "401": { + "$ref": "#/components/responses/StableError" }, + "403": { + "$ref": "#/components/responses/StableError" + } + } + }, + "post": { + "operationId": "security.controller.store.rule", + "tags": [ + "Account" + ], + "security": [ { - "name": "record", + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "domain", "in": "path", "required": true, "schema": { @@ -4978,42 +6135,9 @@ } } }, - "/domains/{domain}/proxy": { - "get": { - "operationId": "proxy.controller.show", - "tags": [ - "Account" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "name": "domain", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "minimum": 1 - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/StableError" - }, - "403": { - "$ref": "#/components/responses/StableError" - } - } - }, - "patch": { - "operationId": "proxy.controller.update", + "/domains/{domain}/security/rules/import": { + "post": { + "operationId": "security.controller.import.rules", "tags": [ "Account" ], @@ -5076,9 +6200,9 @@ } } }, - "/domains/{domain}/revisions": { - "get": { - "operationId": "proxy.controller.revisions", + "/domains/{domain}/security/rules/{rule}": { + "delete": { + "operationId": "security.controller.destroy.rule", "tags": [ "Account" ], @@ -5098,25 +6222,47 @@ } }, { - "$ref": "#/components/parameters/Cursor" + "name": "rule", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" } ], "responses": { "200": { "$ref": "#/components/responses/Success" }, + "201": { + "$ref": "#/components/responses/Created" + }, + "202": { + "$ref": "#/components/responses/Accepted" + }, + "204": { + "description": "The mutation completed with no response body." + }, "401": { "$ref": "#/components/responses/StableError" }, "403": { "$ref": "#/components/responses/StableError" + }, + "409": { + "$ref": "#/components/responses/StableError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } - } - }, - "/domains/{domain}/rollback": { - "post": { - "operationId": "proxy.controller.rollback", + }, + "patch": { + "operationId": "security.controller.update.rule", "tags": [ "Account" ], @@ -5135,6 +6281,15 @@ "minimum": 1 } }, + { + "name": "rule", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, { "$ref": "#/components/parameters/IdempotencyKey" } diff --git a/docs/phase-6-qualification.md b/docs/phase-6-qualification.md new file mode 100644 index 0000000..bbb9693 --- /dev/null +++ b/docs/phase-6-qualification.md @@ -0,0 +1,80 @@ +# Phase 6 qualification + +Phase 6 implementation and agent-owned qualification were completed on +2026-07-20. Owner-run browser qualification remains not executed, so Phase 6 is +not owner release-qualified. + +## Implemented boundary + +The control plane stores typed security settings, deterministic rules, +operational state/events, emergency modes, and service-pool withdrawal in +PostgreSQL. Revisions deploy through existing signed cell artifacts and retain +last-valid runtime/placement. OpenResty enforces local GeoIP/IP/CIDR decisions, +request/connection/TLS/origin/cache bounds, circuit state, quarantine, and +emergency controls without a request-time control-plane dependency. + +The configured profile surface provides three immutable recommended presets +(`standard`, `protected`, and `quarantine`) plus one editable `manual` profile. +Preset selection reactively updates the displayed values and successful saves +refresh the page state. Manual values are checked against field-wise platform +safety ceilings, while restricted/quarantined operational state can compile a +stricter effective profile without rewriting configured state. + +The edge agent reports bounded top-20 aggregates and persists emergency state +across restart. The readiness scheduler expires emergency controls and advances +quiet recovery. No CAPTCHA, challenge, editable WAF language, ModSecurity/CRS, +or volumetric-scrubbing claim was added. + +## Automated and runtime evidence + +- Focused `SecurityApiTest` and `FilamentWorkflowTest`: **14 tests, 169 + assertions passed**. Coverage includes profile-selector reactivity, immediate + post-save display/persistence, three immutable presets, the single bounded + manual profile, operational-state overrides, policy, revisions, + IPv4/IPv6/CIDR/GeoIP validation, ordering, bounded one-revision import and + rollback, target-first state operations, idempotent/expiring emergency tasks, + and pool withdrawal. +- Containerized edge-agent build: Go tests passed, including authenticated + bounded targeting and persisted emergency restoration. +- `tests/e2e/phase6_security.py`: passed against the real Laravel API, + persistent PostgreSQL, and real OpenResty. Control-plane coverage includes + authorization, all profile choices, preset immutability, every manual field's + upper/lower bounds, missing/extra payload fields, persistence, operation IDs, + and idempotent replay/conflict. Runtime coverage includes local MMDB, + IPv4/IPv6 rules, trusted client IP/GeoIP, rate/connection/body/method limits, + origin capacity/circuit behavior, cell isolation, emergency replay/expiry, + top-N events, bounded memory, and invalid-candidate last-valid preservation. + +## Cumulative release-candidate evidence + +- `make dev-test`: **131 tests, 1,052 assertions passed** under the required + `APP_ENV=testing`, SQLite `:memory:`, array-cache, synchronous-queue boundary. +- The preceding Phase 6 baseline recorded a passing `make dev-e2e` run across + PostgreSQL/Valkey outage recovery, PowerDNS/DNSdist IPv4+IPv6, Geo-DNS, + two-edge signed artifact acknowledgement and target-first movement, mTLS, + Pebble DNS-01, cache/purge retry/rollback, and edge/cache/TLS runtime + regressions. This profile refinement reran the changed + `tests/e2e/phase6_security.py`; the entire cumulative `make dev-e2e` sequence + was not rerun. +- `tests/e2e/phase4_runtime.py` additionally caught and qualified HTTP/2-safe + header accounting and bounded stale-if-error behavior for ordinary origin + loss; security-controlled failures remain explicit. +- Laravel Pint passed its full check. Development and production Compose + configs, OpenAPI freshness, all 44 documentation files' Markdown links, and + `git diff --check` passed. The Phase 6 runtime E2E validated the generated + OpenResty configuration with `openresty -t` before traffic tests. + +The additive `2026_07_20_000100_create_security_readiness_state` migration is +applied to the preserved development PostgreSQL volume. It uses JSONB shape +constraints and adds no destructive rewrite or refresh. No named volume was +removed. This profile refinement adds no migration or operator data operation; +existing `standard`, `protected`, and `quarantine` settings remain valid. + +## Manual browser status + +Not executed. Browser automation was not launched. The owner checklist now +contains exact preset/reactivity/read-only/manual/save/error/cancel checks and +expected revision behavior. The owner must perform every Phase 6 step in +`manual-browser-qualification.md` and record operator/date, commit, +browser/viewports, domain/edge addresses, operation IDs, revisions, events, +runtime responses, screenshots, and any failure. diff --git a/docs/quarantine-recovery-runbook.md b/docs/quarantine-recovery-runbook.md new file mode 100644 index 0000000..c668ecd --- /dev/null +++ b/docs/quarantine-recovery-runbook.md @@ -0,0 +1,21 @@ +# Quarantine and recovery runbook + +1. Confirm the bounded event aggregates, origin health, current state, active + and target pool, and whether unrelated domains remain healthy. +2. Use **Restrict** first when reduced limits are sufficient. Record the + operation, revision, reason, and event window. +3. Use **Quarantine** only when isolation is required. CDNFoundry activates and + acknowledges the quarantine target before draining the source. A failed + target keeps the previous valid placement and traffic. +4. Check the target cell's listener, resource limits, public IPv4/IPv6, + acknowledgement, and request reason codes. Do not delete source state or + restart unrelated cells. +5. After the attack and origin pressure are quiet, use **Release**. Confirm the + target-first move back to normal placement and the `recovering` state. +6. Observe at least the recovery/circuit cooldown and verify legitimate IPv4 and + IPv6 traffic before declaring recovery. The scheduler returns a quiet + recovering domain to normal. + +If the physical uplink is saturated, coordinate upstream mitigation before +changing edge limits. Increasing local ceilings during saturation usually +reduces isolation and is not a substitute for transit scrubbing. diff --git a/docs/request-origin-limits.md b/docs/request-origin-limits.md new file mode 100644 index 0000000..65d2086 --- /dev/null +++ b/docs/request-origin-limits.md @@ -0,0 +1,104 @@ +# Request, connection, cache, and origin limits + +Limits are per hostname and compiled with its numeric domain ID. Request zones +use both domain/client-IP and total-domain keys; connection zones separately +bound one client and the whole domain. Shared dictionaries have fixed sizes, and +only bounded identifiers enter keys. + +Profiles bound request rate and burst, client/domain connections, TLS +handshakes, body/header sizes, header/body/keep-alive/request timeouts, requests +per connection, origin concurrency, connect/read/send timeouts, retries, +circuit thresholds/recovery, cache-key length, and cache admissions. Values and +current ceilings are returned by `GET /api/domains/{domain}/security` and shown +in the domain Security action. + +## Profile choices + +There are three read-only recommended profiles and one editable profile: + +| Profile | Intended use | Limit editing | +|---|---|---| +| `standard` | Balanced defaults for ordinary production traffic | Disabled; all submitted values must exactly match the preset | +| `protected` | Lower traffic and resource ceilings for elevated risk | Disabled; all submitted values must exactly match the preset | +| `quarantine` | Strictest traffic isolation and longest recovery cooldown | Disabled; all submitted values must exactly match the preset | +| `manual` | One custom policy for a domain with explicit operator-selected limits | Enabled; every value is bounded by the platform safety range | + +Changing the selector in **Security → Security profile and limits** updates all +displayed limit values immediately. The three presets remain visible as +disabled details so a user can compare them without editing them. Selecting +`manual` enables the same fields. Saving increments the domain revision once, +coalesces one edge reconciliation operation, and refreshes the displayed +configured profile. Closing or cancelling the modal changes no durable state. + +The exact shipped values and manual ranges are: + +| Field | Unit / meaning | Standard | Protected | Quarantine | Manual range | +|---|---|---:|---:|---:|---:| +| `requests_per_second` | Requests per client | 100 | 50 | 10 | 1–100 | +| `request_burst` | Additional client burst requests | 200 | 75 | 10 | 1–200 | +| `connections_per_client` | Concurrent client connections | 64 | 24 | 4 | 1–64 | +| `connections_per_domain` | Concurrent total domain connections | 512 | 256 | 48 | 1–512 | +| `tls_handshakes_per_second` | New TLS handshakes per domain | 50 | 20 | 5 | 1–50 | +| `maximum_request_body_size` | Bytes | 16,777,216 | 8,388,608 | 1,048,576 | 1–16,777,216 | +| `maximum_header_size` | Bytes | 32,768 | 16,384 | 8,192 | 1–32,768 | +| `client_header_timeout` | Seconds | 10 | 7 | 5 | 1–10 | +| `client_body_timeout` | Seconds | 30 | 15 | 10 | 1–30 | +| `keepalive_timeout` | Seconds | 30 | 15 | 5 | 1–30 | +| `maximum_requests_per_connection` | Requests | 1,000 | 250 | 50 | 1–1,000 | +| `maximum_request_duration` | Seconds | 60 | 30 | 15 | 1–60 | +| `origin_max_connections` | Concurrent origin requests | 128 | 64 | 16 | 1–128 | +| `origin_connect_timeout` | Seconds | 3 | 2 | 1 | 1–3 | +| `origin_read_timeout` | Seconds | 30 | 15 | 10 | 1–30 | +| `origin_send_timeout` | Seconds | 30 | 15 | 10 | 1–30 | +| `origin_retry_limit` | Retries per incoming request | 2 | 1 | 0 | 0–2 | +| `origin_failure_threshold` | Failures before circuit opens | 10 | 5 | 3 | 1–10 | +| `origin_recovery_timeout` | Seconds before circuit recovery | 30 | 60 | 120 | 1–120 | +| `maximum_cache_key_length` | Bytes | 4,096 | 2,048 | 1,024 | 1–4,096 | +| `cache_admissions_per_second` | New cache admissions | 50 | 20 | 5 | 1–50 | + +The manual upper bound is calculated per field across all shipped presets. This +matters for controls such as `origin_recovery_timeout`, where a longer cooldown +is stricter even though its number is larger. Operational state is separate +from configured choice: a saved manual or standard profile compiles under the +protected ceilings while the domain is suspected/restricted, and under the +quarantine ceilings while it is quarantined. The configured values remain +available when the domain returns to normal. + +## API behavior + +`GET /api/domains/{domain}/security` returns the configured `profile`, complete +`limits`, and `platform_ceilings`. For `manual`, `platform_ceilings` contains the +field-wise safety ceiling shown in the table. `PATCH` replaces the complete +settings document; it is not a partial limit patch. A successful mutation +returns `202 Accepted` with an operation ID. + +Preset payloads must contain every limit at its exact shipped value. An altered +preset returns `422` and changes neither the revision nor stored settings. +Manual payloads must contain exactly the documented fields: missing, extra, +below-minimum, or above-ceiling values return `422`. `Idempotency-Key` replay +returns the original response, while reuse with different input returns the +stable `idempotency_conflict` error. + +## Automated qualification + +`python3 tests/e2e/phase6_security.py` first exercises the real control-plane +API and persistent PostgreSQL state for policy authorization, all profile +choices, fixed-preset rejection, every manual field boundary, complete payload +shape, persistence, and idempotency. It then builds and runs real OpenResty for +IPv4/IPv6 rules, request/body/rate/connection/origin enforcement, emergency +mode, bounded event reporting, resource isolation, and last-valid-state +preservation. It performs no rendered-UI inspection or browser automation; the +owner checklist remains required. + +Rejection occurs before origin work when possible. Origin concurrency is +reserved before proxying, retries are capped, and repeated failures open a +bounded circuit. Capacity exhaustion or an open circuit returns a controlled +response, while cached/stale policy may continue serving. Cache admission +rejects excessive or oversized keys and the cell cache/temp directories have +fixed quotas. No per-domain shared-memory zone, process, or cache directory is +created. + +HTTP/3 and WebSocket are disabled. Nginx applies bounded HTTP/1 and HTTP/2 +headers, bodies, streams, keep-alive requests, timeouts, listen backlog, worker +connections, and temporary storage. Each cell also has explicit CPU, memory, +PID, file-descriptor, and filesystem limits. diff --git a/docs/roadmap.md b/docs/roadmap.md index 294e40a..4eb3f86 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -670,6 +670,14 @@ The platform defines configurable maximums for: Every proxied hostname receives safe platform ceilings automatically. A domain user may choose a stricter supported profile or stricter values but cannot raise limits above platform safety ceilings. +The security settings surface offers three shipped, immutable recommendations: +`standard`, `protected`, and `quarantine`. Selecting one replaces every displayed +limit with that preset and users cannot edit its values. One `manual` profile is +the only editable limit set; every field is validated against the field-wise +platform safety ceiling derived from the shipped presets. Operational +restriction or quarantine may still compile a stricter effective ceiling than +the configured profile without overwriting the user's saved settings. + The edge node reserves capacity for the operating system, edge agent, configuration activation, health reporting, and telemetry. OpenResty cells must never be allowed to consume all node memory, processes, file descriptors, or disk. Runtime code must avoid: @@ -1923,6 +1931,8 @@ Certificate bundles are part of normal edge revision delivery and atomic activat ### Phase 6 — Basic Security and DDoS Readiness +> **Implementation progress (2026-07-20):** Phase 6 implementation and agent-owned qualification are complete. Ordered local rules, bounded readiness profiles, request/connection/origin/cache controls, operational states, target-first quarantine, persisted emergency controls, and pool withdrawal passed isolated and cumulative real-runtime qualification. The exact owner-run browser/real-host checklist is current but has not been executed, so Phase 6 is not owner release-qualified. Evidence: [Phase 6 qualification](phase-6-qualification.md). + #### Goal Provide understandable request protection, origin protection, noisy-neighbour isolation, quarantine, and emergency controls without claiming to be a volumetric scrubbing or advanced WAF platform. @@ -1963,17 +1973,21 @@ Evaluation is deterministic: ##### DDoS-Readiness Profiles -Each proxied hostname uses one platform-managed profile: +Each proxied hostname uses one configured profile: ```text standard protected quarantine +manual ``` `standard` provides safe normal limits. `protected` lowers expensive limits and increases origin protection during suspicious traffic. `quarantine` applies strict limits, may use cache-only or stale-first behaviour, and may move the domain into the quarantine edge pool. -Users may choose supported profiles and stricter values within platform-defined ranges. Users cannot disable or raise platform safety ceilings. +The first three are immutable platform-managed recommendations. `manual` is the +only profile whose values users can edit, and it remains within field-wise +platform-defined ranges. Users cannot disable or raise platform safety +ceilings. ##### Host and Network Readiness @@ -2246,79 +2260,79 @@ GET /api/domains/{domain}/security/events ##### Rules -- [ ] IPv4, IPv6, CIDR, country, and continent values validate. -- [ ] Priority and tie-breaking are deterministic. -- [ ] Large imports create one desired revision. -- [ ] Rules deploy and roll back through the normal pipeline. -- [ ] Security and analytics use the same geographic vocabulary. -- [ ] Unknown IPv6 geography does not break the domain. +- [x] IPv4, IPv6, CIDR, country, and continent values validate. +- [x] Priority and tie-breaking are deterministic. +- [x] Large imports create one desired revision. +- [x] Rules deploy and roll back through the normal pipeline. +- [x] Security and analytics use the same geographic vocabulary. +- [x] Unknown IPv6 geography does not break the domain. ##### Early Rejection and Limits -- [ ] Unknown HTTP hosts and TLS SNI names are rejected before expensive processing. -- [ ] One client IP cannot consume every connection assigned to a domain. -- [ ] One domain cannot consume every request or connection slot in a cell. -- [ ] Per-client and total-domain request limits work together. -- [ ] Fixed-size limit zones cannot grow without bound from random IPs or hostnames. -- [ ] Maximum header and body sizes reject before unsafe buffering. -- [ ] Slow-header, slow-body, idle, and keep-alive attacks release resources within configured timeouts. -- [ ] HTTP/2 stream, header, request-per-connection, and reset-rate limits remain bounded under attack. -- [ ] Disallowed methods return the defined response. +- [x] Unknown HTTP hosts and TLS SNI names are rejected before expensive processing. +- [x] One client IP cannot consume every connection assigned to a domain. +- [x] One domain cannot consume every request or connection slot in a cell. +- [x] Per-client and total-domain request limits work together. +- [x] Fixed-size limit zones cannot grow without bound from random IPs or hostnames. +- [x] Maximum header and body sizes reject before unsafe buffering. +- [x] Slow-header, slow-body, idle, and keep-alive attacks release resources within configured timeouts. +- [x] HTTP/2 stream, header, request-per-connection, and reset-rate limits remain bounded under attack. +- [x] Disallowed methods return the defined response. ##### Origin and Cache Protection -- [ ] Origin concurrency remains bounded during a request flood. -- [ ] Origin retries do not amplify incoming attacks. -- [ ] The origin circuit breaker serves cached, stale, or controlled error responses. -- [ ] Random URLs and query strings cannot fill cache storage indefinitely. -- [ ] Cache and temporary-storage exhaustion in one cell does not affect other cells. +- [x] Origin concurrency remains bounded during a request flood. +- [x] Origin retries do not amplify incoming attacks. +- [x] The origin circuit breaker serves cached, stale, or controlled error responses. +- [x] Random URLs and query strings cannot fill cache storage indefinitely. +- [x] Cache and temporary-storage exhaustion in one cell does not affect other cells. ##### Isolation and Emergency Operations -- [ ] Restricted mode does not reduce limits for unrelated domains. -- [ ] A quarantined domain moves only after target-cell readiness. -- [ ] Failed quarantine migration keeps the previous valid placement. -- [ ] A quarantined domain does not restart unrelated cells. -- [ ] One cell out-of-memory or crash event does not stop other cells or the agent. -- [ ] Emergency mode can target a domain, cell, pool, service IP, or edge. -- [ ] Emergency mode expires automatically when configured. -- [ ] Service-IP withdrawal removes only the affected pool from new DNS answers. -- [ ] IPv4 and IPv6 follow equivalent readiness and placement rules. +- [x] Restricted mode does not reduce limits for unrelated domains. +- [x] A quarantined domain moves only after target-cell readiness. +- [x] Failed quarantine migration keeps the previous valid placement. +- [x] A quarantined domain does not restart unrelated cells. +- [x] One cell out-of-memory or crash event does not stop other cells or the agent. +- [x] Emergency mode can target a domain, cell, pool, service IP, or edge. +- [x] Emergency mode expires automatically when configured. +- [x] Service-IP withdrawal removes only the affected pool from new DNS answers. +- [x] IPv4 and IPv6 follow equivalent readiness and placement rules. ##### Events and Availability -- [ ] Every rejection emits a stable reason code. -- [ ] Telemetry failure does not interrupt protection or traffic. -- [ ] Protection rules continue while Laravel, PostgreSQL, Redis/Valkey, and ClickHouse are unavailable. -- [ ] Bounded top-N noisy-domain metrics do not create per-domain heartbeat explosions. -- [ ] Legitimate traffic qualification measures false positives for standard and protected profiles. +- [x] Every rejection emits a stable reason code. +- [x] Telemetry failure does not interrupt protection or traffic. +- [x] Protection rules continue while Laravel, PostgreSQL, Redis/Valkey, and ClickHouse are unavailable. +- [x] Bounded top-N noisy-domain metrics do not create per-domain heartbeat explosions. +- [x] Legitimate traffic qualification measures false positives for standard and protected profiles. ##### Scope -- [ ] No CAPTCHA, bot score, challenge, custom WAF language, or volumetric scrubbing exists. -- [ ] No customer-editable ModSecurity or OWASP CRS rule surface exists. -- [ ] No runtime security decision calls Laravel or ClickHouse. -- [ ] Documentation clearly states that physical uplink saturation requires upstream mitigation. +- [x] No CAPTCHA, bot score, challenge, custom WAF language, or volumetric scrubbing exists. +- [x] No customer-editable ModSecurity or OWASP CRS rule surface exists. +- [x] No runtime security decision calls Laravel or ClickHouse. +- [x] Documentation clearly states that physical uplink saturation requires upstream mitigation. ##### Browser and Real Runtime - [ ] User changes between standard and protected profiles within platform ceilings. - [ ] Administrator restricts, quarantines, releases, and moves a domain in the browser. -- [ ] Real IPv4, IPv6, rate, connection, body-size, timeout, origin-capacity, and country tests match displayed events. -- [ ] Cache-abuse and random-query tests remain inside disk and memory limits. -- [ ] A simulated attacked domain does not interrupt traffic for a domain in another cell. -- [ ] Invalid readiness deployment leaves prior rules and placement active. +- [x] Real IPv4, IPv6, rate, connection, body-size, timeout, origin-capacity, and country tests match displayed events. +- [x] Cache-abuse and random-query tests remain inside disk and memory limits. +- [x] A simulated attacked domain does not interrupt traffic for a domain in another cell. +- [x] Invalid readiness deployment leaves prior rules and placement active. ##### Documentation -- [ ] Security-rule and ordering guide -- [ ] DDoS-readiness profile and platform-ceiling guide -- [ ] Request, connection, timeout, and origin-limit semantics -- [ ] Trusted client-IP deployment guide -- [ ] Security and DDoS reason-code reference -- [ ] Quarantine and recovery runbook -- [ ] Edge emergency-mode runbook -- [ ] Explicit volumetric-attack scope boundary +- [x] Security-rule and ordering guide +- [x] DDoS-readiness profile and platform-ceiling guide +- [x] Request, connection, timeout, and origin-limit semantics +- [x] Trusted client-IP deployment guide +- [x] Security and DDoS reason-code reference +- [x] Quarantine and recovery runbook +- [x] Edge emergency-mode runbook +- [x] Explicit volumetric-attack scope boundary --- diff --git a/docs/security-reason-codes.md b/docs/security-reason-codes.md new file mode 100644 index 0000000..72b362f --- /dev/null +++ b/docs/security-reason-codes.md @@ -0,0 +1,23 @@ +# Security and DDoS reason codes + +Edge enforcement emits a stable reason in structured logs, bounded heartbeat +events, and controlled responses where safe. Telemetry loss never disables the +decision. + +| Reason | Meaning | +|---|---| +| `unknown_host` / `unknown_sni` | Host or TLS name is not in the active cell artifact | +| `invalid_method` / `malformed_request` | Method or request syntax violates policy | +| `header_too_large` / `body_too_large` | Configured request size ceiling was exceeded | +| `header_timeout` / `body_timeout` | Client did not supply bounded input in time | +| `client_rate_exceeded` / `domain_rate_exceeded` | Client or whole-domain request budget is exhausted | +| `client_connections_exceeded` / `domain_connections_exceeded` | Concurrent connection budget is exhausted | +| `tls_handshake_rate_exceeded` | Domain TLS handshake budget is exhausted | +| `origin_capacity_exceeded` / `origin_circuit_open` | Origin concurrency is full or its circuit is open | +| `cache_abuse_detected` | Cache key/admission policy rejected high-cardinality input | +| `domain_restricted` / `domain_quarantined` | Operational state is enforcing stronger isolation | +| `edge_emergency_mode` | An active edge/cell emergency action rejected the request | + +Reason codes describe the enforcing boundary, not attacker identity. Use the +domain event endpoint with cursor pagination and correlate its time, domain, +cell, and aggregate count with edge logs. diff --git a/docs/security-rules.md b/docs/security-rules.md new file mode 100644 index 0000000..7454465 --- /dev/null +++ b/docs/security-rules.md @@ -0,0 +1,26 @@ +# Security rules + +Security rules are desired domain state and deploy inside the normal signed, +cell-specific edge artifact. A request-time rule decision never calls Laravel, +Valkey, PostgreSQL, or ClickHouse. + +Each rule has `match_type` (`ip`, `cidr`, `country`, or `continent`), `value`, +`action` (`allow` or `block`), integer `priority`, enabled state, and an optional +250-character note. IPv4 and IPv6 values are normalized before storage. Country +and continent codes use the same vocabulary as Geo-DNS. + +Enabled rules are evaluated by ascending priority and then stable database ID. +The first match wins; no match allows the request. Unknown geography matches no +geographic rule, while IP and CIDR rules continue to work when the MMDB is +unavailable. An explicit allow therefore overrides only later rules. + +The API accepts at most 500 rules per import and 1,000 rules per domain. It +normalizes and validates every row before one transaction increments one desired +revision. `replace_existing` replaces only after the complete candidate is +valid. Deployment uses the normal checksum/activate/acknowledge pipeline; an +invalid candidate cannot replace the last valid runtime. + +In Filament open a domain, then **Security rules**. Create or edit individual +rules in the table. **Import rules** shows a bounded preview before committing. +Use low priority numbers for intentional exceptions, keep notes operational, +and avoid broad allow CIDRs unless they are required. diff --git a/docs/trusted-client-ip.md b/docs/trusted-client-ip.md new file mode 100644 index 0000000..ba52fb4 --- /dev/null +++ b/docs/trusted-client-ip.md @@ -0,0 +1,17 @@ +# Trusted client IP deployment + +By default the connecting socket address is the client identity. Configure +`trusted_proxy_cidrs` only when an approved L4/L7 balancer is immediately in +front of the edge and overwrites `X-Forwarded-For` from untrusted clients. + +The edge accepts the first `X-Forwarded-For` address only when the direct peer +matches one of at most 32 configured IPv4/IPv6 CIDRs. Otherwise it ignores the +header. It never trusts the header merely because it is present. Configure the +smallest balancer ranges, include IPv4 and IPv6 paths explicitly, and prevent +direct access that bypasses the balancer. + +Qualification must send a spoofed header directly and confirm the socket IP is +used, then send through each trusted balancer family and confirm the overwritten +address drives IP/CIDR and GeoIP decisions. A deployment that appends rather +than overwrites client-provided forwarding headers is unsafe and must not enable +this option. diff --git a/edge-agent/main.go b/edge-agent/main.go index 9610cc6..06eadc2 100644 --- a/edge-agent/main.go +++ b/edge-agent/main.go @@ -112,15 +112,19 @@ type edgeTask struct { ID string `json:"id"` Type string `json:"type"` Payload struct { - CellName string `json:"cell_name"` - Addresses []string `json:"addresses"` - Allowlist []string `json:"private_allowlist"` - BlockedNetworks []string `json:"blocked_networks"` - Domain string `json:"domain"` - PurgeType string `json:"type"` - CacheEpoch uint64 `json:"cache_epoch"` - CacheKeys []string `json:"cache_keys"` - Origin struct { + CellName string `json:"cell_name"` + Addresses []string `json:"addresses"` + Allowlist []string `json:"private_allowlist"` + BlockedNetworks []string `json:"blocked_networks"` + Domain string `json:"domain"` + PurgeType string `json:"type"` + CacheEpoch uint64 `json:"cache_epoch"` + CacheKeys []string `json:"cache_keys"` + CellNames []string `json:"cell_names"` + EmergencyActive bool `json:"active"` + EmergencyActions []string `json:"actions"` + EmergencyExpiresAt *int64 `json:"expires_at"` + Origin struct { Host string `json:"host"` Scheme string `json:"scheme"` HostHeader string `json:"host_header"` @@ -153,6 +157,8 @@ func (c *client) processTasks() error { result, status = c.runCellTask(task) } else if task.Type == "cache_purge" { result, status = c.runCachePurge(task) + } else if task.Type == "emergency_mode" { + result, status = c.runEmergencyMode(task) } if err := c.request("POST", "/edge/v1/tasks/"+task.ID+"/result", map[string]any{"status": status, "result": result}, &map[string]any{}, true); err != nil { return err @@ -161,6 +167,47 @@ func (c *client) processTasks() error { return nil } +func (c *client) runEmergencyMode(task edgeTask) (map[string]any, string) { + if len(task.Payload.EmergencyActions) > 11 || len(task.Payload.CellNames) > 32 { + return map[string]any{"status": "failed", "failure_reason": "invalid_emergency_task"}, "failed" + } + command := map[string]any{ + "task_id": task.ID, "action": "emergency_mode", "active": task.Payload.EmergencyActive, + "actions": task.Payload.EmergencyActions, "expires_at": task.Payload.EmergencyExpiresAt, + } + applied := 0 + for _, endpoint := range c.statusURLs { + cell, ok := c.cellStatus(endpoint) + if !ok { + continue + } + cellName, _ := cell["name"].(string) + if cellName == "" || len(task.Payload.CellNames) > 0 && !containsString(task.Payload.CellNames, cellName) { + continue + } + if err := c.control(endpoint, command); err != nil { + return map[string]any{"status": "failed", "failure_reason": "emergency_control_failed", "applied_cells": applied}, "failed" + } + if err := c.saveEmergencyControl(cellName, emergencyControl{Active: task.Payload.EmergencyActive, Actions: task.Payload.EmergencyActions, ExpiresAt: task.Payload.EmergencyExpiresAt}); err != nil { + return map[string]any{"status": "failed", "failure_reason": "control_state_persist_failed", "applied_cells": applied}, "failed" + } + applied++ + } + if applied == 0 { + return map[string]any{"status": "failed", "failure_reason": "cell_not_found"}, "failed" + } + return map[string]any{"status": "completed", "applied_cells": applied, "active": task.Payload.EmergencyActive}, "succeeded" +} + +func containsString(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + func (c *client) runCachePurge(task edgeTask) (map[string]any, string) { if task.Payload.Domain == "" || (task.Payload.PurgeType != "all" && task.Payload.PurgeType != "urls") || task.Payload.CacheEpoch == 0 || len(task.Payload.CacheKeys) > 100 { return map[string]any{"status": "failed", "failure_reason": "invalid_cache_purge_task"}, "failed" @@ -263,6 +310,40 @@ func (c *client) loadCellControls() (map[string]bool, error) { return controls, nil } +type emergencyControl struct { + Active bool `json:"active"` + Actions []string `json:"actions"` + ExpiresAt *int64 `json:"expires_at"` +} + +func (c *client) saveEmergencyControl(cellName string, control emergencyControl) error { + controls, err := c.loadEmergencyControls() + if err != nil { + return err + } + if control.Active { + controls[cellName] = control + } else { + delete(controls, cellName) + } + return atomicJSON(filepath.Join(c.dir, "emergency-controls.json"), controls) +} + +func (c *client) loadEmergencyControls() (map[string]emergencyControl, error) { + controls := map[string]emergencyControl{} + body, err := os.ReadFile(filepath.Join(c.dir, "emergency-controls.json")) + if errors.Is(err, os.ErrNotExist) { + return controls, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &controls); err != nil { + return nil, err + } + return controls, nil +} + func runOriginTest(task edgeTask) map[string]any { started := time.Now() result := map[string]any{"status": "unhealthy"} @@ -665,9 +746,11 @@ func compileRuntime(s state) (map[string]any, map[string]map[string]any, error) for _, raw := range s.Domains { var domain struct { Domain string `json:"domain"` + DomainID uint64 `json:"domain_id"` Revision uint64 `json:"revision"` Settings map[string]any `json:"settings"` Cache map[string]any `json:"cache"` + Security map[string]any `json:"security"` TLS map[string]any `json:"tls"` Pools []string `json:"pools"` Hostnames []struct { @@ -724,7 +807,7 @@ func compileRuntime(s state) (map[string]any, map[string]map[string]any, error) } hostTLS["certificate_id"] = hostCertificateID } - compiled := map[string]any{"domain": domain.Domain, "revision": domain.Revision, "settings": domain.Settings, "cache": domain.Cache, "tls": hostTLS, "origin": host.Origin} + compiled := map[string]any{"domain": domain.Domain, "domain_id": domain.DomainID, "revision": domain.Revision, "settings": domain.Settings, "cache": domain.Cache, "security": domain.Security, "tls": hostTLS, "origin": host.Origin} hosts[name] = compiled for _, pool := range domain.Pools { if !validPoolName(pool) { @@ -764,7 +847,7 @@ func validPoolName(name string) bool { } func (c *client) heartbeat(sequence uint64) error { - cells, failures := c.runtimeStatus() + cells, failures, security := c.runtimeStatus() listenerReady := false for _, cell := range cells { if cell["name"] == "shared-default" && cell["status"] == "ready" { @@ -773,14 +856,16 @@ func (c *client) heartbeat(sequence uint64) error { } return c.request("POST", "/edge/v1/heartbeat", map[string]any{ "agent_version": version, "listener_ready": listenerReady, "active_sequence": sequence, - "cells": cells, "passive_origins": failures, + "cells": cells, "passive_origins": failures, "noisy_domains": security, }, &map[string]any{}, true) } -func (c *client) runtimeStatus() ([]map[string]any, []map[string]any) { +func (c *client) runtimeStatus() ([]map[string]any, []map[string]any, []map[string]any) { cells := []map[string]any{} failures := []map[string]any{} + security := []map[string]any{} controls, _ := c.loadCellControls() + emergencyControls, _ := c.loadEmergencyControls() for _, endpoint := range c.statusURLs { decoded, ok := c.runtimeEndpointStatus(endpoint) if !ok { @@ -799,16 +884,36 @@ func (c *client) runtimeStatus() ([]map[string]any, []map[string]any) { _ = c.controlCell(endpoint, "restore-"+hex.EncodeToString(restoreKey[:16]), action) } } + if emergency, configured := emergencyControls[name]; configured { + active := emergency.Active + if emergency.ExpiresAt != nil && *emergency.ExpiresAt <= time.Now().Unix() { + active = false + } + restoreKey := sha256.Sum256([]byte(name + "|emergency|" + strconv.FormatBool(active) + "|" + strings.Join(emergency.Actions, ","))) + _ = c.control(endpoint, map[string]any{ + "task_id": "restore-" + hex.EncodeToString(restoreKey[:16]), "action": "emergency_mode", + "active": active, "actions": emergency.Actions, "expires_at": emergency.ExpiresAt, + }) + if !active { + _ = c.saveEmergencyControl(name, emergencyControl{}) + } + } cells = append(cells, decoded.Cell) } for _, failure := range decoded.Data { if len(failures) >= 100 { - return cells, failures + return cells, failures, security } failures = append(failures, failure) } + for _, event := range decoded.Security { + if len(security) >= 20 { + break + } + security = append(security, event) + } } - return cells, failures + return cells, failures, security } func (c *client) cellStatus(endpoint string) (map[string]any, bool) { @@ -817,8 +922,9 @@ func (c *client) cellStatus(endpoint string) (map[string]any, bool) { } type runtimeEndpointResponse struct { - Data []map[string]any `json:"data"` - Cell map[string]any `json:"cell"` + Data []map[string]any `json:"data"` + Security []map[string]any `json:"security"` + Cell map[string]any `json:"cell"` } func (c *client) runtimeEndpointStatus(endpoint string) (runtimeEndpointResponse, bool) { diff --git a/edge-agent/main_test.go b/edge-agent/main_test.go index 631114a..64c5e1e 100644 --- a/edge-agent/main_test.go +++ b/edge-agent/main_test.go @@ -263,17 +263,66 @@ func TestPassiveFailuresAreBoundedAndAuthenticated(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{ "domain": "example.test", "hostname": "www.example.test", "failure_count": 2, "last_status": 502, "last_failed_at": 123, - }}, "cell": map[string]any{"name": "shared-default", "status": "ready", "capacity": map[string]any{}}}) + }}, "security": []map[string]any{{"domain_id": 1, "reason_code": "client_rate_exceeded", "count": 3}}, "cell": map[string]any{"name": "shared-default", "status": "ready", "capacity": map[string]any{}}}) })) defer server.Close() c := &client{http: server.Client(), statusToken: "status-secret", statusURLs: []string{server.URL}} - cells, failures := c.runtimeStatus() + cells, failures, security := c.runtimeStatus() if len(failures) != 1 || failures[0]["hostname"] != "www.example.test" { t.Fatalf("passive failures were not collected: %#v", failures) } if len(cells) != 1 || cells[0]["name"] != "shared-default" { t.Fatalf("cell status was not collected: %#v", cells) } + if len(security) != 1 || security[0]["reason_code"] != "client_rate_exceeded" { + t.Fatalf("security top-N was not collected: %#v", security) + } +} + +func TestEmergencyModeTargetsOneCellWithBoundedActions(t *testing.T) { + controlCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Edge-Status-Token") != "status-secret" { + http.NotFound(w, r) + return + } + if r.URL.Path == "/passive-failures" { + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}, "security": []any{}, "cell": map[string]any{"name": "quarantine-default", "status": "ready", "capacity": map[string]any{}}}) + return + } + var command struct { + Action string `json:"action"` + Active bool `json:"active"` + Actions []string `json:"actions"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&command) + if command.Action != "emergency_mode" || !command.Active || len(command.Actions) != 2 { + http.Error(w, "invalid emergency command", http.StatusBadRequest) + return + } + controlCalls++ + _, _ = w.Write([]byte(`{"data":{"accepted":true}}`)) + })) + defer server.Close() + c := &client{http: server.Client(), statusToken: "status-secret", statusURLs: []string{server.URL + "/passive-failures"}, dir: t.TempDir()} + var task edgeTask + task.ID = "emergency-1" + task.Type = "emergency_mode" + task.Payload.CellNames = []string{"quarantine-default"} + task.Payload.EmergencyActive = true + task.Payload.EmergencyActions = []string{"allow_get_head_only", "disable_origin_retries"} + result, status := c.runEmergencyMode(task) + if status != "succeeded" || result["status"] != "completed" || controlCalls != 1 { + t.Fatalf("emergency mode did not reach the selected cell: status=%s result=%#v calls=%d", status, result, controlCalls) + } + controls, err := c.loadEmergencyControls() + if err != nil || !controls["quarantine-default"].Active { + t.Fatalf("emergency mode was not persisted: controls=%#v err=%v", controls, err) + } + c.runtimeStatus() + if controlCalls != 2 { + t.Fatalf("persisted emergency mode was not restored: calls=%d", controlCalls) + } } func TestCellControlTaskUsesAuthenticatedBoundedSupervisorEndpoint(t *testing.T) { diff --git a/tests/e2e/phase4_runtime.py b/tests/e2e/phase4_runtime.py index fdf89cc..ec56682 100644 --- a/tests/e2e/phase4_runtime.py +++ b/tests/e2e/phase4_runtime.py @@ -342,7 +342,7 @@ def main() -> None: tls_client = ("docker", "run", "--rm", "--network", f"container:{NAME}", "curlimages/curl:8.16.0", "-ksS", "--resolve", "runtime.example:8443:127.0.0.1") inbound_tls = run(*tls_client, "--http2", "https://runtime.example:8443/") - assert '"host":"origin-one.example"' in inbound_tls.stdout, inbound_tls.stderr + assert '"host":"origin-one.example"' in inbound_tls.stdout, f"{inbound_tls.stderr}\n{inbound_tls.stdout}" unknown_sni = run(*tls_client, "--connect-timeout", "2", "https://unknown.example:8443/", check=False) assert unknown_sni.returncode != 0, unknown_sni.stdout ipv6_client = run("docker", "run", "--rm", "--network", f"container:{NAME}", "alpine:3.22", @@ -403,11 +403,12 @@ def main() -> None: time.sleep(1.5) assert request("runtime.example").returncode == 0 finally: - run("docker", "rm", "-f", NAME, check=False) - run("docker", "stop", QUARANTINE_NAME, check=False) - run("docker", "stop", AGENT_NAME, check=False) - run("docker", "stop", DEDICATED_NAME, check=False) - run("docker", "stop", TLS_NAME, check=False) + if os.environ.get("CDNF_KEEP_FAILED_RUNTIME") != "1": + run("docker", "rm", "-f", NAME, check=False) + run("docker", "stop", QUARANTINE_NAME, check=False) + run("docker", "stop", AGENT_NAME, check=False) + run("docker", "stop", DEDICATED_NAME, check=False) + run("docker", "stop", TLS_NAME, check=False) print("Phase 4 OpenResty runtime qualification passed.") diff --git a/tests/e2e/phase6_security.py b/tests/e2e/phase6_security.py new file mode 100644 index 0000000..f4018ec --- /dev/null +++ b/tests/e2e/phase6_security.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Real control-plane and OpenResty Phase 6 qualification; no browser automation.""" + +import json +import os +import pathlib +import secrets +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +ROOT = pathlib.Path(__file__).resolve().parents[2] +BASE_URL = os.environ.get("CDNF_BASE_URL", "http://localhost:8080").rstrip("/") +COMPOSE_FILE = os.environ.get("CDNF_COMPOSE_FILE", "compose.dev.yml") +NAME = "cdnf-phase6-security-e2e" +SLOW_CLIENT = "cdnf-phase6-slow-client-e2e" +SLOW_ORIGIN_CLIENT = "cdnf-phase6-origin-client-e2e" +EDGE_NETWORK = os.environ.get("CDNF_EDGE_NETWORK", f"{os.environ.get('COMPOSE_PROJECT_NAME', 'cdnfoundry-dev')}_edge") +MMDB_VOLUME = f"{os.environ.get('COMPOSE_PROJECT_NAME', 'cdnfoundry-dev')}_mmdb" +RUN_ID = f"{int(time.time())}-{secrets.token_hex(4)}" +USER_EMAIL = f"phase6-profile-{RUN_ID}@example.test" +STRANGER_EMAIL = f"phase6-stranger-{RUN_ID}@example.test" +PASSWORD = f"Security-1-{secrets.token_urlsafe(18)}" +DOMAIN = f"profile-{RUN_ID}.phase6.test" +PRESET_LIMITS = { + "standard": { + "requests_per_second": 100, "request_burst": 200, "connections_per_client": 64, + "connections_per_domain": 512, "tls_handshakes_per_second": 50, + "maximum_request_body_size": 16777216, "maximum_header_size": 32768, + "client_header_timeout": 10, "client_body_timeout": 30, "keepalive_timeout": 30, + "maximum_requests_per_connection": 1000, "maximum_request_duration": 60, + "origin_max_connections": 128, "origin_connect_timeout": 3, "origin_read_timeout": 30, + "origin_send_timeout": 30, "origin_retry_limit": 2, "origin_failure_threshold": 10, + "origin_recovery_timeout": 30, "maximum_cache_key_length": 4096, + "cache_admissions_per_second": 50, + }, + "protected": { + "requests_per_second": 50, "request_burst": 75, "connections_per_client": 24, + "connections_per_domain": 256, "tls_handshakes_per_second": 20, + "maximum_request_body_size": 8388608, "maximum_header_size": 16384, + "client_header_timeout": 7, "client_body_timeout": 15, "keepalive_timeout": 15, + "maximum_requests_per_connection": 250, "maximum_request_duration": 30, + "origin_max_connections": 64, "origin_connect_timeout": 2, "origin_read_timeout": 15, + "origin_send_timeout": 15, "origin_retry_limit": 1, "origin_failure_threshold": 5, + "origin_recovery_timeout": 60, "maximum_cache_key_length": 2048, + "cache_admissions_per_second": 20, + }, + "quarantine": { + "requests_per_second": 10, "request_burst": 10, "connections_per_client": 4, + "connections_per_domain": 48, "tls_handshakes_per_second": 5, + "maximum_request_body_size": 1048576, "maximum_header_size": 8192, + "client_header_timeout": 5, "client_body_timeout": 10, "keepalive_timeout": 5, + "maximum_requests_per_connection": 50, "maximum_request_duration": 15, + "origin_max_connections": 16, "origin_connect_timeout": 1, "origin_read_timeout": 10, + "origin_send_timeout": 10, "origin_retry_limit": 0, "origin_failure_threshold": 3, + "origin_recovery_timeout": 120, "maximum_cache_key_length": 1024, + "cache_admissions_per_second": 5, + }, +} + + +class ApiError(RuntimeError): + def __init__(self, status_code: int, payload: object): + super().__init__(f"HTTP {status_code}: {payload}") + self.status_code = status_code + self.payload = payload + + +def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run(args, cwd=ROOT, check=False, text=True, capture_output=True) + if check and result.returncode != 0: + raise RuntimeError(f"command failed ({result.returncode}): {' '.join(args)}\n{result.stdout}\n{result.stderr}") + return result + + +def api(method: str, path: str, payload: object | None = None, token: str | None = None, + idempotency_key: str | None = None) -> tuple[int, object]: + headers = {"Accept": "application/json"} + data = None + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode() + if token: + headers["Authorization"] = f"Bearer {token}" + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + request = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read() + return response.status, json.loads(body) if body else {} + except urllib.error.HTTPError as error: + body = error.read() + raise ApiError(error.code, json.loads(body) if body else {}) from error + + +def expect_api_error(status_code: int, method: str, path: str, **kwargs: object) -> ApiError: + try: + api(method, path, **kwargs) + except ApiError as error: + assert error.status_code == status_code, error + return error + raise AssertionError(f"{method} {path} unexpectedly succeeded; expected HTTP {status_code}") + + +def php_string(value: str) -> str: + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'" + + +def artisan(expression: str) -> str: + result = run( + "docker", "compose", "-f", COMPOSE_FILE, "exec", "-T", "core", + "php", "artisan", "tinker", f"--execute={expression}", + ) + return result.stdout.strip() + + +def wait_for_api() -> None: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + status_code, payload = api("GET", "/api/health") + if status_code == 200 and payload.get("status") == "ok": + return + except (ApiError, OSError): + pass + time.sleep(1) + raise RuntimeError("control-plane API did not become healthy within 60 seconds") + + +def profile_payload(profile: str, selected_limits: dict[str, int]) -> dict: + return { + "profile": profile, + "quarantine_policy": "manual", + "allowed_methods": ["GET", "HEAD", "POST"], + "trusted_proxy_cidrs": [], + "limits": selected_limits, + } + + +def validation_errors(error: ApiError) -> dict: + assert isinstance(error.payload, dict), error.payload + errors = error.payload.get("errors", {}) + assert isinstance(errors, dict), error.payload + return errors + + +def qualify_profile_control_plane() -> None: + wait_for_api() + domain_id = artisan( + "$user=App\\Models\\User::query()->create([" + f"'name'=>'Phase 6 profile user','email'=>{php_string(USER_EMAIL)}," + f"'password'=>Illuminate\\Support\\Facades\\Hash::make({php_string(PASSWORD)}),'type'=>'user']);" + "$stranger=App\\Models\\User::query()->create([" + f"'name'=>'Phase 6 stranger','email'=>{php_string(STRANGER_EMAIL)}," + f"'password'=>Illuminate\\Support\\Facades\\Hash::make({php_string(PASSWORD)}),'type'=>'user']);" + f"$domain=App\\Models\\Domain::query()->create(['name'=>{php_string(DOMAIN)},'display_name'=>'Phase 6 profiles','revision'=>1]);" + "$domain->users()->attach($user);echo $domain->id;" + ) + assert domain_id.isdigit(), domain_id + _, login = api("POST", "/api/auth/login", {"email": USER_EMAIL, "password": PASSWORD, "device_name": "phase6-profile-e2e"}) + _, stranger_login = api("POST", "/api/auth/login", {"email": STRANGER_EMAIL, "password": PASSWORD, "device_name": "phase6-stranger-e2e"}) + token = login["data"]["token"] + stranger_token = stranger_login["data"]["token"] + path = f"/api/domains/{domain_id}/security" + + expect_api_error(403, "GET", path, token=stranger_token) + _, initial = api("GET", path, token=token) + assert initial["data"]["profile"] == "standard", initial + assert initial["data"]["limits"] == PRESET_LIMITS["standard"], initial + + for profile, expected_limits in PRESET_LIMITS.items(): + payload = profile_payload(profile, expected_limits) + status_code, operation = api("PATCH", path, payload, token, str(uuid.uuid4())) + assert status_code == 202 and operation["data"]["operation_id"], operation + _, persisted = api("GET", path, token=token) + assert persisted["data"]["profile"] == profile, persisted + assert persisted["data"]["limits"] == expected_limits, persisted + + changed = profile_payload(profile, {**expected_limits, "requests_per_second": expected_limits["requests_per_second"] - 1}) + immutable = expect_api_error(422, "PATCH", path, payload=changed, token=token, idempotency_key=str(uuid.uuid4())) + assert "limits.requests_per_second" in validation_errors(immutable), immutable.payload + _, unchanged = api("GET", path, token=token) + assert unchanged["data"]["profile"] == profile and unchanged["data"]["limits"] == expected_limits, unchanged + + platform_ceilings = { + field: max(profile[field] for profile in PRESET_LIMITS.values()) + for field in PRESET_LIMITS["standard"] + } + manual_limits = { + field: max(0 if field == "origin_retry_limit" else 1, ceiling // 2) + for field, ceiling in platform_ceilings.items() + } + manual = profile_payload("manual", manual_limits) + replay_key = str(uuid.uuid4()) + first_status, first = api("PATCH", path, manual, token, replay_key) + replay_status, replay = api("PATCH", path, manual, token, replay_key) + assert first_status == replay_status == 202 and first == replay, (first, replay) + conflict_payload = profile_payload("manual", {**manual_limits, "requests_per_second": manual_limits["requests_per_second"] + 1}) + conflict = expect_api_error(409, "PATCH", path, payload=conflict_payload, token=token, idempotency_key=replay_key) + assert conflict.payload["error"]["code"] == "idempotency_conflict", conflict.payload + + _, persisted_manual = api("GET", path, token=token) + assert persisted_manual["data"]["profile"] == "manual", persisted_manual + assert persisted_manual["data"]["limits"] == manual_limits, persisted_manual + assert persisted_manual["data"]["platform_ceilings"] == platform_ceilings, persisted_manual + + too_high = profile_payload("manual", {field: value + 1 for field, value in platform_ceilings.items()}) + high_error = expect_api_error(422, "PATCH", path, payload=too_high, token=token, idempotency_key=str(uuid.uuid4())) + high_keys = validation_errors(high_error) + assert all(f"limits.{field}" in high_keys for field in platform_ceilings), high_keys + + too_low = profile_payload("manual", {field: -1 for field in platform_ceilings}) + low_error = expect_api_error(422, "PATCH", path, payload=too_low, token=token, idempotency_key=str(uuid.uuid4())) + low_keys = validation_errors(low_error) + assert all(f"limits.{field}" in low_keys for field in platform_ceilings), low_keys + + missing = profile_payload("manual", {field: value for field, value in manual_limits.items() if field != "requests_per_second"}) + missing_error = expect_api_error(422, "PATCH", path, payload=missing, token=token, idempotency_key=str(uuid.uuid4())) + assert "limits.requests_per_second" in validation_errors(missing_error), missing_error.payload + extra = profile_payload("manual", {**manual_limits, "unbounded_extra": 1}) + extra_error = expect_api_error(422, "PATCH", path, payload=extra, token=token, idempotency_key=str(uuid.uuid4())) + assert "limits" in validation_errors(extra_error), extra_error.payload + invalid_profile = expect_api_error( + 422, "PATCH", path, payload=profile_payload("custom", manual_limits), + token=token, idempotency_key=str(uuid.uuid4()), + ) + assert "profile" in validation_errors(invalid_profile), invalid_profile.payload + + _, still_manual = api("GET", path, token=token) + assert still_manual["data"]["profile"] == "manual" and still_manual["data"]["limits"] == manual_limits, still_manual + + +def limits(**overrides: int) -> dict[str, int]: + values = { + "requests_per_second": 100, "request_burst": 200, "connections_per_client": 64, + "connections_per_domain": 512, "tls_handshakes_per_second": 50, + "maximum_request_body_size": 1024, "maximum_header_size": 8192, + "client_header_timeout": 5, "client_body_timeout": 5, "keepalive_timeout": 5, + "maximum_requests_per_connection": 100, "maximum_request_duration": 30, + "origin_max_connections": 16, "origin_connect_timeout": 2, "origin_read_timeout": 10, + "origin_send_timeout": 10, "origin_retry_limit": 0, "origin_failure_threshold": 2, + "origin_recovery_timeout": 10, "maximum_cache_key_length": 1024, + "cache_admissions_per_second": 20, + } + values.update(overrides) + return values + + +def host(domain_id: int, rules: list[dict] | None = None, state: str = "normal", **limit_overrides: int) -> dict: + return { + "domain": f"domain-{domain_id}.example", "domain_id": domain_id, "revision": 1, + "settings": {"enabled": True}, "cache": {"enabled": False, "epoch": 1}, + "security": { + "profile": "standard", "effective_profile": "standard", "state": state, + "quarantine_policy": "manual", "allowed_methods": ["GET", "HEAD", "POST"], + "trusted_proxy_cidrs": ["127.0.0.0/8", "::1/128"], "limits": limits(**limit_overrides), + "rules": rules or [], + }, + "origin": { + "host": "origin-http", "port": 80, "scheme": "http", "host_header": "phase6-origin.example", + "sni": None, "verify_tls": False, "connect_timeout_ms": 1000, + "response_timeout_ms": 10000, "retry_count": 0, "websocket": False, + "private_allowlist": ["172.16.0.0/12"], "blocked_networks": [], "blocked_addresses": [], + }, + } + + +def curl(hostname: str, path: str = "/", method: str = "GET", headers: tuple[str, ...] = (), data: str | None = None) -> subprocess.CompletedProcess[str]: + command = ["docker", "run", "--rm", "--network", f"container:{NAME}", "curlimages/curl:8.16.0", "-sS", "-D", "-", "-o", "/dev/null", "-X", method, "-H", f"Host: {hostname}"] + command.extend([value for header in headers for value in ("-H", header)]) + if data is not None: + command.extend(["--data-binary", data]) + command.append(f"http://127.0.0.1:8080{path}") + return run(*command, check=False) + + +def status(response: subprocess.CompletedProcess[str], expected: int, reason: str | None = None) -> None: + assert f"HTTP/1.1 {expected}" in response.stdout, response.stdout + response.stderr + if reason: + assert f"X-CDNFoundry-Security-Reason: {reason}".lower() in response.stdout.lower(), response.stdout + + +def control(task_id: str, active: bool, actions: list[str], expires_at: int | None = None) -> dict: + payload = {"task_id": task_id, "action": "emergency_mode", "active": active, "actions": actions, "expires_at": expires_at} + response = run("docker", "exec", NAME, "wget", "-q", "-O-", "--header=X-Edge-Status-Token: runtime-test-token", "--header=Content-Type: application/json", f"--post-data={json.dumps(payload, separators=(',', ':'))}", "http://127.0.0.1:9080/control") + return json.loads(response.stdout)["data"] + + +def main() -> None: + run("docker", "compose", "-f", "compose.dev.yml", "up", "-d", "--force-recreate", "origin-http") + run("docker", "compose", "-f", "compose.dev.yml", "up", "-d", "mmdb-updater") + qualify_profile_control_plane() + run("docker", "build", "-f", "docker/openresty/Dockerfile", "-t", "cdnfoundry/edge-runtime:phase6", ".") + with tempfile.TemporaryDirectory(prefix="cdnf-phase6-") as directory: + os.chmod(directory, 0o755) + temporary = pathlib.Path(directory) + run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=phase6-runtime", "-keyout", str(temporary / "tls.key"), "-out", str(temporary / "tls.crt")) + hosts = { + "healthy.example": host(1), + "ipv4.example": host(2, [{"id": 1, "match_type": "cidr", "value": "127.0.0.0/8", "action": "block", "priority": 1}]), + "ipv6.example": host(3, [{"id": 2, "match_type": "cidr", "value": "::1/128", "action": "block", "priority": 1}]), + "geo.example": host(4, [{"id": 3, "match_type": "country", "value": "US", "action": "block", "priority": 1}]), + "rate.example": host(5, requests_per_second=2, request_burst=1), + "connections.example": host(6, connections_per_client=1, connections_per_domain=2), + "origin-capacity.example": host(7, origin_max_connections=1), + "quarantine.example": host(8, state="quarantined"), + "body.example": host(9, maximum_request_body_size=8), + } + state = {"schema_version": 1, "sequence": 1, "certificates": {}, "hosts": hosts} + runtime = temporary / "active.json" + runtime.write_text(json.dumps(state, separators=(",", ":"))) + for file in (runtime, temporary / "tls.key", temporary / "tls.crt"): + file.chmod(0o644) + run("docker", "run", "-d", "--name", NAME, "--network", EDGE_NETWORK, + "--tmpfs", "/var/cache/nginx:rw,size=64m", "--tmpfs", "/var/lib/nginx/tmp:rw,size=32m", + "--memory", "256m", "--cpus", "0.5", "--pids-limit", "96", + "--ulimit", "nofile=65536:65536", + "-e", "EDGE_RUNTIME_FILE=/var/lib/cdnfoundry/runtime/active.json", "-e", "EDGE_STATUS_TOKEN=runtime-test-token", + "-e", "GEOIP_DATABASE=/mmdb/GeoLite2-City.mmdb", "-v", f"{directory}:/var/lib/cdnfoundry/runtime:ro", + "-v", f"{temporary / 'tls.crt'}:/run/edge/tls.crt:ro", "-v", f"{temporary / 'tls.key'}:/run/edge/tls.key:ro", + "-v", f"{MMDB_VOLUME}:/mmdb:ro", "cdnfoundry/edge-runtime:phase6") + try: + run("docker", "exec", NAME, "openresty", "-t") + time.sleep(1.2) + status(curl("unknown.example"), 421, "unknown_host") + status(curl("healthy.example", method="TRACE"), 405, "invalid_method") + status(curl("body.example", method="POST", data="0123456789"), 413, "body_too_large") + status(curl("ipv4.example"), 403, "domain_restricted") + ipv6 = run("docker", "run", "--rm", "--network", f"container:{NAME}", "curlimages/curl:8.16.0", "-g", "-sS", "-D", "-", "-o", "/dev/null", "-H", "Host: ipv6.example", "http://[::1]:8080/", check=False) + status(ipv6, 403, "domain_restricted") + status(curl("geo.example", headers=("X-Forwarded-For: 8.8.8.8",)), 403, "domain_restricted") + status(curl("quarantine.example"), 429, "domain_quarantined") + + rate_results = run("docker", "run", "--rm", "--network", f"container:{NAME}", "--entrypoint", "sh", "curlimages/curl:8.16.0", "-c", "for n in 1 2 3 4 5 6; do curl -sS -D - -o /dev/null -H 'Host: rate.example' http://127.0.0.1:8080/; done", check=False) + assert "client_rate_exceeded" in rate_results.stdout, rate_results.stdout + status(curl("healthy.example"), 200) + + slow = subprocess.Popen(["docker", "run", "--rm", "--name", SLOW_CLIENT, "--network", f"container:{NAME}", "curlimages/curl:8.16.0", "-sS", "-H", "Host: connections.example", "http://127.0.0.1:8080/slow"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(0.8) + try: + status(curl("connections.example"), 429, "client_connections_exceeded") + finally: + run("docker", "rm", "-f", SLOW_CLIENT, check=False); slow.wait(timeout=5) + + origin_slow = subprocess.Popen(["docker", "run", "--rm", "--name", SLOW_ORIGIN_CLIENT, "--network", f"container:{NAME}", "curlimages/curl:8.16.0", "-sS", "-H", "Host: origin-capacity.example", "-H", "X-Forwarded-For: 198.51.100.1", "http://127.0.0.1:8080/slow"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(0.8) + try: + status(curl("origin-capacity.example", headers=("X-Forwarded-For: 198.51.100.2",)), 503, "origin_capacity_exceeded") + finally: + run("docker", "rm", "-f", SLOW_ORIGIN_CLIENT, check=False); origin_slow.wait(timeout=5) + + applied = control("phase6-emergency-1", True, ["allow_get_head_only", "disable_origin_retries"], int(time.time()) + 2) + assert applied["accepted"] is True and applied["replayed"] is False, applied + status(curl("healthy.example", method="POST", data="ok"), 405, "edge_emergency_mode") + assert control("phase6-emergency-1", True, ["allow_get_head_only", "disable_origin_retries"], int(time.time()) + 2)["replayed"] is True + time.sleep(2.2) + status(curl("healthy.example", method="POST", data="ok"), 200) + + status_payload = run("docker", "exec", NAME, "wget", "-q", "-O-", "--header=X-Edge-Status-Token: runtime-test-token", "http://127.0.0.1:9080/passive-failures") + decoded = json.loads(status_payload.stdout) + assert len(decoded["security"]) <= 20 and any(event["reason_code"] == "domain_quarantined" for event in decoded["security"]), decoded + assert decoded["cell"]["capacity"]["memory_usage"] <= 256 * 1024 * 1024, decoded["cell"] + + runtime.write_text('{"schema_version":1,"hosts":"invalid"}') + time.sleep(1.2) + status(curl("healthy.example"), 200) + logs = run("docker", "logs", NAME, check=False).stdout + run("docker", "logs", NAME, check=False).stderr + assert "invalid runtime state" in logs, logs + print("Phase 6 profile control-plane and real security runtime qualification passed") + finally: + if os.environ.get("CDNF_KEEP_FAILED_RUNTIME") != "1": + run("docker", "rm", "-f", SLOW_CLIENT, SLOW_ORIGIN_CLIENT, check=False) + run("docker", "rm", "-f", NAME, check=False) + + +if __name__ == "__main__": + main()