diff --git a/app/Actions/Database/RestartDatabase.php b/app/Actions/Database/RestartDatabase.php index 51f4e61636..db3ce6025f 100644 --- a/app/Actions/Database/RestartDatabase.php +++ b/app/Actions/Database/RestartDatabase.php @@ -23,6 +23,10 @@ class RestartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } + $busyError = StartDatabase::operationInProgressError($database); + if ($busyError !== null) { + return $busyError; + } $prerequisiteError = StartDatabase::prerequisiteError($database); if ($prerequisiteError !== null) { return $prerequisiteError; diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 4ed70adffc..e272b7edc4 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -36,6 +36,10 @@ class StartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } + $busyError = self::operationInProgressError($database); + if ($busyError !== null) { + return $busyError; + } $prerequisiteError = self::prerequisiteError($database); if ($prerequisiteError !== null) { return $prerequisiteError; @@ -84,6 +88,29 @@ class StartDatabase return $activity; } + /** + * Refuse a second start/restart while a start, restart or import of this database is + * queued or running. The UI hides Start/Restart in this state; this is the server-side check. + * Stale start activities do not block and are marked as failed, so the UI stays consistent. + */ + public static function operationInProgressError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): ?string + { + if (blank($database->uuid)) { + return null; + } + + $liveOperations = collect([ + ResourceStartActivity::DATABASE_START_OPERATION, + ResourceStartActivity::DATABASE_IMPORT_OPERATION, + ])->flatMap(fn (string $operation) => ResourceStartActivity::failStale( + ResourceStartActivity::active($database->uuid, $operation) + )); + + return $liveOperations->isNotEmpty() || ResourceStartActivity::latestRunning($database->uuid) !== null + ? ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE + : null; + } + /** * Check prerequisites that would make the queued start fail, so the user gets the * error immediately instead of a queued start that can only fail later. diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 0359bbc2ab..aea98b0045 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -2,7 +2,6 @@ namespace App\Actions\Database; -use App\Enums\ProcessStatus; use App\Models\S3Storage; use App\Models\Server; use App\Models\ServiceDatabase; @@ -11,6 +10,7 @@ use App\Support\DatabaseBackupFileValidator; use App\Support\DatabaseImport\DatabaseImportCommandBuilder; use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; +use App\Support\ResourceStartActivity; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; @@ -68,12 +68,8 @@ class StartDatabaseImport private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity { - $active = Activity::query()->where('properties->team_id', $teamId) - ->where('properties->type_uuid', $resource->uuid) - ->where('properties->operation', 'database_import') - ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value]) - ->exists(); - if ($active) { + $active = ResourceStartActivity::active($resource->uuid, ResourceStartActivity::DATABASE_IMPORT_OPERATION, $teamId); + if (ResourceStartActivity::failStale($active)->isNotEmpty()) { throw new DatabaseImportException('A database import is already running.', 409); } @@ -145,11 +141,13 @@ class StartDatabaseImport $commandList[] = 'rm -f '.escapeshellarg($scriptPath); $commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath); - $activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup); - $activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]); - $activity->save(); - - return $activity; + // The operation properties are set when the activity is created: the CoolifyTask job can + // load and save the activity before a later update, which would drop them again. + return remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup, properties: [ + 'operation' => ResourceStartActivity::DATABASE_IMPORT_OPERATION, + 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', + 'operation_uuid' => $operation, + ]); } private function target(Model $resource): array diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index d910b7b0aa..6de9e03de7 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -44,6 +44,10 @@ class GetProxyConfiguration if (empty(trim($proxy_configuration ?? ''))) { $proxy_configuration = $this->backfillFromDisk($server); } + + if (! empty(trim($proxy_configuration ?? '')) && removeLegacyTraefikDashboardExposure($server)) { + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + } } // Generate default configuration as last resort diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php index b47f03d0b6..287df678a1 100644 --- a/app/Actions/Server/ConfigureTrafficAnalytics.php +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -8,25 +8,44 @@ use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyPortParser; use Lorisleiva\Actions\Concerns\AsAction; +use RuntimeException; +use Throwable; class ConfigureTrafficAnalytics { use AsAction; - public function handle(Server $server, bool $enable): void + private const STOPPED_PROXY_STATUSES = ['exited', 'stopped', 'stopping', 'dead']; + + /** + * Turn traffic analytics on or off. The proxy configuration is saved before the setting, + * so a failure leaves both unchanged. + * + * @return bool Whether a proxy restart was queued to apply the new configuration. + */ + public function handle(Server $server, bool $enable): bool { - $configuration = GetProxyConfiguration::run($server); - ProxyPortParser::fromConfiguration($configuration); + if ($enable && ($reason = $server->trafficAnalyticsUnsupportedReason()) !== null) { + throw new RuntimeException($reason); + } $sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled; + // Disabling must still work after the proxy was removed, so there may be no proxy configuration to update. + $hasProxy = $server->hasTrafficAnalyticsProxy(); + if ($hasProxy) { + $this->saveProxyConfiguration($server, $enable); + } + $server->settings->is_traffic_analytics_enabled = $enable; $server->settings->save(); $server->refresh(); - $configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration); - SaveProxyConfiguration::run($server, $configuration); - RestartProxyJob::dispatch($server); + // A proxy the user stopped stays stopped; its next start applies the saved configuration. + $restartProxy = $hasProxy && ! $this->proxyIsStopped($server); + if ($restartProxy) { + RestartProxyJob::dispatch($server); + } // Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount. // Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already @@ -34,5 +53,35 @@ class ConfigureTrafficAnalytics if ($enable || $sentinelWasEnabled) { StartSentinel::run($server, restart: true); } + + return $restartProxy; + } + + private function saveProxyConfiguration(Server $server, bool $enable): void + { + $previousEnabled = (bool) $server->settings->is_traffic_analytics_enabled; + $previousProxy = $server->proxy->all(); + + try { + $configuration = GetProxyConfiguration::run($server); + ProxyPortParser::fromConfiguration($configuration); + + // The configuration is built from the in-memory setting; it is persisted only after the save succeeds. + $server->settings->is_traffic_analytics_enabled = $enable; + $configuration = applyTrafficAnalyticsToProxyConfiguration($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); + } catch (Throwable $exception) { + $server->settings->is_traffic_analytics_enabled = $previousEnabled; + $server->proxy = $previousProxy; + $server->save(); + + throw $exception; + } + } + + private function proxyIsStopped(Server $server): bool + { + return (bool) $server->proxy->get('force_stop') + || in_array($server->proxy->get('status'), self::STOPPED_PROXY_STATUSES, true); } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 3857d8c4d3..914ec7e099 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -17,9 +17,18 @@ class StartSentinel : rtrim($server->proxyPath(), '/'); } + /** + * Sentinel reads the proxy access log only when analytics is on and a Traefik or Caddy proxy writes it. + * After a switch to a proxy without analytics support, Sentinel must not keep the old log mount. + */ + public static function collectsTraffic(Server $server): bool + { + return $server->isTrafficAnalyticsEnabled() && $server->hasTrafficAnalyticsProxy(); + } + public static function sentinelTrafficEnvironment(Server $server): array { - if (! $server->isTrafficAnalyticsEnabled()) { + if (! self::collectsTraffic($server)) { return []; } @@ -44,6 +53,26 @@ class StartSentinel return $env; } + /** + * Sentinel opens the access log once at startup and never retries, so the file must exist + * before the container starts. `touch` keeps an existing log intact. + * + * @return array + */ + public static function trafficLogPreparationCommands(Server $server): array + { + if (! $server->isTrafficAnalyticsEnabled() || ! $server->supportsTrafficAnalytics()) { + return []; + } + + $directory = self::trafficLogDirectory($server); + + return [ + 'mkdir -p '.escapeshellarg($directory), + 'touch '.escapeshellarg($directory.'/access.log'), + ]; + } + public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null) { if ($server->isSwarm() || $server->isBuildServer()) { @@ -84,7 +113,7 @@ class StartSentinel $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); $trafficLogDirectory = self::trafficLogDirectory($server); - $trafficMount = $server->isTrafficAnalyticsEnabled() + $trafficMount = self::collectsTraffic($server) ? '-v '.escapeshellarg("{$trafficLogDirectory}:{$trafficLogDirectory}:ro").' ' : ''; $network = $server->isLocalhost() ? ' --network coolify' : ''; @@ -96,6 +125,7 @@ class StartSentinel instant_remote_process([ 'docker rm -f coolify-sentinel || true', "mkdir -p $mountDir", + ...self::trafficLogPreparationCommands($server), $dockerCommand, "chown -R 9999:root $mountDir", "chmod -R 700 $mountDir", diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 4f3a808df1..f087002da1 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -110,10 +110,10 @@ class Init extends Command $interruptedStartCount = ResourceStartActivity::failInterrupted(); if ($interruptedStartCount > 0) { - echo "Marked {$interruptedStartCount} interrupted database/service starts as failed\n"; + echo "Marked {$interruptedStartCount} interrupted database/service starts and database imports as failed\n"; } } catch (\Throwable $e) { - echo "Could not cleanup interrupted database/service starts: {$e->getMessage()}\n"; + echo "Could not cleanup interrupted database/service starts and database imports: {$e->getMessage()}\n"; } try { diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index e702a21a57..4b5f65a599 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3360,6 +3360,10 @@ class DatabasesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 409, + description: 'Another start, restart or import of this database is already in progress.', + ), ] )] public function action_deploy(Request $request) @@ -3382,6 +3386,9 @@ class DatabasesController extends Controller if (str($database->status)->contains('running')) { return response()->json(['message' => 'Database is already running.'], 400); } + if ($busyError = StartDatabase::operationInProgressError($database)) { + return response()->json(['message' => $busyError], 409); + } StartDatabase::dispatch($database); auditLog('api.database.started', [ @@ -3546,6 +3553,10 @@ class DatabasesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 409, + description: 'Another start, restart or import of this database is already in progress.', + ), ] )] public function action_restart(Request $request) @@ -3565,6 +3576,9 @@ class DatabasesController extends Controller $this->authorize('manage', $database); + if ($busyError = StartDatabase::operationInProgressError($database)) { + return response()->json(['message' => $busyError], 409); + } RestartDatabase::dispatch($database); auditLog('api.database.restarted', [ diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5a0e74d0d9..85238e799b 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -219,8 +219,8 @@ class GithubController extends Controller $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', 'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'], - 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], - 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'api_url' => ['nullable', 'string', 'url', SafeExternalUrl::forGitSource()], + 'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], 'custom_user' => 'nullable|string|max:255', 'custom_port' => 'nullable|integer|min:1|max:65535', 'app_id' => 'required|integer', @@ -615,10 +615,10 @@ class GithubController extends Controller $rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/']; } if (isset($payload['api_url'])) { - $rules['api_url'] = ['url', new SafeExternalUrl]; + $rules['api_url'] = ['url', SafeExternalUrl::forGitSource()]; } if (isset($payload['html_url'])) { - $rules['html_url'] = ['url', new SafeExternalUrl]; + $rules['html_url'] = ['url', SafeExternalUrl::forGitSource()]; } if (isset($payload['custom_user'])) { $rules['custom_user'] = 'string'; diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 7b252c5201..ced9a7a05c 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -20,10 +20,6 @@ class Bitbucket extends Controller public function manual(Request $request) { - if ($this->hasTooManyManualWebhookFailures($request, 'bitbucket')) { - return $this->tooManyManualWebhookFailuresResponse($request, 'bitbucket'); - } - try { $return_payloads = collect([]); $payload = $request->collect(); @@ -76,9 +72,13 @@ class Bitbucket extends Controller 'message' => 'Nothing to do. Invalid repository.', ]); } + $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch); + if ($this->hasTooManyManualWebhookFailures($failure_key)) { + return $this->tooManyManualWebhookFailuresResponse($failure_key); + } $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'bitbucket'); + return $this->unauthenticatedManualWebhookResponse($failure_key); } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); @@ -279,7 +279,7 @@ class Bitbucket extends Controller } } - return $this->manualWebhookResponse($return_payloads, $request, 'bitbucket'); + return $this->manualWebhookResponse($return_payloads, $failure_key); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index 080b4a1725..b987da88c5 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Webhook\Concerns; use App\Models\Application; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Collection; @@ -68,20 +67,25 @@ trait MatchesManualWebhookApplications /** * Respond to a delivery that could not be authenticated (no matching * application or no signature) and count it as a failed attempt. + * + * Deliveries without a matching application are counted too: the failure + * key is scoped to the repository and branch, so this cannot lock out other + * applications, and it keeps the 429 response from revealing which + * repositories exist in this instance. */ - protected function unauthenticatedManualWebhookResponse(Request $request, string $provider): Response + protected function unauthenticatedManualWebhookResponse(string $failureKey): Response { - $this->recordManualWebhookFailure($request, $provider); + $this->recordManualWebhookFailure($failureKey); return response([$this->unauthenticatedManualWebhookFailurePayload()]); } - protected function manualWebhookResponse(Collection $payloads, Request $request, string $provider): Response + protected function manualWebhookResponse(Collection $payloads, string $failureKey): Response { $failure = $this->unauthenticatedManualWebhookFailurePayload(); $authorizedPayloads = $payloads->reject(fn (array $payload): bool => $payload === $failure)->values(); if ($authorizedPayloads->isEmpty() && $payloads->isNotEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, $provider); + return $this->unauthenticatedManualWebhookResponse($failureKey); } return response($authorizedPayloads); diff --git a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php index fe2832eb1a..e86684b9f6 100644 --- a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php +++ b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php @@ -9,9 +9,12 @@ use Illuminate\Support\Facades\RateLimiter; /** * Throttles manual webhook deliveries that fail authentication. * - * Only failed authentication attempts are counted, per provider and client IP, - * so valid signed deliveries from shared IPs (Cloudflare, Git host IP pools) - * are never dropped because of unrelated traffic. + * Failures are counted per provider, client IP, repository and branch filter. + * The repository and branch come from the payload and select the applications + * whose secrets are checked, so a guesser can only exhaust the bucket of the + * applications it targets. Git hosts deliver from shared egress IPs; one + * misconfigured repository (wrong secret, deleted application, untracked + * branch) therefore cannot lock out deliveries for other repositories. */ trait ThrottlesManualWebhookFailures { @@ -19,19 +22,28 @@ trait ThrottlesManualWebhookFailures protected const int MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS = 60; - protected function manualWebhookFailureRateLimitKey(Request $request, string $provider): string + /** + * @param string $fullName Canonical repository path from manualWebhookRepositoryFullName(). + * @param mixed $branch Branch used to select applications, or null when all branches match. + */ + protected function manualWebhookFailureRateLimitKey(Request $request, string $provider, string $fullName, mixed $branch): string { - return "manual-webhook-failures:{$provider}:".auth_rate_limit_ip($request); + $scope = json_encode([ + mb_strtolower($fullName), + is_scalar($branch) ? (string) $branch : null, + ]); + + return "manual-webhook-failures:{$provider}:".auth_rate_limit_ip($request).':'.hash('sha256', (string) $scope); } - protected function hasTooManyManualWebhookFailures(Request $request, string $provider): bool + protected function hasTooManyManualWebhookFailures(string $failureKey): bool { - return RateLimiter::tooManyAttempts($this->manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_MAX_FAILURES); + return RateLimiter::tooManyAttempts($failureKey, self::MANUAL_WEBHOOK_MAX_FAILURES); } - protected function tooManyManualWebhookFailuresResponse(Request $request, string $provider): Response + protected function tooManyManualWebhookFailuresResponse(string $failureKey): Response { - $retryAfter = RateLimiter::availableIn($this->manualWebhookFailureRateLimitKey($request, $provider)); + $retryAfter = RateLimiter::availableIn($failureKey); return response([ 'status' => 'failed', @@ -39,8 +51,8 @@ trait ThrottlesManualWebhookFailures ], 429)->header('Retry-After', (string) max($retryAfter, 1)); } - protected function recordManualWebhookFailure(Request $request, string $provider): void + protected function recordManualWebhookFailure(string $failureKey): void { - RateLimiter::hit($this->manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); + RateLimiter::hit($failureKey, self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); } } diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 975e990973..40f015b41d 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -21,10 +21,6 @@ class Gitea extends Controller public function manual(Request $request) { - if ($this->hasTooManyManualWebhookFailures($request, 'gitea')) { - return $this->tooManyManualWebhookFailuresResponse($request, 'gitea'); - } - try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); @@ -69,17 +65,22 @@ class Gitea extends Controller if ($full_name === null) { return response('Nothing to do. Invalid repository.'); } + $matched_branch = $x_gitea_event === 'pull_request' ? $base_branch : $branch; + $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitea', $full_name, $matched_branch); + if ($this->hasTooManyManualWebhookFailures($failure_key)) { + return $this->tooManyManualWebhookFailuresResponse($failure_key); + } $applications = Application::query(); if ($x_gitea_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'gitea'); + return $this->unauthenticatedManualWebhookResponse($failure_key); } } if ($x_gitea_event === 'pull_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'gitea'); + return $this->unauthenticatedManualWebhookResponse($failure_key); } } foreach ($applications as $application) { @@ -285,7 +286,7 @@ class Gitea extends Controller } } - return $this->manualWebhookResponse($return_payloads, $request, 'gitea'); + return $this->manualWebhookResponse($return_payloads, $failure_key); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index e00c9cf4a4..fb85b148ef 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -25,10 +25,6 @@ class Github extends Controller public function manual(Request $request) { - if ($this->hasTooManyManualWebhookFailures($request, 'github')) { - return $this->tooManyManualWebhookFailuresResponse($request, 'github'); - } - try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); @@ -79,21 +75,22 @@ class Github extends Controller if ($full_name === null) { return response('Nothing to do. Invalid repository.'); } - $applications = Application::query(); - if ($x_github_event === 'push') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); - if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'github'); - } + $matched_branch = match (true) { + $x_github_event === 'push' => $branch, + $action === 'closed' => null, + default => $base_branch, + }; + $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch); + if ($this->hasTooManyManualWebhookFailures($failure_key)) { + return $this->tooManyManualWebhookFailuresResponse($failure_key); } - if ($x_github_event === 'pull_request') { - if ($action !== 'closed') { - $applications->where('git_branch', $base_branch); - } - $applications = $this->manualWebhookApplications($applications, $full_name); - if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'github'); - } + $applications = Application::query(); + if ($x_github_event === 'push' || $action !== 'closed') { + $applications->where('git_branch', $matched_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); + if ($applications->isEmpty()) { + return $this->unauthenticatedManualWebhookResponse($failure_key); } $applicationsByServer = $applications->groupBy(function ($app) { return $app->destination->server_id; @@ -243,7 +240,7 @@ class Github extends Controller } } - return $this->manualWebhookResponse($return_payloads, $request, 'github'); + return $this->manualWebhookResponse($return_payloads, $failure_key); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index e95a98b230..2eec721bb9 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -336,10 +336,6 @@ class Gitlab extends Controller public function manual(Request $request) { - if ($this->hasTooManyManualWebhookFailures($request, 'gitlab')) { - return $this->tooManyManualWebhookFailuresResponse($request, 'gitlab'); - } - try { $return_payloads = collect([]); $payload = $request->collect(); @@ -356,12 +352,14 @@ class Gitlab extends Controller return response($return_payloads); } + // A delivery without a token does not try a secret, so it is not + // counted as a failed attempt. if (empty($x_gitlab_token)) { auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ 'event' => $x_gitlab_event, ]); - return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); + return response([$this->unauthenticatedManualWebhookFailurePayload()]); } if ($x_gitlab_event === 'push') { @@ -412,17 +410,22 @@ class Gitlab extends Controller return response($return_payloads); } + $matched_branch = $x_gitlab_event === 'merge_request' ? $base_branch : $branch; + $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitlab', $full_name, $matched_branch); + if ($this->hasTooManyManualWebhookFailures($failure_key)) { + return $this->tooManyManualWebhookFailuresResponse($failure_key); + } $applications = Application::query(); if ($x_gitlab_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); + return $this->unauthenticatedManualWebhookResponse($failure_key); } } if ($x_gitlab_event === 'merge_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($request, 'gitlab'); + return $this->unauthenticatedManualWebhookResponse($failure_key); } } foreach ($applications as $application) { @@ -631,7 +634,7 @@ class Gitlab extends Controller } } - return $this->manualWebhookResponse($return_payloads, $request, 'gitlab'); + return $this->manualWebhookResponse($return_payloads, $failure_key); } catch (Exception $e) { return handleError($e); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index db704f1dc4..810f3336c9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -769,6 +769,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, + 'skip_command_log' => true, ]); // Modify Dockerfiles for ARGs and build secrets @@ -1151,6 +1152,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ], [ "echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null", + 'skip_command_log' => true, ], [ "echo '{$readme}' > $mainDir/README.md", @@ -1774,12 +1776,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); if (isDev()) { - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), - 'hidden' => true, - ] - ); + $this->logEnvironmentFileKeys("{$this->workdir}/.env", $environment_variables); } // Write .env file to configuration directory @@ -1802,6 +1799,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Development aid: log which variables an env file contains, without their values. + * + * @param Collection $environmentVariables Lines in KEY=VALUE format. + */ + private function logEnvironmentFileKeys(string $path, Collection $environmentVariables): void + { + $keys = $environmentVariables + ->map(fn (string $line): string => explode('=', $line, 2)[0]) + ->implode(', '); + + $this->application_deployment_queue->addLogEntry("[DEBUG] Variable names in {$path}: {$keys}", hidden: true); + } + private function generate_buildtime_environment_variables() { if (isDev()) { @@ -2067,38 +2078,60 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } /** - * Build-time names go into shell and Docker build commands, so they must be valid. Runtime-only - * variables only go into the .env file: existing ones with names that new variables can no longer - * use (such as my-var) keep working, unless the name would break a .env line. + * Build-time names go into shell and Docker build commands, so they must be valid when the + * deployment builds an image. Runtime-only variables only go into the .env file: existing ones + * with names that new variables can no longer use (such as my-var) keep working, unless the + * name would break a .env line. Deployments without a build step (Docker image) treat + * build-time variables the same way, because no build receives them. */ private function validateDeploymentEnvironmentVariableKeys(): void { $environmentVariables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->get(['key', 'is_buildtime']) - : $this->application->environment_variables_preview()->get(['key', 'is_buildtime']); + ? $this->application->environment_variables()->get(['key', 'is_buildtime', 'is_runtime']) + : $this->application->environment_variables_preview()->get(['key', 'is_buildtime', 'is_runtime']); + $passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables(); foreach ($environmentVariables as $environmentVariable) { $key = (string) $environmentVariable->key; $isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false; - if ($environmentVariable->is_buildtime || ! $isEnvFileSafe) { + if (($environmentVariable->is_buildtime && $passesBuildtimeVariables) || ! $isEnvFileSafe) { $this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment'); continue; } if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { - $this->logLegacyRuntimeEnvironmentVariableKey($key); + $this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime); } } } - private function logLegacyRuntimeEnvironmentVariableKey(string $key): void + /** + * Only Docker image deployments never build an image. Other deployments (also restarts, + * rollbacks, and previews) can build and pass build-time variables to the build. + */ + private function deploymentPassesBuildtimeVariables(): bool + { + return $this->application->build_pack !== 'dockerimage'; + } + + private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime): void { $suggestedKey = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $key); if (preg_match('/\A[0-9]/', $suggestedKey) === 1) { $suggestedKey = '_'.$suggestedKey; } - $this->application_deployment_queue->addLogEntry('⚠️ Runtime variable '.ValidationPatterns::displayShellEnvironmentVariableKey($key).' uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.', 'stderr'); + $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); + + if ($isBuildtime) { + $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. This deployment does not build an image, so it is not used as a build-time variable. Rename it before you use it in a build.", 'stderr'); + } + if ($isRuntime) { + $this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr'); + } + if (! $isBuildtime && ! $isRuntime) { + $this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr'); + } $this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info'); } @@ -2159,10 +2192,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); if (isDev()) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), - 'hidden' => true, - ]); + $this->logEnvironmentFileKeys(self::BUILD_TIME_ENV_PATH, $environment_variables); } } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); @@ -2527,6 +2557,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue [ $runCommand, 'hidden' => true, + 'skip_command_log' => filled($env_flags), ], [ 'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"), @@ -3325,7 +3356,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], + [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true, 'skip_command_log' => true], [ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'), 'hidden' => true, @@ -3813,7 +3844,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->docker_compose = Yaml::dump($docker_compose, 10); $this->docker_compose_base64 = base64_encode($this->docker_compose); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true]); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true, 'skip_command_log' => true]); } private function generate_local_persistent_volumes() @@ -4044,7 +4075,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]); if ($this->force_rebuild) { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), @@ -4230,7 +4261,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true]); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]); if ($this->force_rebuild) { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), @@ -4579,8 +4610,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function generate_docker_env_flags_for_secrets() { - // Only generate env flags if build secrets are enabled - if (! $this->application->settings->use_build_secrets) { + // Only generate env flags if build secrets are enabled and the deployment builds an image + if (! $this->application->settings->use_build_secrets || ! $this->deploymentPassesBuildtimeVariables()) { return ''; } @@ -4939,7 +4970,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfilePath = $this->resolveComposeDockerfilePath($service['build']); if ($dockerfilePath === null) { - $this->application_deployment_queue->addLogEntry("The build context of service {$serviceName} is remote or uses variables, skipping ARG injection."); + $this->application_deployment_queue->addLogEntry($this->composeArgInjectionSkippedMessage((string) $serviceName, $service['build'], $variables)); continue; } @@ -5097,6 +5128,41 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return str_starts_with($dockerfile, '/') ? $dockerfile : rtrim($context, '/').'/'.$dockerfile; } + /** + * Tells the user why Coolify cannot add ARG lines to a Compose service Dockerfile, and which + * build-time variables to declare there. Only valid variable names are shown, never values. + * + * @param Collection $variables + */ + private function composeArgInjectionSkippedMessage(string $serviceName, mixed $build, Collection $variables): string + { + $context = is_string($build) ? $build : data_get($build, 'context'); + $dockerfile = is_array($build) ? data_get($build, 'dockerfile') : null; + + $reason = match (true) { + is_array($build) && array_key_exists('dockerfile_inline', $build) => 'the service uses an inline Dockerfile (dockerfile_inline)', + is_string($context) && preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1 => 'the build context is a remote Git URL', + (is_string($context) && str_contains($context, '$')) || (is_string($dockerfile) && str_contains($dockerfile, '$')) => 'the build context or Dockerfile path uses variables', + default => 'Coolify cannot read the build definition', + }; + + $names = $variables->keys() + ->map(fn ($key) => (string) $key) + ->filter(fn (string $key) => ValidationPatterns::isValidEnvironmentVariableKey($key)) + ->sortBy(fn (string $key) => str_starts_with($key, 'COOLIFY_') ? 1 : 0) + ->values(); + + $message = "Skipping ARG injection for service {$serviceName}: {$reason}. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG ' lines to the Dockerfile for the ones you need"; + if ($names->isEmpty()) { + return "{$message}."; + } + + $listedNames = $names->take(10)->implode(', '); + $moreCount = $names->count() - 10; + + return $moreCount > 0 ? "{$message}: {$listedNames} and {$moreCount} more." : "{$message}: {$listedNames}."; + } + private function add_build_secrets_to_compose($composeFile) { // Generate env variables if not already done diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php index 5bbd35b9c7..4ec5630935 100644 --- a/app/Jobs/DatabaseStartJob.php +++ b/app/Jobs/DatabaseStartJob.php @@ -27,9 +27,11 @@ use App\Support\ResourceStartActivity; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Spatie\Activitylog\Models\Activity; use Throwable; @@ -57,6 +59,60 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue abort_unless((int) $database->team()->id === $this->teamId, 403); $activity = Activity::query()->findOrFail($this->activityId); + if (! $this->isStillRequested($activity, $database->uuid)) { + event(new DatabaseStatusChanged($this->userId)); + + return; + } + + $lock = Cache::lock(ResourceStartActivity::databaseStartLockKey($database->uuid), ResourceStartActivity::DATABASE_START_LOCK_SECONDS); + if (! $lock->get()) { + ResourceStartActivity::markFailed($activity, ResourceStartActivity::ALREADY_STARTING_MESSAGE); + event(new DatabaseStatusChanged($this->userId)); + + return; + } + + try { + $this->runStart($database, $activity); + } finally { + $lock->release(); + } + } + + /** + * A start runs only while its activity is still queued and no newer start of the same + * database was requested. A start that waited in a busy queue can be treated as stale and + * replaced by a new one; without this check both would run one after the other. + */ + private function isStillRequested(Activity $activity, string $databaseUuid): bool + { + if (data_get($activity, 'properties.status') !== ProcessStatus::QUEUED->value) { + return false; + } + + $newerStartExists = Activity::query() + ->where('properties->type_uuid', $databaseUuid) + ->where('properties->operation', ResourceStartActivity::DATABASE_START_OPERATION) + ->whereIn('properties->status', [ + ProcessStatus::QUEUED->value, + ProcessStatus::IN_PROGRESS->value, + ProcessStatus::FINISHED->value, + ]) + ->where('id', '>', $activity->getKey()) + ->exists(); + + if ($newerStartExists) { + ResourceStartActivity::markFailed($activity, ResourceStartActivity::SUPERSEDED_MESSAGE); + + return false; + } + + return true; + } + + private function runStart(Model $database, Activity $activity): void + { $result = match ($database->getMorphClass()) { StandalonePostgresql::class => StartPostgresql::run($database, $activity), StandaloneRedis::class => StartRedis::run($database, $activity), diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index be5d603eeb..2ebc6dc78f 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -5,8 +5,10 @@ namespace App\Livewire; use App\Livewire\Concerns\BuildsTrafficChartPayload; use App\Models\Application; use App\Models\Server; +use App\Models\Service; use App\Services\SentinelTrafficClient; use App\Services\TrafficAnalyticsAggregator; +use App\Services\TrafficResource; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -30,12 +32,12 @@ class Analytics extends Component /** @var array uuid => name, for the server filter */ public array $serverOptions = []; - /** @var array uuid => name, for the application filter (scoped to the selected server) */ + /** @var array uuid => name, for the application/service filter (scoped to the selected server) */ public array $appOptions = []; /** - * Listbox options for the application filter, grouped under project headers so - * it's clear which application belongs to which project. + * Listbox options for the application/service filter, grouped under project headers so + * it's clear which resource belongs to which project. Services carry a "(Service)" suffix. * * @var array */ @@ -105,13 +107,21 @@ class Analytics extends Component protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; /** - * Per-request cache of app uuid => display metadata, so resolving a name/domain/link - * for the leaderboard and path domains hits the DB at most once per app. + * Per-request cache of Sentinel key => display metadata, so resolving a name/domain/link + * for the leaderboard and path domains runs at most once per key. * - * @var array + * @var array */ protected array $appMetaCache = []; + /** + * Per-request map of every team Application and Service by uuid; the only source used + * to name Sentinel keys, so a key never resolves to another team's resource. + * + * @var array|null + */ + protected ?array $teamResources = null; + public function mount(?string $scopedServerUuid = null): void { $allServers = Server::ownedByCurrentTeamCached(); @@ -223,9 +233,9 @@ class Analytics extends Component { $enabledUuids = $this->servers->pluck('uuid'); - $apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get() - ->filter(function (Application $app) use ($enabledUuids): bool { - $serverUuid = $app->destination?->server?->uuid; + $resources = collect($this->teamResources()) + ->filter(function (TrafficResource $resource) use ($enabledUuids): bool { + $serverUuid = $resource->server()?->uuid; if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) { return false; @@ -235,26 +245,61 @@ class Analytics extends Component }); // Flat uuid => name map, used to validate a bookmarked ?app= filter. - $options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all(); + $options = $resources->mapWithKeys(fn (TrafficResource $resource) => [$resource->uuid() => $resource->name()])->all(); asort($options); $this->appOptions = $options; - // Grouped listbox options: a header row per project, then its apps (both alpha-sorted). + // Grouped listbox options: a header row per project, then its applications and + // services (both alpha-sorted by name). $grouped = []; - $byProject = $apps - ->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped')) + $byProject = $resources + ->groupBy(fn (TrafficResource $resource) => $resource->projectName()) ->sortKeys(); - foreach ($byProject as $projectName => $projectApps) { + foreach ($byProject as $projectName => $projectResources) { $grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true]; - foreach ($projectApps->sortBy('name') as $app) { - $grouped[] = ['value' => $app->uuid, 'label' => $app->name]; + foreach ($projectResources->sortBy(fn (TrafficResource $resource) => $resource->name()) as $resource) { + $grouped[] = [ + 'value' => $resource->uuid(), + 'label' => $resource->isService() ? $resource->name().' (Service)' : $resource->name(), + ]; } } $this->appGroupedOptions = $grouped; } + /** + * Every Application and Service of the current team, keyed by uuid. Loaded once per + * request with the relations the filter, links, and domain lookups need. + * + * @return array + */ + protected function teamResources(): array + { + if ($this->teamResources !== null) { + return $this->teamResources; + } + + $resources = []; + foreach (Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get() as $application) { + $resources[$application->uuid] = TrafficResource::for($application); + } + foreach (Service::ownedByCurrentTeam()->with(['environment.project', 'server', 'applications'])->get() as $service) { + $resources[$service->uuid] = TrafficResource::for($service); + } + + return $this->teamResources = $resources; + } + + /** + * The team resource selected in the application/service filter, or null. + */ + protected function selectedResource(): ?TrafficResource + { + return $this->appUuid !== '' ? ($this->teamResources()[$this->appUuid] ?? null) : null; + } + /** * Servers this view should query, honoring the active server/app filters. */ @@ -265,8 +310,7 @@ class Analytics extends Component } if ($this->appUuid !== '') { - $server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first() - ?->destination?->server; + $server = $this->selectedResource()?->server(); return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid) ? collect([$server]) @@ -287,111 +331,73 @@ class Analytics extends Component } [$from, $to] = $this->window(); - $appKey = $this->appUuid !== '' ? $this->appUuid : null; + // A selected application or service is queried under all of its Sentinel keys. + $resource = $this->selectedResource(); $servers = $this->targetServers(); + $domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain']; - $overviews = []; + $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); $appRows = []; - $pathTotals = []; - $breakdownTotals = array_fill_keys($this->breakdownDimensions, []); - $seriesByBucket = []; - $attribution = null; + $hostTotals = []; foreach ($servers as $server) { try { $client = $this->trafficClient($server); + if ($resource !== null) { + $keys = $client->prefetchResource($resource->uuid(), $from, $to, $this->breakdownDimensions, $this->range); + foreach ($keys as $key) { + $aggregator->collect($client, $key, $from, $to, $this->range, $domainForKey); + } + + continue; + } + // Warm every server-wide endpoint in one docker exec instead of ~15 serial // SSH round-trips; the per-call methods below then read from cache. - $leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); + $leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); - // Per-application leaderboard only makes sense when not already filtered to one app. - if ($appKey === null && $leaderboardUuids !== []) { - if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) { + if ($leaderboardKeys !== []) { + if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) { Log::warning('Traffic analytics leaderboard truncated', [ 'server' => $server->uuid, - 'total' => count($leaderboardUuids), + 'total' => count($leaderboardKeys), 'shown' => self::MAX_LEADERBOARD_APPS, ]); - $leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS); + $leaderboardKeys = array_slice($leaderboardKeys, 0, self::MAX_LEADERBOARD_APPS); } - // Warm the leaderboard's per-app overviews in a second batched exec. - $client->prefetchAppOverviews($leaderboardUuids, $from, $to); + // Warm the leaderboard's per-key overviews in a second batched exec. + $client->prefetchAppOverviews($leaderboardKeys, $from, $to); } - $overviews[] = $client->overview($appKey, $from, $to); + $aggregator->collect($client, null, $from, $to, $this->range, $domainForKey); - if ($appKey === null) { - foreach ($leaderboardUuids as $uuid) { - $appOverview = $client->overview($uuid, $from, $to)->toArray(); - $meta = $this->appMeta($uuid); + // Leaderboard: fold every key into its owning team resource (compose services + // and previews into their application or service); unknown keys stay raw. + foreach ($leaderboardKeys as $key) { + $keyOverview = $client->overview($key, $from, $to)->toArray(); + $requests = (int) ($keyOverview['requests'] ?? 0); + $bandwidth = (int) ($keyOverview['bytesIn'] ?? 0) + (int) ($keyOverview['bytesOut'] ?? 0); + $meta = $this->appMeta($key); - $appRows[] = [ - 'uuid' => $uuid, - 'name' => $meta['name'], - 'domain' => $meta['domain'], - 'link' => $meta['link'], - 'requests' => (int) ($appOverview['requests'] ?? 0), - 'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0), - ]; - } - } + $appRows[$meta['uuid']] ??= [ + 'uuid' => $meta['uuid'], + 'name' => $meta['name'], + 'domain' => $meta['rowDomain'], + 'link' => $meta['link'], + 'isService' => $meta['isService'], + 'requests' => 0, + 'bandwidth' => 0, + ]; + $appRows[$meta['uuid']]['requests'] += $requests; + $appRows[$meta['uuid']]['bandwidth'] += $bandwidth; - foreach ($client->paths($appKey, $from, $to, 50) as $path) { - $data = $path->toArray(); - $pathStr = (string) ($data['path'] ?? ''); - // Prefer the per-path app from Sentinel; fall back to the active app filter - // (older Sentinel omits `app`, but a filtered view still knows the app). - $appId = (string) ($data['app'] ?? ''); - $resolveId = $appId !== '' ? $appId : ($appKey ?? ''); - // Key by (app, path) so the same path under two apps stays two rows, each - // carrying its own domain. - $key = $resolveId."\n".$pathStr; - $domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null; - - $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0]; - $pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0); - $pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); - $pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0); - $pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0); - $pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0)); - } - - foreach ($this->breakdownDimensions as $dimension) { - foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) { - $data = $row->toArray(); - $value = (string) ($data['value'] ?? ''); - - $breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0]; - $breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0); - $breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); - } - } - - $attribution ??= $client->attribution(); - - // Per-bucket status series; summed by bucket across servers. Isolated so a - // series hiccup (or an older Sentinel lacking the endpoint) never discards a - // server's other data — an empty result simply flips the chart to the donut. - try { - foreach ($client->series($appKey, $this->range) as $bucket) { - $data = $bucket->toArray(); - $ts = (int) ($data['bucket'] ?? 0); - - $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; - $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0); - $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0); - $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0); - $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0); - $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0); - $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0); - $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); - // Uniques summed across servers (approximate); p95 takes the worst bucket. - $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0); - $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0)); - } - } catch (\Throwable $e) { - // Leave this server out of the series; donut fallback covers it. + // Top hosts: fold per-key volume up to the served hostname. Keys without + // a configured domain collapse into one "Unknown host" row. + $host = $meta['domain'] ?? ''; + $hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0]; + $hostTotals[$host]['requests'] += $requests; + $hostTotals[$host]['bandwidth'] += $bandwidth; } } catch (\Throwable $e) { // Skip unreachable/failed servers so one bad server doesn't break the whole view. @@ -399,7 +405,7 @@ class Analytics extends Component } } - if (empty($overviews)) { + if (! $aggregator->hasOverview()) { $this->resetData(); // The chart lives under wire:ignore, so it only updates via this event — dispatch // even when cleared so a previously-populated chart flips to its no-data state @@ -409,46 +415,17 @@ class Analytics extends Component return; } - $result = TrafficAnalyticsAggregator::sumOverviews($overviews); + $result = $aggregator->overview(); $this->overview = $result['overview']->toArray(); $this->latencyApproximate = $result['latencyApproximate']; $this->uniquesApproximate = $result['uniquesApproximate']; - usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']); - $this->topApps = array_slice($appRows, 0, 50); - - // Top hosts: fold per-app volume up to the served hostname (an app's primary - // domain). Apps without a configured FQDN collapse into one "Unknown host" row. - $hostTotals = []; - foreach ($appRows as $row) { - $host = $row['domain'] ?? ''; - $hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0]; - $hostTotals[$host]['requests'] += (int) $row['requests']; - $hostTotals[$host]['bandwidth'] += (int) $row['bandwidth']; - } - $hosts = array_values($hostTotals); - usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']); - $this->topHosts = array_slice($hosts, 0, 50); - - $paths = array_values($pathTotals); - usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']); - $this->topPaths = array_slice($paths, 0, 50); - - $breakdowns = []; - foreach ($this->breakdownDimensions as $dimension) { - $rows = array_values($breakdownTotals[$dimension]); - usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']); - if ($dimension === 'referer') { - $rows = groupRefererBreakdownRows($rows); - } - $breakdowns[$dimension] = array_slice($rows, 0, 50); - } - $this->breakdowns = $breakdowns; - - $this->attribution = $attribution; - - ksort($seriesByBucket); - $this->series = array_values($seriesByBucket); + $this->topApps = TrafficAnalyticsAggregator::topByRequests(array_values($appRows)); + $this->topHosts = TrafficAnalyticsAggregator::topByRequests(array_values($hostTotals)); + $this->topPaths = $aggregator->topPaths(); + $this->breakdowns = $aggregator->breakdowns(); + $this->attribution = $aggregator->attribution(); + $this->series = $aggregator->series(); $this->hasSeries = $this->series !== []; $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); @@ -534,39 +511,47 @@ class Analytics extends Component } /** - * Resolve an app uuid to its display name, primary domain, and analytics-page link, - * memoized per request. Returns the uuid as the name for apps not owned by the team - * so a Sentinel-reported uuid never discloses another team's application name. + * Resolve a Sentinel key to its owning team resource: row id (resource uuid), name, + * key-specific domain (compose service / preview), primary domain for the leaderboard + * row, and analytics link. Memoized per request. A key that no team resource owns + * keeps the raw key as its name and no domain or link, so a Sentinel-reported key + * never discloses another team's resource. * - * @return array{name: string, domain: ?string, link: ?string} + * @return array{uuid: string, name: string, domain: ?string, rowDomain: ?string, link: ?string, isService: bool} */ - protected function appMeta(string $uuid): array + protected function appMeta(string $key): array { - if (isset($this->appMetaCache[$uuid])) { - return $this->appMetaCache[$uuid]; + if (isset($this->appMetaCache[$key])) { + return $this->appMetaCache[$key]; } - $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first(); - - $domain = null; - if ($app) { - $first = collect($app->fqdns)->first(); - $domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null; + $resources = $this->teamResources(); + $owner = null; + foreach (SentinelTrafficClient::candidateOwnerUuids($key) as $candidate) { + if (isset($resources[$candidate])) { + $owner = $resources[$candidate]; + break; + } } - $link = null; - if ($app && data_get($app, 'environment.project.uuid')) { - $link = route('project.application.analytics', [ - 'project_uuid' => $app->environment->project->uuid, - 'environment_uuid' => $app->environment->uuid, - 'application_uuid' => $app->uuid, - ]); + if ($owner === null) { + return $this->appMetaCache[$key] = [ + 'uuid' => $key, + 'name' => $key, + 'domain' => null, + 'rowDomain' => null, + 'link' => null, + 'isService' => false, + ]; } - return $this->appMetaCache[$uuid] = [ - 'name' => $app?->name ?? $uuid, - 'domain' => $domain, - 'link' => $link, + return $this->appMetaCache[$key] = [ + 'uuid' => $owner->uuid(), + 'name' => $owner->name(), + 'domain' => $owner->domainForKey($key), + 'rowDomain' => $owner->primaryDomain(), + 'link' => $owner->analyticsLink(), + 'isService' => $owner->isService(), ]; } diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php index 532460d454..c24d7dac79 100644 --- a/app/Livewire/Dashboard/TrafficAnalytics.php +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -60,33 +60,18 @@ class TrafficAnalytics extends Component [$from, $to] = $this->window(); - $overviews = []; - $seriesByBucket = []; + $aggregator = new TrafficAnalyticsAggregator; foreach ($this->servers as $server) { try { $client = $this->trafficClient($server); - $overviews[] = $client->overview(null, $from, $to); + $aggregator->addOverview($client->overview(null, $from, $to)); // Per-bucket status series, summed across servers, for the sparklines. // Isolated so a series hiccup (older Sentinel) never drops a server's overview. try { - foreach ($client->series(null, $this->range) as $bucket) { - $data = $bucket->toArray(); - $ts = (int) ($data['bucket'] ?? 0); - - $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; - $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0); - $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0); - $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0); - $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0); - $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0); - $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0); - $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); - $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0); - $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0)); - } + $aggregator->addSeries($client->series(null, $this->range)); } catch (\Throwable $e) { // Leave this server out of the sparkline series. \Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]); @@ -99,7 +84,7 @@ class TrafficAnalytics extends Component } } - if (empty($overviews)) { + if (! $aggregator->hasOverview()) { // Every server's fetch failed; don't present an all-zero KPI panel as if it were real data. $this->overview = null; $this->latencyApproximate = false; @@ -109,14 +94,13 @@ class TrafficAnalytics extends Component return; } - $result = TrafficAnalyticsAggregator::sumOverviews($overviews); + $result = $aggregator->overview(); $this->overview = $result['overview']->toArray(); $this->latencyApproximate = $result['latencyApproximate']; $this->uniquesApproximate = $result['uniquesApproximate']; - ksort($seriesByBucket); - $this->series = array_values($seriesByBucket); + $this->series = $aggregator->series(); $this->dispatch("refreshChartData-{$this->chartId}-status", [ 'requestsSpark' => $this->requestsSpark(), diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php index 52d15d9e10..b6f4578b5f 100644 --- a/app/Livewire/Project/Application/Analytics.php +++ b/app/Livewire/Project/Application/Analytics.php @@ -2,245 +2,19 @@ namespace App\Livewire\Project\Application; -use App\Livewire\Concerns\BuildsTrafficChartPayload; +use App\Livewire\Project\Shared\ResourceTrafficAnalytics; use App\Models\Application; -use App\Services\SentinelTrafficClient; -use Illuminate\Contracts\View\View; use Livewire\Attributes\Lazy; -use Livewire\Component; #[Lazy] -class Analytics extends Component +class Analytics extends ResourceTrafficAnalytics { - use BuildsTrafficChartPayload; - public Application $application; public string $chartId = 'application-analytics'; - public string $range = '24h'; - - public bool $enabled = false; - - // Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the - // 24h range; the 60s cadence matches the SentinelTrafficClient cache TTL and - // Sentinel's per-minute rollups. The control is disabled for 7d/30d. - public bool $live = false; - - public ?array $overview = null; - - public array $topPaths = []; - - /** @var array>> */ - public array $breakdowns = []; - - public ?string $attribution = null; - - /** - * Per-bucket status-class time series for the stacked area chart. Empty when this - * app's Sentinel lacks the series endpoint, which flips the chart to the donut. - * - * @var array - */ - public array $series = []; - - public bool $hasSeries = false; - - /** @var array */ - protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; - - public function mount(): void + protected function resourceProperty(): string { - $this->enabled = (bool) $this->application->destination?->server?->isTrafficAnalyticsEnabled(); - - if ($this->enabled) { - $this->loadData(); - } - } - - public function setRange(string $range): void - { - $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; - $this->loadData(); - } - - public function toggleLive(): void - { - if ($this->range !== '24h') { - return; - } - $this->live = ! $this->live; - } - - /** - * Realtime polling is only armed when the user has it on and the range is 24h. - */ - public function isLivePollable(): bool - { - return $this->live && $this->range === '24h'; - } - - public function loadData(): void - { - if (! $this->enabled) { - return; - } - - try { - [$from, $to] = $this->window(); - $client = $this->trafficClient(); - $key = $this->application->uuid; - - // Warm every endpoint for this app in one docker exec instead of ~14 serial - // SSH round-trips; the per-call methods below then read from cache. - $client->prefetchServerWide($key, $from, $to, $this->breakdownDimensions, $this->range); - - $this->overview = $client->overview($key, $from, $to)->toArray(); - - // Every path belongs to this one app, so decorate each row with its domain - // for a consistent "domain + path" presentation and an openable live link. - $domain = $this->applicationDomain(); - $this->topPaths = $client->paths($key, $from, $to, 50) - ->map(fn ($path) => ['domain' => $domain] + $path->toArray()) - ->all(); - - $breakdowns = []; - foreach ($this->breakdownDimensions as $dimension) { - $rows = $client->breakdown($key, $dimension, $from, $to, 50) - ->map(fn ($row) => $row->toArray()) - ->all(); - $breakdowns[$dimension] = $dimension === 'referer' - ? groupRefererBreakdownRows($rows) - : $rows; - } - $this->breakdowns = $breakdowns; - - $this->attribution = $client->attribution(); - - // Per-bucket status series; absent on older Sentinel builds (empty → donut fallback). - // Isolated so a series hiccup never errors the rest of the widget. - try { - $this->series = $client->series($key, $this->range) - ->map(fn ($bucket) => $bucket->toArray()) - ->all(); - } catch (\Throwable $e) { - $this->series = []; - } - $this->hasSeries = $this->series !== []; - - $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - /** - * Payload for the status chart: the stacked-area time series when available, - * plus the donut totals as a fallback for older Sentinel builds. - * - * @return array - */ - protected function chartPayload(): array - { - $device = $this->deviceChartData(); - - return [ - 'hasSeries' => $this->hasSeries, - 'range' => $this->range, - 'seriesData' => [ - $this->overview['s2xx'] ?? 0, - $this->overview['s3xx'] ?? 0, - $this->overview['s4xx'] ?? 0, - $this->overview['s5xx'] ?? 0, - ], - 'timeSeries' => [ - 'categories' => array_column($this->series, 'bucket'), - 'requests' => $this->requestsSpark(), - 's2xx' => array_column($this->series, 's2xx'), - 's3xx' => array_column($this->series, 's3xx'), - 's4xx' => array_column($this->series, 's4xx'), - 's5xx' => array_column($this->series, 's5xx'), - ], - 'requestsSpark' => $this->requestsSpark(), - 'sparkCategories' => array_column($this->series, 'bucket'), - 'errorsSpark' => $this->errorsSpark(), - 'bandwidthSpark' => $this->bandwidthSpark(), - 'uniquesSpark' => $this->uniquesSpark(), - 'latencySpark' => $this->latencySpark(), - 'geo' => $this->geoMarkers(), - 'deviceLabels' => $device['labels'], - 'deviceSeries' => $device['series'], - ]; - } - - public function errorRate(): float - { - if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { - return 0.0; - } - - $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); - - return round(($errors / $this->overview['requests']) * 100, 2); - } - - public function bandwidthBytes(): int - { - if (! $this->overview) { - return 0; - } - - return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); - } - - protected function trafficClient(): SentinelTrafficClient - { - return app(SentinelTrafficClient::class, ['server' => $this->application->destination->server]); - } - - /** - * Primary domain host for this application (first configured FQDN), or null when - * none is set — used to present paths as "domain + path" with an openable link. - */ - protected function applicationDomain(): ?string - { - $first = collect($this->application->fqdns)->first(); - - return $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null; - } - - /** - * @return array{0: string, 1: string} - */ - private function window(): array - { - $to = now(); - $from = match ($this->range) { - '7d' => now()->subDays(7), - '30d' => now()->subDays(30), - default => now()->subDay(), - }; - - return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; - } - - public function placeholder(array $params = []): View - { - $application = $params['application'] ?? null; - - if ($application instanceof Application && ! $application->destination?->server?->isTrafficAnalyticsEnabled()) { - $this->application = $application; - $this->enabled = false; - - return view('livewire.project.application.analytics'); - } - - // Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request. - return view('livewire.project.application.analytics-placeholder'); - } - - public function render() - { - return view('livewire.project.application.analytics'); + return 'application'; } } diff --git a/app/Livewire/Project/Application/TrafficOverview.php b/app/Livewire/Project/Application/TrafficOverview.php index 223c6047ae..86a6ff64f4 100644 --- a/app/Livewire/Project/Application/TrafficOverview.php +++ b/app/Livewire/Project/Application/TrafficOverview.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application; use App\Models\Application; use App\Services\SentinelTrafficClient; +use App\Services\TrafficAnalyticsAggregator; use Livewire\Attributes\Lazy; use Livewire\Component; @@ -36,7 +37,14 @@ class TrafficOverview extends Component if ($this->enabled && $server) { try { $client = app(SentinelTrafficClient::class, ['server' => $server]); - $this->overview = $client->appOverview($this->application->uuid, '24h')->toArray(); + // Compose services and previews record under their own keys; sum all of them. + $keys = $client->resourceKeys($this->application->uuid); + [$from, $to] = SentinelTrafficClient::rangeWindow('24h'); + if (count($keys) > 1) { + $client->prefetchAppOverviews($keys, $from, $to); + } + $overviews = array_map(fn (string $key) => $client->overview($key, $from, $to), $keys); + $this->overview = TrafficAnalyticsAggregator::sumOverviews($overviews)['overview']->toArray(); } catch (\Throwable $e) { $this->overview = null; } diff --git a/app/Livewire/Project/Service/Analytics.php b/app/Livewire/Project/Service/Analytics.php new file mode 100644 index 0000000000..9bdb130077 --- /dev/null +++ b/app/Livewire/Project/Service/Analytics.php @@ -0,0 +1,20 @@ +containers->count() === 1) { $this->selected_container = $this->containerTarget($this->containers->first()); $this->connectToContainer(); + } elseif ($this->containers->count() > 1) { + // The terminal was rendered with auto-start before the containers were known. + $this->dispatch(Terminal::AUTO_START_CANCELLED_EVENT)->to(Terminal::class); } $this->containersLoaded = true; @@ -190,7 +193,7 @@ class ExecuteContainerCommand extends Component // Dispatch a frontend event to ensure terminal gets focus after connection $this->dispatch('terminal-should-focus'); } catch (\Throwable $e) { - return handleError($e, $this); + $this->failTerminalSession(Terminal::sessionFailureMessage($e)); } finally { $this->isConnecting = false; } @@ -200,7 +203,7 @@ class ExecuteContainerCommand extends Component public function connectToContainer() { if ($this->selected_container === 'default') { - $this->dispatch('error', 'Please select a container.'); + $this->failTerminalSession('Please select a container.'); return; } @@ -251,12 +254,20 @@ class ExecuteContainerCommand extends Component // Dispatch a frontend event to ensure terminal gets focus after connection $this->dispatch('terminal-should-focus'); } catch (\Throwable $e) { - return handleError($e, $this); + $this->failTerminalSession(Terminal::sessionFailureMessage($e)); } finally { $this->isConnecting = false; } } + /** + * Stop the terminal "connecting…" state and show why. The terminal also shows the toast. + */ + private function failTerminalSession(string $message): void + { + $this->dispatch(Terminal::SESSION_FAILED_EVENT, message: $message)->to(Terminal::class); + } + public function render() { return view('livewire.project.shared.execute-container-command'); diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php new file mode 100644 index 0000000000..7ba0829855 --- /dev/null +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -0,0 +1,236 @@ +>> */ + public array $breakdowns = []; + + public ?string $attribution = null; + + /** + * Per-bucket status-class time series for the stacked area chart. Empty when this + * resource's Sentinel lacks the series endpoint, which flips the chart to the donut. + * + * @var array + */ + public array $series = []; + + public bool $hasSeries = false; + + /** @var array */ + protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; + + /** + * Name of the public model property and lazy-load parameter ('application' / 'service'). + */ + abstract protected function resourceProperty(): string; + + protected function resourceModel(): Application|Service + { + return $this->{$this->resourceProperty()}; + } + + protected function trafficResource(): TrafficResource + { + return TrafficResource::for($this->resourceModel()); + } + + public function mount(): void + { + $this->authorize('view', $this->resourceModel()); + + $server = $this->trafficResource()->server(); + $this->analyticsServerUuid = $server?->uuid; + $this->enabled = (bool) $server?->isTrafficAnalyticsEnabled(); + + if ($this->enabled) { + $this->loadData(); + } + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + public function toggleLive(): void + { + if ($this->range !== '24h') { + return; + } + $this->live = ! $this->live; + } + + /** + * Realtime polling is only armed when the user has it on and the range is 24h. + */ + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + public function loadData(): void + { + if (! $this->enabled) { + return; + } + + try { + $this->authorize('view', $this->resourceModel()); + + $resource = $this->trafficResource(); + [$from, $to] = SentinelTrafficClient::rangeWindow($this->range); + $client = app(SentinelTrafficClient::class, ['server' => $resource->server()]); + + // Resolve every key of this resource and warm all of them in one or two + // docker execs; the per-call methods below then read from cache. + $keys = $client->prefetchResource($resource->uuid(), $from, $to, $this->breakdownDimensions, $this->range); + + $aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions); + foreach ($keys as $key) { + $aggregator->collect($client, $key, $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey)); + } + + $result = $aggregator->overview(); + $this->overview = $result['overview']->toArray(); + $this->latencyApproximate = $result['latencyApproximate']; + $this->uniquesApproximate = $result['uniquesApproximate']; + $this->topPaths = $aggregator->topPaths(); + $this->breakdowns = $aggregator->breakdowns(); + $this->attribution = $aggregator->attribution(); + $this->series = $aggregator->series(); + $this->hasSeries = $this->series !== []; + + $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload()); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Payload for the status chart: the stacked-area time series when available, + * plus the donut totals as a fallback for older Sentinel builds. + * + * @return array + */ + protected function chartPayload(): array + { + $device = $this->deviceChartData(); + + return [ + 'hasSeries' => $this->hasSeries, + 'range' => $this->range, + 'seriesData' => [ + $this->overview['s2xx'] ?? 0, + $this->overview['s3xx'] ?? 0, + $this->overview['s4xx'] ?? 0, + $this->overview['s5xx'] ?? 0, + ], + 'timeSeries' => [ + 'categories' => array_column($this->series, 'bucket'), + 'requests' => $this->requestsSpark(), + 's2xx' => array_column($this->series, 's2xx'), + 's3xx' => array_column($this->series, 's3xx'), + 's4xx' => array_column($this->series, 's4xx'), + 's5xx' => array_column($this->series, 's5xx'), + ], + 'requestsSpark' => $this->requestsSpark(), + 'sparkCategories' => array_column($this->series, 'bucket'), + 'errorsSpark' => $this->errorsSpark(), + 'bandwidthSpark' => $this->bandwidthSpark(), + 'uniquesSpark' => $this->uniquesSpark(), + 'latencySpark' => $this->latencySpark(), + 'geo' => $this->geoMarkers(), + 'deviceLabels' => $device['labels'], + 'deviceSeries' => $device['series'], + ]; + } + + public function errorRate(): float + { + if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { + return 0.0; + } + + $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); + + return round(($errors / $this->overview['requests']) * 100, 2); + } + + public function bandwidthBytes(): int + { + if (! $this->overview) { + return 0; + } + + return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0); + } + + public function placeholder(array $params = []): View + { + $model = $params[$this->resourceProperty()] ?? null; + + if ($model instanceof Application || $model instanceof Service) { + $this->authorize('view', $model); + $server = TrafficResource::for($model)->server(); + + if (! $server?->isTrafficAnalyticsEnabled()) { + $this->{$this->resourceProperty()} = $model; + $this->enabled = false; + $this->analyticsServerUuid = $server?->uuid; + + return view('livewire.project.application.analytics'); + } + } + + // Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request. + return view('livewire.project.application.analytics-placeholder'); + } + + public function render() + { + return view('livewire.project.application.analytics'); + } +} diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index de4e00cf20..01a89b721a 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -5,14 +5,27 @@ namespace App\Livewire\Project\Shared; use App\Models\Server; use App\Services\TerminalSessionService; use App\Support\ValidationPatterns; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\On; use Livewire\Component; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Throwable; class Terminal extends Component { use AuthorizesRequests; + /** Browser event: no terminal token was issued. Payload: `message`. */ + public const SESSION_FAILED_EVENT = 'terminal-session-failed'; + + /** Browser event: the page did not auto-select a target, so stop the auto-start wait. */ + public const AUTO_START_CANCELLED_EVENT = 'terminal-auto-start-cancelled'; + + /** Same text for denied and unknown targets, so other teams' resources stay hidden. */ + public const NOT_ALLOWED_MESSAGE = 'You are not allowed to open a terminal for this resource.'; + public bool $hasShell = true; public bool $isTerminalConnected = false; @@ -31,38 +44,52 @@ class Terminal extends Component ], $server); return true; - } catch (\Throwable) { + } catch (Throwable) { return false; } } #[On('send-terminal-command')] - public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService) + public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService): void { - $this->authorize('canAccessTerminal'); + try { + $this->authorize('canAccessTerminal'); - $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); - $this->authorize('view', $server); + $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); + $this->authorize('view', $server); + } catch (AuthorizationException|ModelNotFoundException) { + $this->failTerminalSession(self::NOT_ALLOWED_MESSAGE); + + return; + } if (! $server->isTerminalEnabled() || $server->isForceDisabled()) { - abort(403, 'Terminal access is disabled on this server.'); + $this->failTerminalSession('Terminal access is disabled on this server.'); + + return; } if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) - if (! ValidationPatterns::isValidContainerName($identifier)) { - throw new \InvalidArgumentException('Invalid container identifier format'); + if (! is_string($identifier) || ! ValidationPatterns::isValidContainerName($identifier)) { + $this->failTerminalSession('The container name is not valid.'); + + return; } // Verify container exists and belongs to the user's team $status = getContainerStatus($server, $identifier); if ($status !== 'running') { + $this->failTerminalSession('The container is not running.'); + return; } // Check shell availability $this->hasShell = $this->checkShellAvailability($server, $identifier); if (! $this->hasShell) { + $this->failTerminalSession('No shell is available in this container.'); + return; } } @@ -71,6 +98,28 @@ class Terminal extends Component $this->dispatch('send-terminal-token', $token); } + /** + * Message for an exception on the terminal start path. Denied and missing + * resources get the same text, so the message does not reveal other teams' data. + */ + public static function sessionFailureMessage(Throwable $exception): string + { + $isDenied = $exception instanceof AuthorizationException + || $exception instanceof ModelNotFoundException + || ($exception instanceof HttpExceptionInterface && in_array($exception->getStatusCode(), [403, 404], true)); + + if ($isDenied) { + return self::NOT_ALLOWED_MESSAGE; + } + + return $exception->getMessage() ?: 'Could not start the terminal session.'; + } + + private function failTerminalSession(string $message): void + { + $this->dispatch(self::SESSION_FAILED_EVENT, message: $message)->self(); + } + #[On('terminalConnected')] public function markTerminalConnected(): void { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index b64adf10a3..545494db51 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -258,6 +258,7 @@ class Navbar extends Component 'proxy-configuration-state-changed', pending: $this->server->hasPendingProxyConfiguration(), traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(), + proxyNotRunning: $this->server->proxySet() && ($this->server->proxy->status ?? 'unknown') !== 'running', ); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 5c3624a2b6..af30b3a02b 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -201,7 +201,6 @@ class Proxy extends Component } } -<<<<<<< Updated upstream public function getTraefikVersionForWarningProperty(): ?string { if ($this->server->detected_traefik_version) { @@ -218,7 +217,8 @@ class Proxy extends Component } return $matches[1]; -======= + } + public function loadTraefikCertificates(): void { $this->traefikCertificates = []; @@ -243,7 +243,6 @@ class Proxy extends Component } catch (\Throwable $e) { handleError($e, $this); } ->>>>>>> Stashed changes } /** diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index b0d80f00c0..221e724a2a 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureTrafficAnalytics; +use App\Enums\ProxyTypes; use App\Livewire\Analytics; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -74,20 +75,18 @@ class TrafficAnalyticsSettings extends Component { try { $this->authorize('update', $this->server); - if ($this->server->isSwarm() || $this->server->isBuildServer()) { - $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.'); + $enable = ! $this->server->isTrafficAnalyticsEnabled(); + if ($enable && ($reason = $this->server->trafficAnalyticsUnsupportedReason()) !== null) { + $this->dispatch('error', $reason); return; } - $enable = ! $this->server->isTrafficAnalyticsEnabled(); - ConfigureTrafficAnalytics::run($this->server, $enable); + $proxyRestarted = ConfigureTrafficAnalytics::run($this->server, $enable); $this->server->refresh(); $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); $this->dispatch('trafficAnalyticsStateChanged')->to(Analytics::class); - $this->dispatch('success', $enable - ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' - : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); + $this->dispatch('success', $this->toggleMessage($enable, $proxyRestarted)); auditLog($enable ? 'ui.server.traffic_analytics.enabled' : 'ui.server.traffic_analytics.disabled', $this->auditContext()); } catch (\Throwable $e) { handleError($e, $this); @@ -110,7 +109,43 @@ class TrafficAnalyticsSettings extends Component public function render(): View { - return view('livewire.server.traffic-analytics-settings'); + return view('livewire.server.traffic-analytics-settings', [ + 'unsupportedReason' => $this->server->trafficAnalyticsUnsupportedReason(), + 'caddyRedeployNote' => $this->caddyRedeployNote(), + ]); + } + + /** + * Caddy gets its log labels at deploy time, so resources that already run are not logged until a redeploy. + */ + private function caddyRedeployNote(): ?string + { + return $this->server->proxyType() === ProxyTypes::CADDY->value + ? 'Caddy logs a resource only after you redeploy it.' + : null; + } + + private function toggleMessage(bool $enabled, bool $proxyRestarted): string + { + $message = $this->proxyToggleMessage($enabled, $proxyRestarted); + $caddyRedeployNote = $this->caddyRedeployNote(); + + return $enabled && $caddyRedeployNote !== null ? "{$message} {$caddyRedeployNote}" : $message; + } + + private function proxyToggleMessage(bool $enabled, bool $proxyRestarted): string + { + $state = $enabled ? 'enabled' : 'disabled'; + + if ($proxyRestarted) { + return "Traffic analytics {$state}. Restarting proxy and Sentinel."; + } + + if ($this->server->hasTrafficAnalyticsProxy()) { + return "Traffic analytics {$state}. The proxy is stopped, so the new configuration applies the next time you start it."; + } + + return "Traffic analytics {$state}."; } private function auditContext(array $context = []): array diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 6bb896fcc6..6c65190e80 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -89,8 +89,8 @@ class Change extends Component return [ 'name' => 'required|string', 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], - 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], - 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], 'customUser' => 'required|string', 'customPort' => 'required|int', 'appId' => 'nullable|int', diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 6a5bf6e607..37eea732c3 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -59,8 +59,8 @@ class Create extends Component $this->validate([ 'name' => 'required|string', 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], - 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], - 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], 'custom_user' => 'required|string', 'custom_port' => 'required|int', 'is_system_wide' => 'required|bool', diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 29374105b2..50c9e9115c 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -70,8 +70,8 @@ class Change extends Component { return [ 'name' => 'required|string', - 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], - 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], 'customUser' => 'required|string', 'customPort' => 'required|int', 'clientId' => 'nullable|string', diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php index 200bd6d403..2846d20b1b 100644 --- a/app/Livewire/Source/Gitlab/Create.php +++ b/app/Livewire/Source/Gitlab/Create.php @@ -59,8 +59,8 @@ class Create extends Component $this->validate([ 'name' => 'required|string', - 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], - 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], 'custom_user' => 'required|string', 'custom_port' => 'required|int', 'is_system_wide' => 'required|bool', diff --git a/app/Models/Server.php b/app/Models/Server.php index d4afa8a8a6..aa813600d7 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1061,6 +1061,35 @@ $siteAddress { return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); } + /** + * Traffic analytics reads the access log of a Coolify-managed Traefik or Caddy proxy. + */ + public function hasTrafficAnalyticsProxy(): bool + { + return in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true); + } + + /** + * Why traffic analytics cannot be enabled on this server, or null when it can. + */ + public function trafficAnalyticsUnsupportedReason(): ?string + { + if ($this->isSwarm() || $this->isBuildServer()) { + return 'Traffic analytics is not supported on Swarm/Build servers.'; + } + + if (! $this->hasTrafficAnalyticsProxy()) { + return 'Traffic analytics needs the Traefik or Caddy proxy.'; + } + + return null; + } + + public function supportsTrafficAnalytics(): bool + { + return $this->trafficAnalyticsUnsupportedReason() === null; + } + /** * Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs * Caddy 2.8+, which caddy-docker-proxy ships from 2.9: the 2.8 image (the default before 2.13) @@ -1916,6 +1945,7 @@ $siteAddress { return str($proxyType->value)->lower(); }); if ($validProxyTypes->contains(str($proxyType)->lower())) { + $previousProxyType = $this->proxyType(); $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); $this->proxy->set('last_saved_proxy_configuration', null); @@ -1931,11 +1961,26 @@ $siteAddress { StartProxy::run($this); } } + if ($previousProxyType !== $this->proxyType() && $this->shouldRestartSentinelForTrafficAnalytics()) { + // Sentinel keeps the traffic log mount, path and log format of the old proxy until it is recreated. + $this->restartSentinel(); + } } else { throw new \Exception('Invalid proxy type.'); } } + /** + * Sentinel reads the proxy access log only when it runs and traffic analytics is on. + * The raw setting is used, because a switch to a proxy without analytics support must also drop the old log mount. + */ + private function shouldRestartSentinelForTrafficAnalytics(): bool + { + return (bool) $this->settings->is_sentinel_enabled + && $this->isSentinelEnabled() + && $this->isTrafficAnalyticsEnabled(); + } + public function isEmpty() { return $this->applications()->count() == 0 && diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 624458ef91..32afb2a2a7 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -135,6 +135,16 @@ class ServerSetting extends Model 'compose_version_checked_at', ]; + /** + * Model-level defaults that mirror database column defaults, so a freshly + * created instance exposes them without a refresh. + * + * @var array + */ + protected $attributes = [ + 'is_traffic_analytics_enabled' => false, + ]; + protected $casts = [ 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fbdaaecab0..27f095ac45 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -100,7 +100,10 @@ class AppServiceProvider extends ServiceProvider private function configureGitHubHttp(): void { Http::macro('GitSource', function (string $url) { - return Http::withOptions(SafeExternalUrl::httpClientOptions($url)); + return Http::withOptions(SafeExternalUrl::httpClientOptions( + $url, + allowPrivateNetworks: SafeExternalUrl::gitSourcesMayUsePrivateNetworks(), + )); }); Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 5add4c1896..7d614d03de 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -14,11 +14,29 @@ class SafeWebhookUrl implements ValidationRule { /** * @param (Closure(string): array)|null $resolver - */ - /** * @param array $trustedInternalHosts + * @param bool $allowPrivateNetworks Allow private, CGNAT, and unique local addresses and internal hostnames. + * Loopback, link-local (cloud metadata), and reserved targets stay blocked. */ - public function __construct(private ?Closure $resolver = null, private array $trustedInternalHosts = []) {} + public function __construct( + private ?Closure $resolver = null, + private array $trustedInternalHosts = [], + private bool $allowPrivateNetworks = false, + ) {} + + /** + * Git sources such as GitHub Enterprise or GitLab often run on a private network. + * Self-hosted instances allow them. Coolify Cloud keeps them blocked. + */ + public static function forGitSource(): static + { + return new static(allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks()); + } + + public static function gitSourcesMayUsePrivateNetworks(): bool + { + return ! isCloud(); + } /** * Run the validation rule. @@ -112,7 +130,7 @@ class SafeWebhookUrl implements ValidationRule * @param (Closure(string): array)|null $resolver * @return array */ - public static function httpClientOptions(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null): array + public static function httpClientOptions(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array { $options = ['allow_redirects' => false]; @@ -120,7 +138,7 @@ class SafeWebhookUrl implements ValidationRule throw new \RuntimeException('Webhook URL DNS pinning is unavailable.'); } - $target = self::resolveUrlForRequest($url, $trustedInternalHosts, $resolver); + $target = self::resolveUrlForRequest($url, $trustedInternalHosts, $resolver, $allowPrivateNetworks); if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) { return $options; @@ -187,9 +205,9 @@ class SafeWebhookUrl implements ValidationRule /** * @return array{host: string, port: int, ips: array} */ - private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null): array + private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array { - $rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts); + $rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks); if (! filter_var($url, FILTER_VALIDATE_URL)) { throw new \RuntimeException('Webhook URL is invalid.'); } @@ -372,7 +390,11 @@ class SafeWebhookUrl implements ValidationRule } if ($this->isPrivateIp($ip)) { - return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip); + return $this->allowPrivateNetworks || $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip); + } + + if ($this->allowPrivateNetworks && $this->ipv4InCidr($ip, '100.64.0.0/10')) { + return true; } return $this->isAllowlistedIp($ip); @@ -491,8 +513,16 @@ class SafeWebhookUrl implements ValidationRule private function isBlockedHostname(string $host): bool { - return in_array($host, ['localhost'], true) - || str_ends_with($host, '.local') + if ($host === 'localhost') { + return true; + } + + // The resolved addresses of internal hostnames are still checked. + if ($this->allowPrivateNetworks) { + return false; + } + + return str_ends_with($host, '.local') || str_ends_with($host, '.internal') || str_ends_with($host, '.cluster.local'); } diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 3cd7eabe6c..74e70367dd 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -50,17 +50,6 @@ class SentinelTrafficClient return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; } - /** - * Slim shared fetch for a single application's overview over a UI range, so the - * General-page widget and the full analytics tab don't duplicate window + client calls. - */ - public function appOverview(string $appKey, string $range = '24h'): TrafficOverviewData - { - [$from, $to] = self::rangeWindow($range); - - return $this->overview($appKey, $from, $to); - } - public function paths(?string $appKey, string $from, string $to, int $limit = 50): Collection { $rows = json_decode($this->raw($this->pathsUrl($appKey, $from, $to, $limit)), true) ?? []; @@ -102,6 +91,137 @@ class SentinelTrafficClient return json_decode($this->raw($this->appsUrl()), true) ?? []; } + /** + * True when Sentinel key $key belongs to the resource with $resourceUuid. A resource + * (Application or Service) owns the bare `{uuid}` key and every `{uuid}-…` key: compose + * services (`{uuid}-{service}`), previews (`{uuid}-{pr}`), and preview compose services. + */ + public static function keyBelongsTo(string $key, string $resourceUuid): bool + { + return $resourceUuid !== '' && ($key === $resourceUuid || str_starts_with($key, $resourceUuid.'-')); + } + + /** + * Uuids that could own $key, most specific first: the key itself, then every prefix + * that ends before a `-`. Lets a caller match a key against a uuid map in O(key length). + * + * @return array + */ + public static function candidateOwnerUuids(string $key): array + { + $candidates = [$key]; + $position = strlen($key); + while (($position = strrpos(substr($key, 0, $position), '-')) !== false) { + if ($position > 0) { + $candidates[] = substr($key, 0, $position); + } + } + + return $candidates; + } + + /** + * Every Sentinel key recorded on this server that belongs to the resource. Falls back to + * the bare uuid when none is recorded (or the key list is unavailable), so per-key queries + * still run and return an empty result instead of failing. + * + * @return array + */ + public function resourceKeys(string $resourceUuid): array + { + $this->assertSafeKey($resourceUuid); + + try { + $recorded = $this->apps(); + } catch (\Throwable) { + $recorded = []; + } + + $keys = array_values(array_unique(array_filter( + $recorded, + fn ($key) => is_string($key) + && $this->isSafeKey($key) + && self::keyBelongsTo($key, $resourceUuid) + ))); + sort($keys); + + return $keys === [] ? [$resourceUuid] : $keys; + } + + /** + * Resolve the resource's keys and warm every endpoint for each key, in as few SSH + * round-trips as possible: the key list and the bare-uuid dashboard bundle (the common + * single-key case) share one exec, and extra keys share one more. + * + * @param array $dimensions + * @return array + */ + public function prefetchResource(string $resourceUuid, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50): array + { + $this->assertSafeKey($resourceUuid); + + $urls = [$this->appsUrl()]; + if (Cache::get($this->dashboardAbsenceKey()) !== true) { + $urls[] = $this->dashboardUrl($resourceUuid, $from, $to, $range, $pathLimit, $breakdownLimit, 0); + } + $this->warm($urls); + + $keys = $this->resourceKeys($resourceUuid); + $this->prefetchKeys($keys, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit); + + return $keys; + } + + /** + * Warm every endpoint for several app keys. One key uses prefetchServerWide(); several + * keys batch their dashboard bundles (or, on an older Sentinel, their individual + * endpoints) into one exec instead of one SSH round-trip per key. + * + * @param array $appKeys + * @param array $dimensions + */ + public function prefetchKeys(array $appKeys, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50): void + { + $appKeys = array_values(array_unique($appKeys)); + if ($appKeys === []) { + return; + } + + if (count($appKeys) === 1 || ! $this->usesBatchableTransport()) { + foreach ($appKeys as $appKey) { + $this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit); + } + + return; + } + + if (Cache::get($this->dashboardAbsenceKey()) !== true) { + $dashboardUrls = array_map( + fn (string $appKey) => $this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, 0), + $appKeys + ); + $this->warm($dashboardUrls); + + if (Cache::has($this->cacheKey($dashboardUrls[0]))) { + // Each call now reads its bundle from cache and seeds the per-endpoint cache. + foreach ($appKeys as $appKey) { + $this->prefetchServerWide($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit); + } + + return; + } + + // Older Sentinel without the dashboard route: remember it like fetchDashboard() does. + Cache::put($this->dashboardAbsenceKey(), true, 60); + } + + $urls = [$this->attributionUrl()]; + foreach ($appKeys as $appKey) { + array_push($urls, ...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit)); + } + $this->warm($urls); + } + public function attribution(): ?string { $json = json_decode($this->raw($this->attributionUrl()), true) ?? []; @@ -139,14 +259,9 @@ class SentinelTrafficClient // Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints. $urls = [ - $this->overviewUrl($appKey, $from, $to), - $this->pathsUrl($appKey, $from, $to, $pathLimit), - $this->seriesUrl($appKey, $range), + ...$this->endpointUrls($appKey, $from, $to, $dimensions, $range, $pathLimit, $breakdownLimit), $this->attributionUrl(), ]; - foreach ($dimensions as $dimension) { - $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit); - } // The per-application leaderboard only exists on the unfiltered view. if ($appKey === null) { $urls[] = $this->appsUrl(); @@ -178,7 +293,7 @@ class SentinelTrafficClient // so without a marker every refresh would re-probe over SSH before falling back to the // batch. Remember the absence for the same 60s window as the data cache: at most one // wasted probe per minute, and a Sentinel upgrade is picked up on the next window. - $absenceKey = 'traffic:dashboard-absent:'.$this->server->uuid; + $absenceKey = $this->dashboardAbsenceKey(); if (Cache::get($absenceKey) === true) { return null; } @@ -242,6 +357,32 @@ class SentinelTrafficClient $this->warm($urls); } + private function dashboardAbsenceKey(): string + { + return 'traffic:dashboard-absent:'.$this->server->uuid; + } + + /** + * The individual endpoint urls (overview, paths, series, breakdowns) for one key or the + * whole server, used by the batched fallback on Sentinel builds without the dashboard. + * + * @param array $dimensions + * @return array + */ + private function endpointUrls(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit, int $breakdownLimit): array + { + $urls = [ + $this->overviewUrl($appKey, $from, $to), + $this->pathsUrl($appKey, $from, $to, $pathLimit), + $this->seriesUrl($appKey, $range), + ]; + foreach ($dimensions as $dimension) { + $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit); + } + + return $urls; + } + private function overviewUrl(?string $appKey, string $from, string $to): string { $path = $this->appScopedPath($appKey, 'overview'); @@ -324,11 +465,16 @@ class SentinelTrafficClient */ private function assertSafeKey(string $value): void { - if ($value === '' || ! preg_match('/\A[A-Za-z0-9._:-]+\z/', $value)) { + if (! $this->isSafeKey($value)) { throw new \InvalidArgumentException('Invalid traffic analytics app key.'); } } + private function isSafeKey(string $value): bool + { + return $value !== '' && preg_match('/\A[A-Za-z0-9._:-]+\z/', $value) === 1; + } + private function assertSafeDimension(string $dimension): void { if (! in_array($dimension, self::ALLOWED_DIMENSIONS, true)) { diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php index 6e057a3816..8ff50dc0fa 100644 --- a/app/Services/TrafficAnalyticsAggregator.php +++ b/app/Services/TrafficAnalyticsAggregator.php @@ -4,8 +4,35 @@ namespace App\Services; use App\Data\Traffic\TrafficOverviewData; +/** + * Merges Sentinel traffic data from several sources (servers, or the several app keys of + * one resource) into one view. Counters are summed; latency takes the worst value and + * uniques are summed, so both are flagged approximate once more than one source is merged. + */ class TrafficAnalyticsAggregator { + /** @var array */ + private array $overviews = []; + + /** @var array */ + private array $paths = []; + + /** @var array> */ + private array $breakdowns; + + /** @var array> */ + private array $series = []; + + private ?string $attribution = null; + + /** + * @param array $dimensions + */ + public function __construct(private array $dimensions = []) + { + $this->breakdowns = array_fill_keys($dimensions, []); + } + /** * @param array $overviews * @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool} @@ -36,4 +63,165 @@ class TrafficAnalyticsAggregator 'uniquesApproximate' => $multi, ]; } + + /** + * Fetch every shape for one app key (or the whole server when null) and merge it in. + * The series fetch is isolated: a failure (or an older Sentinel without the endpoint) + * never discards the other data; an empty series flips the chart to the donut. + * + * @param callable(string): ?string $domainForKey resolves a path row's app key to its domain + */ + public function collect(SentinelTrafficClient $client, ?string $appKey, string $from, string $to, string $range, callable $domainForKey, int $limit = 50): void + { + $this->addOverview($client->overview($appKey, $from, $to)); + $this->addPaths($client->paths($appKey, $from, $to, $limit), $appKey, $domainForKey); + + foreach ($this->dimensions as $dimension) { + $this->addBreakdown($dimension, $client->breakdown($appKey, $dimension, $from, $to, $limit)); + } + + $this->attribution ??= $client->attribution(); + + try { + $this->addSeries($client->series($appKey, $range)); + } catch (\Throwable) { + // Leave this source out of the series; the donut fallback covers it. + } + } + + public function addOverview(TrafficOverviewData $overview): void + { + $this->overviews[] = $overview; + } + + /** + * Merge path rows keyed by (app, path), so the same path under two apps stays two + * rows, each with its own domain. Sentinel's per-row `app` wins; older Sentinel omits + * it, so a key-scoped fetch falls back to the key it queried. + * + * @param iterable $paths + * @param callable(string): ?string $domainForKey + */ + public function addPaths(iterable $paths, ?string $fallbackAppKey, callable $domainForKey): void + { + foreach ($paths as $path) { + $data = $path->toArray(); + $pathStr = (string) ($data['path'] ?? ''); + $appId = (string) ($data['app'] ?? ''); + $resolveId = $appId !== '' ? $appId : ($fallbackAppKey ?? ''); + $key = $resolveId."\n".$pathStr; + + $this->paths[$key] ??= [ + 'path' => $pathStr, + 'domain' => $resolveId !== '' ? $domainForKey($resolveId) : null, + 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0, + ]; + $this->paths[$key]['requests'] += (int) ($data['requests'] ?? 0); + $this->paths[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + $this->paths[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0); + $this->paths[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0); + $this->paths[$key]['p95'] = max($this->paths[$key]['p95'], (float) ($data['p95'] ?? 0)); + } + } + + /** + * @param iterable $rows + */ + public function addBreakdown(string $dimension, iterable $rows): void + { + foreach ($rows as $row) { + $data = $row->toArray(); + $value = (string) ($data['value'] ?? ''); + + $this->breakdowns[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0]; + $this->breakdowns[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0); + $this->breakdowns[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + } + } + + /** + * Sum status series by bucket. Uniques are summed (approximate); p95 takes the worst bucket. + * + * @param iterable $buckets + */ + public function addSeries(iterable $buckets): void + { + foreach ($buckets as $bucket) { + $data = $bucket->toArray(); + $ts = (int) ($data['bucket'] ?? 0); + + $this->series[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0]; + foreach (['s2xx', 's3xx', 's4xx', 's5xx', 'requests', 'bytesIn', 'bytesOut', 'uniqueVisitors'] as $field) { + $this->series[$ts][$field] += (int) ($data[$field] ?? 0); + } + $this->series[$ts]['p95'] = max($this->series[$ts]['p95'], (float) ($data['p95'] ?? 0)); + } + } + + public function hasOverview(): bool + { + return $this->overviews !== []; + } + + /** + * @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool} + */ + public function overview(): array + { + return self::sumOverviews($this->overviews); + } + + /** + * @return array + */ + public function topPaths(int $limit = 50): array + { + return self::topByRequests(array_values($this->paths), $limit); + } + + /** + * @return array> + */ + public function breakdowns(int $limit = 50): array + { + $result = []; + foreach ($this->dimensions as $dimension) { + $rows = self::topByRequests(array_values($this->breakdowns[$dimension] ?? []), PHP_INT_MAX); + if ($dimension === 'referer') { + $rows = groupRefererBreakdownRows($rows); + } + $result[$dimension] = array_slice($rows, 0, $limit); + } + + return $result; + } + + /** + * @return array> + */ + public function series(): array + { + $series = $this->series; + ksort($series); + + return array_values($series); + } + + public function attribution(): ?string + { + return $this->attribution; + } + + /** + * @template T of array{requests: int} + * + * @param array $rows + * @return array + */ + public static function topByRequests(array $rows, int $limit = 50): array + { + usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']); + + return array_slice($rows, 0, $limit); + } } diff --git a/app/Services/TrafficResource.php b/app/Services/TrafficResource.php new file mode 100644 index 0000000000..e89189c95a --- /dev/null +++ b/app/Services/TrafficResource.php @@ -0,0 +1,189 @@ +model->uuid; + } + + public function name(): string + { + return (string) $this->model->name; + } + + public function isService(): bool + { + return $this->model instanceof Service; + } + + public function server(): ?Server + { + if ($this->model instanceof Service) { + return $this->model->server ?? $this->model->destination?->server; + } + + return $this->model->destination?->server; + } + + public function projectName(): string + { + return (string) (data_get($this->model, 'environment.project.name') ?: 'Ungrouped'); + } + + public function analyticsLink(): ?string + { + $projectUuid = data_get($this->model, 'environment.project.uuid'); + $environmentUuid = data_get($this->model, 'environment.uuid'); + if (! $projectUuid || ! $environmentUuid) { + return null; + } + + if ($this->model instanceof Service) { + return route('project.service.analytics', [ + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'service_uuid' => $this->model->uuid, + ]); + } + + return route('project.application.analytics', [ + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'application_uuid' => $this->model->uuid, + ]); + } + + /** + * First configured domain host of the resource, or null when it has none. + */ + public function primaryDomain(): ?string + { + if ($this->model instanceof Service) { + foreach ($this->model->applications as $serviceApplication) { + if ($host = self::firstHost($serviceApplication->fqdn)) { + return $host; + } + } + + return null; + } + + if ($host = self::firstHost($this->model->fqdn)) { + return $host; + } + + foreach (self::composeDomains($this->model->docker_compose_domains) as $domain) { + if ($host = self::firstHost($domain)) { + return $host; + } + } + + return null; + } + + /** + * Domain host served under one of this resource's Sentinel keys: the compose service + * (`{uuid}-{service}`), the preview (`{uuid}-pr-{pr}` or `{uuid}-{pr}`), or the preview compose service + * (`{uuid}-{pr}-{service}`). Falls back to the primary domain. + */ + public function domainForKey(string $key): ?string + { + if (! SentinelTrafficClient::keyBelongsTo($key, $this->uuid()) || $key === $this->uuid()) { + return $this->primaryDomain(); + } + + $suffix = substr($key, strlen($this->uuid()) + 1); + + if ($this->model instanceof Service) { + $serviceApplication = $this->model->applications + ->first(fn (ServiceApplication $app) => self::matchesServiceName($suffix, (string) $app->name)); + + return self::firstHost($serviceApplication?->fqdn) ?? $this->primaryDomain(); + } + + // Normal previews use `{uuid}-pr-{id}`, compose previews `{uuid}-{id}` and `{uuid}-{id}-{service}`. + if (preg_match('/\A(?:pr-)?(\d+)(?:-(.+))?\z/', $suffix, $matches)) { + /** @var ApplicationPreview|null $preview */ + $preview = $this->model->previews()->where('pull_request_id', (int) $matches[1])->first(); + if ($preview) { + $host = isset($matches[2]) + ? self::composeDomainFor($preview->docker_compose_domains, $matches[2]) + : self::firstHost($preview->fqdn); + if ($host) { + return $host; + } + } + } + + return self::composeDomainFor($this->model->docker_compose_domains, $suffix) ?? $this->primaryDomain(); + } + + /** + * Key suffixes use the label-safe compose service name; accept the raw name too. + */ + private static function matchesServiceName(string $suffix, string $serviceName): bool + { + return $serviceName !== '' + && ($suffix === $serviceName || $suffix === traefikSafeServiceNameSegment($serviceName)); + } + + private static function composeDomainFor(?string $composeDomains, string $suffix): ?string + { + foreach (self::composeDomains($composeDomains) as $serviceName => $domain) { + if (self::matchesServiceName($suffix, (string) $serviceName)) { + return self::firstHost($domain); + } + } + + return null; + } + + /** + * @return array compose service name => comma-separated domains + */ + private static function composeDomains(?string $json): array + { + $decoded = json_decode($json ?: '[]', true); + if (! is_array($decoded)) { + return []; + } + + $domains = []; + foreach ($decoded as $serviceName => $config) { + $domain = data_get($config, 'domain'); + if (is_string($domain) && trim($domain) !== '') { + $domains[(string) $serviceName] = $domain; + } + } + + return $domains; + } + + private static function firstHost(?string $domains): ?string + { + $first = trim((string) str($domains ?? '')->explode(',')->first()); + + return $first !== '' ? (parse_url($first, PHP_URL_HOST) ?: null) : null; + } +} diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index 3568fe3e22..f93c51cc66 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -148,25 +148,42 @@ SH; */ private function postgresqlSingle(bool $replaceExisting): string { + // Every restore runs in one transaction, so a failure part-way rolls back and leaves the + // current data as it was, also when --clean dropped objects first. $clean = $replaceExisting ? ' --clean --if-exists' : ''; - $sqlNotice = $replaceExisting ? <<<'SH' + $sqlRestore = $replaceExisting ? <<<'SH' - echo 'SQL backups cannot replace single objects. The database is recreated before the restore.' - echo "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" | psql -v db="$db" -U $POSTGRES_USER -d template1 >/dev/null || exit 1 - dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$db" || exit 1 - createdb -U $POSTGRES_USER "$db" || exit 1 -SH : ''; + echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.' + new=coolify_restore_new + old=coolify_restore_old + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" || exit 1 + createdb -U $POSTGRES_USER "$new" || exit 1 + if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$new"; then + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + fail 'The SQL restore failed. The current database was not changed.' + fi + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 + if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U $POSTGRES_USER -d template1 >/dev/null; then + # Undo a half-done swap: the current database keeps its name and data. + printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U $POSTGRES_USER -d template1 >/dev/null 2>&1 + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + fail 'The current database is in use and could not be replaced. Nothing was changed.' + fi + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 +SH : <<<'SH' + + stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$db" +SH; return <<value, ProcessStatus::IN_PROGRESS->value, @@ -65,13 +94,58 @@ class ResourceStartActivity return true; } - $staleAfterSeconds = data_get($activity, 'properties.status') === ProcessStatus::QUEUED->value - ? self::QUEUED_STALE_AFTER_SECONDS - : self::IN_PROGRESS_STALE_AFTER_SECONDS; + $staleAfterSeconds = match (true) { + data_get($activity, 'properties.operation') === self::DATABASE_IMPORT_OPERATION => self::importStaleAfterSeconds(), + data_get($activity, 'properties.status') === ProcessStatus::QUEUED->value => self::QUEUED_STALE_AFTER_SECONDS, + default => self::IN_PROGRESS_STALE_AFTER_SECONDS, + }; return $lastProgressAt->lte(now()->subSeconds($staleAfterSeconds)); } + public static function importStaleAfterSeconds(): int + { + $commandTimeout = (int) config('constants.ssh.command_timeout'); + + return max(self::IMPORT_MIN_STALE_AFTER_SECONDS, $commandTimeout + self::IMPORT_STALE_MARGIN_SECONDS); + } + + public static function databaseStartLockKey(string $databaseUuid): string + { + return "database-start:{$databaseUuid}"; + } + + /** + * Queued or in-progress activities of one operation for a resource, oldest first. + * + * @return Collection + */ + public static function active(string $typeUuid, string $operation, ?int $teamId = null): Collection + { + return Activity::query() + ->where('properties->type_uuid', $typeUuid) + ->where('properties->operation', $operation) + ->whereIn('properties->status', self::ACTIVE_STATUSES) + ->when($teamId !== null, fn ($query) => $query->where('properties->team_id', $teamId)) + ->orderBy('id') + ->get(); + } + + /** + * Fail the stale activities in the list, so the UI and API stop reporting them as running. + * + * @param Collection $activities + * @return Collection The activities that are not stale. + */ + public static function failStale(Collection $activities): Collection + { + [$stale, $live] = $activities->partition(fn (Activity $activity): bool => self::isStale($activity)); + + $stale->each(fn (Activity $activity) => self::markFailed($activity, self::STALE_MESSAGE)); + + return $live->values(); + } + /** * Mark an activity as failed, append the reason to its log and stop log polling. */ @@ -92,8 +166,8 @@ class ResourceStartActivity } /** - * Fail database and service start activities left queued or in progress by a restart. - * Runs during app:init, before any queue worker is started. + * Fail database starts, database imports and service starts left queued or in progress + * by a restart. Runs during app:init, before any queue worker is started. */ public static function failInterrupted(): int { @@ -104,7 +178,11 @@ class ResourceStartActivity $serviceUuids = self::existingServiceUuids($activities); $interrupted = $activities->filter( - fn (Activity $activity): bool => data_get($activity, 'properties.operation') === self::DATABASE_START_OPERATION + fn (Activity $activity): bool => in_array( + data_get($activity, 'properties.operation'), + [self::DATABASE_START_OPERATION, self::DATABASE_IMPORT_OPERATION], + true, + ) || $serviceUuids->contains(data_get($activity, 'properties.type_uuid')) ); diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0d8dfdc29f..5a4540ea3a 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -16,6 +16,8 @@ trait ExecuteRemoteCommand { use SshRetryable; + private const SENSITIVE_COMMAND_PLACEHOLDER = '[command hidden because it contains sensitive data]'; + public ?string $save = null; public static int $batch_counter = 0; @@ -184,7 +186,7 @@ trait ExecuteRemoteCommand $new_log_entry = [ 'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command), - 'output' => $this->redact_sensitive_info($log_output), + 'output' => $this->redact_sensitive_info($skip_command_log ? $this->redactSensitiveCommandPayloads((string) $log_output, (string) $command) : $log_output), 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, @@ -241,12 +243,54 @@ trait ExecuteRemoteCommand if (empty($error)) { $error = $process_result->output() ?: 'Command failed with no error output'; } - $redactedCommand = $this->redact_sensitive_info($command); - throw new DeploymentException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}"); + throw new DeploymentException($this->commandFailureMessage((string) $command, (int) $process_result->exitCode(), (string) $error, $skip_command_log)); } } } + /** + * Commands marked with skip_command_log embed secrets (for example base64 encoded .env files or + * private keys), so their text must never reach a log line or an exception message. + */ + private function commandFailureMessage(string $command, int $exitCode, string $error, bool $isSensitiveCommand): string + { + if ($isSensitiveCommand) { + $commandText = self::SENSITIVE_COMMAND_PLACEHOLDER; + $error = $this->redactSensitiveCommandPayloads($error, $command); + } else { + $commandText = $this->redact_sensitive_info($command); + } + + $error = $this->redact_sensitive_info($error); + + return "Command execution failed (exit code {$exitCode}): {$commandText}\nError: {$error}"; + } + + /** + * Removes the encoded payloads of a sensitive command from output that could echo the command. + */ + private function redactSensitiveCommandPayloads(string $text, string $command): string + { + if ($text === '' || preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $command, $matches) === false) { + return $text; + } + + $payloads = collect($matches[0]) + ->unique() + ->filter(function (string $candidate): bool { + $decoded = base64_decode($candidate, true); + + return $decoded !== false + && mb_check_encoding($decoded, 'UTF-8') + && preg_match('/[^\P{C}\t\n\r]/u', $decoded) !== 1; + }) + ->sortByDesc(fn (string $payload): int => strlen($payload)) + ->values() + ->all(); + + return $payloads === [] ? $text : str_replace($payloads, REDACTED, $text); + } + private function saveCommandOutput(string $output, bool $append): void { if (! $this->save) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 898e6d7412..746d2c2691 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -43,6 +43,19 @@ function traefikSafeServiceNameSegment(string $serviceName): string return $normalized.'-'.traefikServiceNameHash($serviceName); } +/** + * Key that Sentinel uses to group Caddy access-log lines. It is the same key that Sentinel gets + * from the Traefik router name (`https-{i}-{KEY}@docker`), so both proxies report a resource the same way. + * Only [A-Za-z0-9-] can occur, so the value is safe in a Docker label and in a Caddyfile. + */ +function caddyTrafficAppKey(string $uuid, ?string $serviceName = null): string +{ + // Same truthiness check as fqdnLabelsForTraefik(), so the keys stay equal. + $key = $serviceName ? $uuid.'-'.traefikSafeServiceNameSegment($serviceName) : $uuid; + + return (string) preg_replace('/[^A-Za-z0-9-]+/', '-', $key); +} + function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { $containers = collect([]); @@ -559,6 +572,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); } + $trafficAppKey = caddyTrafficAppKey($uuid, $service_name); + foreach ($domains as $loop => $domain) { $url = Url::fromString($domain); $host = $url->getHost(); @@ -623,7 +638,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $labels->push("caddy_{$loop}.log.format=json"); // Only Caddy 2.8+ knows log_append; see Server::caddySupportsLogAppend(). if ($supports_log_append) { - $labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}"); + $labels->push("caddy_{$loop}.log_append=coolify_app_id {$trafficAppKey}"); } } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 4c5dff37cc..c0fc2d731b 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1510,6 +1510,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); $onlyPort = firstDockerComposeServicePort($service); + $isTrafficAnalyticsEnabled = (bool) $server?->isTrafficAnalyticsEnabled(); + $supportsLogAppend = (bool) $server?->caddySupportsLogAppend(); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1547,6 +1549,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled, + supports_log_append: $supportsLogAppend, )); break; } @@ -1580,6 +1584,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled, + supports_log_append: $supportsLogAppend, )); } } @@ -2766,6 +2772,8 @@ function serviceParser(Service $resource): Collection $onlyPort = $originalResource instanceof ServiceApplication ? $originalResource->getRequiredPort() : $predefinedPort; + $isTrafficAnalyticsEnabled = (bool) $server?->isTrafficAnalyticsEnabled(); + $supportsLogAppend = (bool) $server?->caddySupportsLogAppend(); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -2802,7 +2810,9 @@ function serviceParser(Service $resource): Collection predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled, + supports_log_append: $supportsLogAppend, )); break; } @@ -2835,7 +2845,9 @@ function serviceParser(Service $resource): Collection predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled, + supports_log_append: $supportsLogAppend, )); } } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 68a2c93a15..6e70b2fcb1 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -45,43 +45,139 @@ function applyTrafficAnalyticsToProxyConfiguration(Server $server, string $confi return Yaml::dump($config, 12, 2); } +/** + * Server proxy attribute that remembers the user's own `--accesslog*` Traefik flags while + * traffic analytics replaces them with the managed set, so disabling can restore them exactly. + */ +const TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY = 'traffic_analytics_user_accesslog_commands'; + +function isTraefikAccessLogCommand(mixed $command): bool +{ + if (! is_string($command)) { + return false; + } + + $flag = strtolower(explode('=', $command, 2)[0]); + + return $flag === '--accesslog' || str_starts_with($flag, '--accesslog.'); +} + +/** + * Rotates the Traefik access log with BusyBox tools from the Alpine base image only (no package + * install, no network). Uses copytruncate semantics: Traefik keeps its file handle and Sentinel's + * tailer handles the truncation. Keeps at most 5 gzip-compressed rotations (access.log.1.gz..5.gz). + * The environment overrides exist for tests; the sidecar does not set them. + */ +function traefikAccessLogRotationScript(): string +{ + return <<<'SH' +log="${TRAEFIK_ACCESS_LOG:-/traefik/access.log}" +max_bytes="${TRAEFIK_ACCESS_LOG_MAX_BYTES:-20971520}" +interval="${TRAEFIK_ACCESS_LOG_ROTATE_INTERVAL:-60}" +keep=5 +while true; do + size=$(stat -c %s "$log" 2>/dev/null || echo 0) + if [ "$size" -gt "$max_bytes" ]; then + rm -f "$log.$keep.gz" + i=$((keep - 1)) + while [ "$i" -ge 1 ]; do + if [ -f "$log.$i.gz" ]; then mv -f "$log.$i.gz" "$log.$((i + 1)).gz"; fi + i=$((i - 1)) + done + if cp "$log" "$log.1"; then + : > "$log" + gzip -f "$log.1" + fi + fi + sleep "$interval" +done +SH; +} + +/** + * Replace the user's own access log flags with the managed set while analytics is enabled, and + * restore them exactly when it is disabled. Flags that were never added by Coolify are kept. + * + * @param array $commands + * @return array + */ +function applyTraefikAccessLogCommands(Server $server, array $commands, bool $enabled): array +{ + $managedCommands = traefikAccessLogCommands(true); + $storedUserCommands = $server->proxy->get(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY); + $hasStoredUserCommands = is_array($storedUserCommands); + $userCommands = $hasStoredUserCommands ? array_values($storedUserCommands) : []; + + // Managed flags are Coolify's when their user flags were remembered on enable, or (analytics + // enabled before flags were remembered) when the complete managed set is present. Otherwise a + // matching flag such as `--accesslog=true` belongs to the user and is kept. + $managedCommandsAddedByCoolify = $hasStoredUserCommands || array_diff($managedCommands, $commands) === []; + if ($managedCommandsAddedByCoolify) { + $commands = array_values(array_filter( + $commands, + fn (mixed $command): bool => ! in_array($command, $managedCommands, true) + )); + } + + if ($enabled) { + foreach ($commands as $command) { + if (isTraefikAccessLogCommand($command) && ! in_array($command, $userCommands, true)) { + $userCommands[] = $command; + } + } + + $commands = [ + ...array_filter($commands, fn (mixed $command): bool => ! isTraefikAccessLogCommand($command)), + ...$managedCommands, + ]; + + if ($storedUserCommands !== $userCommands) { + $server->proxy->set(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY, $userCommands); + $server->save(); + } + } elseif ($hasStoredUserCommands) { + foreach ($userCommands as $command) { + if (! in_array($command, $commands, true)) { + $commands[] = $command; + } + } + + $server->proxy->forget(TRAEFIK_USER_ACCESSLOG_COMMANDS_KEY); + $server->save(); + } + + return array_values($commands); +} + function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config): array { $enabled = $server->isTrafficAnalyticsEnabled(); if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { - $managedCommands = traefikAccessLogCommands(true); $commands = data_get($config, 'services.traefik.command', []); if (! is_array($commands)) { throw new RuntimeException('Traefik commands must be a YAML list.'); } - $commands = array_values(array_filter( - $commands, - fn (mixed $command): bool => ! in_array($command, $managedCommands, true) - )); - - if ($enabled) { - $commands = [...$commands, ...$managedCommands]; - } - - data_set($config, 'services.traefik.command', $commands); + data_set($config, 'services.traefik.command', applyTraefikAccessLogCommands($server, $commands, $enabled)); unset($config['services']['traefik-logrotate']); if ($enabled && ! $server->isSwarm() && ! isDev()) { $proxyPath = $server->proxyPath(); $config['services']['traefik-logrotate'] = [ 'container_name' => 'coolify-proxy-logrotate', - 'image' => 'alpine:3.20', + 'image' => 'alpine:3.24', 'restart' => RESTART_MODE, + 'network_mode' => 'none', 'volumes' => [ "{$proxyPath}:/traefik", ], 'labels' => [ 'coolify.managed=true', ], - 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'', + // Docker Compose interpolates `$VAR`, so every `$` is escaped as `$$`. + 'entrypoint' => ['/bin/sh', '-c', str_replace('$', '$$', traefikAccessLogRotationScript())], ]; } } elseif ($server->proxyType() === ProxyTypes::CADDY->value) { @@ -326,6 +422,97 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar return $custom_commands; } + +/** + * Removes the dashboard router labels that older Coolify versions generated for Traefik. + * These labels route requests with the proxy container name as Host header to the Traefik API and dashboard. + * Comments and formatting are kept. A router or label set that the user changed is not touched. + */ +function removeLegacyTraefikDashboardLabels(string $configuration): string +{ + $legacyLabels = [ + 'traefik.enable=true', + 'traefik.http.routers.traefik.entrypoints=http', + 'traefik.http.routers.traefik.service=api@internal', + 'traefik.http.services.traefik.loadbalancer.server.port=8080', + ]; + + try { + $yaml = Yaml::parse($configuration); + } catch (Throwable) { + return $configuration; + } + + $expected = $yaml; + $changed = false; + foreach (['services.traefik.labels', 'services.traefik.deploy.labels'] as $path) { + $labels = data_get($yaml, $path); + if (! is_array($labels) || ! array_is_list($labels) || array_filter($labels, 'is_string') !== $labels) { + continue; + } + + $traefikLabels = array_filter($labels, fn (string $label) => str_starts_with($label, 'traefik.')); + if (! in_array('traefik.http.routers.traefik.service=api@internal', $traefikLabels, true) || array_diff($traefikLabels, $legacyLabels) !== []) { + continue; + } + + $newLabels = []; + foreach ($labels as $label) { + if ($label === 'traefik.enable=true') { + $newLabels[] = 'traefik.enable=false'; + } elseif (! in_array($label, $legacyLabels, true)) { + $newLabels[] = $label; + } + } + data_set($expected, $path, $newLabels); + $changed = true; + } + + if (! $changed) { + return $configuration; + } + + $fixed = preg_replace('/^([ \t]*-[ \t]*([\'"]?))traefik\.enable=true(\2[ \t]*)(?=\R|\z)/m', '$1traefik.enable=false$3', $configuration); + foreach (array_slice($legacyLabels, 1) as $label) { + $fixed = preg_replace('/^[ \t]*-[ \t]*([\'"]?)'.preg_quote($label, '/').'\1[ \t]*(?:\R|\z)/m', '', $fixed); + } + + // Keep the original when the line edit also changed other parts of the file. + try { + return Yaml::parse($fixed) === $expected ? $fixed : $configuration; + } catch (Throwable) { + return $configuration; + } +} + +/** + * Saves the Traefik configuration without the legacy dashboard labels. The next proxy restart applies it. + */ +function removeLegacyTraefikDashboardExposure(Server $server): bool +{ + $configuration = $server->proxy->get('last_saved_proxy_configuration'); + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value || ! is_string($configuration) || blank($configuration)) { + return false; + } + + $fixed = removeLegacyTraefikDashboardLabels($configuration); + if ($fixed === $configuration) { + return false; + } + + // The running proxy still uses the old configuration, so the UI must ask for a restart. + if (blank($server->proxy->get('last_applied_settings'))) { + $server->proxy->last_applied_settings = md5(base64_encode($configuration)); + } + $server->proxy->last_saved_proxy_configuration = $fixed; + $server->proxy->last_saved_settings = md5(base64_encode($fixed)); + $server->save(); + + Log::info('Removed legacy Traefik dashboard labels from the proxy configuration', ['server_id' => $server->id]); + + return true; +} + function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { Log::info('Generating default proxy configuration', [ diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 241368d388..8e767ed484 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -25,7 +25,8 @@ function remote_process( ?Model $model = null, bool $ignore_errors = false, $callEventOnFinish = null, - $callEventData = null + $callEventData = null, + array $properties = [], ): Activity { $type = $type ?? ActivityTypes::INLINE->value; $command = $command instanceof Collection ? $command->toArray() : $command; @@ -46,6 +47,7 @@ function remote_process( SshMultiplexingHelper::ensureMultiplexedConnection($server); $properties = [ + ...$properties, 'server_uuid' => $server->uuid, 'command' => $command_string, 'type' => $type, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 88bdf12038..d9589ad6d9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3191,6 +3191,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $resource->server?->isTrafficAnalyticsEnabled() ?? false, + supports_log_append: $resource->server?->caddySupportsLogAppend() ?? false, )); break; } @@ -3224,6 +3226,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $resource->server?->isTrafficAnalyticsEnabled() ?? false, + supports_log_append: $resource->server?->caddySupportsLogAppend() ?? false, )); } } @@ -3995,6 +3999,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal domains: $fqdns, serviceLabels: $serviceLabels, image: data_get($service, 'image'), + service_name: $serviceName, is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), @@ -4002,6 +4007,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $server?->isTrafficAnalyticsEnabled() ?? false, + supports_log_append: $server?->caddySupportsLogAppend() ?? false, ) ); break; @@ -4030,6 +4037,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal domains: $fqdns, serviceLabels: $serviceLabels, image: data_get($service, 'image'), + service_name: $serviceName, is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), @@ -4037,6 +4045,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + is_traffic_analytics_enabled: $server?->isTrafficAnalyticsEnabled() ?? false, + supports_log_append: $server?->caddySupportsLogAppend() ?? false, ) ); } diff --git a/database/migrations/2026_09_25_210000_remove_legacy_traefik_dashboard_labels.php b/database/migrations/2026_09_25_210000_remove_legacy_traefik_dashboard_labels.php new file mode 100644 index 0000000000..9bab033205 --- /dev/null +++ b/database/migrations/2026_09_25_210000_remove_legacy_traefik_dashboard_labels.php @@ -0,0 +1,38 @@ +chunkById(100, function ($servers) { + foreach ($servers as $server) { + try { + removeLegacyTraefikDashboardExposure($server); + } catch (Throwable $e) { + Log::warning('Could not remove legacy Traefik dashboard labels', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + } + } + }); + } + + public function down(): void + { + // + } +}; diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index a955247dca..394cf5277d 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -4,6 +4,9 @@ import pty from 'node-pty'; import { parseCookie } from 'cookie'; import 'dotenv/config'; import { + TERMINAL_CLOSE_CODES, + authenticateTerminalUpgrade, + createTerminalUpgradeHandler, extractHereDocContent, extractSshArgs, extractTargetHost, @@ -11,6 +14,7 @@ import { getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, + rejectTerminalSocket, sanitizeSshArgs, validateSshArgs, } from './terminal-utils.js'; @@ -117,54 +121,44 @@ const getSessionCookie = (req) => { } } -const verifyClient = async (info, callback) => { - const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); +const verifyClient = async (req) => { + const sessionCookie = getSessionCookie(req); const requestContext = { - remoteAddress: info.req.socket?.remoteAddress, - origin: info.origin, - sessionCookieName, - hasXsrfToken: Boolean(xsrfToken), - hasLaravelSession: Boolean(laravelSession), + remoteAddress: req.socket?.remoteAddress, + origin: req.headers.origin, + sessionCookieName: sessionCookie.sessionCookieName, + hasXsrfToken: Boolean(sessionCookie.xsrfToken), + hasLaravelSession: Boolean(sessionCookie.laravelSession), }; logTerminal('log', 'Verifying websocket client.', requestContext); - // Verify presence of required tokens - if (!laravelSession || !xsrfToken) { - logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext); - return callback(false, 401, 'Unauthorized: Missing required tokens'); - } + // Authenticate with Laravel backend + const result = await authenticateTerminalUpgrade(sessionCookie, postToCoolify); - try { - // Authenticate with Laravel backend - const response = await postToCoolify('/terminal/auth', { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }); - - if (response.status === 200) { - logTerminal('log', 'Websocket client authentication succeeded.', requestContext); - callback(true); - } else { - logTerminal('warn', 'Websocket client authentication returned a non-success status.', { - ...requestContext, - status: response.status, - }); - callback(false, 401, 'Unauthorized: Invalid credentials'); - } - } catch (error) { + if (result.authenticated) { + logTerminal('log', 'Websocket client authentication succeeded.', requestContext); + } else if (result.error) { logTerminal('error', 'Websocket client authentication failed.', { ...requestContext, - error: error.message, - responseStatus: error.response?.status, - responseData: error.response?.data, + error: result.error.message, + }); + } else { + logTerminal('warn', 'Rejecting websocket client.', { + ...requestContext, + reason: result.reason, + status: result.status, + closeCode: result.closeCode, }); - callback(false, 500, 'Internal Server Error'); } + + return result; }; - -const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +// Upgrades are authenticated manually (instead of ws `verifyClient`) so a +// rejected browser receives a readable close code rather than an opaque 1006. +const wss = new WebSocketServer({ noServer: true, path: '/terminal/ws' }); +server.on('upgrade', createTerminalUpgradeHandler({ wss, authenticate: verifyClient })); const HEARTBEAT_INTERVAL_MS = 30000; @@ -220,7 +214,7 @@ wss.on('connection', async (ws, req) => { // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); - ws.close(401, 'Unauthorized: Missing required tokens'); + rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Missing required tokens'); return; } @@ -457,16 +451,25 @@ async function handleCommand(ws, command, userId) { }, terminalSessionTimeout * 1000); } +// The text message is kept for clients that predate the close code. +function rejectTerminalToken(userSession, message) { + logTerminal('warn', 'Closing websocket connection because the terminal token was rejected.', { + userId: userSession.userId, + reason: message, + }); + rejectTerminalSocket(userSession.ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, message, { message }); +} + async function handleTerminalToken(userSession, token) { if (typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)) { - userSession.ws.send('Unauthorized: Invalid terminal token'); + rejectTerminalToken(userSession, 'Unauthorized: Invalid terminal token'); return; } try { const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token }); if (response.status !== 200 || typeof response.data?.command !== 'string') { - userSession.ws.send('Unauthorized: Terminal token was rejected'); + rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected'); return; } @@ -476,7 +479,7 @@ async function handleTerminalToken(userSession, token) { userId: userSession.userId, error: error.message, }); - userSession.ws.send('Unauthorized: Terminal token was rejected'); + rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected'); } } diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index cc6d6fefd0..a1207b028a 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -1,5 +1,126 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +/** + * WebSocket close codes sent to the browser. Browsers cannot read the HTTP + * status of a rejected WebSocket handshake, so rejections are reported with + * application close codes (4000-4999) that the client can act on. + */ +export const TERMINAL_CLOSE_CODES = Object.freeze({ + AUTH_REJECTED: 4401, + TOKEN_REJECTED: 4403, + AUTH_UNAVAILABLE: 1011, +}); + +export const TERMINAL_REJECTED_SOCKET_TERMINATE_MS = 2000; + +/** + * Authenticates a terminal WebSocket upgrade with the browser's Laravel session + * cookie and XSRF token. Performs the same checks as the former verifyClient + * callback; only the way a rejection is reported to the client differs. + * + * @param {{ laravelSession?: string, xsrfToken?: string, sessionCookieName: string }} session + * @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify + * @returns {Promise<{ authenticated: true } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>} + */ +export async function authenticateTerminalUpgrade({ laravelSession, xsrfToken, sessionCookieName }, postToCoolify) { + if (!laravelSession || !xsrfToken) { + return { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Missing required tokens', + }; + } + + try { + const response = await postToCoolify('/terminal/auth', { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken, + }); + + if (response.status === 200) { + return { authenticated: true }; + } + + return { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Invalid credentials', + status: response.status, + }; + } catch (error) { + return { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE, + reason: 'Internal Server Error', + error, + }; + } +} + +/** + * Closes a WebSocket that must not be used, optionally sending a legacy text + * message first for clients that predate close codes. The socket is terminated + * if the peer does not complete the close handshake quickly. + */ +export function rejectTerminalSocket(ws, closeCode, reason, { message = null, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS } = {}) { + const terminateTimer = setTimeout(() => ws.terminate(), terminateAfterMs); + terminateTimer.unref?.(); + ws.once('close', () => clearTimeout(terminateTimer)); + // Rejected sockets have no other listeners; an unhandled 'error' (e.g. a + // malformed frame from an unauthenticated client) would crash the server. + ws.on('error', () => ws.terminate()); + + if (message !== null) { + ws.send(message); + } + + ws.close(closeCode, reason); +} + +/** + * Builds the HTTP `upgrade` listener for the terminal WebSocket server. + * Unauthenticated clients never reach the `connection` event: they are upgraded + * only to receive a close frame with a machine-readable code, then dropped. + */ +export function createTerminalUpgradeHandler({ wss, authenticate, onRejected = () => {}, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS }) { + return (req, socket, head) => { + if (!wss.shouldHandle(req)) { + // Let ws answer unknown paths exactly as it did with the `server` option. + wss.handleUpgrade(req, socket, head, (ws) => ws.terminate()); + return; + } + + // The client may disconnect while Coolify is checking the session. + const destroyOnError = () => socket.destroy(); + socket.on('error', destroyOnError); + + Promise.resolve() + .then(() => authenticate(req)) + .catch((error) => ({ + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE, + reason: 'Internal Server Error', + error, + })) + .then((result) => { + socket.removeListener('error', destroyOnError); + if (socket.destroyed) { + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + if (result?.authenticated === true) { + wss.emit('connection', ws, req); + return; + } + + onRejected(result, req); + rejectTerminalSocket(ws, result.closeCode, result.reason, { terminateAfterMs }); + }); + }); + }; +} + const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; export function getTerminalProcessEnv(environment = process.env) { diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index bc2525ded8..fd6297fad4 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -2,12 +2,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, + TERMINAL_CLOSE_CODES, + authenticateTerminalUpgrade, + createTerminalUpgradeHandler, extractSshArgs, extractTargetHost, getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, + rejectTerminalSocket, sanitizeSshArgs, validateSshArgs, } from './terminal-utils.js'; @@ -180,3 +184,277 @@ test('getTerminalSessionTimeout always enforces the maximum terminal session lif assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); }); + +function createFakeSocket() { + const listeners = {}; + + return { + destroyed: false, + on(event, listener) { + (listeners[event] ??= []).push(listener); + }, + removeListener(event, listener) { + listeners[event] = (listeners[event] ?? []).filter((candidate) => candidate !== listener); + }, + emit(event, ...args) { + (listeners[event] ?? []).forEach((listener) => listener(...args)); + }, + destroy() { + this.destroyed = true; + }, + listenerCount(event) { + return (listeners[event] ?? []).length; + }, + }; +} + +function createFakeWebSocket() { + const closeListeners = []; + const errorListeners = []; + + return { + events: [], + terminated: false, + on(event, listener) { + if (event === 'error') { + errorListeners.push(listener); + } + }, + emitError(error) { + if (errorListeners.length === 0) { + throw error; + } + errorListeners.forEach((listener) => listener(error)); + }, + send(message) { + this.events.push(['send', message]); + }, + close(code, reason) { + this.events.push(['close', code, reason]); + }, + terminate() { + this.terminated = true; + }, + once(event, listener) { + if (event === 'close') { + closeListeners.push(listener); + } + }, + peerClosed() { + closeListeners.forEach((listener) => listener()); + }, + }; +} + +function createFakeWebSocketServer({ handles = true } = {}) { + return { + connections: [], + upgrades: [], + sockets: [], + shouldHandle() { + return handles; + }, + handleUpgrade(req, socket, head, callback) { + this.upgrades.push(req); + if (!handles) { + return; + } + const ws = createFakeWebSocket(); + this.sockets.push(ws); + callback(ws); + }, + emit(event, ws, req) { + if (event === 'connection') { + this.connections.push([ws, req]); + } + }, + }; +} + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +test('authenticateTerminalUpgrade rejects missing session cookies with the auth close code without calling Coolify', async () => { + let requests = 0; + const postToCoolify = async () => { + requests++; + return { status: 200 }; + }; + + for (const session of [ + { sessionCookieName: 'coolify_session', laravelSession: undefined, xsrfToken: 'xsrf' }, + { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: undefined }, + ]) { + assert.deepEqual(await authenticateTerminalUpgrade(session, postToCoolify), { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Missing required tokens', + }); + } + + assert.equal(requests, 0); + assert.equal(TERMINAL_CLOSE_CODES.AUTH_REJECTED, 4401); + assert.equal(TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 4403); +}); + +test('authenticateTerminalUpgrade checks the Laravel session exactly like the previous verifyClient', async () => { + const requests = []; + const result = await authenticateTerminalUpgrade( + { sessionCookieName: 'coolify_session', laravelSession: 'session-value', xsrfToken: 'xsrf-value' }, + async (path, headers) => { + requests.push([path, headers]); + return { status: 200 }; + }, + ); + + assert.deepEqual(result, { authenticated: true }); + assert.deepEqual(requests, [[ + '/terminal/auth', + { 'Cookie': 'coolify_session=session-value', 'X-XSRF-TOKEN': 'xsrf-value' }, + ]]); +}); + +test('authenticateTerminalUpgrade rejects every non-200 Coolify response', async () => { + for (const status of [201, 204, 302, 401, 403, 419, 500]) { + const result = await authenticateTerminalUpgrade( + { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' }, + async () => ({ status }), + ); + + assert.equal(result.authenticated, false, `status ${status} must not authenticate`); + assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_REJECTED); + assert.equal(result.reason, 'Unauthorized: Invalid credentials'); + } +}); + +test('authenticateTerminalUpgrade does not authenticate when Coolify is unreachable', async () => { + const result = await authenticateTerminalUpgrade( + { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' }, + async () => { + throw new Error('connect ECONNREFUSED'); + }, + ); + + assert.equal(result.authenticated, false); + assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE); + assert.equal(result.error.message, 'connect ECONNREFUSED'); +}); + +test('rejectTerminalSocket sends the legacy message before closing with a readable code', () => { + const ws = createFakeWebSocket(); + + rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 'Unauthorized: Terminal token was rejected', { + message: 'Unauthorized: Terminal token was rejected', + }); + ws.peerClosed(); + + assert.deepEqual(ws.events, [ + ['send', 'Unauthorized: Terminal token was rejected'], + ['close', 4403, 'Unauthorized: Terminal token was rejected'], + ]); +}); + +test('rejectTerminalSocket terminates peers that ignore the close handshake', async () => { + const ws = createFakeWebSocket(); + + rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials', { terminateAfterMs: 5 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(ws.events, [['close', 4401, 'Unauthorized: Invalid credentials']]); + assert.equal(ws.terminated, true); +}); + +test('createTerminalUpgradeHandler only emits connection for authenticated upgrades', async () => { + const wss = createFakeWebSocketServer(); + const req = { url: '/terminal/ws' }; + const handler = createTerminalUpgradeHandler({ wss, authenticate: async () => ({ authenticated: true }) }); + + handler(req, createFakeSocket(), Buffer.alloc(0)); + await flushPromises(); + + assert.equal(wss.connections.length, 1); + assert.equal(wss.connections[0][1], req); + assert.deepEqual(wss.sockets[0].events, []); +}); + +test('createTerminalUpgradeHandler closes rejected upgrades with the auth close code and never emits connection', async () => { + const wss = createFakeWebSocketServer(); + const rejected = []; + const handler = createTerminalUpgradeHandler({ + wss, + authenticate: async () => ({ + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Invalid credentials', + }), + onRejected: (result) => rejected.push(result.closeCode), + }); + + handler({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0)); + await flushPromises(); + + assert.equal(wss.connections.length, 0); + assert.deepEqual(rejected, [4401]); + assert.deepEqual(wss.sockets[0].events, [['close', 4401, 'Unauthorized: Invalid credentials']]); +}); + +test('createTerminalUpgradeHandler treats authentication errors and malformed results as rejections', async () => { + for (const authenticate of [ + async () => { + throw new Error('boom'); + }, + async () => ({ authenticated: 'yes', closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, reason: 'Unauthorized' }), + ]) { + const wss = createFakeWebSocketServer(); + createTerminalUpgradeHandler({ wss, authenticate })({ url: '/terminal/ws' }, createFakeSocket(), Buffer.alloc(0)); + await flushPromises(); + + assert.equal(wss.connections.length, 0); + assert.equal(wss.sockets[0].events[0][0], 'close'); + assert.notEqual(wss.sockets[0].events[0][1], 1000); + } +}); + +test('createTerminalUpgradeHandler does not authenticate requests for other paths', async () => { + const wss = createFakeWebSocketServer({ handles: false }); + let authenticated = 0; + + createTerminalUpgradeHandler({ wss, authenticate: async () => ++authenticated })({ url: '/other' }, createFakeSocket(), Buffer.alloc(0)); + await flushPromises(); + + assert.equal(authenticated, 0); + assert.equal(wss.upgrades.length, 1); + assert.equal(wss.connections.length, 0); +}); + +test('createTerminalUpgradeHandler drops sockets that disconnect while authentication is pending', async () => { + const wss = createFakeWebSocketServer(); + const socket = createFakeSocket(); + let finishAuthentication; + + createTerminalUpgradeHandler({ + wss, + authenticate: () => new Promise((resolve) => { + finishAuthentication = resolve; + }), + })({ url: '/terminal/ws' }, socket, Buffer.alloc(0)); + + socket.emit('error', new Error('ECONNRESET')); + await flushPromises(); + finishAuthentication({ authenticated: true }); + await flushPromises(); + + assert.equal(socket.destroyed, true); + assert.equal(socket.listenerCount('error'), 0); + assert.equal(wss.upgrades.length, 0); + assert.equal(wss.connections.length, 0); +}); + +test('rejectTerminalSocket handles socket errors so unauthenticated clients cannot crash the server', () => { + const ws = createFakeWebSocket(); + + rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Invalid credentials'); + + assert.doesNotThrow(() => ws.emitError(new Error('Invalid WebSocket frame: RSV1 must be clear'))); + assert.equal(ws.terminated, true); + ws.peerClosed(); +}); diff --git a/resources/js/terminal-connection.js b/resources/js/terminal-connection.js new file mode 100644 index 0000000000..f2f463f94a --- /dev/null +++ b/resources/js/terminal-connection.js @@ -0,0 +1,163 @@ +/** + * Close codes sent by the terminal WebSocket server (docker/coolify-terminal). + * 4401: the browser session was rejected; 4403: the terminal token was rejected. + */ +export const TERMINAL_CLOSE_CODES = Object.freeze({ + NORMAL: 1000, + AUTH_REJECTED: 4401, + TOKEN_REJECTED: 4403, +}); + +/** Maximum time to wait for the WebSocket to open on the first attempt. */ +export const TERMINAL_CONNECT_TIMEOUT_MS = 15000; + +/** Maximum time to wait for the server's `pty-ready` after a session was requested. */ +export const TERMINAL_SESSION_START_TIMEOUT_MS = 20000; + +export const TERMINAL_CONNECTION_ERRORS = Object.freeze({ + authRejected: 'Terminal access was rejected. Reload the page and try again.', + connectionFailed: 'Could not connect to the terminal server. Reload the page and try again.', + timeout: 'Timed out while connecting to the terminal. Reload the page and try again.', +}); + +const AUTH_REJECTION_MESSAGES = new Set([ + 'Unauthorized: Missing required tokens', + 'Unauthorized: Invalid terminal token', + 'Unauthorized: Terminal token was rejected', +]); + +export function isTerminalAuthRejectionCode(code) { + return code === TERMINAL_CLOSE_CODES.AUTH_REJECTED || code === TERMINAL_CLOSE_CODES.TOKEN_REJECTED; +} + +/** + * Classifies a plain-text message from the terminal server. + * + * @returns {'auth-rejected'|'startup-rejected'|null} + */ +export function classifyTerminalServerMessage(data) { + if (typeof data !== 'string') { + return null; + } + + if (AUTH_REJECTION_MESSAGES.has(data)) { + return 'auth-rejected'; + } + + if (data.startsWith('Unauthorized:') || data.startsWith('Invalid SSH command:')) { + return 'startup-rejected'; + } + + return null; +} + +/** + * Decides how the terminal reacts to a WebSocket close event. + * + * - Auth rejections never reconnect: a new socket would be rejected again. + * - A clean close (1000) is intentional and needs no action. + * - Any other close reconnects with backoff until the attempt limit, and + * reports an error when a session was waiting to start or retries ran out. + * + * @param {{ code: number, sessionPending: boolean, reconnectAttempts: number, maxReconnectAttempts: number }} state + * @returns {{ authRejected: boolean, reconnect: boolean, error: string|null }} + */ +export function resolveTerminalCloseOutcome({ code, sessionPending, reconnectAttempts, maxReconnectAttempts }) { + if (isTerminalAuthRejectionCode(code)) { + return { authRejected: true, reconnect: false, error: TERMINAL_CONNECTION_ERRORS.authRejected }; + } + + if (code === TERMINAL_CLOSE_CODES.NORMAL) { + return { authRejected: false, reconnect: false, error: null }; + } + + const reconnect = reconnectAttempts < maxReconnectAttempts; + + return { + authRejected: false, + reconnect, + error: sessionPending || !reconnect ? TERMINAL_CONNECTION_ERRORS.connectionFailed : null, + }; +} + +/** + * Session-start state for the terminal Alpine component (`terminalData()` spreads it in). + * + * The host provides `starting`, `terminalActive`, `connectionError`, `authRejected`, + * `pendingCommand`, `sessionStartTimeoutId`, `$wire`, and `ensureWebSocketConnection()`. + * Only one session-start timer exists at a time: every start clears the previous timer. + */ +export const terminalSessionStartMethods = { + /** A session was requested and the UI shows "connecting…" until `pty-ready`. */ + isTerminalSessionPending() { + return this.starting && !this.terminalActive; + }, + + /** + * Leave the "connecting" state and show why. The first reason wins unless + * `override` is set (auth rejections replace generic connection errors). + */ + failTerminalConnection(message, { override = false } = {}) { + this.starting = false; + this.clearSessionStartTimeout(); + + if (this.connectionError === message || (this.connectionError && !override)) { + return; + } + + this.connectionError = message; + this.$wire.dispatch('error', message); + }, + + /** Called whenever a new terminal session is requested (target chosen or token issued). */ + beginTerminalSessionStart() { + this.starting = true; + this.connectionError = null; + this.authRejected = false; + this.armTerminalSessionStartTimeout(); + this.ensureWebSocketConnection(); + }, + + /** Show the timeout error if no session is ready in time. Replaces an earlier timer. */ + armTerminalSessionStartTimeout() { + this.clearSessionStartTimeout(); + this.sessionStartTimeoutId = setTimeout(() => { + this.sessionStartTimeoutId = null; + if (this.isTerminalSessionPending()) { + // Single-use tokens must not be sent after the user was told to retry. + this.pendingCommand = null; + this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.timeout); + } + }, TERMINAL_SESSION_START_TIMEOUT_MS); + }, + + clearSessionStartTimeout() { + if (this.sessionStartTimeoutId) { + clearTimeout(this.sessionStartTimeoutId); + this.sessionStartTimeoutId = null; + } + }, + + /** The server reported `pty-ready`. */ + completeTerminalSessionStart() { + this.starting = false; + this.connectionError = null; + this.clearSessionStartTimeout(); + }, + + /** Coolify refused to issue a terminal token (`terminal-session-failed`). */ + failTerminalSessionStart(message) { + this.pendingCommand = null; + this.failTerminalConnection(message || TERMINAL_CONNECTION_ERRORS.connectionFailed, { override: true }); + }, + + /** No container was auto-selected (`terminal-auto-start-cancelled`); wait for the user. */ + cancelTerminalAutoStart() { + if (this.terminalActive || this.pendingCommand) { + return; + } + + this.starting = false; + this.clearSessionStartTimeout(); + }, +}; diff --git a/resources/js/terminal-connection.test.js b/resources/js/terminal-connection.test.js new file mode 100644 index 0000000000..2f0827283f --- /dev/null +++ b/resources/js/terminal-connection.test.js @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + TERMINAL_CLOSE_CODES, + TERMINAL_CONNECT_TIMEOUT_MS, + TERMINAL_CONNECTION_ERRORS, + TERMINAL_SESSION_START_TIMEOUT_MS, + classifyTerminalServerMessage, + isTerminalAuthRejectionCode, + resolveTerminalCloseOutcome, +} from './terminal-connection.js'; + +const closeOutcome = (overrides = {}) => resolveTerminalCloseOutcome({ + code: 1006, + sessionPending: false, + reconnectAttempts: 0, + maxReconnectAttempts: 10, + ...overrides, +}); + +test('terminal close codes match the codes sent by the terminal server', () => { + assert.equal(TERMINAL_CLOSE_CODES.AUTH_REJECTED, 4401); + assert.equal(TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 4403); + assert.equal(isTerminalAuthRejectionCode(4401), true); + assert.equal(isTerminalAuthRejectionCode(4403), true); + assert.equal(isTerminalAuthRejectionCode(1006), false); + assert.equal(isTerminalAuthRejectionCode(4000), false); +}); + +test('auth rejection closes stop reconnecting and show the reload message', () => { + for (const code of [4401, 4403]) { + for (const sessionPending of [true, false]) { + assert.deepEqual(closeOutcome({ code, sessionPending }), { + authRejected: true, + reconnect: false, + error: 'Terminal access was rejected. Reload the page and try again.', + }); + } + } +}); + +test('a clean close neither reconnects nor reports an error', () => { + assert.deepEqual(closeOutcome({ code: 1000, sessionPending: true }), { + authRejected: false, + reconnect: false, + error: null, + }); +}); + +test('a close before the session is ready leaves the connecting state with a generic error', () => { + for (const code of [1006, 1011, 1001, 4000]) { + assert.deepEqual(closeOutcome({ code, sessionPending: true }), { + authRejected: false, + reconnect: true, + error: TERMINAL_CONNECTION_ERRORS.connectionFailed, + }); + } +}); + +test('an idle close reconnects silently until the retry limit, then reports an error', () => { + assert.deepEqual(closeOutcome({ reconnectAttempts: 9 }), { + authRejected: false, + reconnect: true, + error: null, + }); + assert.deepEqual(closeOutcome({ reconnectAttempts: 10 }), { + authRejected: false, + reconnect: false, + error: TERMINAL_CONNECTION_ERRORS.connectionFailed, + }); +}); + +test('server text messages are classified for backward compatible rejection handling', () => { + assert.equal(classifyTerminalServerMessage('Unauthorized: Invalid terminal token'), 'auth-rejected'); + assert.equal(classifyTerminalServerMessage('Unauthorized: Terminal token was rejected'), 'auth-rejected'); + assert.equal(classifyTerminalServerMessage('Unauthorized: Missing required tokens'), 'auth-rejected'); + assert.equal(classifyTerminalServerMessage('Unauthorized: Target host 10.0.0.5 not in authorized list'), 'startup-rejected'); + assert.equal(classifyTerminalServerMessage('Invalid SSH command: No target host found'), 'startup-rejected'); + assert.equal(classifyTerminalServerMessage('pty-ready'), null); + assert.equal(classifyTerminalServerMessage('root@host:~# '), null); + assert.equal(classifyTerminalServerMessage(new Blob([])), null); +}); + +test('connection timeouts stay within a bounded window', () => { + assert.equal(TERMINAL_CONNECT_TIMEOUT_MS, 15000); + assert.ok(TERMINAL_SESSION_START_TIMEOUT_MS >= TERMINAL_CONNECT_TIMEOUT_MS); + assert.ok(TERMINAL_SESSION_START_TIMEOUT_MS <= 30000); + assert.equal(TERMINAL_CONNECTION_ERRORS.timeout, 'Timed out while connecting to the terminal. Reload the page and try again.'); +}); diff --git a/resources/js/terminal-session-start.test.js b/resources/js/terminal-session-start.test.js new file mode 100644 index 0000000000..1f1181803b --- /dev/null +++ b/resources/js/terminal-session-start.test.js @@ -0,0 +1,179 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + TERMINAL_CONNECTION_ERRORS, + TERMINAL_SESSION_START_TIMEOUT_MS, + terminalSessionStartMethods, +} from './terminal-connection.js'; + +/** + * Builds the smallest host that the terminal Alpine component gives to the mixin. + */ +function createTerminalHost(overrides = {}) { + const host = { + starting: false, + terminalActive: false, + connectionError: null, + authRejected: false, + pendingCommand: null, + sessionStartTimeoutId: null, + toasts: [], + websocketStarts: 0, + $wire: { + dispatch(name, message) { + host.toasts.push([name, message]); + }, + }, + ensureWebSocketConnection() { + host.websocketStarts++; + }, + ...terminalSessionStartMethods, + ...overrides, + }; + + return host; +} + +/** Same steps as `init()` in terminal.js for `data-auto-start="true"`. */ +function autoStart(host) { + host.starting = true; + host.armTerminalSessionStartTimeout(); +} + +test('auto-start without a token leaves connecting and shows the timeout error', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost(); + + autoStart(host); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS - 1); + + assert.equal(host.starting, true); + assert.equal(host.connectionError, null); + + t.mock.timers.tick(1); + + assert.equal(host.starting, false); + assert.equal(host.connectionError, TERMINAL_CONNECTION_ERRORS.timeout); + assert.equal(host.sessionStartTimeoutId, null); + assert.deepEqual(host.toasts, [['error', TERMINAL_CONNECTION_ERRORS.timeout]]); +}); + +test('a token after auto-start replaces the timer and pty-ready clears it', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost(); + + autoStart(host); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS - 1000); + + // send-terminal-token + host.beginTerminalSessionStart(); + assert.equal(host.websocketStarts, 1); + + // The auto-start timer was replaced, so its deadline passes without an error. + t.mock.timers.tick(2000); + assert.equal(host.starting, true); + assert.equal(host.connectionError, null); + + // pty-ready + host.terminalActive = true; + host.completeTerminalSessionStart(); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS * 2); + + assert.equal(host.starting, false); + assert.equal(host.connectionError, null); + assert.equal(host.sessionStartTimeoutId, null); + assert.deepEqual(host.toasts, []); +}); + +test('a token that arrives after the timeout error still starts the session', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost(); + + autoStart(host); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS); + assert.equal(host.connectionError, TERMINAL_CONNECTION_ERRORS.timeout); + + host.beginTerminalSessionStart(); + + assert.equal(host.starting, true); + assert.equal(host.connectionError, null); + + host.terminalActive = true; + host.completeTerminalSessionStart(); + + assert.equal(host.starting, false); + assert.equal(host.sessionStartTimeoutId, null); +}); + +test('arming the timer twice keeps one timer only', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost(); + + autoStart(host); + host.armTerminalSessionStartTimeout(); + // Teardown must clear the only timer; an orphaned first timer would still fire. + host.clearSessionStartTimeout(); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS * 2); + + assert.equal(host.starting, true); + assert.equal(host.connectionError, null); + assert.deepEqual(host.toasts, []); +}); + +test('a server-side start failure stops the spinner at once with its message', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost({ pendingCommand: { terminalToken: 'stale' } }); + + autoStart(host); + host.failTerminalSessionStart('The container is not running.'); + + assert.equal(host.starting, false); + assert.equal(host.connectionError, 'The container is not running.'); + assert.equal(host.pendingCommand, null); + assert.equal(host.sessionStartTimeoutId, null); + + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS); + + assert.deepEqual(host.toasts, [['error', 'The container is not running.']]); +}); + +test('a server-side start failure replaces an earlier timeout error', () => { + const host = createTerminalHost({ connectionError: TERMINAL_CONNECTION_ERRORS.timeout }); + + host.failTerminalSessionStart('No shell is available in this container.'); + + assert.equal(host.connectionError, 'No shell is available in this container.'); +}); + +test('a server-side start failure without a message falls back to the generic error', () => { + const host = createTerminalHost(); + + host.failTerminalSessionStart(undefined); + + assert.equal(host.connectionError, TERMINAL_CONNECTION_ERRORS.connectionFailed); +}); + +test('a cancelled auto-start waits for a container choice without an error', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost(); + + autoStart(host); + host.cancelTerminalAutoStart(); + t.mock.timers.tick(TERMINAL_SESSION_START_TIMEOUT_MS * 2); + + assert.equal(host.starting, false); + assert.equal(host.connectionError, null); + assert.equal(host.sessionStartTimeoutId, null); + assert.deepEqual(host.toasts, []); +}); + +test('a cancelled auto-start does not stop a session that is already starting', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const host = createTerminalHost({ pendingCommand: { terminalToken: 'token' } }); + + autoStart(host); + host.cancelTerminalAutoStart(); + + assert.equal(host.starting, true); + assert.notEqual(host.sessionStartTimeoutId, null); +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index e643a72510..420e20fb11 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -6,6 +6,13 @@ import { TERMINAL_SESSION_WARNING_SECONDS, formatTerminalSessionRemainingTime, } from './terminal-session-timer.js'; +import { + TERMINAL_CONNECT_TIMEOUT_MS, + TERMINAL_CONNECTION_ERRORS, + classifyTerminalServerMessage, + resolveTerminalCloseOutcome, + terminalSessionStartMethods, +} from './terminal-connection.js'; import { FitAddon } from '@xterm/addon-fit'; const terminalDebugParameter = new URLSearchParams(window.location.search).get('terminal-debug'); @@ -178,6 +185,11 @@ export function initializeTerminalComponent() { maxReconnectDelay: 30000, connectionTimeout: 10000, connectionTimeoutId: null, + // Shown instead of "connecting…" once a connection or session start failed. + connectionError: null, + // Set when the server rejected authentication; disables automatic reconnects. + authRejected: false, + sessionStartTimeoutId: null, lastPingTime: null, pingTimeout: 35000, // 5 seconds longer than ping interval pingTimeoutId: null, @@ -214,8 +226,15 @@ export function initializeTerminalComponent() { ? localStorage.getItem('coolify-console-theme') : 'system', + ...terminalSessionStartMethods, + init() { this.starting = this.$el.dataset.autoStart === 'true'; + // Auto-start requests a token without a user action. If no token or error + // arrives, the session-start timeout ends "connecting…" with an error. + if (this.starting) { + this.armTerminalSessionStartTimeout(); + } this.updateKeyboardInset = () => { const viewport = window.visualViewport; const viewportWidth = viewport?.width ?? window.innerWidth; @@ -287,9 +306,26 @@ export function initializeTerminalComponent() { this.setupTerminalEventListeners(); this.$wire.on('send-terminal-token', ([token]) => { + // A token requested before the rejection arrived must not reconnect; + // only a user-initiated start (terminal-starting) retries. + if (this.authRejected) { + logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.'); + return; + } + this.beginTerminalSessionStart(); this.sendCommandWhenReady({ terminalToken: token }); }); + // Coolify did not issue a token (container stopped, no shell, access denied). + this.$wire.on('terminal-session-failed', ({ message }) => { + this.failTerminalSessionStart(message); + }); + + // The page shows a container picker; nothing starts until the user chooses. + this.$wire.on('terminal-auto-start-cancelled', () => { + this.cancelTerminalAutoStart(); + }); + this.$wire.on('terminal-should-focus', () => { // Wait for terminal to be ready, then focus const focusWhenReady = () => { @@ -365,6 +401,7 @@ export function initializeTerminalComponent() { clearTimeout(this.keyboardInsetSettleTimeout); this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); + this.clearSessionStartTimeout(); this.connectionState = 'disconnected'; this.pendingCommand = null; this.resetTerminalSessionCountdown(); @@ -621,12 +658,15 @@ export function initializeTerminalComponent() { this.socket = new WebSocket(url); // Set connection timeout - increased for initial connection - const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; + const timeoutMs = this.reconnectAttempts === 0 ? TERMINAL_CONNECT_TIMEOUT_MS : this.connectionTimeout; this.connectionTimeoutId = setTimeout(() => { if (this.connectionState === 'connecting') { logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`); + if (this.isTerminalSessionPending()) { + this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.timeout); + } + // The resulting close event schedules the (bounded) reconnect. this.socket.close(); - this.handleConnectionError('Connection timeout'); } }, timeoutMs); @@ -659,6 +699,8 @@ export function initializeTerminalComponent() { if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + this.connectionError = null; + this.starting = true; } // (Re)start application-level keepalive on every successful connect. @@ -679,7 +721,11 @@ export function initializeTerminalComponent() { logTerminal('error', '[Terminal] WebSocket error:', error); logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); - this.handleConnectionError('WebSocket error occurred'); + // Browsers always follow an error event with a close event, which + // schedules the reconnect. Only surface the failure here. + if (this.isTerminalSessionPending()) { + this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed); + } }, handleSocketClose(event) { @@ -687,10 +733,31 @@ export function initializeTerminalComponent() { logTerminal('log', '[Terminal] Was clean close:', event.code === 1000); logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1); + const outcome = resolveTerminalCloseOutcome({ + code: event.code, + sessionPending: this.isTerminalSessionPending(), + reconnectAttempts: this.reconnectAttempts, + maxReconnectAttempts: this.maxReconnectAttempts, + }); + this.connectionState = 'disconnected'; this.clearAllTimers(); this.resetTerminalSessionCountdown(); + if (outcome.authRejected) { + // Reconnecting would be rejected again; wait for a reload or a new session request. + logTerminal('error', '[Terminal] Server rejected terminal authentication:', event.reason); + this.authRejected = true; + this.pendingCommand = null; + if (this.terminalActive) { + this.exitFullscreen(); + this.terminalActive = false; + this.$wire.dispatch('terminalDisconnected'); + } + this.failTerminalConnection(outcome.error, { override: true }); + return; + } + // Only reset terminal and reconnect if it wasn't a clean close if (event.code !== 1000) { // Don't show terminal reset message on first connection attempt @@ -699,6 +766,9 @@ export function initializeTerminalComponent() { this.message = '(connection closed)'; this.terminalActive = false; } + if (outcome.error) { + this.failTerminalConnection(outcome.error); + } this.scheduleReconnect(); } }, @@ -707,21 +777,47 @@ export function initializeTerminalComponent() { logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); this.connectionState = 'disconnected'; - // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load - if (this.reconnectAttempts >= 2) { - this.$wire.dispatch('error', `Terminal connection error: ${reason}`); + if (this.isTerminalSessionPending() || this.reconnectAttempts >= 2) { + this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed); } this.scheduleReconnect(); }, + /** Reconnect immediately for a user-requested session if the socket is closed. */ + ensureWebSocketConnection() { + if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { + return; + } + + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval); + this.reconnectInterval = null; + } + this.reconnectAttempts = 0; + this.initializeWebSocket(); + }, + + reloadTerminalPage() { + window.location.reload(); + }, + scheduleReconnect() { + if (this.authRejected) { + return; + } + if (this.reconnectAttempts >= this.maxReconnectAttempts) { logTerminal('error', '[Terminal] Max reconnection attempts reached'); this.message = '(connection failed - max retries exceeded)'; + this.failTerminalConnection(TERMINAL_CONNECTION_ERRORS.connectionFailed); return; } + if (this.reconnectInterval) { + return; // A reconnect is already scheduled. + } + this.connectionState = 'reconnecting'; // Exponential backoff with jitter @@ -733,6 +829,7 @@ export function initializeTerminalComponent() { logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); this.reconnectInterval = setTimeout(() => { + this.reconnectInterval = null; this.reconnectAttempts++; this.initializeWebSocket(); }, delay); @@ -768,7 +865,7 @@ export function initializeTerminalComponent() { } if (event.data === 'pty-ready') { - this.starting = false; + this.completeTerminalSessionStart(); if (!this.term._initialized) { this.term.open(document.getElementById('terminal')); this.term._initialized = true; @@ -809,6 +906,7 @@ export function initializeTerminalComponent() { this.$wire.dispatch('terminalConnected'); } else if (event.data === 'unprocessable') { this.starting = false; + this.clearSessionStartTimeout(); if (this.term) this.term.reset(); this.terminalActive = false; this.resetTerminalSessionCountdown(); @@ -827,14 +925,18 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); - } else if ( - typeof event.data === 'string' && - (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) - ) { + } else if (classifyTerminalServerMessage(event.data) !== null) { logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); - this.$wire.dispatch('error', event.data); + this.pendingCommand = null; this.terminalActive = false; this.resetTerminalSessionCountdown(); + // Newer servers follow an auth rejection with a 4401/4403 close. + this.failTerminalConnection( + classifyTerminalServerMessage(event.data) === 'auth-rejected' + ? TERMINAL_CONNECTION_ERRORS.authRejected + : event.data, + { override: true }, + ); } else { try { this.pendingWrites++; @@ -896,6 +998,7 @@ export function initializeTerminalComponent() { }, destroy() { + this.clearSessionStartTimeout(); this.themeObserver?.disconnect(); window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset); window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset); @@ -985,7 +1088,7 @@ export function initializeTerminalComponent() { keepAlive() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); - } else if (this.connectionState === 'disconnected') { + } else if (this.connectionState === 'disconnected' && !this.authRejected) { // Attempt to reconnect if we're disconnected this.initializeWebSocket(); } @@ -1025,7 +1128,7 @@ export function initializeTerminalComponent() { // ignore — close handler will run on its own } }, 5000); - } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { + } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected' && !this.authRejected) { // Was connected before but now disconnected - attempt reconnection this.reconnectAttempts = 0; this.initializeWebSocket(); diff --git a/resources/views/components/danger-zone.blade.php b/resources/views/components/danger-zone.blade.php index cd2f75fbce..37dc97c807 100644 --- a/resources/views/components/danger-zone.blade.php +++ b/resources/views/components/danger-zone.blade.php @@ -1,9 +1,17 @@ @props(['title']) -
class('flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between') }}> +
class('flex flex-col gap-4 rounded-lg border border-red-200 bg-red-50 p-5 sm:flex-row sm:items-start sm:justify-between dark:border-red-500/25 dark:bg-red-500/[0.08]') }}>
-

{{ $title }}

-
+
+

{{ $title }}

+ + + Permanent + +
+
{{ $slot }}
diff --git a/resources/views/components/proxy-configuration-warning.blade.php b/resources/views/components/proxy-configuration-warning.blade.php index 51f4282944..5deb10dc21 100644 --- a/resources/views/components/proxy-configuration-warning.blade.php +++ b/resources/views/components/proxy-configuration-warning.blade.php @@ -17,19 +17,16 @@

- The saved proxy configuration has not been applied -

-

- Restart the proxy to apply these changes. - @if ($canRestart) - - @endif + Your configuration changed, please restart the proxy.

+ @if ($canRestart) + + @endif
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 65fe3cabbc..3645dd2a27 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -3,6 +3,7 @@ @php $serverRouteParameters = ['server_uuid' => $server->uuid]; $sentinelStatus = $server->sentinelStatus(); + $proxyNotRunning = $server->proxySet() && ($server->proxy->status ?? 'unknown') !== 'running'; $sentinelStatusStartedAt = $server->sentinel_waiting_since ?? \Illuminate\Support\Carbon::parse($server->sentinel_updated_at); $sentinelTimeoutSeconds = $server->sentinel_waiting_since !== null ? $server->firstSentinelReportTimeoutSeconds() @@ -199,6 +200,7 @@ x-data="{ proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()), traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()), + proxyNotRunning: @js($proxyNotRunning), sentinelOutOfSync: @js($server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync'), sentinelExpiryTimer: null, scheduleSentinelExpiry(delay) { @@ -212,6 +214,7 @@ @proxy-configuration-state-changed.window=" proxyConfigurationPending = $event.detail.pending; traefikOutdated = $event.detail.traefikOutdated; + proxyNotRunning = $event.detail.proxyNotRunning; " @sentinel-status-changed.window=" sentinelOutOfSync = $event.detail.outOfSync; @@ -247,7 +250,7 @@ {{ $menuItem['label'] }} @if ($menuItem['tracks_proxy_configuration'] ?? false) @elseif ($menuItem['tracks_sentinel_status'] ?? false) 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false], ['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], ['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], + ['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'], ['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'], ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], ['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'], @@ -35,7 +36,7 @@ $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'], ]; diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index 3ae6f2f137..1e984ac775 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -16,7 +16,7 @@ $serverListboxOptions = array_merge( collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), ); $appListboxOptions = array_merge( - [['value' => '', 'label' => 'All applications']], + [['value' => '', 'label' => 'All resources']], $appGroupedOptions, ); ?> @@ -54,7 +54,7 @@ $appListboxOptions = array_merge( newly-scoped options (and reset value) instead of showing stale Alpine state. --}}
- +
- Unique visitors + + Unique visitors + @if ($uniquesApproximate) + {!! $approxBadge('Summed across services; visitors of more than one service may be double-counted.') !!} + @endif + {{ number_format($overview['uniqueVisitors'] ?? 0) }}
@include('livewire.traffic._sparkline', [ @@ -115,7 +122,12 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
- p95 latency + + p95 latency + @if ($latencyApproximate) + {!! $approxBadge('Highest p95 latency across services; not a true merged percentile.') !!} + @endif + {{ number_format($overview['latencyP95'] ?? 0, 1) }} ms
@include('livewire.traffic._sparkline', [ diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index fe2799e3bb..51dd41a9f7 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -21,6 +21,7 @@ ['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false], ['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], ['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], + ['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'], ['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'], ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], ['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'], @@ -35,7 +36,7 @@ $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'], ]; @@ -190,6 +191,9 @@ @endforeach
+ @elseif ($currentRoute === 'project.service.analytics') + @elseif ($currentRoute === 'project.service.domains') @elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index 480ad3b539..dbaac3d51a 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -11,6 +11,7 @@ || request()->routeIs('project.service.scheduled-tasks*') || request()->routeIs('project.service.webhooks') || request()->routeIs('project.service.resource-operations') + || request()->routeIs('project.service.analytics') || request()->routeIs('project.service.tags') || request()->routeIs('project.service.danger') || request()->routeIs('project.service.index*') diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index b4a0961a0f..8c5bd50767 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -3,7 +3,7 @@ @endphp
$isApplicationConsole, @@ -48,7 +48,13 @@ @if ($isApplicationConsole)
-
+ +
@else +
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 04b10cb609..bfb6f2a44f 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -36,9 +36,7 @@ @if ( $server->proxy->last_applied_settings && $server->proxy->last_saved_settings !== $server->proxy->last_applied_settings) - - Restart the proxy to apply the saved configuration. - + @else
@if ($isTrafficAnalyticsEnabled) + @if ($caddyRedeployNote) + {{ $caddyRedeployNote }} + @endif
@else
+ @if ($unsupportedReason) + + Enable traffic analytics + + @else + @endif
diff --git a/routes/web.php b/routes/web.php index 712c415b79..a3ca3630f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -339,6 +339,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', ServiceConfiguration::class)->name('project.service.configuration'); Route::get('/domains', ServiceConfiguration::class)->name('project.service.domains'); Route::get('/logs', Logs::class)->name('project.service.logs'); + Route::get('/analytics', ServiceConfiguration::class)->name('project.service.analytics'); Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables'); Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages'); Route::get('/import-backup', ServiceImportBackup::class)->name('project.service.import-backup')->middleware('can.update.resource'); diff --git a/tests/Feature/AdvisorySecurityRegressionTest.php b/tests/Feature/AdvisorySecurityRegressionTest.php index 62af383364..c9d9469bb7 100644 --- a/tests/Feature/AdvisorySecurityRegressionTest.php +++ b/tests/Feature/AdvisorySecurityRegressionTest.php @@ -67,19 +67,26 @@ it('throttles only failed authentication on manual webhook routes', function (st { use MatchesManualWebhookApplications; - public function reply(array $payloads, Request $request, string $provider): int + public function key(Request $request, string $provider): string { - return $this->manualWebhookResponse(collect($payloads), $request, $provider)->getStatusCode(); + return $this->manualWebhookFailureRateLimitKey($request, $provider, 'test-org/test-repo', 'main'); + } + + public function reply(array $payloads, string $failureKey): int + { + return $this->manualWebhookResponse(collect($payloads), $failureKey)->getStatusCode(); } }; + $failureKey = $helper->key($request, $provider); expect($route->gatherMiddleware())->not->toContain('throttle:60,1'); + expect($failureKey)->toStartWith("manual-webhook-failures:{$provider}:192.0.2.44:"); - $helper->reply([['status' => 'success', 'message' => 'queued']], $request, $provider); - expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(0); + $helper->reply([['status' => 'success', 'message' => 'queued']], $failureKey); + expect(RateLimiter::attempts($failureKey))->toBe(0); - $helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $request, $provider); - expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(1); + $helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $failureKey); + expect(RateLimiter::attempts($failureKey))->toBe(1); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); it('does not reveal how many applications share a manual webhook repository', function () { @@ -89,7 +96,7 @@ it('does not reveal how many applications share a manual webhook repository', fu public function reply(array $payloads): string { - return $this->manualWebhookResponse(collect($payloads), Request::create('/webhooks/source/github/events/manual', 'POST'), 'github')->getContent(); + return $this->manualWebhookResponse(collect($payloads), 'manual-webhook-failures:test')->getContent(); } }; $failure = ['status' => 'failed', 'message' => 'Invalid signature.']; diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 1aa668e116..5f61f78c47 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1030,6 +1030,94 @@ it('rejects existing variable names that would break the .env file or build comm 'build-time name with a hyphen' => ['my-var', true], ]); +it('warns instead of failing for invalid build-time names when the deployment does not build an image', function (bool $isRuntime) { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); + $environmentVariable = createApplicationEnvironmentVariable($application, [ + 'key' => 'SAFE_KEY', + 'value' => 'secret', + 'is_buildtime' => true, + 'is_runtime' => $isRuntime, + ]); + DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); + + expect(collect($job->recordedLogEntries)->implode("\n")) + ->toContain('my-var') + ->toContain('Suggested name: my_var') + ->not->toContain('Invalid environment variable name'); + expect($job->recordedCommands)->toBeEmpty(); +})->with([ + 'build-time only' => [false], + 'build-time and runtime' => [true], +]); + +it('warns instead of failing for invalid build-time preview names of Docker image deployments', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); + $environmentVariable = createApplicationEnvironmentVariable($application, [ + 'key' => 'SAFE_KEY', + 'value' => 'secret', + 'is_buildtime' => true, + 'is_preview' => true, + ]); + DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 7]); + invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); + + expect(collect($job->recordedLogEntries)->implode("\n")) + ->toContain('Suggested name: my_var') + ->not->toContain('Invalid environment variable name'); +}); + +it('still rejects Docker image variable names that would break the .env file', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); + $environmentVariable = createApplicationEnvironmentVariable($application, [ + 'key' => 'SAFE_KEY', + 'value' => 'secret', + 'is_buildtime' => true, + ]); + DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'A=B']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys')) + ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment'); +}); + +it('fails with a clear message for invalid build-time names when the deployment builds an image', function (string $buildPack) { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => $buildPack]); + $environmentVariable = createApplicationEnvironmentVariable($application, [ + 'key' => 'SAFE_KEY', + 'value' => 'secret', + 'is_buildtime' => true, + ]); + DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => $buildPack]); + + expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys')) + ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment: my-var'); + expect(collect($job->recordedLogEntries)->implode("\n")) + ->toContain('Build-time variable names must start with a letter or underscore'); +})->with(['dockerfile', 'nixpacks', 'static', 'railpack', 'dockercompose']); + +it('does not pass build-time variables to the helper container of Docker image deployments', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); + $application->settings()->update(['use_build_secrets' => true]); + $environmentVariable = createApplicationEnvironmentVariable($application, [ + 'key' => 'SAFE_KEY', + 'value' => 'harmless-build-value', + 'is_buildtime' => true, + ]); + DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe(''); +}); + it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) { [$application, $server] = makeDeploymentControlVarFixture(); diff --git a/tests/Feature/ComposeBuildContextDeploymentTest.php b/tests/Feature/ComposeBuildContextDeploymentTest.php index 581179a47f..d515a815d5 100644 --- a/tests/Feature/ComposeBuildContextDeploymentTest.php +++ b/tests/Feature/ComposeBuildContextDeploymentTest.php @@ -58,7 +58,7 @@ function composeDockerfile(string $path): string return $path; } -function runComposeArgInjection(object $test, mixed $build, string $workdir): LocallyExecutedComposeDeploymentJob +function runComposeArgInjection(object $test, mixed $build, string $workdir, array $envArgs = ['APP_ENV' => 'production']): LocallyExecutedComposeDeploymentJob { $application = Application::factory()->create(['build_pack' => 'dockercompose']); $application->settings()->update(['inject_build_args_to_dockerfile' => true]); @@ -76,7 +76,7 @@ function runComposeArgInjection(object $test, mixed $build, string $workdir): Lo 'deployment_uuid' => 'deployment-uuid', 'basedir' => $test->basedir, 'workdir' => $workdir, - 'env_args' => collect(['APP_ENV' => 'production']), + 'env_args' => collect($envArgs), 'saved_outputs' => collect(), 'dockerSecretsSupported' => false, 'pull_request_id' => 0, @@ -106,19 +106,45 @@ test('compose deployments inject build args for Dockerfiles inside the repositor 'custom Dockerfile in a parent folder' => [['context' => 'services/api', 'dockerfile' => '../docker/api.Dockerfile'], '', 'services/docker/api.Dockerfile'], ]); -test('compose deployments skip build contexts they cannot inspect locally', function (mixed $build) { +test('compose deployments skip build contexts they cannot inspect locally', function (mixed $build, string $reason) { $dockerfile = composeDockerfile($this->basedir.'/Dockerfile'); - $job = runComposeArgInjection($this, $build, $this->basedir); + $job = runComposeArgInjection($this, $build, $this->basedir, [ + 'COOLIFY_URL' => 'https://secret-host.example.com', + 'APP_ENV' => 'production-secret-value', + 'API_TOKEN' => 'super-secret-token', + ]); expect(file_get_contents($dockerfile))->not->toContain('ARG APP_ENV') - ->and($job->logEntries)->toContain('The build context of service api is remote or uses variables, skipping ARG injection.'); + ->and($job->logEntries)->toContain("Skipping ARG injection for service api: {$reason}. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG ' lines to the Dockerfile for the ones you need: APP_ENV, API_TOKEN, COOLIFY_URL.") + ->and(implode("\n", $job->logEntries))->not->toContain('secret'); })->with([ - 'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker'], - 'context variable' => ['${APP_DIR:-.}'], - 'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"]], + 'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker', 'the build context is a remote Git URL'], + 'git SSH URL' => [['context' => 'git@github.com:coollabsio/coolify.git'], 'the build context is a remote Git URL'], + 'context variable' => ['${APP_DIR:-.}', 'the build context or Dockerfile path uses variables'], + 'Dockerfile variable' => [['context' => '.', 'dockerfile' => '${DOCKERFILE}'], 'the build context or Dockerfile path uses variables'], + 'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"], 'the service uses an inline Dockerfile (dockerfile_inline)'], + 'invalid build definition' => [['context' => ['nested']], 'Coolify cannot read the build definition'], ]); +test('skipped ARG injection lists at most ten build-time variable names', function () { + composeDockerfile($this->basedir.'/Dockerfile'); + $envArgs = collect(range(1, 13))->mapWithKeys(fn (int $number) => ["VAR_{$number}" => "value-{$number}"])->all(); + + $job = runComposeArgInjection($this, '${APP_DIR}', $this->basedir, $envArgs); + + expect($job->logEntries)->toContain("Skipping ARG injection for service api: the build context or Dockerfile path uses variables. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG ' lines to the Dockerfile for the ones you need: VAR_1, VAR_2, VAR_3, VAR_4, VAR_5, VAR_6, VAR_7, VAR_8, VAR_9, VAR_10 and 3 more.") + ->and(implode("\n", $job->logEntries))->not->toContain('value-'); +}); + +test('skipped ARG injection does not show invalid build-time variable names', function () { + composeDockerfile($this->basedir.'/Dockerfile'); + + $job = runComposeArgInjection($this, '${APP_DIR}', $this->basedir, ['BAD NAME;id' => 'x', 'GOOD_NAME' => 'y']); + + expect($job->logEntries)->toContain("Skipping ARG injection for service api: the build context or Dockerfile path uses variables. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG ' lines to the Dockerfile for the ones you need: GOOD_NAME."); +}); + test('compose deployments never change Dockerfiles outside the repository', function (string $escape) { $outside = composeDockerfile($this->root.'/outside/Dockerfile'); symlink($this->root.'/outside', $this->basedir.'/linked'); diff --git a/tests/Feature/DangerZoneLayoutTest.php b/tests/Feature/DangerZoneLayoutTest.php index 6f212301f8..5395958c73 100644 --- a/tests/Feature/DangerZoneLayoutTest.php +++ b/tests/Feature/DangerZoneLayoutTest.php @@ -5,10 +5,11 @@ it('uses one shared visual treatment for all danger zone actions', function () { expect($component) ->toContain("@props(['title'])") - ->toContain('sm:flex-row sm:items-center sm:justify-between') - ->toContain('text-red-700 dark:text-red-300') - ->toContain('@isset($action)') - ->not->toContain('bg-red-50'); + ->toContain('sm:flex-row sm:items-start sm:justify-between') + ->toContain('rounded-lg border border-red-200 bg-red-50') + ->toContain('dark:border-red-500/25 dark:bg-red-500/[0.08]') + ->toContain('Permanent') + ->toContain('@isset($action)'); foreach ([ 'livewire/destination/show.blade.php', diff --git a/tests/Feature/DatabaseStartConcurrencyTest.php b/tests/Feature/DatabaseStartConcurrencyTest.php new file mode 100644 index 0000000000..3645368e30 --- /dev/null +++ b/tests/Feature/DatabaseStartConcurrencyTest.php @@ -0,0 +1,276 @@ + 0]); + config()->set('cache.default', 'array'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'docker'] + ); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'status' => 'exited', + 'environment_id' => $environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function concurrentStartActivity(string $databaseUuid, int $teamId, ProcessStatus $status = ProcessStatus::QUEUED, int $minutesSinceLastUpdate = 0): Activity +{ + $activity = activity() + ->withProperties([ + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $databaseUuid, + 'status' => $status->value, + 'team_id' => $teamId, + 'operation' => ResourceStartActivity::DATABASE_START_OPERATION, + ]) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); + + Activity::query()->whereKey($activity->id)->update([ + 'created_at' => now()->subMinutes($minutesSinceLastUpdate), + 'updated_at' => now()->subMinutes($minutesSinceLastUpdate), + ]); + + return $activity->refresh(); +} + +function databaseStartActivityCount(string $databaseUuid): int +{ + return Activity::query() + ->where('properties->type_uuid', $databaseUuid) + ->where('properties->operation', ResourceStartActivity::DATABASE_START_OPERATION) + ->count(); +} + +function databaseStartApiHeaders(string $token): array +{ + return ['Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json']; +} + +describe('start and restart guards', function () { + it('does not queue a second start from the UI while a start is queued', function () { + Bus::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id); + + Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database]) + ->call('start') + ->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE); + + Bus::assertNotDispatched(DatabaseStartJob::class); + expect(databaseStartActivityCount($this->database->uuid))->toBe(1); + }); + + it('does not stop the database on a UI restart while a start is in progress', function () { + Bus::fake(); + StopDatabase::shouldRun()->never(); + concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::IN_PROGRESS); + + Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database]) + ->call('restart') + ->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE); + + Bus::assertNotDispatched(DatabaseStartJob::class); + expect(databaseStartActivityCount($this->database->uuid))->toBe(1); + }); + + it('rejects an API start while a start is queued', function () { + Queue::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id); + + $this->withHeaders(databaseStartApiHeaders($this->bearerToken)) + ->postJson("/api/v1/databases/{$this->database->uuid}/start") + ->assertStatus(409) + ->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]); + + StartDatabase::assertNotPushed(); + }); + + it('rejects an API restart while a start is queued', function () { + Queue::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id); + + $this->withHeaders(databaseStartApiHeaders($this->bearerToken)) + ->postJson("/api/v1/databases/{$this->database->uuid}/restart") + ->assertStatus(409) + ->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]); + + RestartDatabase::assertNotPushed(); + }); + + it('still queues an API start when no start is running', function () { + Queue::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::FINISHED); + + $this->withHeaders(databaseStartApiHeaders($this->bearerToken)) + ->postJson("/api/v1/databases/{$this->database->uuid}/start") + ->assertOk(); + + StartDatabase::assertPushed(); + }); + + it('does not reveal a running start of another team database through the API', function () { + Queue::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id); + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken; + + $this->withHeaders(databaseStartApiHeaders($otherToken)) + ->postJson("/api/v1/databases/{$this->database->uuid}/start") + ->assertNotFound(); + + StartDatabase::assertNotPushed(); + }); + + it('refuses to queue a second start in the action itself', function () { + Bus::fake(); + concurrentStartActivity($this->database->uuid, $this->team->id); + + expect(StartDatabase::run($this->database))->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE); + + Bus::assertNotDispatched(DatabaseStartJob::class); + expect(databaseStartActivityCount($this->database->uuid))->toBe(1); + }); + + it('does not stop the database when a queued restart action finds a running start', function () { + Bus::fake(); + StopDatabase::shouldRun()->never(); + concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::IN_PROGRESS); + + expect(RestartDatabase::run($this->database))->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE); + }); + + it('lets a new start replace a stale queued start and marks the stale one as failed', function () { + Bus::fake(); + $stale = concurrentStartActivity($this->database->uuid, $this->team->id, minutesSinceLastUpdate: 11); + + expect(StartDatabase::run($this->database))->toBeInstanceOf(Activity::class); + + Bus::assertDispatched(DatabaseStartJob::class); + $stale->refresh(); + expect(data_get($stale, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($stale, 'properties.error'))->toBe(ResourceStartActivity::STALE_MESSAGE); + }); +}); + +describe('start job', function () { + beforeEach(function () { + Event::fake([DatabaseStatusChanged::class]); + }); + + it('does not run the start commands while another start of the same database holds the lock', function () { + StartPostgresql::shouldRun()->never(); + $activity = concurrentStartActivity($this->database->uuid, $this->team->id); + $lock = Cache::lock(ResourceStartActivity::databaseStartLockKey($this->database->uuid), 60); + expect($lock->get())->toBeTrue(); + + try { + DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null); + } finally { + $lock->release(); + } + + $activity->refresh(); + expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($activity, 'properties.error'))->toBe(ResourceStartActivity::ALREADY_STARTING_MESSAGE); + }); + + it('holds the start lock while the start commands run and releases it afterwards', function () { + $activity = concurrentStartActivity($this->database->uuid, $this->team->id); + $lockKey = ResourceStartActivity::databaseStartLockKey($this->database->uuid); + $lockWasHeld = null; + StartPostgresql::shouldRun()->once()->andReturnUsing(function ($database, Activity $activity) use ($lockKey, &$lockWasHeld) { + $probe = Cache::lock($lockKey, 60); + $lockWasHeld = ! $probe->get(); + if (! $lockWasHeld) { + $probe->release(); + } + $activity->properties = $activity->properties->merge(['status' => ProcessStatus::FINISHED->value]); + $activity->save(); + + return $activity; + }); + + DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null); + + expect($lockWasHeld)->toBeTrue(); + $lock = Cache::lock($lockKey, 60); + expect($lock->get())->toBeTrue(); + $lock->release(); + }); + + it('skips a start that a newer start of the same database replaced', function () { + StartPostgresql::shouldRun()->never(); + $older = concurrentStartActivity($this->database->uuid, $this->team->id, minutesSinceLastUpdate: 20); + concurrentStartActivity($this->database->uuid, $this->team->id); + + DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $older->id, null); + + $older->refresh(); + expect(data_get($older, 'properties.status'))->toBe(ProcessStatus::ERROR->value) + ->and(data_get($older, 'properties.error'))->toBe(ResourceStartActivity::SUPERSEDED_MESSAGE); + }); + + it('skips a start whose activity is no longer queued', function () { + StartPostgresql::shouldRun()->never(); + $activity = concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::ERROR); + + DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null); + + expect(data_get($activity->refresh(), 'properties.status'))->toBe(ProcessStatus::ERROR->value); + }); +}); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php new file mode 100644 index 0000000000..2fa3798d2e --- /dev/null +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -0,0 +1,449 @@ +set('constants.ssh.mux_enabled', false); + config()->set('constants.ssh.max_retries', 1); +}); + +/** + * @return array{0: SensitiveCommandFailureDeploymentJob, 1: ReflectionClass, 2: ApplicationDeploymentQueue, 3: Application} + */ +function makeSensitiveCommandFailureJob(array $environmentVariables = []): array +{ + $user = User::factory()->create(); + $team = $user->teams()->first(); + + $privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + ."b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" + ."QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n" + ."hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n" + ."AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n" + ."uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n" + .'-----END OPENSSH PRIVATE KEY-----'; + + $privateKey = PrivateKey::create([ + 'name' => 'sensitive-failure-key-'.uniqid(), + 'private_key' => $privateKeyContent, + 'team_id' => $team->id, + ]); + + Storage::fake('ssh-keys'); + Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => 'root', + ]); + + $project = Project::create(['name' => 'Sensitive Failure Project', 'team_id' => $team->id]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ]); + $application->settings()->update([ + 'include_source_commit_in_build' => false, + 'is_env_sorting_enabled' => false, + ]); + + foreach ($environmentVariables as $attributes) { + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'is_multiline' => false, + 'is_literal' => false, + ...$attributes, + ]); + } + + $queue = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'sensitive-failure-deployment', + 'application_id' => $application->id, + 'server_id' => $server->id, + 'status' => 'in_progress', + ]); + + $job = new SensitiveCommandFailureDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + foreach ([ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'server' => $server, + 'mainServer' => $server, + 'build_pack' => 'dockerfile', + 'pull_request_id' => 0, + 'commit' => 'HEAD', + 'basedir' => '/artifacts/sensitive-failure', + 'workdir' => '/artifacts/sensitive-failure', + 'configuration_dir' => '/data/coolify/applications/sensitive-failure', + 'deployment_uuid' => 'sensitive-failure-deployment', + 'dockerfile_location' => '/Dockerfile', + 'container_name' => 'sensitive-failure-app', + 'coolify_variables' => null, + 'dockerSecretsSupported' => false, + 'saved_outputs' => new Collection, + ] as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection, $queue, $application]; +} + +function invokeSensitiveFailureJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invoke($job, ...$arguments); +} + +function captureDeploymentException(callable $callback): DeploymentException +{ + try { + $callback(); + } catch (DeploymentException $exception) { + return $exception; + } + + throw new RuntimeException('Expected a DeploymentException.'); +} + +/** + * @return list + */ +function base64PayloadsInCommand(string $command): array +{ + preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $command, $matches); + + return array_values(array_filter( + $matches[0], + static fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), SENSITIVE_FAILURE_SECRET) + )); +} + +it('hides the .env write command and its base64 payload when the write fails', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob([ + ['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET], + ]); + + $payloads = []; + Process::fake(function (PendingProcess $process) use (&$payloads) { + $payloads = array_merge($payloads, base64PayloadsInCommand($process->command)); + + return Process::result(errorOutput: 'tee: /artifacts/sensitive-failure/.env: No space left on device', exitCode: 1); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables')); + + expect($payloads)->not->toBeEmpty(); + expect($exception->getMessage()) + ->toContain('Command execution failed (exit code 1): [command hidden because it contains sensitive data]') + ->toContain('No space left on device') + ->not->toContain(SENSITIVE_FAILURE_SECRET) + ->not->toContain('base64 -d'); + foreach ($payloads as $payload) { + expect($exception->getMessage())->not->toContain($payload); + } + + $storedLogs = (string) $queue->fresh()->logs; + expect($storedLogs) + ->toContain('No space left on device') + ->not->toContain(SENSITIVE_FAILURE_SECRET); + foreach ($payloads as $payload) { + expect($storedLogs)->not->toContain($payload); + } +}); + +it('redacts the sensitive payload when the error output echoes the command', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob([ + ['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET], + ]); + + $payloads = []; + Process::fake(function (PendingProcess $process) use (&$payloads) { + $commandPayloads = base64PayloadsInCommand($process->command); + $payloads = array_merge($payloads, $commandPayloads); + + return Process::result(errorOutput: "bash: line 1: echo '".($commandPayloads[0] ?? '')."': unexpected failure", exitCode: 2); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables')); + + expect($payloads)->not->toBeEmpty(); + $storedLogs = (string) $queue->fresh()->logs; + foreach ($payloads as $payload) { + expect($exception->getMessage())->not->toContain($payload); + expect($storedLogs)->not->toContain($payload); + } + expect($exception->getMessage())->toContain('unexpected failure'); +}); + +it('hides the build-time env write command when the write fails', function () { + [$job, $reflection] = makeSensitiveCommandFailureJob([ + ['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false], + ]); + + $payloads = []; + Process::fake(function (PendingProcess $process) use (&$payloads) { + $payloads = array_merge($payloads, base64PayloadsInCommand($process->command)); + + return Process::result(errorOutput: 'tee: /artifacts/build-time.env: No such file or directory', exitCode: 1); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_buildtime_environment_variables')); + + expect($payloads)->not->toBeEmpty(); + expect($exception->getMessage()) + ->toContain('[command hidden because it contains sensitive data]') + ->toContain('No such file or directory') + ->not->toContain(SENSITIVE_FAILURE_SECRET); + foreach ($payloads as $payload) { + expect($exception->getMessage())->not->toContain($payload); + } +}); + +it('keeps the command in the error message for failed commands that are not sensitive', function () { + [$job] = makeSensitiveCommandFailureJob(); + + Process::fake(['*' => Process::result(errorOutput: 'ls: cannot access: No such file or directory', exitCode: 2)]); + + $exception = captureDeploymentException(fn () => $job->execute_remote_command([ + 'command' => 'ls /artifacts/harmless-missing-directory', + 'hidden' => true, + ])); + + expect($exception->getMessage()) + ->toContain('Command execution failed (exit code 2): ls /artifacts/harmless-missing-directory') + ->toContain('No such file or directory') + ->not->toContain('[command hidden because it contains sensitive data]'); +}); + +it('redacts locked values from the error output of failed commands', function () { + [$job] = makeSensitiveCommandFailureJob([ + ['key' => 'LOCKED_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_shown_once' => true], + ]); + + Process::fake(['*' => Process::result(errorOutput: 'failed with value '.SENSITIVE_FAILURE_SECRET, exitCode: 1)]); + + $exception = captureDeploymentException(fn () => $job->execute_remote_command([ + 'command' => 'harmless-command --flag', + ])); + + expect($exception->getMessage()) + ->toContain('harmless-command --flag') + ->toContain('failed with value '.REDACTED) + ->not->toContain(SENSITIVE_FAILURE_SECRET); +}); + +it('logs only the variable names of the .env files in development mode', function () { + config()->set('app.env', 'local'); + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob([ + ['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET], + ]); + + $ranCommands = []; + Process::fake(function (PendingProcess $process) use (&$ranCommands) { + $ranCommands[] = $process->command; + + return Process::result(output: str_contains($process->command, 'cat ') ? 'APP_SECRET='.SENSITIVE_FAILURE_SECRET : ''); + }); + + invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables'); + invokeSensitiveFailureJobMethod($job, $reflection, 'save_buildtime_environment_variables'); + + $logEntries = collect(json_decode((string) $queue->fresh()->logs, true)); + $keyListEntries = $logEntries->filter(fn (array $entry): bool => str_contains((string) $entry['output'], '[DEBUG] Variable names in')); + + expect($keyListEntries)->toHaveCount(2) + ->each(fn ($entry) => $entry->hidden->toBeTrue()->output->toContain('APP_SECRET')); + expect($logEntries->pluck('output')->merge($logEntries->pluck('command'))->filter()->implode("\n")) + ->not->toContain('APP_SECRET='.SENSITIVE_FAILURE_SECRET) + ->not->toContain('APP_SECRET="'.SENSITIVE_FAILURE_SECRET); + expect(collect($ranCommands)->filter(fn (string $command): bool => str_contains($command, 'cat /artifacts/sensitive-failure/.env') || str_contains($command, 'cat '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH))) + ->toBeEmpty(); +}); + +function setSensitiveFailureJobProperties(object $job, ReflectionClass $reflection, array $properties): void +{ + foreach ($properties as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } +} + +it('hides the helper container command when build secrets are passed as environment flags', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([ + ['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false], + ]); + $application->settings()->update(['use_build_secrets' => true]); + $server = readSensitiveFailureJobProperty($job, $reflection, 'server'); + setSensitiveFailureJobProperties($job, $reflection, [ + 'application' => $application->fresh(), + 'destination' => StandaloneDocker::forceCreate([ + 'name' => 'sensitive-failure-network', + 'network' => 'sensitive-failure-network', + 'server_id' => $server->id, + ]), + ]); + + Process::fake(function (PendingProcess $process) { + if (str_contains($process->command, 'docker run -d')) { + return Process::result(errorOutput: 'docker: Error response from daemon: network not found.', exitCode: 125); + } + + return Process::result(output: str_contains($process->command, 'echo $HOME') ? '/root' : 'NOK'); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); + + expect($exception->getMessage()) + ->toContain('[command hidden because it contains sensitive data]') + ->toContain('network not found') + ->not->toContain(SENSITIVE_FAILURE_SECRET); + expect((string) $queue->fresh()->logs)->not->toContain(SENSITIVE_FAILURE_SECRET); +}); + +it('hides the Railpack prepare command that passes build-time values', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([ + ['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false], + ]); + $application->update(['build_pack' => 'railpack']); + setSensitiveFailureJobProperties($job, $reflection, [ + 'application' => $application->fresh(), + 'build_pack' => 'railpack', + 'dockerBuildxAvailable' => true, + ]); + + Process::fake(function (PendingProcess $process) { + if (str_contains($process->command, 'railpack prepare')) { + return Process::result(errorOutput: 'railpack: failed to detect a provider', exitCode: 1); + } + + return Process::result(output: str_contains($process->command, 'buildx version') ? 'available' : 'missing'); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'build_railpack_image')); + + expect($exception->getMessage()) + ->toContain('[command hidden because it contains sensitive data]') + ->toContain('failed to detect a provider') + ->not->toContain(SENSITIVE_FAILURE_SECRET); + expect((string) $queue->fresh()->logs)->not->toContain(SENSITIVE_FAILURE_SECRET); +}); + +it('hides the Nixpacks plan write command that contains build-time values', function (bool $isStatic) { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); + $application->update(['build_pack' => 'nixpacks']); + $application->settings()->update(['is_static' => $isStatic]); + setSensitiveFailureJobProperties($job, $reflection, [ + 'application' => $application->fresh(), + 'build_pack' => 'nixpacks', + 'build_args' => collect(), + 'disableBuildCache' => false, + 'force_rebuild' => false, + 'nixpacks_plan' => json_encode(['variables' => ['BUILD_SECRET' => SENSITIVE_FAILURE_SECRET]]), + ]); + + $payloads = []; + Process::fake(function (PendingProcess $process) use (&$payloads) { + $commandPayloads = base64PayloadsInCommand($process->command); + if ($commandPayloads !== []) { + $payloads = array_merge($payloads, $commandPayloads); + + return Process::result(errorOutput: 'tee: /artifacts/thegameplan.json: No space left on device', exitCode: 1); + } + + return Process::result(); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'build_image')); + + expect($payloads)->not->toBeEmpty(); + expect($exception->getMessage()) + ->toContain('[command hidden because it contains sensitive data]') + ->not->toContain(SENSITIVE_FAILURE_SECRET); + $storedLogs = (string) $queue->fresh()->logs; + foreach ($payloads as $payload) { + expect($exception->getMessage())->not->toContain($payload); + expect($storedLogs)->not->toContain($payload); + } +})->with([ + 'static' => [true], + 'not static' => [false], +]); + +it('hides the compose file write command because compose files can contain secrets', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); + $server = readSensitiveFailureJobProperty($job, $reflection, 'server'); + setSensitiveFailureJobProperties($job, $reflection, [ + 'destination' => $server->standaloneDockers()->firstOrFail(), + 'production_image_name' => 'example/app:latest', + ]); + + $payloads = []; + Process::fake(function (PendingProcess $process) use (&$payloads) { + preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $process->command, $matches); + $composePayloads = array_filter($matches[0], fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), 'services:')); + if ($composePayloads !== []) { + $payloads = array_merge($payloads, $composePayloads); + + return Process::result(errorOutput: 'tee: docker-compose.yaml: No space left on device', exitCode: 1); + } + + return Process::result(); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_compose_file')); + + expect($payloads)->not->toBeEmpty(); + expect($exception->getMessage()) + ->toContain('[command hidden because it contains sensitive data]') + ->toContain('No space left on device'); + $storedLogs = (string) $queue->fresh()->logs; + foreach ($payloads as $payload) { + expect($exception->getMessage())->not->toContain($payload); + expect($storedLogs)->not->toContain($payload); + } +}); + +function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflection, string $property): mixed +{ + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($job); +} diff --git a/tests/Feature/GitSourcePrivateNetworkTest.php b/tests/Feature/GitSourcePrivateNetworkTest.php new file mode 100644 index 0000000000..cfaed850b2 --- /dev/null +++ b/tests/Feature/GitSourcePrivateNetworkTest.php @@ -0,0 +1,95 @@ + InstanceSettings::query()->create(['id' => 0])); + config(['constants.coolify.self_hosted' => true]); +}); + +function gitSourceOptions(string $url, array $resolvedIps = ['203.0.113.10']): array +{ + return SafeExternalUrl::httpClientOptions($url, resolver: fn (string $host): array => $resolvedIps, allowPrivateNetworks: true); +} + +function gitSourceUrlIsValid(string $url): bool +{ + return Validator::make(['api_url' => $url], ['api_url' => [SafeExternalUrl::forGitSource()]])->passes(); +} + +test('self-hosted Git sources can use private networks and internal hostnames', function (string $url, array $resolvedIps) { + $options = gitSourceOptions($url, $resolvedIps); + + expect($options['allow_redirects'])->toBeFalse(); +})->with([ + 'RFC 1918 IP' => ['https://10.0.0.5/api/v3', []], + 'private IP with port' => ['http://192.168.1.20:8929/api/v4', []], + 'Tailscale CGNAT IP' => ['https://100.101.102.103/api/v4', []], + 'IPv6 unique local address' => ['https://[fd12:3456::1]/api/v4', []], + 'hostname on a private IP' => ['https://github.company.lan/api/v3', ['10.1.2.3']], + '.internal hostname' => ['https://gitlab.corp.internal/api/v4', ['172.16.5.4']], + '.local hostname' => ['https://gitea.local/api/v1', ['192.168.1.30']], + 'single-label container name' => ['http://gitea:3000/api/v1', ['172.18.0.7']], + 'Tailscale MagicDNS name' => ['https://git.tail1234.ts.net/api/v4', ['100.64.0.9']], +]); + +test('the private-network mode pins the resolved private address', function () { + $options = gitSourceOptions('https://gitlab.corp.internal/api/v4', ['172.16.5.4']); + + expect($options['curl'][CURLOPT_RESOLVE][0])->toBe('gitlab.corp.internal:443:172.16.5.4'); +}); + +test('Git sources still cannot reach metadata, loopback, or reserved targets', function (string $url, array $resolvedIps) { + expect(fn () => gitSourceOptions($url, $resolvedIps))->toThrow(RuntimeException::class); +})->with([ + 'cloud metadata IP' => ['http://169.254.169.254/latest', []], + 'metadata hostname' => ['http://metadata.google.internal/computeMetadata', ['169.254.169.254']], + 'IPv6 link-local' => ['http://[fe80::1]/', []], + 'loopback IP' => ['http://127.0.0.1:6379/', []], + 'IPv6 loopback' => ['http://[::1]/', []], + 'localhost' => ['http://localhost:8080/', []], + 'hostname on loopback' => ['https://git.example.com/', ['127.0.0.1']], + 'unspecified address' => ['http://0.0.0.0/', []], + 'IPv4-mapped metadata IP' => ['http://[::ffff:169.254.169.254]/', []], + 'multicast' => ['http://224.0.0.1/', []], + 'hostname with one private and one metadata IP' => ['https://git.example.com/', ['10.0.0.5', '169.254.169.254']], +]); + +test('other outbound URLs still block private networks by default', function () { + expect(fn () => SafeExternalUrl::httpClientOptions('https://10.0.0.5/hook')) + ->toThrow(RuntimeException::class, 'unsafe IP address') + ->and(fn () => SafeExternalUrl::httpClientOptions('https://gitlab.corp.internal/', resolver: fn (): array => ['172.16.5.4'])) + ->toThrow(RuntimeException::class); +}); + +test('the Git source HTTP client allows a private GitHub Enterprise on self-hosted', function () { + $options = Http::GitHub('https://10.20.30.40/api/v3', 'secret')->getOptions(); + + expect($options['allow_redirects'])->toBeFalse() + ->and($options['headers']['Authorization'])->toBe('Bearer secret'); +}); + +test('the Git source HTTP client keeps private networks blocked on Coolify Cloud', function () { + config(['constants.coolify.self_hosted' => false]); + + expect(fn () => Http::GitHub('https://10.20.30.40/api/v3', 'secret')) + ->toThrow(RuntimeException::class, 'unsafe IP address'); +}); + +test('Git source URL validation follows the same private-network rules', function () { + expect(gitSourceUrlIsValid('https://10.20.30.40/api/v3'))->toBeTrue() + ->and(gitSourceUrlIsValid('https://100.101.102.103/api/v4'))->toBeTrue() + ->and(gitSourceUrlIsValid('http://169.254.169.254/latest'))->toBeFalse() + ->and(gitSourceUrlIsValid('http://127.0.0.1/'))->toBeFalse() + ->and(gitSourceUrlIsValid('http://localhost/'))->toBeFalse(); + + config(['constants.coolify.self_hosted' => false]); + + expect(gitSourceUrlIsValid('https://10.20.30.40/api/v3'))->toBeFalse(); +}); diff --git a/tests/Feature/MobileResourceMenuTest.php b/tests/Feature/MobileResourceMenuTest.php index d6f3177146..182d11f85f 100644 --- a/tests/Feature/MobileResourceMenuTest.php +++ b/tests/Feature/MobileResourceMenuTest.php @@ -1,6 +1,6 @@ toBeGreaterThanOrEqual(2); + + expect($heading) + ->not->toContain('name="play-circle" class="size-3.5 text-warning"') + ->not->toContain('name="play-circle" class="size-3.5 text-orange-500'); } }); -it('uses native mobile menus for databases and services', function () { - $applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')); - $databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); - $serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); - $applicationMobileActions = mobileActionsMarkup($applicationHeading, 'application-mobile-actions', 'application-mobile-section'); - $databaseMobileActions = mobileActionsMarkup($databaseHeading, 'database-mobile-actions', 'database-mobile-section'); - $serviceMobileActions = mobileActionsMarkup($serviceHeading, 'service-mobile-actions', 'service-mobile-section'); +it('uses full-width split action menus on mobile resource headings', function () { + $headings = [ + 'application' => file_get_contents(resource_path('views/livewire/project/application/heading.blade.php')), + 'database' => file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')), + 'service' => file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')), + ]; - expect(mobileActionsAreBeforeSelect($applicationHeading, 'application-mobile-actions', 'application-mobile-section'))->toBeTrue(); - expect(mobileActionsAreBeforeSelect($databaseHeading, 'database-mobile-actions', 'database-mobile-section'))->toBeTrue(); - expect(mobileActionsAreBeforeSelect($serviceHeading, 'service-mobile-actions', 'service-mobile-section'))->toBeTrue(); + foreach ($headings as $resource => $heading) { + expect(mobileActionsAreBeforeDesktopActions($heading, "{$resource}-mobile-actions", "{$resource}-desktop-actions"))->toBeTrue(); - expect($applicationHeading) - ->toContain('application-mobile-actions') - ->toContain("'route' => 'project.application.command'") - ->toContain("'navigate' => false") - ->toContain("value.startsWith('location|')") - ->toContain('window.location.href = url') - ->toContain('application-mobile-stop-trigger') - ->toContain('application-mobile-restart-trigger') + expect($heading) + ->toContain('
') + ->toContain("") + ->not->toContain("{$resource}-mobile-section") + ->not->toContain('not->toContain('not->toContain('@selected'); + + expect(mobileActionsMarkup($heading, "{$resource}-mobile-actions")) + ->toContain('toContain('listbox-option justify-start! gap-2.5!') + ->toContain('role="menuitem"') + ->toContain('name="play-circle" class="size-3.5"') + ->toContain('name="restart" class="size-3.5') + ->toContain('name="stop-circle" class="size-3.5 text-error"'); + } + + expect(mobileActionsMarkup($headings['application'], 'application-mobile-actions')) ->toContain('wire:click="deploy"') - ->toContain('wire:click="force_deploy_without_cache"') + ->toContain('wire:click="deploy(true)"') + ->toContain('force_deploy_without_cache') + ->toContain('Deploy (without cache)') + ->toContain("document.getElementById('application-mobile-stop-trigger')?.click()") + ->toContain("document.getElementById('application-mobile-restart-trigger')?.click()"); + + expect($headings['application']) + ->toContain('