From 6ae45684f99032b6057c52a2b9b2102264eb79f7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:40:37 +0200 Subject: [PATCH] feat(v5): add server reconciliation and canvas APIs Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens. --- .ai/todo.md | 64 + .../V5/Application/DeployNginxApplication.php | 69 +- .../V5/Flux/ApplyFluxResourceStatusUpdate.php | 180 +- .../GenerateCaddyIngressConfiguration.php | 48 +- app/Actions/V5/Proxy/StartCaddyIngress.php | 42 +- app/Actions/V5/Proxy/StopCaddyIngress.php | 44 +- app/Actions/V5/Server/PushHostAgentToken.php | 101 + .../V5/Server/RemoveBootstrapMarker.php | 75 + app/Actions/V5/Server/SyncDevLimaServers.php | 16 +- app/Console/Commands/V5FluxGenerateKeys.php | 135 + app/Console/Kernel.php | 10 + app/Enums/V5/ApplicationStatus.php | 28 + app/Enums/V5/ContainerState.php | 26 + app/Enums/V5/IngressStatus.php | 25 + app/Enums/V5/ServerStatus.php | 15 + app/Events/V5CanvasResourceUpdated.php | 82 +- app/Events/V5ClusterUpdated.php | 70 +- app/Exceptions/Handler.php | 5 +- app/Exceptions/V5/UnsupportedCooldVerb.php | 18 + .../Internal/FluxResourceStatusController.php | 54 +- .../Controllers/V5/ApplicationController.php | 635 ++ app/Http/Controllers/V5/ClusterController.php | 207 + .../V5/Concerns/HandlesIngressSyncErrors.php | 40 + .../V5/Concerns/ResolvesCurrentTeam.php | 22 + .../V5/Concerns/ResolvesProjectSelection.php | 115 + .../V5/Concerns/SerializesCanvasResources.php | 63 + .../ValidatesBuilderConfiguration.php | 30 + .../Controllers/V5/DashboardController.php | 1957 +----- .../V5/ResourceConnectionController.php | 330 + app/Http/Controllers/V5/ServerController.php | 761 ++ app/Http/Kernel.php | 1 + app/Http/Middleware/V5/EnsureCurrentTeam.php | 10 +- app/Jobs/V5BootstrapServerJob.php | 467 +- app/Jobs/V5DeployApplicationJob.php | 53 + app/Jobs/V5ReconcileServerStateJob.php | 255 + app/Jobs/V5ReconcileServersJob.php | 89 + app/Jobs/V5RotateAgentTokenJob.php | 150 + app/Jobs/V5RotateAgentTokensJob.php | 65 + app/Jobs/V5TeardownTeamJob.php | 325 + app/Models/Team.php | 12 + app/Models/V5/Application.php | 8 +- app/Models/V5/ApplicationDomain.php | 2 + app/Models/V5/Cluster.php | 6 + app/Models/V5/ContainerStatus.php | 4 + app/Models/V5/ResourceConnectionRule.php | 2 + app/Models/V5/RevokedAgentToken.php | 45 + app/Models/V5/Server.php | 69 +- app/Models/V5/V5Model.php | 45 +- app/Policies/V5/ApplicationPolicy.php | 74 + app/Policies/V5/ClusterPolicy.php | 64 + app/Policies/V5/ResourceConnectionPolicy.php | 55 + app/Policies/V5/ServerPolicy.php | 121 + app/Providers/AppServiceProvider.php | 15 + app/Providers/AuthServiceProvider.php | 14 + app/Providers/RouteServiceProvider.php | 8 + app/Rules/ValidHostname.php | 13 +- app/Rules/ValidServerIp.php | 31 + app/Services/Flux/AgentTokenIssuer.php | 203 +- app/Services/Flux/FluxClient.php | 173 +- app/Support/V5/CanvasResourceSerializer.php | 87 + app/Support/V5/ClusterSerializer.php | 90 + app/Support/V5/ConnectionFirewallSync.php | 157 + .../V5/ResourceConnectionSerializer.php | 73 + app/Support/V5/StatusObservation.php | 68 + config/coold.php | 17 + config/flux.php | 100 + config/horizon.php | 29 +- ...05_215736_v5_add_status_lookup_indexes.php | 48 + ...5_215736_v5_make_servers_uuid_not_null.php | 39 + ...2616_v5_add_status_observed_at_columns.php | 44 + ..._v5_add_coold_version_to_servers_table.php | 28 + ..._resource_connection_morphs_to_aliases.php | 74 + ...onvert_server_capabilities_to_booleans.php | 74 + ...5_add_agent_token_jti_to_servers_table.php | 22 + ...0_v5_create_revoked_agent_tokens_table.php | 25 + ...gent_token_expires_at_to_servers_table.php | 22 + docker-compose.dev.yml | 1 + .../etc/s6-overlay/s6-rc.d/flux/run | 1 + package-lock.json | 41 +- phpunit.xml | 3 + resources/js/v5/Pages/Clusters.tsx | 641 +- resources/js/v5/Pages/Dashboard.tsx | 2075 ++---- resources/js/v5/Pages/RealtimeTest.tsx | 80 +- resources/js/v5/components/app-navbar.tsx | 74 +- .../v5/components/canvas/application-card.tsx | 129 + .../canvas/application-ingress-button.tsx | 39 + .../canvas/application-inspector-sheet.tsx | 276 + .../components/canvas/caddy-ingress-card.tsx | 49 + .../js/v5/components/canvas/canvas-notice.tsx | 38 + .../v5/components/canvas/canvas-toolbar.tsx | 139 + .../v5/components/canvas/connection-lines.tsx | 111 + .../canvas/connection-ports-editor.tsx | 146 + .../v5/components/canvas/ingress-dialog.tsx | 82 + .../js/v5/components/canvas/status-badge.ts | 19 + resources/js/v5/lib/api.ts | 7 + resources/js/v5/lib/canvas-api.ts | 24 + resources/js/v5/lib/canvas-geometry.ts | 132 + resources/js/v5/lib/optimistic.ts | 57 + .../js/v5/lib/use-application-ingress.ts | 169 + resources/js/v5/lib/use-canvas-channel.ts | 19 + resources/js/v5/lib/use-canvas-connections.ts | 314 + .../js/v5/lib/use-canvas-resource-merge.ts | 98 + resources/js/v5/lib/use-canvas-viewport.ts | 125 + resources/js/v5/lib/use-team-channel.ts | 116 + resources/js/v5/types.ts | 9 +- resources/views/v5/app.blade.php | 2 +- routes/v5.php | 47 +- tests/Feature/DevEnvironmentExampleTest.php | 8 + tests/Feature/FluxDevCommandTest.php | 13 +- tests/Feature/V5/AgentTokenRevocationTest.php | 167 + tests/Feature/V5/AgentTokenRotationTest.php | 366 + .../Feature/V5/ApplicationControllerTest.php | 1771 +++++ tests/Feature/V5/BootstrapPreflightTest.php | 251 + tests/Feature/V5/BroadcastChannelAuthTest.php | 71 + tests/Feature/V5/ClusterControllerTest.php | 417 ++ tests/Feature/V5/DashboardControllerTest.php | 483 ++ tests/Feature/V5/DashboardTest.php | 6127 ----------------- tests/Feature/V5/EnsureCurrentTeamTest.php | 153 + tests/Feature/V5/FluxGenerateKeysTest.php | 81 + tests/Feature/V5/FluxInboundTokenTest.php | 72 + tests/Feature/V5/FluxStatusIngestionTest.php | 341 + .../Feature/V5/HorizonReconcileQueueTest.php | 36 + tests/Feature/V5/ModelHygieneTest.php | 295 + tests/Feature/V5/ReconcileServerStateTest.php | 397 ++ .../Feature/V5/RemoveBootstrapMarkerTest.php | 111 + .../V5/ResourceConnectionControllerTest.php | 626 ++ ...ourceConnectionFirewallConsistencyTest.php | 370 + tests/Feature/V5/ServerControllerTest.php | 1459 ++++ tests/Feature/V5/V5BootstrapServerJobTest.php | 920 +++ tests/Feature/V5/V5CanvasBroadcastTest.php | 292 + tests/Feature/V5/V5DevLimaSeederTest.php | 139 + tests/Feature/V5/V5FluxStatusUpdateTest.php | 639 ++ .../V5/V5FrontendSourceContractTest.php | 932 +++ tests/Feature/V5/V5MigrationSchemaTest.php | 343 + tests/Feature/V5/V5RouteMiddlewareTest.php | 94 + tests/Feature/V5/V5TeardownTeamTest.php | 329 + tests/Pest.php | 11 + tests/Support/V5TestHelpers.php | 246 + tests/Support/V5TestSchema.php | 230 + tests/Unit/V5/AgentTokenIssuerTest.php | 151 + tests/Unit/V5/BroadcastPayloadTest.php | 100 + .../Unit/V5/CaddyIngressConfigurationTest.php | 252 +- tests/Unit/V5/CooldVerbContractTest.php | 314 + .../V5/NginxApplicationDeploymentTest.php | 207 + tests/Unit/V5/Policies/V5PolicyTest.php | 253 + tests/Unit/V5/V5QueueIdempotencyTest.php | 73 + tests/Unit/ValidHostnameTest.php | 5 + 147 files changed, 22444 insertions(+), 10407 deletions(-) create mode 100644 .ai/todo.md create mode 100644 app/Actions/V5/Server/PushHostAgentToken.php create mode 100644 app/Actions/V5/Server/RemoveBootstrapMarker.php create mode 100644 app/Console/Commands/V5FluxGenerateKeys.php create mode 100644 app/Enums/V5/ApplicationStatus.php create mode 100644 app/Enums/V5/ContainerState.php create mode 100644 app/Enums/V5/IngressStatus.php create mode 100644 app/Enums/V5/ServerStatus.php create mode 100644 app/Exceptions/V5/UnsupportedCooldVerb.php create mode 100644 app/Http/Controllers/V5/ApplicationController.php create mode 100644 app/Http/Controllers/V5/ClusterController.php create mode 100644 app/Http/Controllers/V5/Concerns/HandlesIngressSyncErrors.php create mode 100644 app/Http/Controllers/V5/Concerns/ResolvesCurrentTeam.php create mode 100644 app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php create mode 100644 app/Http/Controllers/V5/Concerns/SerializesCanvasResources.php create mode 100644 app/Http/Controllers/V5/Concerns/ValidatesBuilderConfiguration.php create mode 100644 app/Http/Controllers/V5/ResourceConnectionController.php create mode 100644 app/Http/Controllers/V5/ServerController.php create mode 100644 app/Jobs/V5DeployApplicationJob.php create mode 100644 app/Jobs/V5ReconcileServerStateJob.php create mode 100644 app/Jobs/V5ReconcileServersJob.php create mode 100644 app/Jobs/V5RotateAgentTokenJob.php create mode 100644 app/Jobs/V5RotateAgentTokensJob.php create mode 100644 app/Jobs/V5TeardownTeamJob.php create mode 100644 app/Models/V5/RevokedAgentToken.php create mode 100644 app/Policies/V5/ApplicationPolicy.php create mode 100644 app/Policies/V5/ClusterPolicy.php create mode 100644 app/Policies/V5/ResourceConnectionPolicy.php create mode 100644 app/Policies/V5/ServerPolicy.php create mode 100644 app/Support/V5/CanvasResourceSerializer.php create mode 100644 app/Support/V5/ClusterSerializer.php create mode 100644 app/Support/V5/ConnectionFirewallSync.php create mode 100644 app/Support/V5/ResourceConnectionSerializer.php create mode 100644 app/Support/V5/StatusObservation.php create mode 100644 database/migrations/2026_07_05_215736_v5_add_status_lookup_indexes.php create mode 100644 database/migrations/2026_07_05_215736_v5_make_servers_uuid_not_null.php create mode 100644 database/migrations/2026_07_05_222616_v5_add_status_observed_at_columns.php create mode 100644 database/migrations/2026_07_05_222940_v5_add_coold_version_to_servers_table.php create mode 100644 database/migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php create mode 100644 database/migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php create mode 100644 database/migrations/2026_07_06_100000_v5_add_agent_token_jti_to_servers_table.php create mode 100644 database/migrations/2026_07_06_100100_v5_create_revoked_agent_tokens_table.php create mode 100644 database/migrations/2026_07_06_110000_v5_add_agent_token_expires_at_to_servers_table.php create mode 100644 resources/js/v5/components/canvas/application-card.tsx create mode 100644 resources/js/v5/components/canvas/application-ingress-button.tsx create mode 100644 resources/js/v5/components/canvas/application-inspector-sheet.tsx create mode 100644 resources/js/v5/components/canvas/caddy-ingress-card.tsx create mode 100644 resources/js/v5/components/canvas/canvas-notice.tsx create mode 100644 resources/js/v5/components/canvas/canvas-toolbar.tsx create mode 100644 resources/js/v5/components/canvas/connection-lines.tsx create mode 100644 resources/js/v5/components/canvas/connection-ports-editor.tsx create mode 100644 resources/js/v5/components/canvas/ingress-dialog.tsx create mode 100644 resources/js/v5/components/canvas/status-badge.ts create mode 100644 resources/js/v5/lib/api.ts create mode 100644 resources/js/v5/lib/canvas-api.ts create mode 100644 resources/js/v5/lib/canvas-geometry.ts create mode 100644 resources/js/v5/lib/optimistic.ts create mode 100644 resources/js/v5/lib/use-application-ingress.ts create mode 100644 resources/js/v5/lib/use-canvas-channel.ts create mode 100644 resources/js/v5/lib/use-canvas-connections.ts create mode 100644 resources/js/v5/lib/use-canvas-resource-merge.ts create mode 100644 resources/js/v5/lib/use-canvas-viewport.ts create mode 100644 resources/js/v5/lib/use-team-channel.ts create mode 100644 tests/Feature/V5/AgentTokenRevocationTest.php create mode 100644 tests/Feature/V5/AgentTokenRotationTest.php create mode 100644 tests/Feature/V5/ApplicationControllerTest.php create mode 100644 tests/Feature/V5/BootstrapPreflightTest.php create mode 100644 tests/Feature/V5/BroadcastChannelAuthTest.php create mode 100644 tests/Feature/V5/ClusterControllerTest.php create mode 100644 tests/Feature/V5/DashboardControllerTest.php delete mode 100644 tests/Feature/V5/DashboardTest.php create mode 100644 tests/Feature/V5/EnsureCurrentTeamTest.php create mode 100644 tests/Feature/V5/FluxGenerateKeysTest.php create mode 100644 tests/Feature/V5/FluxInboundTokenTest.php create mode 100644 tests/Feature/V5/FluxStatusIngestionTest.php create mode 100644 tests/Feature/V5/HorizonReconcileQueueTest.php create mode 100644 tests/Feature/V5/ModelHygieneTest.php create mode 100644 tests/Feature/V5/ReconcileServerStateTest.php create mode 100644 tests/Feature/V5/RemoveBootstrapMarkerTest.php create mode 100644 tests/Feature/V5/ResourceConnectionControllerTest.php create mode 100644 tests/Feature/V5/ResourceConnectionFirewallConsistencyTest.php create mode 100644 tests/Feature/V5/ServerControllerTest.php create mode 100644 tests/Feature/V5/V5BootstrapServerJobTest.php create mode 100644 tests/Feature/V5/V5CanvasBroadcastTest.php create mode 100644 tests/Feature/V5/V5DevLimaSeederTest.php create mode 100644 tests/Feature/V5/V5FluxStatusUpdateTest.php create mode 100644 tests/Feature/V5/V5FrontendSourceContractTest.php create mode 100644 tests/Feature/V5/V5MigrationSchemaTest.php create mode 100644 tests/Feature/V5/V5RouteMiddlewareTest.php create mode 100644 tests/Feature/V5/V5TeardownTeamTest.php create mode 100644 tests/Support/V5TestHelpers.php create mode 100644 tests/Support/V5TestSchema.php create mode 100644 tests/Unit/V5/AgentTokenIssuerTest.php create mode 100644 tests/Unit/V5/BroadcastPayloadTest.php create mode 100644 tests/Unit/V5/CooldVerbContractTest.php create mode 100644 tests/Unit/V5/Policies/V5PolicyTest.php create mode 100644 tests/Unit/V5/V5QueueIdempotencyTest.php diff --git a/.ai/todo.md b/.ai/todo.md new file mode 100644 index 000000000..7a0ef59db --- /dev/null +++ b/.ai/todo.md @@ -0,0 +1,64 @@ +# V5 Architecture Fix Plan + +Source: /Users/heyandras/.claude/plans/what-do-you-think-soft-firefly.md + +## Wave 1 (parallel) — DONE +- [x] 1. Split DashboardController into domain controllers + Laravel policies (denyAsNotFound), dedupe cluster serializer +- [x] 6. Frontend: extract Dashboard.tsx components, useCallback/memo, unified optimistic rollback, use-pending-ids reuse, mid-drag snap-back fix, types.ts drift, env-scoped merge +- [x] 5. Hot-path index migration (wireguard_management_ip, node_address, host, runtime_container_id, last_seen_at) + +## Wave 2 (parallel, after wave 1) — DONE +- [x] 2. Status enums (ApplicationStatus/ServerStatus/IngressStatus/ContainerState) + observed_at ordered ingestion +- [x] 3. Reconcile + prune scheduled jobs (V5ReconcileServersJob every 5m + per-server V5ReconcileServerStateJob, 24h container-status prune) +- [x] 4. Job uniqueness (ShouldBeUnique deploy+bootstrap) + queued broadcasts (ShouldBroadcast, afterCommit, null-safe payloads) +- [x] 7. Laravel↔coold verb handshake: UnsupportedCooldVerb detection (flux 501), graceful ingress degradation, coold_version persisted + +## Wave 3 (everything else) — DONE +- [x] Morph map (v5.application alias) + uuid collision retry + drop per-insert Schema::hasColumn + defaults dedup +- [x] v5_servers.uuid non-null; capabilities → indexed has_coold/is_ingress booleans (wire format preserved) +- [x] Firewall vs DB atomicity (DB=desired state, flux converge, compensating rollback; revoke-first destroy) +- [x] Deploy failure compensation (stop+force-remove orphaned container, original error preserved) +- [x] Caddyfile hostname/port validation + ValidHostname newline-bypass fix +- [x] Ambiguous host_id resolution warning + +## Wave 4 — DONE +- [x] Full V5 suite: 262 passed (1901 assertions); tsc clean; npm build ok; pint clean + +## Wave 5 (deep dives) +- [ ] Clusters.tsx + remaining frontend audit +- [ ] coold/flux Rust internals + security audit +- [ ] V5 test quality/coverage audit + +## Skipped (product decisions, documented) +- Soft deletes on infra rows (changes cascade semantics — needs product call) +- TLS in v5 ingress (feature, not fix) +- config coold.php/flux.php merge (cosmetic) + +## Wave 5 (deep dives) — DONE +- [x] coold/flux Rust audit → findings reported (NOT fixed — separate repo, see session recap: no-TLS gRPC, wildcard cap profiles, lost status updates on outage, exec exit_code always 0, mount-allowlist gaps, unauthenticated Corrosion gossip) +- [x] Frontend audit → all MUST/SHOULD-FIX applied (stale connections on env switch, deleteCluster shadow null-deref, persistSelection ok-guard, useTeamChannel extraction, apiRequest timeouts in Clusters, echo logging gated) +- [x] Test-quality audit → all applied (shared V5TestSchema helper killed schema drift, DashboardTest 174-test monolith split into 12 files, substring tests quarantined in V5FrontendSourceContractTest, +20 new tests: policies, RemoveBootstrapMarker, broadcast payloads, channel auth) + +## Wave 6 (audit fixes) — DONE +- [x] v4/v5 currentTeam session cross-contamination (full Team model, write-on-change only) +- [x] flux_url preflight 422 before bootstrap dispatch +- [x] Bootstrap marker/coold_version ordering +- [x] Enum literals sweep (jobs + StopCaddyIngress) +- [x] ManagesConnectionFirewallRules + SerializesResourceConnections → app/Support/V5 classes + +## Final state +289 V5 tests passed (2005 assertions) + 333 v4 unit slice green; tsc clean; npm build ok; pint clean. Nothing committed. + +## Wave 7 (security + JWT, cut off by session limit, then recovered) — DONE +- [x] JWT: mint explicit 21-primitive caps (config flux.host_capabilities), NOT the host-agent:default wildcard that flux treats as authorize-all; escape-hatch profile config; jti claim + persisted agent_token_jti; kid header; TTL 24h→1h (config); RevokedAgentToken model + migration + isRevoked API; inbound bearer array (laravel_api_tokens) for rotation +- [x] Authz: V5 policies role-gate mutations via isAdminOfTeam (403), keep denyAsNotFound (404) for cross-team; ClusterController::store authorize +- [x] Input: ValidServerIp rejects private/reserved ranges behind config('coold.allow_private_server_ips'); error-detail leak → generic messages + Log::warning; throttle:v5 limiter (RouteServiceProvider) +- [x] Stability: reconcile+refresh honor/advance status_observed_at (shared StatusObservation); Configured + full podman states in enums; deploy persists runtime_container_id after create; reconcile jobs on v5-reconcile queue; status_message churn fixed +- [x] Team-delete teardown: Team::deleting → V5TeardownTeamJob (best-effort per-server container/ingress/marker teardown, self-contained payload) + +## Wave 7 recovery fix (post-cutoff) +- [x] FATAL: V5ReconcileServersJob + V5ReconcileServerStateJob redeclared `public $queue = 'v5-reconcile'` — incompatible with Queueable trait's `public $queue;` on PHP 8.5 → hard fatal crashing BOTH pest suite and `php artisan test` bootstrap (job discovery). Moved queue assignment to onQueue() in constructor. +- [x] Stale test: ResourceConnectionControllerTest asserted old snapshot-fail detail; scenario hits the restore path → updated to "The previous rules were restored." (correct behavior) + +## Final state (Wave 7) +322 V5 tests passed (2124 assertions) via BOTH vendor/bin/pest AND php artisan test; v4 slice 308 passed; tsc clean; npm build ok; pint clean. diff --git a/app/Actions/V5/Application/DeployNginxApplication.php b/app/Actions/V5/Application/DeployNginxApplication.php index 1974d6be9..6e0e50e06 100644 --- a/app/Actions/V5/Application/DeployNginxApplication.php +++ b/app/Actions/V5/Application/DeployNginxApplication.php @@ -2,8 +2,12 @@ namespace App\Actions\V5\Application; +use App\Enums\V5\ApplicationStatus; +use App\Enums\V5\ContainerState; +use App\Enums\V5\ServerStatus; use App\Models\V5\Application; use App\Services\Flux\FluxClient; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class DeployNginxApplication @@ -21,34 +25,91 @@ class DeployNginxApplication return $this->markFailed($application, 'No server is attached to this application.'); } - $hostId = $server->wireguard_management_ip ?: $server->node_address ?: $server->host; + if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) { + return $this->markFailed($application, "Bootstrap server {$server->name} before deploying to it."); + } + + $hostId = $server->fluxHostId(); if (! is_string($hostId) || $hostId === '') { return $this->markFailed($application, 'No Flux host ID is available for this server.'); } + $containerId = null; + try { $this->fluxClient->pullImage($hostId, $application->image); $containerId = $this->fluxClient->createContainer($hostId, $this->containerSpec($application)); + + // Persist the runtime id the instant the container exists, before + // start/inspect. A worker SIGKILL at the job timeout would otherwise + // orphan a created container whose id only lived in this local var, + // leaving failed()/reconcile unable to find and clean it by id. + $application->update([ + 'status' => ApplicationStatus::Created->value, + 'status_message' => 'Container created.', + 'runtime_container_id' => $containerId, + ]); + $this->fluxClient->startContainer($hostId, $containerId); $inspect = $this->fluxClient->inspectContainer($hostId, $containerId); if (! $this->isContainerRunning($inspect)) { + $this->cleanUpContainer($application, $hostId, $containerId); + return $this->markFailed($application, 'Container did not stay running.'); } $application->update([ - 'status' => 'running', + 'status' => ApplicationStatus::Running->value, 'status_message' => 'Container started.', 'runtime_container_id' => $containerId, ]); return $application->refresh()->load('server'); } catch (\Throwable $e) { + if (is_string($containerId) && $containerId !== '') { + $this->cleanUpContainer($application, $hostId, $containerId); + } + return $this->markFailed($application, $e->getMessage()); } } + /** + * Best-effort compensation for a failed deploy: stop and force-remove the + * container this run created so it is never left orphaned on the node, then + * null the runtime id we persisted right after create so a cleaned-up + * failure never leaves a dangling id that reconcile would try to reap. + * Cleanup failures only log a warning and never mask the original error. + */ + private function cleanUpContainer(Application $application, string $hostId, string $containerId): void + { + try { + $this->fluxClient->stopContainer($hostId, $containerId); + } catch (\Throwable $e) { + Log::warning('Could not stop the container created by a failed v5 deploy.', [ + 'application_id' => $application->getKey(), + 'container_id' => $containerId, + 'error' => $e->getMessage(), + ]); + } + + try { + $this->fluxClient->removeContainer($hostId, $containerId, force: true); + } catch (\Throwable $e) { + Log::warning('Could not remove the container created by a failed v5 deploy.', [ + 'application_id' => $application->getKey(), + 'container_id' => $containerId, + 'error' => $e->getMessage(), + ]); + } + + if ($application->runtime_container_id === $containerId) { + $application->update(['runtime_container_id' => null]); + } + } + /** * @return array */ @@ -92,13 +153,13 @@ class DeployNginxApplication return true; } - return is_string($inspect['state'] ?? null) && $inspect['state'] === 'running'; + return is_string($inspect['state'] ?? null) && $inspect['state'] === ContainerState::Running->value; } private function markFailed(Application $application, string $message): Application { $application->update([ - 'status' => 'failed', + 'status' => ApplicationStatus::Failed->value, 'status_message' => str($message)->limit(10000)->toString(), ]); diff --git a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php index e763ab44f..5ed35cc5c 100644 --- a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php +++ b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php @@ -2,10 +2,18 @@ namespace App\Actions\V5\Flux; +use App\Enums\V5\ApplicationStatus; +use App\Enums\V5\ContainerState; +use App\Enums\V5\IngressStatus; +use App\Enums\V5\ServerStatus; use App\Models\V5\Application as V5Application; use App\Models\V5\ContainerStatus; use App\Models\V5\Server as V5Server; +use App\Support\V5\StatusObservation; +use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class ApplyFluxResourceStatusUpdate @@ -37,7 +45,7 @@ class ApplyFluxResourceStatusUpdate */ private function upsertContainerStatus(array $payload): ?ContainerStatus { - $status = $this->status($payload); + $status = $this->status($payload, ContainerState::class); $containerId = $this->stringValue($payload, 'container_id') ?? $this->stringValue($payload, 'runtime_container_id'); $server = $this->findServer($payload); @@ -45,17 +53,36 @@ class ApplyFluxResourceStatusUpdate return null; } - ContainerStatus::query()->updateOrCreate([ + $observedAt = $this->observedAt($payload); + $existing = ContainerStatus::query() + ->where('server_id', $server->id) + ->where('container_id', $containerId) + ->first(); + + if ($this->isStaleObservation($observedAt, $existing?->status_observed_at, 'container status', [ 'server_id' => $server->id, 'container_id' => $containerId, - ], [ + ])) { + return $existing; + } + + $attributes = [ 'team_id' => $server->team_id, 'container_name' => $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'), 'image' => $this->stringValue($payload, 'image'), 'status' => $status, 'status_message' => $this->statusMessage($payload, 'Container state received from coold.'), 'last_seen_at' => now(), - ]); + ]; + + if ($observedAt !== null) { + $attributes['status_observed_at'] = $observedAt; + } + + ContainerStatus::query()->updateOrCreate([ + 'server_id' => $server->id, + 'container_id' => $containerId, + ], $attributes); return ContainerStatus::query() ->where('server_id', $server->id) @@ -68,7 +95,7 @@ class ApplyFluxResourceStatusUpdate */ private function updateApplication(array $payload): ?V5Application { - $status = $this->status($payload); + $status = $this->status($payload, ApplicationStatus::class); if ($status === null) { return null; @@ -80,13 +107,39 @@ class ApplyFluxResourceStatusUpdate return null; } - $application->update([ + $observedAt = $this->observedAt($payload); + + if ($this->isStaleObservation($observedAt, $application->status_observed_at, 'application status', [ + 'application_id' => $application->id, + ])) { + return $application; + } + + $payloadContainerId = $this->stringValue($payload, 'runtime_container_id') + ?? $this->stringValue($payload, 'container_id'); + + // Payloads may carry no timestamp, so the container id remains an + // ordering signal as a second layer: an update for a superseded + // container is stale and must not overwrite the current one's state. + if ( + $payloadContainerId !== null + && $application->runtime_container_id !== null + && $payloadContainerId !== $application->runtime_container_id + ) { + return $application; + } + + $attributes = [ 'status' => $status, 'status_message' => $this->statusMessage($payload, 'Status updated by flux.'), - 'runtime_container_id' => $this->stringValue($payload, 'runtime_container_id') - ?? $this->stringValue($payload, 'container_id') - ?? $application->runtime_container_id, - ]); + 'runtime_container_id' => $payloadContainerId ?? $application->runtime_container_id, + ]; + + if ($observedAt !== null) { + $attributes['status_observed_at'] = $observedAt; + } + + $application->update($attributes); return $application->refresh(); } @@ -96,7 +149,7 @@ class ApplyFluxResourceStatusUpdate */ private function updateServer(array $payload): ?V5Server { - $status = $this->status($payload); + $status = $this->status($payload, ServerStatus::class); if ($status === null) { return null; @@ -108,22 +161,40 @@ class ApplyFluxResourceStatusUpdate return null; } - $server->update([ + $observedAt = $this->observedAt($payload); + + if ($this->isStaleObservation($observedAt, $server->status_observed_at, 'server status', [ + 'server_id' => $server->id, + ])) { + return $server; + } + + $attributes = [ 'status' => $status, 'last_status_check' => 'flux', 'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'), 'last_status_checked_at' => now(), - ]); + ]; + + if ($observedAt !== null) { + $attributes['status_observed_at'] = $observedAt; + } + + $server->update($attributes); return $server->refresh(); } /** + * The ingress state shares the server row but describes a different + * resource, so it deliberately does not read or write the server's + * `status_observed_at` watermark. + * * @param array $payload */ private function updateCaddyIngress(array $payload): ?V5Server { - $status = $this->status($payload); + $status = $this->status($payload, IngressStatus::class); if ($status === null) { return null; @@ -151,13 +222,17 @@ class ApplyFluxResourceStatusUpdate */ private function findApplication(array $payload): ?V5Application { - $query = V5Application::query()->with('server'); $server = $this->findServer($payload); - if ($server instanceof V5Server) { - $query->where('server_id', $server->id); + if (! $server instanceof V5Server) { + return null; } + $query = V5Application::query() + ->with('server') + ->where('server_id', $server->id) + ->where('team_id', $server->team_id); + $applicationUuid = $this->stringValue($payload, 'application_uuid') ?? $this->stringValue($payload, 'resource_uuid'); if ($applicationUuid !== null) { @@ -211,21 +286,62 @@ class ApplyFluxResourceStatusUpdate return null; } - return V5Server::query() - ->where('wireguard_management_ip', $hostId) - ->orWhere('node_address', $hostId) - ->orWhere('host', $hostId) - ->first(); + $matches = V5Server::query() + ->where('uuid', $hostId) + ->limit(2) + ->get(); + + if ($matches->count() > 1) { + Log::warning('Dropping flux resource status update: host id matches multiple v5 servers.', [ + 'host_id' => $hostId, + 'server_ids' => $matches->pluck('id')->all(), + ]); + + return null; + } + + return $matches->first(); + } + + /** + * Map the raw payload status onto the given status enum. Unknown values + * are never written to the database: they fall back to the enum's + * Unknown case and are logged. + * + * @param array $payload + * @param class-string $enumClass + */ + private function status(array $payload, string $enumClass): ?string + { + $raw = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state'); + + return StatusObservation::normalize($raw, $enumClass); } /** * @param array $payload */ - private function status(array $payload): ?string + private function observedAt(array $payload): ?CarbonInterface { - $status = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state'); + $observedAt = $this->stringValue($payload, 'observed_at'); - return $status === null ? null : strtolower($status); + if ($observedAt === null) { + return null; + } + + return rescue(fn (): CarbonImmutable => CarbonImmutable::parse($observedAt), null, false); + } + + /** + * A payload that carries an observation timestamp older than the one + * already persisted is stale (delivered out of order) and must not + * clobber the newer state. + * + * @param array $logContext + */ + private function isStaleObservation(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool + { + return StatusObservation::isStale($observedAt, $currentObservedAt, $context, $logContext); } /** @@ -247,18 +363,4 @@ class ApplyFluxResourceStatusUpdate return is_string($value) && $value !== '' ? $value : null; } - - /** - * @param array $payload - */ - private function intValue(array $payload, string $key): ?int - { - $value = data_get($payload, $key); - - if (is_int($value)) { - return $value; - } - - return is_string($value) && ctype_digit($value) ? (int) $value : null; - } } diff --git a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php index aeb3768db..2c17f89b0 100644 --- a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php +++ b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php @@ -5,6 +5,7 @@ namespace App\Actions\V5\Proxy; use App\Models\V5\Application; use App\Models\V5\ApplicationDomain; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -12,6 +13,14 @@ class GenerateCaddyIngressConfiguration { use AsAction; + /** + * Strict RFC 1123 hostname: dot-separated alphanumeric labels with inner + * hyphens, max 253 characters. Anchored with \A/\z (never $) so values + * containing newlines, braces, quotes, whitespace, or control characters + * can never inject extra directives into the generated Caddyfile. + */ + private const HOSTNAME_PATTERN = '/\A(?=.{1,253}\z)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\z/i'; + /** * @param Collection|null $applications * @return array{compose: string, caddyfile: string, apps: array} @@ -92,12 +101,42 @@ CADDY; private function applicationRoute(Application $application, ApplicationDomain $domain): ?string { - if ($domain->domain === '') { + if ($domain->domain === null || $domain->domain === '') { return null; } $namespace = $application->mesh_namespace ?: 'default'; - $upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$application->internal_port}"; + $internalPort = (int) $application->internal_port; + + if (! $this->isSafeHostname($domain->domain)) { + Log::warning('Skipping a caddy ingress route with an unsafe domain.', [ + 'application_id' => $application->getKey(), + 'domain' => $domain->domain, + ]); + + return null; + } + + if (! $this->isSafeHostname($application->container_name) || ! $this->isSafeHostname($namespace)) { + Log::warning('Skipping a caddy ingress route with an unsafe container name or namespace.', [ + 'application_id' => $application->getKey(), + 'container_name' => $application->container_name, + 'namespace' => $namespace, + ]); + + return null; + } + + if ($internalPort < 1 || $internalPort > 65535) { + Log::warning('Skipping a caddy ingress route with an out-of-range internal port.', [ + 'application_id' => $application->getKey(), + 'internal_port' => $application->internal_port, + ]); + + return null; + } + + $upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$internalPort}"; return implode("\n", [ "http://{$domain->domain} {", @@ -106,6 +145,11 @@ CADDY; ]); } + private function isSafeHostname(mixed $value): bool + { + return is_string($value) && preg_match(self::HOSTNAME_PATTERN, $value) === 1; + } + private function appFileName(Application $application): string { return 'app_'.$application->getKey(); diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php index 778ccf3c1..127c00bd6 100644 --- a/app/Actions/V5/Proxy/StartCaddyIngress.php +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -2,17 +2,19 @@ namespace App\Actions\V5\Proxy; +use App\Exceptions\V5\UnsupportedCooldVerb; use App\Models\V5\Application; use App\Models\V5\Server; use App\Services\Flux\FluxClient; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class StartCaddyIngress { use AsAction; - private const FIREWALL_PORTS = [80, 443]; + private const FIREWALL_PORTS = [80]; public function __construct(private readonly FluxClient $fluxClient) {} @@ -22,30 +24,48 @@ class StartCaddyIngress return 'Server is not an ingress server.'; } - $hostId = $server->wireguard_management_ip ?: $server->node_address; + $hostId = $server->fluxHostId(); if (! is_string($hostId) || $hostId === '') { - return 'Server is missing its Flux host id.'; + throw new \RuntimeException('Server is missing its Flux host id.'); } $configuration = GenerateCaddyIngressConfiguration::run($this->applications($server)); $output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps'])); + $firewallWarning = null; + foreach (self::FIREWALL_PORTS as $port) { - $this->fluxClient->applyFirewallRule($hostId, [ - 'id' => "v5-caddy-ingress:{$port}", - 'namespace' => 'default', - 'src' => '0.0.0.0/0', - 'dst' => 'coolify-v5-caddy', - 'proto' => 'tcp', - 'port' => $port, - ]); + try { + $this->fluxClient->applyFirewallRule($hostId, [ + 'id' => "v5-caddy-ingress:{$port}", + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => $port, + ]); + } catch (UnsupportedCooldVerb $exception) { + $firewallWarning = "Caddy ingress is running, but this node's coold does not support {$exception->verb}, so the managed firewall was not updated for port {$port}."; + Log::warning('V5 caddy ingress firewall rule skipped: coold verb unsupported', [ + 'server_id' => $server->id, + 'port' => $port, + 'verb' => $exception->verb, + 'message' => $exception->getMessage(), + ]); + + break; + } } if ($server->exists) { $server->update([ 'ingress_type' => 'caddy', 'ingress_status' => 'running', + ...($firewallWarning === null ? [] : [ + 'last_status_check' => 'flux', + 'last_status_output' => $firewallWarning, + ]), ]); } diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php index 2b3e27442..105d07523 100644 --- a/app/Actions/V5/Proxy/StopCaddyIngress.php +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -2,36 +2,64 @@ namespace App\Actions\V5\Proxy; +use App\Enums\V5\IngressStatus; +use App\Exceptions\V5\UnsupportedCooldVerb; use App\Models\V5\Server; use App\Services\Flux\FluxClient; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Lorisleiva\Actions\Concerns\AsAction; class StopCaddyIngress { use AsAction; - private const FIREWALL_PORTS = [80, 443]; + private const FIREWALL_PORTS = [80]; public function __construct(private readonly FluxClient $fluxClient) {} public function handle(Server $server): string { - $hostId = $server->wireguard_management_ip ?: $server->node_address; + if (! $server->isIngress() && $server->ingress_type === null) { + return 'Server is not an ingress server.'; + } + + $hostId = $server->fluxHostId(); if (! is_string($hostId) || $hostId === '') { - return 'Server is missing its Flux host id.'; + throw new \RuntimeException('Server is missing its Flux host id.'); + } + + // Revoke first: if stopping the container fails the allow rules must not + // stay orphaned on the host. + foreach (self::FIREWALL_PORTS as $port) { + $this->revokeFirewallRuleIfPresent($hostId, "v5-caddy-ingress:{$port}"); } $output = $this->fluxClient->stopIngress($hostId, 'caddy'); - foreach (self::FIREWALL_PORTS as $port) { - $this->fluxClient->revokeFirewallRule($hostId, "v5-caddy-ingress:{$port}"); - } - if ($server->exists) { - $server->update(['ingress_status' => 'exited']); + $server->update(['ingress_status' => IngressStatus::Exited->value]); } return $output; } + + private function revokeFirewallRuleIfPresent(string $hostId, string $ruleId): void + { + try { + $this->fluxClient->revokeFirewallRule($hostId, $ruleId); + } catch (UnsupportedCooldVerb $exception) { + Log::warning('V5 caddy ingress firewall revoke skipped: coold verb unsupported', [ + 'host_id' => $hostId, + 'rule_id' => $ruleId, + 'verb' => $exception->verb, + 'message' => $exception->getMessage(), + ]); + } catch (\RuntimeException $exception) { + if (! str_contains(Str::lower($exception->getMessage()), 'not found')) { + throw $exception; + } + } + } } diff --git a/app/Actions/V5/Server/PushHostAgentToken.php b/app/Actions/V5/Server/PushHostAgentToken.php new file mode 100644 index 000000000..a6e87d737 --- /dev/null +++ b/app/Actions/V5/Server/PushHostAgentToken.php @@ -0,0 +1,101 @@ + | sudo tee ; chmod 600) and RemoveBootstrapMarker + * for the SSH/temp-key mechanics. Returns whether the write succeeded; + * every failure path (missing key, SSH error, exception) resolves to false + * and always cleans up the temporary key file. + */ + public function handle(Server $server, string $token): bool + { + $server->loadMissing('privateKey'); + + if (! $server->privateKey instanceof PrivateKey) { + return false; + } + + $jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt')); + + if ($jwtPath === '') { + $jwtPath = '/etc/coolify/host-jwt'; + } + + $jwtPath = str_replace(["\r", "\n"], '', $jwtPath); + $token = str_replace(["\r", "\n"], '', $token); + + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); + if ($keyLocation === false) { + return false; + } + + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + $tokenArgument = escapeshellarg($token); + $jwtPathArgument = $this->shellPathArg($jwtPath); + $script = <</dev/null +\$SUDO chmod 600 {$jwtPathArgument} +SH; + + try { + $result = Process::timeout(30)->run([ + 'ssh', + '-o', 'BatchMode=yes', + '-o', 'LogLevel=ERROR', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'ConnectTimeout=10', + '-o', 'IdentitiesOnly=yes', + '-i', $keyLocation, + '-p', (string) $server->ssh_port, + "{$server->ssh_user}@{$server->host}", + $script, + ]); + + return $result->successful(); + } catch (\Throwable) { + return false; + } finally { + @unlink($keyLocation); + } + } + + private function shellPathArg(string $value): string + { + if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) { + return $value; + } + + return escapeshellarg($value); + } +} diff --git a/app/Actions/V5/Server/RemoveBootstrapMarker.php b/app/Actions/V5/Server/RemoveBootstrapMarker.php new file mode 100644 index 000000000..ba1a6e7e2 --- /dev/null +++ b/app/Actions/V5/Server/RemoveBootstrapMarker.php @@ -0,0 +1,75 @@ +revoke($server); + + $server->loadMissing('privateKey'); + + if (! $server->privateKey instanceof PrivateKey) { + return false; + } + + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); + if ($keyLocation === false) { + return false; + } + + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + $script = implode("\n", [ + "SUDO=''", + 'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi', + '$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf', + ]); + + try { + $result = Process::timeout(15)->run([ + 'ssh', + '-o', 'BatchMode=yes', + '-o', 'LogLevel=ERROR', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'ConnectTimeout=10', + '-o', 'IdentitiesOnly=yes', + '-i', $keyLocation, + '-p', (string) $server->ssh_port, + "{$server->ssh_user}@{$server->host}", + $script, + ]); + + return $result->successful(); + } catch (\Throwable) { + return false; + } finally { + @unlink($keyLocation); + } + } +} diff --git a/app/Actions/V5/Server/SyncDevLimaServers.php b/app/Actions/V5/Server/SyncDevLimaServers.php index 8bf98b2f0..1eb68feb1 100644 --- a/app/Actions/V5/Server/SyncDevLimaServers.php +++ b/app/Actions/V5/Server/SyncDevLimaServers.php @@ -2,6 +2,7 @@ namespace App\Actions\V5\Server; +use App\Enums\V5\ServerStatus; use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; @@ -9,6 +10,14 @@ use App\Models\V5\Cluster; use App\Models\V5\Server; use Lorisleiva\Actions\Concerns\AsAction; +/** + * Registers local Lima development VMs (provisioned by scripts/dev.sh) as + * cluster servers. They are intentionally seeded as Installed with + * last_bootstrapped_at already set but has_coold=false, so they skip the real + * bootstrap flow by design: V5BootstrapServerJob early-returns on a non-null + * last_bootstrapped_at, and V5ReconcileServersJob ignores them until + * something marks has_coold=true. + */ class SyncDevLimaServers { use AsAction; @@ -39,8 +48,6 @@ class SyncDevLimaServers 'description' => 'Local Lima development cluster managed by scripts/dev.sh.', ]); - $capabilities = []; - foreach ($servers as $server) { $wireguardManagementIp = $server['wireguard_management_ip'] ?? null; $values = [ @@ -49,8 +56,9 @@ class SyncDevLimaServers 'host' => $server['host'], 'ssh_user' => $server['ssh_user'], 'ssh_port' => $server['ssh_port'], - 'status' => 'installed', - 'capabilities' => $capabilities, + 'status' => ServerStatus::Installed->value, + 'has_coold' => false, + 'is_ingress' => false, 'builder_enabled' => false, 'builder_capacity' => 0, 'node_address' => $wireguardManagementIp ?: $server['host'], diff --git a/app/Console/Commands/V5FluxGenerateKeys.php b/app/Console/Commands/V5FluxGenerateKeys.php new file mode 100644 index 000000000..6f459ad59 --- /dev/null +++ b/app/Console/Commands/V5FluxGenerateKeys.php @@ -0,0 +1,135 @@ +error('Flux JWT key paths are not configured (flux.jwt_private_key_path / flux.jwt_public_key_path).'); + + return self::FAILURE; + } + + // Idempotent by default: re-running during provisioning must not clobber + // a live key (which would instantly invalidate every host token on + // disk). Refuse unless --force is passed, and exit SUCCESS so a + // provisioning script can call this unconditionally on every deploy. + if (File::exists($privateKeyPath) && ! $this->option('force')) { + $this->warn("A Flux JWT private key already exists at {$privateKeyPath}."); + $this->line('Refusing to overwrite it. Re-run with --force to replace it (this invalidates every host token currently on disk).'); + + return self::SUCCESS; + } + + // curve_name drives the actual EC key (P-256). private_key_bits is + // still validated by PHP's generic length check (>= 384) even though it + // is irrelevant to EC, so it must be present or openssl_pkey_new fails + // with "Private key length must be at least 384 bits, configured to 0". + $keyPair = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + 'private_key_bits' => 384, + ]); + + if ($keyPair === false) { + $this->error('Failed to generate an EC P-256 keypair: '.openssl_error_string()); + + return self::FAILURE; + } + + $privatePem = ''; + + if (! openssl_pkey_export($keyPair, $privatePem)) { + $this->error('Failed to export the private key PEM: '.openssl_error_string()); + + return self::FAILURE; + } + + $details = openssl_pkey_get_details($keyPair); + + if ($details === false || ! isset($details['key'])) { + $this->error('Failed to read the generated public key PEM.'); + + return self::FAILURE; + } + + $publicPem = (string) $details['key']; + + $this->writeKeyFile($privateKeyPath, $privatePem, 0600); + $this->writeKeyFile($publicKeyPath, $publicPem, 0644); + + // Self-check: the whole point of this command is that AgentTokenIssuer + // can mint with the key we just wrote. If the format were wrong (e.g. + // not a PEM EC private key Firebase\JWT accepts for ES256) this fails + // loudly here instead of silently at the first real host bootstrap. + try { + $token = $agentTokenIssuer->issue('flux-keygen-selfcheck'); + } catch (\Throwable $exception) { + $this->error('Generated a keypair but AgentTokenIssuer could not mint a token with it: '.$exception->getMessage()); + + return self::FAILURE; + } + + if (substr_count($token, '.') !== 2) { + $this->error('Generated key produced a malformed JWT (expected 3 segments).'); + + return self::FAILURE; + } + + $this->info('Generated a fresh ES256 (EC P-256) Flux keypair.'); + $this->line(" Private key (0600): {$privateKeyPath}"); + $this->line(" Public key (0644): {$publicKeyPath}"); + $this->newLine(); + $this->line('Provision the PUBLIC key to flux — flux verifies every host JWT with it.'); + $this->line('Keep the PRIVATE key secret and on the Laravel host only.'); + + if ($this->option('show-public')) { + $this->newLine(); + $this->line(rtrim($publicPem)); + } + + return self::SUCCESS; + } + + /** + * Write a key file with exact permissions, creating the parent directory at + * 0700 if missing. chmod is applied after the write because umask can + * loosen both the mkdir mode and the created file mode. + */ + private function writeKeyFile(string $path, string $contents, int $mode): void + { + $directory = dirname($path); + + if (! is_dir($directory)) { + File::makeDirectory($directory, 0700, true); + @chmod($directory, 0700); + } + + File::put($path, $contents); + @chmod($path, $mode); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6dc32383..f06bec45c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -15,6 +15,8 @@ use App\Jobs\RegenerateSslCertJob; use App\Jobs\ScheduledJobManager; use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; +use App\Jobs\V5ReconcileServersJob; +use App\Jobs\V5RotateAgentTokensJob; use App\Models\InstanceSettings; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -49,6 +51,14 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); + // V5 reconciliation loop: pull-based safety net for the push-only + // coold -> flux -> webhook status pipeline, plus container status pruning. + $this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); + + // V5 host JWT rotation: re-mints and SSH-pushes a fresh host token + // before the on-disk token expires so coold reconnects stay authorized. + $this->scheduleInstance->job(new V5RotateAgentTokensJob)->hourly()->withoutOverlapping()->onOneServer(); + if (isDev()) { // Instance Jobs $this->scheduleInstance->command('horizon:snapshot')->everyMinute(); diff --git a/app/Enums/V5/ApplicationStatus.php b/app/Enums/V5/ApplicationStatus.php new file mode 100644 index 000000000..66fafa555 --- /dev/null +++ b/app/Enums/V5/ApplicationStatus.php @@ -0,0 +1,28 @@ +applicationId !== null ? V5Application::query()->with(['server', 'domains'])->find($this->applicationId) : null; @@ -52,78 +60,14 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow : null; return [ - 'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null, + 'application' => $application instanceof V5Application ? $serializer->serializeApplication($application) : null, 'applications' => $applications - ->map(fn (V5Application $application) => $this->serializeApplication($application)) + ->map(fn (V5Application $application) => $serializer->serializeApplication($application)) ->values() ->all(), 'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress() - ? $this->serializeCaddyIngress($caddyIngress) + ? $serializer->serializeCaddyIngress($caddyIngress) : null, ]; } - - /** - * @return array - */ - private function serializeApplication(V5Application $application): array - { - $server = $application->server; - $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); - - return [ - 'id' => (string) $application->id, - 'name' => $application->name, - 'image' => $application->image, - 'containerName' => $application->container_name, - 'status' => $application->status, - 'statusMessage' => $application->status_message, - 'effectiveStatus' => $isServerReachable ? $application->status : 'unknown', - 'effectiveStatusMessage' => $isServerReachable - ? $application->status_message - : $this->serverStatusMessage($server), - 'runtimeContainerId' => $application->runtime_container_id, - 'serverName' => $server?->name, - 'serverStatus' => $server?->status, - 'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null, - 'isServerReachable' => $isServerReachable, - 'serverIngressEnabled' => (bool) $server?->isIngress(), - 'meshNamespace' => $application->mesh_namespace, - 'ingressEnabled' => $application->ingress_enabled, - 'internalPort' => $application->internal_port, - 'domains' => $application->domains->pluck('domain')->values()->all(), - 'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal', - 'canvasX' => $application->canvas_x, - 'canvasY' => $application->canvas_y, - ]; - } - - private function isServerReachable(V5Server $server): bool - { - return $server->status !== 'unreachable'; - } - - private function serverStatusMessage(?V5Server $server): ?string - { - return $server?->last_status_output ?: null; - } - - /** - * @return array - */ - private function serializeCaddyIngress(V5Server $server): array - { - $isServerReachable = $this->isServerReachable($server); - - return [ - 'id' => (string) $server->id, - 'name' => $server->name, - 'host' => $server->host, - 'type' => $server->ingressType(), - 'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable', - 'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server), - 'canvasX' => $server->canvas_x ?? -352, - 'canvasY' => $server->canvas_y ?? 0, - ]; - } } diff --git a/app/Events/V5ClusterUpdated.php b/app/Events/V5ClusterUpdated.php index a820ba6d4..a5c863cbb 100644 --- a/app/Events/V5ClusterUpdated.php +++ b/app/Events/V5ClusterUpdated.php @@ -3,17 +3,23 @@ namespace App\Events; use App\Models\V5\Cluster as V5Cluster; -use App\Models\V5\Server as V5Server; +use App\Support\V5\ClusterSerializer; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class V5ClusterUpdated implements ShouldBroadcastNow +class V5ClusterUpdated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; + /** + * Push the queued broadcast job only after the dispatching database + * transaction commits, so workers never serialize pre-commit state. + */ + public bool $afterCommit = true; + public function __construct(public int $teamId, public int $clusterId) {} public function broadcastOn(): array @@ -42,63 +48,7 @@ class V5ClusterUpdated implements ShouldBroadcastNow ->find($this->clusterId); return [ - 'cluster' => $cluster instanceof V5Cluster ? $this->serializeCluster($cluster) : null, - ]; - } - - /** - * @return array - */ - private function serializeCluster(V5Cluster $cluster): array - { - return [ - 'id' => (string) $cluster->id, - 'name' => $cluster->name, - 'description' => $cluster->description, - 'wireguardInterface' => $cluster->wireguard_interface, - 'wireguardManagementPool' => $cluster->wireguard_management_pool, - 'wireguardListenPort' => $cluster->wireguard_listen_port, - 'containerNetworkPool' => $cluster->container_network_pool, - 'containerNetworkPrefix' => $cluster->container_network_prefix, - 'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES, - 'defaultDenyContainers' => $cluster->default_deny_containers, - 'cooldVersion' => $cluster->coold_version, - 'corrosionVersion' => $cluster->corrosion_version, - 'corrosionGossipPort' => $cluster->corrosion_gossip_port, - 'corrosionApiPort' => $cluster->corrosion_api_port, - 'builderEnabled' => $cluster->builder_enabled, - 'builderCapacity' => $cluster->builder_capacity, - 'builderCpuQuota' => $cluster->builder_cpu_quota, - 'builderMemoryMax' => $cluster->builder_memory_max, - 'builderTimeoutSecs' => $cluster->builder_timeout_secs, - 'lastCliAction' => $cluster->last_cli_action, - 'lastCliStatus' => $cluster->last_cli_status, - 'lastCliSummary' => $cluster->last_cli_summary, - 'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(), - 'serversCount' => $cluster->servers_count ?? $cluster->servers->count(), - 'servers' => $cluster->servers->map(fn (V5Server $server) => [ - 'id' => (string) $server->id, - 'name' => $server->name, - 'host' => $server->host, - 'status' => $server->status, - 'capabilities' => $server->capabilities ?? [], - 'builderEnabled' => $server->builder_enabled, - 'builderCapacity' => $server->builder_capacity, - 'builderCpuQuota' => $server->builder_cpu_quota, - 'uuid' => $server->uuid, - 'nodeAddress' => $server->node_address, - 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, - 'wireguardEndpointOverride' => $server->wireguard_endpoint_override, - 'wireguardManagementIp' => $server->wireguard_management_ip, - 'wireguardPublicKey' => $server->wireguard_public_key, - 'containerSubnets' => $server->container_subnets ?? [], - 'privateKeyName' => $server->privateKey?->name, - 'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(), - 'lastBootstrapAction' => $server->last_bootstrap_action, - 'lastBootstrapStatus' => $server->last_bootstrap_status, - 'lastBootstrapOutput' => $server->last_bootstrap_output, - 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), - ])->all(), + 'cluster' => $cluster instanceof V5Cluster ? app(ClusterSerializer::class)->serialize($cluster) : null, ]; } } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 58f21c793..4b4df4971 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -69,8 +69,9 @@ class Handler extends ExceptionHandler */ public function render($request, Throwable $e) { - // Handle authorization exceptions for API routes - if ($e instanceof AuthorizationException) { + // Handle authorization exceptions for API routes. Exceptions carrying + // an explicit status (e.g. denyAsNotFound) keep it via parent::render. + if ($e instanceof AuthorizationException && ! $e->hasStatus()) { if ($request->is('api/*') || $request->expectsJson()) { if ($request->is('api/*')) { auditLog('api.auth.policy_denied', [ diff --git a/app/Exceptions/V5/UnsupportedCooldVerb.php b/app/Exceptions/V5/UnsupportedCooldVerb.php new file mode 100644 index 000000000..57a86dec5 --- /dev/null +++ b/app/Exceptions/V5/UnsupportedCooldVerb.php @@ -0,0 +1,18 @@ +bearerToken())) { + if (! $this->authorizedBearer($request)) { abort(401); } @@ -40,6 +38,7 @@ class FluxResourceStatusController extends Controller 'state' => ['required_without:status', 'string', 'max:64'], 'status_message' => ['nullable', 'string', 'max:1000'], 'message' => ['nullable', 'string', 'max:1000'], + 'observed_at' => ['nullable', 'string', 'date'], ])->validate(); $resource = ApplyFluxResourceStatusUpdate::run($validated); @@ -60,4 +59,53 @@ class FluxResourceStatusController extends Controller 'message' => 'Resource status updated.', ]); } + + /** + * Constant-time match the presented bearer token against every accepted + * inbound token. Accepting an array (config('flux.laravel_api_tokens'), + * falling back to the single config('flux.laravel_api_token')) lets an + * operator rotate by serving old+new tokens simultaneously. + * + * SECURITY: this is still a shared global secret — every flux instance + * presents the same token, so it cannot be scoped or revoked per-flux, and + * a leak forces a fleet-wide rotation. The target design is per-flux, + * individually rotatable tokens; until then the array support above is the + * mitigation that makes rotation possible without downtime. + */ + private function authorizedBearer(Request $request): bool + { + $presented = (string) $request->bearerToken(); + + if ($presented === '') { + return false; + } + + foreach ($this->acceptedTokens() as $token) { + if (hash_equals($token, $presented)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function acceptedTokens(): array + { + $tokens = config('flux.laravel_api_tokens', []); + $tokens = is_array($tokens) ? $tokens : []; + + $single = config('flux.laravel_api_token'); + + if (is_string($single) && $single !== '') { + $tokens[] = $single; + } + + return array_values(array_filter( + array_map(fn ($token): string => is_string($token) ? $token : '', $tokens), + fn (string $token): bool => $token !== '' + )); + } } diff --git a/app/Http/Controllers/V5/ApplicationController.php b/app/Http/Controllers/V5/ApplicationController.php new file mode 100644 index 000000000..8d11cd6b6 --- /dev/null +++ b/app/Http/Controllers/V5/ApplicationController.php @@ -0,0 +1,635 @@ +currentTeamOrFail($request); + $projects = $this->projects($currentTeam); + [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); + + if ($selectedProject === null || $selectedEnvironment === null) { + return response()->json([ + 'message' => 'Select a project and environment before deploying nginx.', + ], 422); + } + + $project = $this->projectQuery($currentTeam) + ->where('uuid', $selectedProject['uuid']) + ->first(); + + if (! $project instanceof Project) { + abort(403); + } + + $environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']); + + if (! $environment instanceof Environment) { + abort(403); + } + + $validated = $request->validate([ + 'server_uuid' => ['nullable', 'string', 'max:255'], + 'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'], + ]); + $image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE; + + $server = V5Server::query() + ->where('team_id', $currentTeam->id) + ->when( + isset($validated['server_uuid']), + fn (Builder $query) => $query->where('uuid', $validated['server_uuid']), + fn (Builder $query) => $query + ->orderByRaw('last_bootstrapped_at is null') + ->orderBy('name') + ) + ->first(); + + if (! $server instanceof V5Server) { + return response()->json([ + 'message' => 'Add a v5 server before deploying nginx.', + ], 422); + } + + if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) { + return response()->json([ + 'message' => "Bootstrap server {$server->name} before deploying to it.", + ], 422); + } + + $canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment); + + $application = V5Application::query()->create([ + 'team_id' => $currentTeam->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $request->user()->id, + 'name' => 'nginx-test', + 'image' => $image, + 'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()), + 'status' => ApplicationStatus::Creating->value, + 'status_message' => 'Starting nginx container.', + 'mesh_namespace' => 'default', + 'canvas_x' => $canvasPosition['canvas_x'], + 'canvas_y' => $canvasPosition['canvas_y'], + ]); + + V5DeployApplicationJob::dispatch($application->id); + + return response()->json([ + 'application' => $this->serializeApplication($application), + ], 202); + } + + public function refresh(Request $request, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $projects = $this->projects($currentTeam); + [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); + + if ($selectedProject === null || $selectedEnvironment === null) { + return response()->json([ + 'message' => 'Select a project and environment before refreshing applications.', + ], 422); + } + + $applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) + ->with('server') + ->get(); + $errors = []; + + $applications + ->groupBy('server_id') + ->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void { + /** @var V5Application|null $firstApplication */ + $firstApplication = $serverApplications->first(); + $server = $firstApplication?->server; + $hostId = $server?->fluxHostId(); + + if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') { + $errors[] = 'A server is missing its Flux host id.'; + + return; + } + + // The moment we query coold is the observation time for the rows + // this refresh writes, so a fresher webhook always wins the + // status_observed_at watermark and is never clobbered. + $observedAt = CarbonImmutable::now(); + + try { + $containers = collect($fluxClient->listContainers($hostId)); + } catch (\Throwable $e) { + $errors[] = $e->getMessage(); + + return; + } + + $serverApplications->each(function (V5Application $application) use ($containers, $observedAt): void { + $container = $containers->first(function (array $container) use ($application): bool { + return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id) + || ($container['name'] ?? null) === $application->container_name; + }); + + if (! is_array($container)) { + // A creating application without a container id simply has + // not materialized yet; the deploy job will settle it. + if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) { + return; + } + + if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { + return; + } + + $application->update([ + 'status' => ApplicationStatus::Exited->value, + 'status_message' => 'Container not found on server.', + 'status_observed_at' => $observedAt, + ]); + + return; + } + + if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { + return; + } + + $rawState = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null; + + $application->update([ + 'status' => StatusObservation::normalize($rawState, ApplicationStatus::class) ?? ApplicationStatus::Unknown->value, + 'status_message' => 'Container state refreshed from coold.', + 'status_observed_at' => $observedAt, + 'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id, + ]); + }); + }); + + V5Server::query() + ->where('team_id', $currentTeam->id) + ->orderBy('name') + ->get() + ->filter(fn (V5Server $server) => $server->isIngress()) + ->each(function (V5Server $server) use ($fluxClient, &$errors): void { + $hostId = $server->fluxHostId(); + + if (! is_string($hostId) || $hostId === '') { + $errors[] = "Caddy ingress server {$server->name} is missing its Flux host id."; + + return; + } + + try { + $containers = collect($fluxClient->listContainers($hostId)); + } catch (\Throwable $e) { + $errors[] = $e->getMessage(); + + return; + } + + $container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy'); + $rawState = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null; + $state = $rawState !== null + ? (StatusObservation::normalize($rawState, IngressStatus::class) ?? IngressStatus::Unknown->value) + : IngressStatus::Exited->value; + + $server->update([ + 'ingress_type' => 'caddy', + 'ingress_status' => $state, + 'last_status_check' => 'flux', + 'last_status_output' => 'Caddy ingress state refreshed from coold.', + 'last_status_checked_at' => now(), + ]); + }); + + return response()->json([ + 'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) + ->with('server') + ->orderBy('created_at') + ->get() + ->map(fn (V5Application $application) => $this->serializeApplication($application)) + ->all(), + 'caddyIngresses' => $this->caddyIngresses($currentTeam), + 'errors' => $errors, + ]); + } + + public function logs(Request $request, V5Application $application): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('view', [$application, $currentTeam]); + + $application->loadMissing('server'); + $server = $application->server; + $hostId = $server?->fluxHostId(); + $containerId = $application->runtime_container_id; + + $logs = null; + $logsError = null; + + // A container id only appears once the deploy actually created one; a + // deploy that failed before that (e.g. host not connected) has none, so + // there is nothing to fetch and the frontend just shows the status. + if (is_string($containerId) && $containerId !== '' && $server instanceof V5Server && $server->status !== ServerStatus::Unreachable->value && is_string($hostId) && $hostId !== '') { + try { + $logs = app(FluxClient::class)->containerLogs($hostId, $containerId); + } catch (UnsupportedCooldVerb $exception) { + $logsError = "This node's coold does not support container logs."; + } catch (\RuntimeException $exception) { + Log::warning('V5 application container logs request failed', [ + 'application_id' => $application->id, + 'message' => $exception->getMessage(), + ]); + $logsError = 'Could not fetch container logs through Flux. Check the Flux and coold status, then try again.'; + } + } + + return response()->json([ + 'status' => $application->status, + 'statusMessage' => $application->status_message, + 'containerId' => $containerId, + 'logs' => $logs, + 'logsError' => $logsError, + ]); + } + + public function updatePosition(Request $request, V5Application $application): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('update', [$application, $currentTeam]); + + $validated = $request->validate([ + 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], + 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], + ]); + + $application->update([ + 'canvas_x' => $validated['canvas_x'], + 'canvas_y' => $validated['canvas_y'], + ]); + + return response()->json([ + 'application' => $this->serializeApplication($application->refresh()->load('server')), + ]); + } + + public function updateIngress(Request $request, V5Application $application): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('updateIngress', [$application, $currentTeam]); + + $validated = $request->validate([ + 'ingress_enabled' => ['required', 'boolean'], + 'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'], + 'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname], + ]); + + $application->loadMissing('server'); + + if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) { + return response()->json([ + 'message' => 'Enable ingress on the server before enabling app ingress.', + ], 422); + } + + if ($validated['ingress_enabled'] && array_key_exists('domains', $validated)) { + $conflict = $this->conflictingApplicationDomain($application, $validated['domains']); + + if ($conflict instanceof V5ApplicationDomain) { + return response()->json([ + 'message' => "The domain {$conflict->domain} is already used by application \"{$conflict->application?->name}\" on this server.", + ], 422); + } + } + + $originalAttributes = $application->only(['ingress_enabled', 'internal_port']); + $originalDomains = $application->domains()->pluck('domain')->all(); + + DB::transaction(function () use ($application, $validated): void { + $application->update([ + 'ingress_enabled' => $validated['ingress_enabled'], + 'internal_port' => $validated['internal_port'] ?? null, + ]); + + if (array_key_exists('domains', $validated)) { + $application->domains()->delete(); + + collect($validated['domains']) + ->map(fn (string $domain) => trim($domain)) + ->filter() + ->unique() + ->each(fn (string $domain) => V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => $domain, + ])); + } + }); + + $application->refresh()->load(['server', 'domains']); + + if ($application->server?->isIngress() && $application->server->status === ServerStatus::Installed->value) { + try { + StartCaddyIngress::run($application->server); + } catch (\RuntimeException $exception) { + $this->restoreApplicationIngress($application, $originalAttributes, $originalDomains); + + return $this->ingressSyncErrorResponse($exception); + } + } + + return response()->json([ + 'application' => $this->serializeApplication($application), + ]); + } + + public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('updateCanvasPosition', [$server, $currentTeam]); + + $validated = $request->validate([ + 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], + 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], + ]); + + $server->update([ + 'canvas_x' => $validated['canvas_x'], + 'canvas_y' => $validated['canvas_y'], + ]); + + return response()->json([ + 'caddyIngress' => $this->serializeCaddyIngress($server->refresh()), + ]); + } + + public function destroy(Request $request, V5Application $application, FluxClient $fluxClient): Response|JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('delete', [$application, $currentTeam]); + + $application->loadMissing(['server', 'domains']); + $server = $application->server; + $connections = $this->applicationResourceConnections($application); + + if ($request->boolean('delete_locally')) { + $this->deleteApplicationLocally($application, $connections); + + return response()->noContent(); + } + $oldFirewallRules = $connections + ->flatMap(function (ResourceConnection $connection): Collection { + // Deletion must never be blocked by an endpoint that already lost + // its server; those rules can no longer be revoked anyway. + try { + return $this->firewallSync->rulesFor($connection->load('rules')); + } catch (\RuntimeException $exception) { + report($exception); + + return collect(); + } + }); + $originalIngressAttributes = null; + $originalIngressDomains = []; + $ingressConfigurationChanged = false; + + try { + $this->firewallSync->sync($fluxClient, $oldFirewallRules, collect()); + } catch (\RuntimeException $exception) { + report($exception); + + return response()->json([ + 'message' => 'Could not sync firewall rules through Flux.', + 'detail' => $exception->getMessage(), + ], 502); + } + + if ($server instanceof V5Server && $server->isIngress() && $server->status === ServerStatus::Installed->value && $application->ingress_enabled) { + $originalIngressAttributes = $application->only(['ingress_enabled', 'internal_port']); + $originalIngressDomains = $application->domains()->pluck('domain')->all(); + + DB::transaction(function () use ($application): void { + $application->update([ + 'ingress_enabled' => false, + 'internal_port' => null, + ]); + $application->domains()->delete(); + }); + + try { + StartCaddyIngress::run($server); + $ingressConfigurationChanged = true; + } catch (\RuntimeException $exception) { + $this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains); + + return $this->ingressSyncErrorResponse($exception); + } + } + + $error = DestroyNginxApplication::run($application); + + if ($error !== null) { + if ($originalIngressAttributes !== null) { + $this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains); + + if ($ingressConfigurationChanged && $server instanceof V5Server) { + try { + StartCaddyIngress::run($server); + } catch (\RuntimeException $exception) { + report($exception); + } + } + } + + try { + $this->firewallSync->sync($fluxClient, collect(), $oldFirewallRules); + } catch (\RuntimeException $exception) { + report($exception); + } + + return response()->json([ + 'message' => $error, + 'can_delete_locally' => true, + ], 422); + } + + $this->deleteApplicationLocally($application, $connections); + + return response()->noContent(); + } + + /** + * @param Collection $connections + */ + private function deleteApplicationLocally(V5Application $application, Collection $connections): void + { + DB::transaction(function () use ($application, $connections): void { + $connections->each(function (ResourceConnection $connection): void { + $connection->rules()->delete(); + $connection->delete(); + }); + + $application->delete(); + }); + } + + /** + * @return Collection + */ + private function applicationResourceConnections(V5Application $application): Collection + { + return ResourceConnection::query() + ->where('team_id', $application->team_id) + ->where(function (Builder $query) use ($application): void { + $query + ->where(function (Builder $query) use ($application): void { + $query + ->where('resource_one_type', $application->getMorphClass()) + ->where('resource_one_id', $application->id); + }) + ->orWhere(function (Builder $query) use ($application): void { + $query + ->where('resource_two_type', $application->getMorphClass()) + ->where('resource_two_id', $application->id); + }); + }) + ->with('rules') + ->get(); + } + + /** + * @return array{canvas_x: int, canvas_y: int} + */ + private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array + { + $existingApplications = V5Application::query() + ->where('team_id', $currentTeam->id) + ->where('project_id', $project->id) + ->where('environment_id', $environment->id) + ->get(['canvas_x', 'canvas_y']); + + $horizontalStep = CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP; + $verticalStep = CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP; + + for ($row = 0; $row < 100; $row++) { + for ($column = 0; $column < 100; $column++) { + $candidate = [ + 'canvas_x' => $column * $horizontalStep, + 'canvas_y' => $row * $verticalStep, + ]; + + if (! $this->canvasPositionCollides($candidate, $existingApplications)) { + return $candidate; + } + } + } + + return [ + 'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep, + 'canvas_y' => 0, + ]; + } + + /** + * @param array{canvas_x: int, canvas_y: int} $candidate + * @param Collection $existingApplications + */ + private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool + { + return $existingApplications->contains(function (V5Application $application) use ($candidate) { + return abs($candidate['canvas_x'] - $application->canvas_x) < CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP + && abs($candidate['canvas_y'] - $application->canvas_y) < CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP; + }); + } + + /** + * @param array $domains + */ + private function conflictingApplicationDomain(V5Application $application, array $domains): ?V5ApplicationDomain + { + $normalizedDomains = collect($domains) + ->map(fn (string $domain) => Str::lower(trim($domain))) + ->filter() + ->values(); + + if ($normalizedDomains->isEmpty()) { + return null; + } + + return V5ApplicationDomain::query() + ->whereIn(DB::raw('LOWER(domain)'), $normalizedDomains->all()) + ->whereHas('application', fn (Builder $query) => $query + ->where('server_id', $application->server_id) + ->whereKeyNot($application->id) + ->where('ingress_enabled', true)) + ->with('application:id,name') + ->first(); + } + + /** + * @param array $attributes + * @param array $domains + */ + private function restoreApplicationIngress(V5Application $application, array $attributes, array $domains): void + { + DB::transaction(function () use ($application, $attributes, $domains): void { + $application->update($attributes); + $application->domains()->delete(); + + foreach ($domains as $domain) { + V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => $domain, + ]); + } + }); + } +} diff --git a/app/Http/Controllers/V5/ClusterController.php b/app/Http/Controllers/V5/ClusterController.php new file mode 100644 index 000000000..5477a893c --- /dev/null +++ b/app/Http/Controllers/V5/ClusterController.php @@ -0,0 +1,207 @@ +attributes->get('v5.currentTeam'); + $projects = $this->projects($currentTeam); + [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); + + return Inertia::render('Clusters', [ + 'currentTeam' => $this->serializeCurrentTeam($currentTeam), + 'flux' => $fluxHealth->check(), + 'clusters' => $this->clusters($currentTeam), + 'privateKeys' => $this->privateKeys($currentTeam), + 'projects' => $projects, + 'selectedProjectUuid' => $selectedProject['uuid'] ?? null, + 'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null, + ]); + } + + public function show(Request $request, V5Cluster $cluster): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('view', [$cluster, $currentTeam]); + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + ]); + } + + public function store(Request $request): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('create', [V5Cluster::class, $currentTeam]); + + $validated = $request->validate([ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id), + ], + 'description' => ['nullable', 'string', 'max:1000'], + 'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'], + 'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], + 'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], + 'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'], + 'namespaces' => ['sometimes', 'array', 'min:1'], + 'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'], + 'default_deny_containers' => ['sometimes', 'boolean'], + 'coold_version' => ['sometimes', 'string', 'max:64'], + 'corrosion_version' => ['sometimes', 'string', 'max:64'], + 'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'builder_enabled' => ['sometimes', 'boolean'], + 'builder_capacity' => $this->builderCapacityRules( + $this->requestedBuilderEnabled($request, true) + ), + 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], + 'builder_memory_max' => ['sometimes', 'string', 'max:32'], + 'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'], + ]); + + $cluster = V5Cluster::query()->create([ + ...$this->defaultClusterConfiguration(), + ...collect($validated)->except(['name', 'description'])->all(), + 'team_id' => $currentTeam->id, + 'created_by_user_id' => $request->user()->id, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + ], 201); + } + + public function destroy(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('delete', [$cluster, $currentTeam]); + + if ($cluster->servers()->exists()) { + return response()->json([ + 'message' => 'Only empty clusters can be deleted.', + ], 422); + } + + $cluster->delete(); + + return response()->noContent(); + } + + /** + * @return array> + */ + private function clusters(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + $serializer = app(ClusterSerializer::class); + + return V5Cluster::query() + ->where('team_id', $currentTeam->id) + ->with(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]) + ->withCount('servers') + ->orderBy('name') + ->get() + ->map(fn (V5Cluster $cluster) => $serializer->serialize($cluster)) + ->all(); + } + + /** + * @return array + */ + private function privateKeys(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->where('is_git_related', false) + ->orderBy('name') + ->get(['id', 'uuid', 'name']) + ->map(fn (PrivateKey $privateKey) => [ + 'id' => $privateKey->uuid, + 'name' => $privateKey->name, + ]) + ->all(); + } + + /** + * @return array + */ + private function defaultClusterConfiguration(): array + { + return [ + 'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE, + 'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL, + 'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT, + 'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL, + 'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX, + 'namespaces' => V5Cluster::DEFAULT_NAMESPACES, + 'default_deny_containers' => true, + 'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION, + 'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION, + 'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT, + 'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT, + 'builder_enabled' => true, + 'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY, + 'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA, + 'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX, + 'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS, + ]; + } + + private function ipv4CidrRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || ! str_contains($value, '/')) { + $fail('The :attribute must be a valid IPv4 CIDR range.'); + + return; + } + + [$ip, $prefix] = explode('/', $value, 2); + + if ( + filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false + || ! ctype_digit($prefix) + || (int) $prefix < 0 + || (int) $prefix > 32 + ) { + $fail('The :attribute must be a valid IPv4 CIDR range.'); + } + }; + } +} diff --git a/app/Http/Controllers/V5/Concerns/HandlesIngressSyncErrors.php b/app/Http/Controllers/V5/Concerns/HandlesIngressSyncErrors.php new file mode 100644 index 000000000..94ccc1b52 --- /dev/null +++ b/app/Http/Controllers/V5/Concerns/HandlesIngressSyncErrors.php @@ -0,0 +1,40 @@ +json([ + 'message' => $this->friendlyIngressSyncError($exception->getMessage()), + 'detail' => $exception->getMessage(), + ], 502); + } + + protected function friendlyIngressSyncError(string $message): string + { + $normalized = Str::lower($message); + + if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) { + return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.'; + } + + if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) { + return 'coold did not respond in time. Check that the server agent is running and connected to Flux.'; + } + + if (str_contains($normalized, 'validate caddyfile')) { + return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.'; + } + + if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) { + return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.'; + } + + return 'Could not update ingress. Check Flux and coold logs, then try again.'; + } +} diff --git a/app/Http/Controllers/V5/Concerns/ResolvesCurrentTeam.php b/app/Http/Controllers/V5/Concerns/ResolvesCurrentTeam.php new file mode 100644 index 000000000..915cf373b --- /dev/null +++ b/app/Http/Controllers/V5/Concerns/ResolvesCurrentTeam.php @@ -0,0 +1,22 @@ +attributes->get('v5.currentTeam'); + + abort_unless($currentTeam instanceof Team, 404); + + return $currentTeam; + } +} diff --git a/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php b/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php new file mode 100644 index 000000000..1d48eff33 --- /dev/null +++ b/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php @@ -0,0 +1,115 @@ + $currentTeam->id, + ]; + } + + /** + * @param array}> $projects + * @return array{0: array{uuid: string, name: string, environments: array}|null, 1: array{uuid: string, name: string}|null} + */ + protected function selectedProjectAndEnvironment(Request $request, array $projects): array + { + $selectedProjectUuid = $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY); + $selectedEnvironmentUuid = $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY); + $selectedProject = null; + + foreach ($projects as $project) { + if ($project['uuid'] === $selectedProjectUuid) { + $selectedProject = $project; + + break; + } + } + + $selectedProject ??= $projects[0] ?? null; + $selectedEnvironment = null; + + foreach ($selectedProject['environments'] ?? [] as $environment) { + if ($environment['uuid'] === $selectedEnvironmentUuid) { + $selectedEnvironment = $environment; + + break; + } + } + + $selectedEnvironment ??= $selectedProject['environments'][0] ?? null; + + return [$selectedProject, $selectedEnvironment]; + } + + protected function selectedEnvironment(Project $project, ?string $environmentUuid): ?Environment + { + if ($environmentUuid === null) { + return $project->environments->first(); + } + + $environment = $project->environments->firstWhere('uuid', $environmentUuid); + + if (! $environment instanceof Environment) { + abort(422, 'The selected environment is not available for the selected project.'); + } + + return $environment; + } + + /** + * @return array}> + */ + protected function projects(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return $this->projectQuery($currentTeam) + ->get() + ->map(fn (Project $project) => [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'environments' => $project->environments + ->map(fn ($environment) => [ + 'uuid' => $environment->uuid, + 'name' => $environment->name, + ]) + ->all(), + ]) + ->all(); + } + + protected function projectQuery(Team $currentTeam): Builder + { + return Project::query() + ->select(['id', 'uuid', 'name', 'team_id']) + ->where('team_id', $currentTeam->id) + ->with(['environments' => fn ($query) => $query + ->select(['id', 'uuid', 'name', 'project_id']) + ->orderByRaw("CASE WHEN LOWER(name) = 'production' THEN 0 ELSE 1 END") + ->orderByRaw('LOWER(name)')]) + ->orderByRaw('LOWER(name)'); + } +} diff --git a/app/Http/Controllers/V5/Concerns/SerializesCanvasResources.php b/app/Http/Controllers/V5/Concerns/SerializesCanvasResources.php new file mode 100644 index 000000000..5dc02e800 --- /dev/null +++ b/app/Http/Controllers/V5/Concerns/SerializesCanvasResources.php @@ -0,0 +1,63 @@ + + */ + protected function serializeApplication(V5Application $application): array + { + return app(CanvasResourceSerializer::class)->serializeApplication($application); + } + + /** + * @return array + */ + protected function serializeCaddyIngress(V5Server $server, int $index = 0): array + { + return app(CanvasResourceSerializer::class)->serializeCaddyIngress($server, $index); + } + + /** + * @return array> + */ + protected function caddyIngresses(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return V5Server::query() + ->where('team_id', $currentTeam->id) + ->orderBy('name') + ->get() + ->filter(fn (V5Server $server) => $server->isIngress()) + ->values() + ->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index)) + ->all(); + } + + /** + * @param array{uuid: string} $selectedProject + * @param array{uuid: string} $selectedEnvironment + * @return Builder + */ + protected function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder + { + return V5Application::query() + ->where('team_id', $currentTeam->id) + ->whereHas('project', fn (Builder $query) => $query + ->where('team_id', $currentTeam->id) + ->where('uuid', $selectedProject['uuid'])) + ->whereHas('environment', fn (Builder $query) => $query + ->where('uuid', $selectedEnvironment['uuid'])); + } +} diff --git a/app/Http/Controllers/V5/Concerns/ValidatesBuilderConfiguration.php b/app/Http/Controllers/V5/Concerns/ValidatesBuilderConfiguration.php new file mode 100644 index 000000000..ff97c272f --- /dev/null +++ b/app/Http/Controllers/V5/Concerns/ValidatesBuilderConfiguration.php @@ -0,0 +1,30 @@ + + */ + protected function builderCapacityRules(bool $builderEnabled, bool $required = false): array + { + return [ + $required ? 'required' : 'sometimes', + 'integer', + $builderEnabled ? 'min:1' : 'min:0', + 'max:1000', + ]; + } + + protected function requestedBuilderEnabled(Request $request, bool $default): bool + { + if (! $request->has('builder_enabled')) { + return $default; + } + + return $request->boolean('builder_enabled'); + } +} diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index d46a2ad04..9f5e26736 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -2,51 +2,31 @@ namespace App\Http\Controllers\V5; -use App\Actions\V5\Application\DeployNginxApplication; -use App\Actions\V5\Application\DestroyNginxApplication; -use App\Actions\V5\Proxy\StartCaddyIngress; -use App\Actions\V5\Proxy\StopCaddyIngress; -use App\Events\V5ClusterUpdated; use App\Events\V5RealtimeTestEvent; use App\Http\Controllers\Controller; -use App\Jobs\V5BootstrapServerJob; -use App\Models\Environment; -use App\Models\PrivateKey; +use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam; +use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection; +use App\Http\Controllers\V5\Concerns\SerializesCanvasResources; use App\Models\Project; use App\Models\Team; use App\Models\V5\Application as V5Application; -use App\Models\V5\ApplicationDomain as V5ApplicationDomain; -use App\Models\V5\Cluster as V5Cluster; use App\Models\V5\ResourceConnection; use App\Models\V5\Server as V5Server; -use App\Rules\ValidHostname; -use App\Services\Flux\FluxClient; use App\Services\Flux\FluxHealth; +use App\Support\V5\ResourceConnectionSerializer; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Process; -use Illuminate\Support\Str; -use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { - private const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine'; + use ResolvesCurrentTeam; + use ResolvesProjectSelection; + use SerializesCanvasResources; - private const CANVAS_CARD_WIDTH = 320; - - private const CANVAS_CARD_HEIGHT = 144; - - private const CANVAS_CARD_GAP = 32; - - private const SELECTED_PROJECT_SESSION_KEY = 'v5.selectedProjectUuid'; - - private const SELECTED_ENVIRONMENT_SESSION_KEY = 'v5.selectedEnvironmentUuid'; + public function __construct(private readonly ResourceConnectionSerializer $connectionSerializer) {} public function __invoke(Request $request, FluxHealth $fluxHealth): Response { @@ -67,43 +47,9 @@ class DashboardController extends Controller ]); } - public function clustersIndex(Request $request, FluxHealth $fluxHealth): Response - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - $projects = $this->projects($currentTeam); - [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); - - return Inertia::render('Clusters', [ - 'currentTeam' => $this->serializeCurrentTeam($currentTeam), - 'flux' => $fluxHealth->check(), - 'clusters' => $this->clusters($currentTeam), - 'privateKeys' => $this->privateKeys($currentTeam), - 'projects' => $projects, - 'selectedProjectUuid' => $selectedProject['uuid'] ?? null, - 'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null, - ]); - } - - public function showCluster(Request $request, V5Cluster $cluster): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { - abort(404); - } - - return response()->json([ - 'cluster' => $this->freshSerializedCluster($cluster), - ]); - } - public function realtimeTest(Request $request): Response { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } + $currentTeam = $this->currentTeamOrFail($request); return Inertia::render('RealtimeTest', [ 'currentTeam' => [ @@ -114,11 +60,7 @@ class DashboardController extends Controller public function broadcastRealtimeTest(Request $request): JsonResponse { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } + $currentTeam = $this->currentTeamOrFail($request); $validated = $request->validate([ 'message' => ['nullable', 'string', 'max:255'], @@ -136,11 +78,7 @@ class DashboardController extends Controller public function updateSelection(Request $request): \Illuminate\Http\Response { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } + $currentTeam = $this->currentTeamOrFail($request); $validated = $request->validate([ 'project_uuid' => ['required', 'string'], @@ -165,1221 +103,6 @@ class DashboardController extends Controller return response()->noContent(); } - public function storeNginxApplication(Request $request): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } - - $projects = $this->projects($currentTeam); - [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); - - if ($selectedProject === null || $selectedEnvironment === null) { - return response()->json([ - 'message' => 'Select a project and environment before deploying nginx.', - ], 422); - } - - $project = $this->projectQuery($currentTeam) - ->where('uuid', $selectedProject['uuid']) - ->first(); - - if (! $project instanceof Project) { - abort(403); - } - - $environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']); - - if (! $environment instanceof Environment) { - abort(403); - } - - $validated = $request->validate([ - 'server_uuid' => ['nullable', 'string', 'max:255'], - 'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'], - ]); - $image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE; - - $server = V5Server::query() - ->where('team_id', $currentTeam->id) - ->when( - isset($validated['server_uuid']), - fn (Builder $query) => $query->where('uuid', $validated['server_uuid']), - fn (Builder $query) => $query - ->orderByRaw('last_bootstrapped_at is null') - ->orderBy('name') - ) - ->first(); - - if (! $server instanceof V5Server) { - return response()->json([ - 'message' => 'Add a v5 server before deploying nginx.', - ], 422); - } - - $canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment); - - $application = V5Application::query()->create([ - 'team_id' => $currentTeam->id, - 'project_id' => $project->id, - 'environment_id' => $environment->id, - 'server_id' => $server->id, - 'created_by_user_id' => $request->user()->id, - 'name' => 'nginx-test', - 'image' => $image, - 'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()), - 'status' => 'creating', - 'status_message' => 'Starting nginx container.', - 'mesh_namespace' => 'default', - 'canvas_x' => $canvasPosition['canvas_x'], - 'canvas_y' => $canvasPosition['canvas_y'], - ]); - - $application = DeployNginxApplication::run($application); - - return response()->json([ - 'application' => $this->serializeApplication($application), - ], $application->status === 'running' ? 201 : 422); - } - - public function refreshApplications(Request $request, FluxClient $fluxClient): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } - - $projects = $this->projects($currentTeam); - [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); - - if ($selectedProject === null || $selectedEnvironment === null) { - return response()->json([ - 'message' => 'Select a project and environment before refreshing applications.', - ], 422); - } - - $applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) - ->with('server') - ->get(); - $errors = []; - - $applications - ->groupBy('server_id') - ->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void { - /** @var V5Application|null $firstApplication */ - $firstApplication = $serverApplications->first(); - $server = $firstApplication?->server; - $hostId = $server?->wireguard_management_ip ?: $server?->node_address; - - if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') { - $errors[] = 'A server is missing its Flux host id.'; - - return; - } - - try { - $containers = collect($fluxClient->listContainers($hostId)); - } catch (\Throwable $e) { - $errors[] = $e->getMessage(); - - return; - } - - $serverApplications->each(function (V5Application $application) use ($containers): void { - $container = $containers->first(function (array $container) use ($application): bool { - return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id) - || ($container['name'] ?? null) === $application->container_name; - }); - - if (! is_array($container)) { - $application->update([ - 'status' => 'exited', - 'status_message' => 'Container not found on server.', - ]); - - return; - } - - $state = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : 'unknown'; - - $application->update([ - 'status' => strtolower($state), - 'status_message' => 'Container state refreshed from coold.', - 'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id, - ]); - }); - }); - - V5Server::query() - ->where('team_id', $currentTeam->id) - ->orderBy('name') - ->get() - ->filter(fn (V5Server $server) => $server->isIngress()) - ->each(function (V5Server $server) use ($fluxClient, &$errors): void { - $hostId = $server->wireguard_management_ip ?: $server->node_address; - - if (! is_string($hostId) || $hostId === '') { - $errors[] = "Caddy ingress server {$server->name} is missing its Flux host id."; - - return; - } - - try { - $containers = collect($fluxClient->listContainers($hostId)); - } catch (\Throwable $e) { - $errors[] = $e->getMessage(); - - return; - } - - $container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy'); - $state = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== '' - ? strtolower($container['state']) - : 'exited'; - - $server->update([ - 'ingress_type' => 'caddy', - 'ingress_status' => $state, - 'last_status_check' => 'flux', - 'last_status_output' => 'Caddy ingress state refreshed from coold.', - 'last_status_checked_at' => now(), - ]); - }); - - return response()->json([ - 'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) - ->with('server') - ->orderBy('created_at') - ->get() - ->map(fn (V5Application $application) => $this->serializeApplication($application)) - ->all(), - 'caddyIngresses' => $this->caddyIngresses($currentTeam), - 'errors' => $errors, - ]); - } - - public function updateApplicationPosition(Request $request, V5Application $application): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) { - abort(404); - } - - $validated = $request->validate([ - 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], - 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], - ]); - - $application->update([ - 'canvas_x' => $validated['canvas_x'], - 'canvas_y' => $validated['canvas_y'], - ]); - - return response()->json([ - 'application' => $this->serializeApplication($application->refresh()->load('server')), - ]); - } - - public function updateApplicationIngress(Request $request, V5Application $application): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) { - abort(404); - } - - $validated = $request->validate([ - 'ingress_enabled' => ['required', 'boolean'], - 'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'], - 'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'], - 'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname], - ]); - - $application->loadMissing('server'); - - if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) { - return response()->json([ - 'message' => 'Enable ingress on the server before enabling app ingress.', - ], 422); - } - - DB::transaction(function () use ($application, $validated): void { - $application->update([ - 'ingress_enabled' => $validated['ingress_enabled'], - 'internal_port' => $validated['internal_port'] ?? null, - ]); - - if (array_key_exists('domains', $validated)) { - $application->domains()->delete(); - - collect($validated['domains']) - ->map(fn (string $domain) => trim($domain)) - ->filter() - ->unique() - ->each(fn (string $domain) => V5ApplicationDomain::query()->create([ - 'application_id' => $application->id, - 'domain' => $domain, - ])); - } - }); - - $application->refresh()->load(['server', 'domains']); - - if ($application->server?->isIngress() && $application->server->status === 'installed') { - try { - StartCaddyIngress::run($application->server); - } catch (\RuntimeException $exception) { - return $this->ingressSyncErrorResponse($exception); - } - } - - return response()->json([ - 'application' => $this->serializeApplication($application), - ]); - } - - public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $server->team_id !== $currentTeam->id || ! $server->isIngress()) { - abort(404); - } - - $validated = $request->validate([ - 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], - 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], - ]); - - $server->update([ - 'canvas_x' => $validated['canvas_x'], - 'canvas_y' => $validated['canvas_y'], - ]); - - return response()->json([ - 'caddyIngress' => $this->serializeCaddyIngress($server->refresh()), - ]); - } - - public function destroyApplication(Request $request, V5Application $application): \Illuminate\Http\Response|JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) { - abort(404); - } - - $error = DestroyNginxApplication::run($application); - - if ($error !== null) { - return response()->json([ - 'message' => $error, - ], 422); - } - - $application->delete(); - - return response()->noContent(); - } - - public function storeResourceConnection(Request $request): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } - - $projects = $this->projects($currentTeam); - [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); - - if ($selectedProject === null || $selectedEnvironment === null) { - return response()->json([ - 'message' => 'Select a project and environment before connecting resources.', - ], 422); - } - - $project = $this->projectQuery($currentTeam) - ->where('uuid', $selectedProject['uuid']) - ->first(); - - if (! $project instanceof Project) { - abort(403); - } - - $environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']); - - if (! $environment instanceof Environment) { - abort(403); - } - - $validated = $request->validate([ - 'resource_one' => ['required', 'array'], - 'resource_one.type' => ['required', 'string', Rule::in(['application'])], - 'resource_one.uuid' => ['required', 'string', 'max:255'], - 'resource_two' => ['required', 'array'], - 'resource_two.type' => ['required', 'string', Rule::in(['application'])], - 'resource_two.uuid' => ['required', 'string', 'max:255'], - ]); - - $resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']); - $resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']); - - if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) { - return response()->json([ - 'message' => 'A resource cannot connect to itself.', - ], 422); - } - - $connection = ResourceConnection::query()->firstOrCreate( - [ - 'team_id' => $currentTeam->id, - 'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo), - ], - [ - 'project_id' => $project->id, - 'environment_id' => $environment->id, - 'resource_one_type' => $resourceOne->getMorphClass(), - 'resource_one_id' => $resourceOne->getKey(), - 'resource_two_type' => $resourceTwo->getMorphClass(), - 'resource_two_id' => $resourceTwo->getKey(), - 'created_by_user_id' => $request->user()->id, - ], - ); - - return response()->json([ - 'connection' => $this->serializeResourceConnection($connection->load('rules')), - ], $connection->wasRecentlyCreated ? 201 : 200); - } - - public function updateResourceConnection(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $connection->team_id !== $currentTeam->id) { - abort(404); - } - - $validated = $request->validate([ - 'ports_by_direction' => ['present', 'array'], - 'ports_by_direction.*' => ['array'], - 'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'], - ]); - - $oldFirewallRules = $this->connectionFirewallRules($connection->load('rules')); - - DB::transaction(function () use ($connection, $validated): void { - $connection->rules()->delete(); - $resourcesByUuid = $this->connectionApplicationsByUuid($connection); - - foreach ($validated['ports_by_direction'] as $direction => $ports) { - [$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null); - $sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null; - $targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null; - - if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) { - continue; - } - - foreach (array_unique($ports) as $port) { - $connection->rules()->create([ - 'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid), - 'source_resource_id' => $sourceResource->id, - 'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid), - 'target_resource_id' => $targetResource->id, - 'protocol' => 'tcp', - 'port' => (int) $port, - ]); - } - } - }); - - $connection->refresh()->load('rules'); - $newFirewallRules = $this->connectionFirewallRules($connection); - - try { - $this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, $newFirewallRules); - } catch (\RuntimeException $exception) { - report($exception); - - return response()->json([ - 'message' => 'Could not sync firewall rules through Flux.', - 'detail' => $exception->getMessage(), - ], 502); - } - - return response()->json([ - 'connection' => $this->serializeResourceConnection($connection), - ]); - } - - public function destroyResourceConnection(Request $request, ResourceConnection $connection, FluxClient $fluxClient): \Illuminate\Http\Response|JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $connection->team_id !== $currentTeam->id) { - abort(404); - } - - $oldFirewallRules = $this->connectionFirewallRules($connection->load('rules')); - - try { - $this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, collect()); - } catch (\RuntimeException $exception) { - report($exception); - - return response()->json([ - 'message' => 'Could not sync firewall rules through Flux.', - 'detail' => $exception->getMessage(), - ], 502); - } - - $connection->delete(); - - return response()->noContent(); - } - - public function storeCluster(Request $request): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team) { - abort(403); - } - - $validated = $request->validate([ - 'name' => [ - 'required', - 'string', - 'max:255', - Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id), - ], - 'description' => ['nullable', 'string', 'max:1000'], - 'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'], - 'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], - 'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], - 'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], - 'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'], - 'namespaces' => ['sometimes', 'array', 'min:1'], - 'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'], - 'default_deny_containers' => ['sometimes', 'boolean'], - 'coold_version' => ['sometimes', 'string', 'max:64'], - 'corrosion_version' => ['sometimes', 'string', 'max:64'], - 'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], - 'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], - 'builder_enabled' => ['sometimes', 'boolean'], - 'builder_capacity' => $this->builderCapacityRules( - $this->requestedBuilderEnabled($request, true) - ), - 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], - 'builder_memory_max' => ['sometimes', 'string', 'max:32'], - 'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'], - ]); - - $cluster = V5Cluster::query()->create([ - ...$this->defaultClusterConfiguration(), - ...collect($validated)->except(['name', 'description'])->all(), - 'team_id' => $currentTeam->id, - 'created_by_user_id' => $request->user()->id, - 'name' => $validated['name'], - 'description' => $validated['description'] ?? null, - ]); - - $cluster->load(['servers' => fn ($query) => $query - ->with('privateKey') - ->orderBy('name')]); - $cluster->loadCount('servers'); - - return response()->json([ - 'cluster' => $this->serializeCluster($cluster), - ], 201); - } - - public function bootstrapServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - if ($server->last_bootstrapped_at !== null) { - return response()->json([ - 'message' => 'This server is already bootstrapped.', - ], 409); - } - - if (in_array($server->last_bootstrap_status, ['queued', 'running'], true)) { - return response()->json([ - 'cluster' => $this->freshSerializedCluster($cluster), - 'message' => 'Bootstrap is already queued or running for this server.', - ], 409); - } - - $installedServers = $cluster->servers() - ->with('privateKey') - ->whereNotNull('last_bootstrapped_at') - ->orderBy('name') - ->get(); - $server->load('privateKey'); - $servers = $installedServers->toBase() - ->push($server) - ->unique('id') - ->values(); - - if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) { - return response()->json([ - 'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.', - ], 422); - } - - $server->update([ - 'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend', - 'last_bootstrap_status' => 'queued', - 'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.", - 'last_bootstrap_ran_at' => now(), - ]); - - V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id); - V5BootstrapServerJob::dispatch($cluster->id, $server->id); - - return response()->json([ - 'cluster' => $this->freshSerializedCluster($cluster), - 'message' => 'Bootstrap queued.', - ], 202); - } - - public function storeServer(Request $request, V5Cluster $cluster): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { - abort(403); - } - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'host' => [ - 'required', - 'string', - 'max:255', - Rule::unique('v5_servers', 'host') - ->where('team_id', $currentTeam->id) - ->where('ssh_port', (int) $request->input('ssh_port', 22)), - ], - 'ssh_user' => ['required', 'string', 'max:255'], - 'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'], - 'private_key_uuid' => [ - 'required', - 'string', - Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id), - ], - 'node_address' => ['nullable', 'string', 'max:255'], - 'builder_enabled' => ['sometimes', 'boolean'], - 'builder_capacity' => $this->builderCapacityRules( - $this->requestedBuilderEnabled($request, $cluster->builder_enabled) - ), - 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], - 'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'], - 'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'], - 'ingress_enabled' => ['sometimes', 'boolean'], - 'ingress_type' => [ - Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), - 'nullable', - 'string', - Rule::in(['caddy']), - ], - ]); - - $builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled); - $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false); - $ingressType = $ingressEnabled ? $validated['ingress_type'] : null; - $builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity); - $builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota; - $devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']); - $privateKey = PrivateKey::query() - ->where('team_id', $currentTeam->id) - ->where('uuid', $validated['private_key_uuid']) - ->firstOrFail(); - - V5Server::query()->create([ - 'team_id' => $currentTeam->id, - 'cluster_id' => $cluster->id, - 'created_by_user_id' => $request->user()->id, - 'name' => $validated['name'], - 'host' => $validated['host'], - 'ssh_user' => $validated['ssh_user'], - 'ssh_port' => $validated['ssh_port'], - 'private_key_id' => $privateKey->id, - 'status' => 'added', - 'ingress_type' => $ingressType, - 'capabilities' => $this->serverCapabilities($builderEnabled, $ingressEnabled), - 'builder_enabled' => $builderEnabled, - 'builder_capacity' => $builderCapacity, - 'builder_cpu_quota' => $builderCpuQuota, - 'node_address' => $validated['node_address'] ?? $validated['host'], - 'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'], - 'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'], - ]); - - $cluster->load(['servers' => fn ($query) => $query - ->with('privateKey') - ->orderBy('name')]); - $cluster->loadCount('servers'); - - return response()->json([ - 'cluster' => $this->serializeCluster($cluster), - ], 201); - } - - public function updateServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - $validated = $request->validate([ - 'builder_enabled' => ['required', 'boolean'], - 'builder_capacity' => $this->builderCapacityRules( - $request->boolean('builder_enabled'), - required: true - ), - 'builder_cpu_quota' => ['required', 'string', 'max:32'], - 'ingress_enabled' => ['sometimes', 'boolean'], - 'ingress_type' => [ - Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), - 'nullable', - 'string', - Rule::in(['caddy']), - ], - ]); - - $wasIngress = $server->isIngress(); - $builderEnabled = (bool) $validated['builder_enabled']; - $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress); - $ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null; - $capabilities = $this->serverCapabilities($builderEnabled, $ingressEnabled); - - $server->update([ - 'capabilities' => $capabilities, - 'ingress_type' => $ingressType, - 'builder_enabled' => $builderEnabled, - 'builder_capacity' => (int) $validated['builder_capacity'], - 'builder_cpu_quota' => $validated['builder_cpu_quota'], - ]); - - $server->refresh(); - - try { - $this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled); - } catch (\RuntimeException $exception) { - return $this->ingressSyncErrorResponse($exception); - } - - $cluster->load(['servers' => fn ($query) => $query - ->with('privateKey') - ->orderBy('name')]); - $cluster->loadCount('servers'); - - return response()->json([ - 'cluster' => $this->serializeCluster($cluster), - ]); - } - - public function checkServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - if (! $server->privateKey instanceof PrivateKey) { - return response()->json([ - 'status' => 'failed', - 'output' => 'No private key is attached to this server.', - 'checkedAt' => now()->toJSON(), - ]); - } - - $keyDirectory = storage_path('app/ssh/keys'); - if (! is_dir($keyDirectory)) { - mkdir($keyDirectory, 0700, true); - } - - $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); - if ($keyLocation === false) { - return response()->json([ - 'status' => 'failed', - 'output' => 'Could not create a temporary SSH key file.', - 'checkedAt' => now()->toJSON(), - ]); - } - - file_put_contents($keyLocation, $server->privateKey->private_key); - chmod($keyLocation, 0600); - - $target = "{$server->ssh_user}@{$server->host}"; - $command = [ - 'ssh', - '-o', - 'BatchMode=yes', - '-o', - 'LogLevel=ERROR', - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'UserKnownHostsFile=/dev/null', - '-o', - 'ConnectTimeout=10', - '-o', - 'IdentitiesOnly=yes', - '-i', - $keyLocation, - '-p', - (string) $server->ssh_port, - $target, - "printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true", - ]; - - try { - $result = Process::timeout(15)->run($command); - $output = trim($result->output()."\n".$result->errorOutput()); - $status = $result->successful() ? 'reachable' : 'failed'; - } catch (\Throwable $e) { - $output = $e->getMessage(); - $status = 'failed'; - } finally { - @unlink($keyLocation); - } - - return response()->json([ - 'status' => $status, - 'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(), - 'checkedAt' => now()->toJSON(), - ]); - } - - public function serverCooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - $validated = $request->validate([ - 'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'], - ]); - - $hostId = $server->wireguard_management_ip ?: $server->node_address; - - if (! is_string($hostId) || $hostId === '') { - return response()->json([ - 'message' => 'This server is missing its Flux host id.', - ], 422); - } - - try { - $output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200)); - } catch (\Throwable $e) { - return response()->json([ - 'message' => $e->getMessage(), - ], 502); - } - - return response()->json([ - 'output' => $output, - 'source' => 'flux', - 'fetchedAt' => now()->toJSON(), - ]); - } - - public function serverCorrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - $validated = $request->validate([ - 'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'], - ]); - - $hostId = $server->wireguard_management_ip ?: $server->node_address; - - if (! is_string($hostId) || $hostId === '') { - return response()->json([ - 'message' => 'This server is missing its Flux host id.', - ], 422); - } - - try { - $output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200)); - } catch (\Throwable $e) { - return response()->json([ - 'message' => $e->getMessage(), - ], 502); - } - - return response()->json([ - 'output' => $output, - 'source' => 'flux', - 'fetchedAt' => now()->toJSON(), - ]); - } - - public function serverFirewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - $validated = $request->validate([ - 'namespace' => ['sometimes', 'string', 'max:63'], - ]); - - $hostId = $server->wireguard_management_ip ?: $server->node_address; - - if (! is_string($hostId) || $hostId === '') { - return response()->json([ - 'message' => 'This server is missing its Flux host id.', - ], 422); - } - - try { - $rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? '')); - } catch (\Throwable $e) { - return response()->json([ - 'message' => $e->getMessage(), - ], 502); - } - - return response()->json([ - 'rules' => $rules, - 'source' => 'flux', - 'fetchedAt' => now()->toJSON(), - ]); - } - - public function destroyServer(Request $request, V5Cluster $cluster, V5Server $server): \Illuminate\Http\Response|JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if ( - ! $currentTeam instanceof Team - || $cluster->team_id !== $currentTeam->id - || $server->team_id !== $currentTeam->id - || $server->cluster_id !== $cluster->id - ) { - abort(404); - } - - $server->delete(); - - return response()->json([ - 'cluster' => $this->freshSerializedCluster($cluster), - ]); - } - - public function destroyCluster(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse - { - $currentTeam = $request->attributes->get('v5.currentTeam'); - - if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { - abort(404); - } - - if ($cluster->servers()->exists()) { - return response()->json([ - 'message' => 'Only empty clusters can be deleted.', - ], 422); - } - - $cluster->delete(); - - return response()->noContent(); - } - - /** - * @param Collection $servers - * @return array - */ - /** - * @return array{listen_port: int|null, endpoint: string|null} - */ - private function devLimaWireguardOverrides(string $host, int $sshPort): array - { - if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') { - return ['listen_port' => null, 'endpoint' => null]; - } - - if ($sshPort < 60001 || $sshPort > 60009) { - return ['listen_port' => null, 'endpoint' => null]; - } - - $wireguardPort = $sshPort - 8180; - - return [ - 'listen_port' => $wireguardPort, - 'endpoint' => "host.lima.internal:{$wireguardPort}", - ]; - } - - private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array - { - $command = [ - $this->coolifyCliBin(), - 'init', - $action, - '--format', - 'table', - '--nodes', - $servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','), - '--ssh-config', - $sshConfigLocation, - '--namespaces', - implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES), - '--container-pool', - $cluster->container_network_pool, - '--container-prefix', - (string) $cluster->container_network_prefix, - '--wg-mgmt-pool', - $cluster->wireguard_management_pool, - '--wg-interface', - $cluster->wireguard_interface, - '--wg-listen-port', - (string) $cluster->wireguard_listen_port, - '--coold-version', - $cluster->coold_version, - '--corrosion-version', - $cluster->corrosion_version, - '--corrosion-gossip-port', - (string) $cluster->corrosion_gossip_port, - '--corrosion-api-port', - (string) $cluster->corrosion_api_port, - ]; - - if ($action === 'extend') { - array_push($command, '--new-nodes', $this->bootstrapNode($newServer)); - } - - $listenOverrides = $this->wireguardListenPortOverrides($servers); - if ($listenOverrides !== '') { - array_push($command, '--wg-listen-port-overrides', $listenOverrides); - } - - $endpointOverrides = $this->wireguardEndpointOverrides($servers); - if ($endpointOverrides !== '') { - array_push($command, '--wg-endpoint-overrides', $endpointOverrides); - } - - if (! $cluster->default_deny_containers) { - $command[] = '--skip-default-deny'; - } - - $command[] = '--yes'; - - return $command; - } - - private function coolifyCliBin(): string - { - $configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify'); - $devBinary = base_path('.dev/bin/coolify'); - - if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) { - return $devBinary; - } - - return $configuredBinary; - } - - private function isRunnableDevelopmentCliBinary(string $binary): bool - { - if (! is_file($binary)) { - return false; - } - - $header = file_get_contents($binary, false, null, 0, 4); - - if ($header === false) { - return false; - } - - if (str_starts_with($header, '#!')) { - return true; - } - - if ($header === "\x7FELF") { - return true; - } - - return false; - } - - private function bootstrapNode(V5Server $server): string - { - return "v5-server-{$server->id}"; - } - - /** - * @param Collection $servers - */ - private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string - { - $config = ''; - - $servers->each(function (V5Server $server) use (&$config, $tempDirectory): void { - $keyLocation = "{$tempDirectory}/server-{$server->id}.key"; - file_put_contents($keyLocation, $server->privateKey->private_key); - chmod($keyLocation, 0600); - - $config .= implode("\n", [ - 'Host '.$this->bootstrapNode($server), - ' HostName '.$server->host, - ' Port '.$server->ssh_port, - ' User '.$server->ssh_user, - ' IdentityFile '.$keyLocation, - ' IdentitiesOnly yes', - ' LogLevel ERROR', - ' StrictHostKeyChecking no', - ' UserKnownHostsFile /dev/null', - ' BatchMode yes', - '', - ]); - }); - - $sshConfigLocation = "{$tempDirectory}/ssh.config"; - file_put_contents($sshConfigLocation, $config); - chmod($sshConfigLocation, 0600); - - return $sshConfigLocation; - } - - private function deleteDirectory(string $directory): void - { - if (! is_dir($directory)) { - return; - } - - foreach (scandir($directory) ?: [] as $file) { - if ($file === '.' || $file === '..') { - continue; - } - - $path = "{$directory}/{$file}"; - - if (is_dir($path)) { - $this->deleteDirectory($path); - - continue; - } - - @unlink($path); - } - - @rmdir($directory); - } - - /** - * @param Collection $servers - */ - private function wireguardListenPortOverrides(Collection $servers): string - { - return $servers - ->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null) - ->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override) - ->implode(','); - } - - /** - * @param Collection $servers - */ - private function wireguardEndpointOverrides(Collection $servers): string - { - return $servers - ->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null) - ->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override) - ->implode(','); - } - - /** - * @return array - */ - /** - * @return array{id: int}|null - */ - private function serializeCurrentTeam(mixed $currentTeam): ?array - { - if (! $currentTeam instanceof Team) { - return null; - } - - return [ - 'id' => $currentTeam->id, - ]; - } - - private function nginxServers(mixed $currentTeam): array - { - if (! $currentTeam instanceof Team) { - return []; - } - - return V5Server::query() - ->where('team_id', $currentTeam->id) - ->orderByRaw('last_bootstrapped_at is null') - ->orderBy('name') - ->get(['id', 'uuid', 'name', 'host', 'status']) - ->map(fn (V5Server $server) => [ - 'id' => $server->uuid, - 'name' => $server->name, - 'host' => $server->host, - 'status' => $server->status, - ]) - ->all(); - } - /** * @return array> */ @@ -1397,25 +120,6 @@ class DashboardController extends Controller ->all(); } - /** - * @return array> - */ - private function caddyIngresses(mixed $currentTeam): array - { - if (! $currentTeam instanceof Team) { - return []; - } - - return V5Server::query() - ->where('team_id', $currentTeam->id) - ->orderBy('name') - ->get() - ->filter(fn (V5Server $server) => $server->isIngress()) - ->values() - ->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index)) - ->all(); - } - /** * @return array> */ @@ -1435,654 +139,27 @@ class DashboardController extends Controller ->with('rules') ->orderBy('id') ->get() - ->map(fn (ResourceConnection $connection) => $this->serializeResourceConnection($connection)) + ->map(fn (ResourceConnection $connection) => $this->connectionSerializer->serialize($connection)) ->all(); } - /** - * @return array - */ - private function serializeCaddyIngress(V5Server $server, int $index = 0): array - { - $isServerReachable = $this->isServerReachable($server); - - return [ - 'id' => $server->uuid, - 'name' => $server->name, - 'host' => $server->host, - 'type' => $server->ingressType(), - 'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable', - 'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server), - 'canvasX' => $server->canvas_x ?? -self::CANVAS_CARD_WIDTH - self::CANVAS_CARD_GAP, - 'canvasY' => $server->canvas_y ?? $index * (self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP), - ]; - } - - /** - * @return array - */ - private function serializeResourceConnection(ResourceConnection $connection): array - { - $applications = $this->connectionApplicationsById($connection); - $resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid; - $resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid; - $applicationsById = $applications; - - return [ - 'id' => $connection->uuid, - 'applicationIds' => array_values(array_filter([ - $resourceOneUuid, - $resourceTwoUuid, - ])), - 'fromApplicationId' => $resourceOneUuid, - 'toApplicationId' => $resourceTwoUuid, - 'portsByDirection' => $connection->rules - ->groupBy(function ($rule) use ($applicationsById): string { - $sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid; - $targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid; - - return "{$sourceUuid}->{$targetUuid}"; - }) - ->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->')) - ->map(fn (Collection $rules) => $rules - ->sortBy('port') - ->pluck('port') - ->map(fn ($port) => (string) $port) - ->values() - ->all()) - ->all(), - ]; - } - - /** - * @param array{type: string, uuid: string} $resource - */ - private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model - { - return match ($resource['type']) { - 'application' => V5Application::query() - ->where('team_id', $team->id) - ->where('project_id', $project->id) - ->where('environment_id', $environment->id) - ->where('uuid', $resource['uuid']) - ->firstOrFail(), - }; - } - - private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string - { - return collect([ - $this->resourceIdentity($resourceOne), - $this->resourceIdentity($resourceTwo), - ])->sort()->implode('|'); - } - - private function resourceIdentity(Model $resource): string - { - return $resource->getMorphClass().':'.$resource->getKey(); - } - - /** - * @return Collection - */ - private function connectionApplicationsByUuid(ResourceConnection $connection): Collection - { - return $this->connectionApplicationsById($connection)->keyBy('uuid'); - } - - /** - * @return Collection - */ - private function connectionApplicationsById(ResourceConnection $connection): Collection - { - return V5Application::query() - ->whereIn('id', [ - (int) $connection->resource_one_id, - (int) $connection->resource_two_id, - ]) - ->get() - ->keyBy('id'); - } - - private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string - { - $resourcesByUuid = $this->connectionApplicationsByUuid($connection); - $resource = $resourcesByUuid->get($resourceUuid); - - return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id - ? $connection->resource_one_type - : $connection->resource_two_type; - } - - /** - * @return Collection - */ - private function connectionFirewallRules(ResourceConnection $connection): Collection - { - $applicationIds = $connection->rules - ->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id]) - ->unique() - ->values(); - - $applications = V5Application::query() - ->whereIn('id', $applicationIds) - ->with('server') - ->get() - ->keyBy('id'); - - return $connection->rules - ->flatMap(function ($rule) use ($applications, $connection): Collection { - $source = $applications->get($rule->source_resource_id); - $target = $applications->get($rule->target_resource_id); - - if ( - ! $source instanceof V5Application - || ! $target instanceof V5Application - || ! $source->server instanceof V5Server - || ! $target->server instanceof V5Server - ) { - return collect(); - } - - $hostIds = collect([$source->server, $target->server]) - ->map(fn (V5Server $server) => $server->wireguard_management_ip ?: $server->node_address) - ->filter(fn (mixed $hostId): bool => is_string($hostId) && $hostId !== '') - ->unique() - ->values(); - - if ($hostIds->isEmpty()) { - return collect(); - } - - $firewallRule = [ - 'id' => $this->connectionFirewallRuleId($connection, $rule), - 'namespace' => $target->mesh_namespace ?: 'default', - 'src' => $source->container_name, - 'dst' => $target->container_name, - 'proto' => $rule->protocol ?: 'tcp', - 'port' => (int) $rule->port, - ]; - - return $hostIds->map(fn (string $hostId): array => [ - 'id' => $firewallRule['id'], - 'hostId' => $hostId, - 'rule' => $firewallRule, - ]); - }) - ->values(); - } - - /** - * @param Collection $oldRules - * @param Collection $newRules - */ - private function syncConnectionFirewallRules(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void - { - $newRuleKeys = $newRules->map(fn (array $rule): string => $this->firewallRuleSyncKey($rule))->all(); - $oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->firewallRuleSyncKey($rule))->all(); - - $oldRules - ->reject(fn (array $oldRule): bool => in_array($this->firewallRuleSyncKey($oldRule), $newRuleKeys, true)) - ->each(fn (array $oldRule): string => $fluxClient->revokeFirewallRule($oldRule['hostId'], $oldRule['id'])); - - $newRules - ->reject(fn (array $newRule): bool => in_array($this->firewallRuleSyncKey($newRule), $oldRuleKeys, true)) - ->each(fn (array $newRule): string => $fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule'])); - } - - /** - * @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule - */ - private function firewallRuleSyncKey(array $rule): string - { - return $rule['hostId'].'|'.$rule['id']; - } - - private function connectionFirewallRuleId(ResourceConnection $connection, mixed $rule): string - { - return implode(':', [ - 'v5-resource-connection', - $connection->id, - $rule->source_resource_id, - $rule->target_resource_id, - $rule->protocol ?: 'tcp', - (int) $rule->port, - ]); - } - - /** - * @param array{uuid: string} $selectedProject - * @param array{uuid: string} $selectedEnvironment - * @return Builder - */ - private function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder - { - return V5Application::query() - ->where('team_id', $currentTeam->id) - ->whereHas('project', fn (Builder $query) => $query - ->where('team_id', $currentTeam->id) - ->where('uuid', $selectedProject['uuid'])) - ->whereHas('environment', fn (Builder $query) => $query - ->where('uuid', $selectedEnvironment['uuid'])); - } - - /** - * @return array{canvas_x: int, canvas_y: int} - */ - private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array - { - $existingApplications = V5Application::query() - ->where('team_id', $currentTeam->id) - ->where('project_id', $project->id) - ->where('environment_id', $environment->id) - ->get(['canvas_x', 'canvas_y']); - - $horizontalStep = self::CANVAS_CARD_WIDTH + self::CANVAS_CARD_GAP; - $verticalStep = self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP; - - for ($row = 0; $row < 100; $row++) { - for ($column = 0; $column < 100; $column++) { - $candidate = [ - 'canvas_x' => $column * $horizontalStep, - 'canvas_y' => $row * $verticalStep, - ]; - - if (! $this->canvasPositionCollides($candidate, $existingApplications)) { - return $candidate; - } - } - } - - return [ - 'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep, - 'canvas_y' => 0, - ]; - } - - /** - * @param array{canvas_x: int, canvas_y: int} $candidate - * @param Collection $existingApplications - */ - private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool - { - return $existingApplications->contains(function (V5Application $application) use ($candidate) { - return abs($candidate['canvas_x'] - $application->canvas_x) < self::CANVAS_CARD_WIDTH + self::CANVAS_CARD_GAP - && abs($candidate['canvas_y'] - $application->canvas_y) < self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP; - }); - } - - /** - * @return array - */ - private function serializeApplication(V5Application $application): array - { - $application->loadMissing(['server', 'domains']); - $server = $application->server; - $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); - - return [ - 'id' => $application->uuid, - 'name' => $application->name, - 'image' => $application->image, - 'containerName' => $application->container_name, - 'status' => $application->status, - 'statusMessage' => $application->status_message, - 'effectiveStatus' => $isServerReachable ? $application->status : 'unknown', - 'effectiveStatusMessage' => $isServerReachable - ? $application->status_message - : $this->serverStatusMessage($server), - 'runtimeContainerId' => $application->runtime_container_id, - 'serverName' => $server?->name, - 'serverStatus' => $server?->status, - 'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null, - 'isServerReachable' => $isServerReachable, - 'serverIngressEnabled' => (bool) $server?->isIngress(), - 'meshNamespace' => $application->mesh_namespace, - 'ingressEnabled' => $application->ingress_enabled, - 'internalPort' => $application->internal_port, - 'domains' => $application->domains->pluck('domain')->values()->all(), - 'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal', - 'canvasX' => $application->canvas_x, - 'canvasY' => $application->canvas_y, - ]; - } - - private function isServerReachable(V5Server $server): bool - { - return $server->status !== 'unreachable'; - } - - private function serverStatusMessage(V5Server $server): ?string - { - return $server->last_status_output ?: null; - } - - /** - * @return array> - */ - private function clusters(mixed $currentTeam): array + private function nginxServers(mixed $currentTeam): array { if (! $currentTeam instanceof Team) { return []; } - return V5Cluster::query() + return V5Server::query() ->where('team_id', $currentTeam->id) - ->with(['servers' => fn ($query) => $query - ->with('privateKey') - ->orderBy('name')]) - ->withCount('servers') + ->orderByRaw('last_bootstrapped_at is null') ->orderBy('name') - ->get() - ->map(fn (V5Cluster $cluster) => $this->serializeCluster($cluster)) - ->all(); - } - - /** - * @return array - */ - private function privateKeys(mixed $currentTeam): array - { - if (! $currentTeam instanceof Team) { - return []; - } - - return PrivateKey::query() - ->where('team_id', $currentTeam->id) - ->where('is_git_related', false) - ->orderBy('name') - ->get(['id', 'uuid', 'name']) - ->map(fn (PrivateKey $privateKey) => [ - 'id' => $privateKey->uuid, - 'name' => $privateKey->name, - ]) - ->all(); - } - - /** - * @return array - */ - private function serverCapabilities(bool $builderEnabled, bool $ingressEnabled): array - { - return collect() - ->when($ingressEnabled, fn ($capabilities) => $capabilities->push('ingress')) - ->unique() - ->values() - ->all(); - } - - private function reconcileCaddyIngress(V5Server $server, bool $wasIngress, bool $isIngress): void - { - if ($server->status !== 'installed') { - return; - } - - if (! $wasIngress && $isIngress) { - StartCaddyIngress::run($server); - - return; - } - - if ($wasIngress && ! $isIngress) { - StopCaddyIngress::run($server); - } - } - - private function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse - { - return response()->json([ - 'message' => $this->friendlyIngressSyncError($exception->getMessage()), - 'detail' => $exception->getMessage(), - ], 502); - } - - private function friendlyIngressSyncError(string $message): string - { - $normalized = Str::lower($message); - - if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) { - return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.'; - } - - if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) { - return 'coold did not respond in time. Check that the server agent is running and connected to Flux.'; - } - - if (str_contains($normalized, 'validate caddyfile')) { - return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.'; - } - - if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) { - return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.'; - } - - return 'Could not update ingress. Check Flux and coold logs, then try again.'; - } - - /** - * @return array - */ - private function serializeCluster(V5Cluster $cluster): array - { - return [ - 'id' => $cluster->uuid, - 'name' => $cluster->name, - 'description' => $cluster->description, - 'wireguardInterface' => $cluster->wireguard_interface, - 'wireguardManagementPool' => $cluster->wireguard_management_pool, - 'wireguardListenPort' => $cluster->wireguard_listen_port, - 'containerNetworkPool' => $cluster->container_network_pool, - 'containerNetworkPrefix' => $cluster->container_network_prefix, - 'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES, - 'defaultDenyContainers' => $cluster->default_deny_containers, - 'cooldVersion' => $cluster->coold_version, - 'corrosionVersion' => $cluster->corrosion_version, - 'corrosionGossipPort' => $cluster->corrosion_gossip_port, - 'corrosionApiPort' => $cluster->corrosion_api_port, - 'builderEnabled' => $cluster->builder_enabled, - 'builderCapacity' => $cluster->builder_capacity, - 'builderCpuQuota' => $cluster->builder_cpu_quota, - 'builderMemoryMax' => $cluster->builder_memory_max, - 'builderTimeoutSecs' => $cluster->builder_timeout_secs, - 'lastCliAction' => $cluster->last_cli_action, - 'lastCliStatus' => $cluster->last_cli_status, - 'lastCliSummary' => $cluster->last_cli_summary, - 'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(), - 'serversCount' => $cluster->servers_count ?? $cluster->servers->count(), - 'servers' => $cluster->servers->map(fn (V5Server $server) => [ + ->get(['id', 'uuid', 'name', 'host', 'status']) + ->map(fn (V5Server $server) => [ 'id' => $server->uuid, 'name' => $server->name, 'host' => $server->host, 'status' => $server->status, - 'capabilities' => $server->capabilities ?? [], - 'builderEnabled' => $server->builder_enabled, - 'builderCapacity' => $server->builder_capacity, - 'builderCpuQuota' => $server->builder_cpu_quota, - 'ingressEnabled' => $server->isIngress(), - 'ingressType' => $server->ingress_type, - 'uuid' => $server->uuid, - 'nodeAddress' => $server->node_address, - 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, - 'wireguardEndpointOverride' => $server->wireguard_endpoint_override, - 'wireguardManagementIp' => $server->wireguard_management_ip, - 'wireguardPublicKey' => $server->wireguard_public_key, - 'containerSubnets' => $server->container_subnets ?? [], - 'privateKeyName' => $server->privateKey?->name, - 'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(), - 'lastBootstrapAction' => $server->last_bootstrap_action, - 'lastBootstrapStatus' => $server->last_bootstrap_status, - 'lastBootstrapOutput' => $server->last_bootstrap_output, - 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), - 'lastStatusOutput' => $server->last_status_output, - 'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(), - ])->all(), - ]; - } - - /** - * @return array - */ - private function freshSerializedCluster(V5Cluster $cluster): array - { - $cluster->load(['servers' => fn ($query) => $query - ->with('privateKey') - ->orderBy('name')]); - $cluster->loadCount('servers'); - - return $this->serializeCluster($cluster); - } - - /** - * @return array - */ - private function defaultClusterConfiguration(): array - { - return [ - 'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE, - 'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL, - 'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT, - 'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL, - 'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX, - 'namespaces' => V5Cluster::DEFAULT_NAMESPACES, - 'default_deny_containers' => true, - 'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION, - 'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION, - 'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT, - 'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT, - 'builder_enabled' => true, - 'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY, - 'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA, - 'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX, - 'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS, - ]; - } - - private function ipv4CidrRule(): \Closure - { - return function (string $attribute, mixed $value, \Closure $fail): void { - if (! is_string($value) || ! str_contains($value, '/')) { - $fail('The :attribute must be a valid IPv4 CIDR range.'); - - return; - } - - [$ip, $prefix] = explode('/', $value, 2); - - if ( - filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false - || ! ctype_digit($prefix) - || (int) $prefix < 0 - || (int) $prefix > 32 - ) { - $fail('The :attribute must be a valid IPv4 CIDR range.'); - } - }; - } - - /** - * @return array - */ - private function builderCapacityRules(bool $builderEnabled, bool $required = false): array - { - return [ - $required ? 'required' : 'sometimes', - 'integer', - $builderEnabled ? 'min:1' : 'min:0', - 'max:1000', - ]; - } - - private function requestedBuilderEnabled(Request $request, bool $default): bool - { - if (! $request->has('builder_enabled')) { - return $default; - } - - return $request->boolean('builder_enabled'); - } - - /** - * @param array}> $projects - * @return array{0: array{uuid: string, name: string, environments: array}|null, 1: array{uuid: string, name: string}|null} - */ - private function selectedProjectAndEnvironment(Request $request, array $projects): array - { - $selectedProjectUuid = $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY); - $selectedEnvironmentUuid = $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY); - $selectedProject = null; - - foreach ($projects as $project) { - if ($project['uuid'] === $selectedProjectUuid) { - $selectedProject = $project; - - break; - } - } - - $selectedProject ??= $projects[0] ?? null; - $selectedEnvironment = null; - - foreach ($selectedProject['environments'] ?? [] as $environment) { - if ($environment['uuid'] === $selectedEnvironmentUuid) { - $selectedEnvironment = $environment; - - break; - } - } - - $selectedEnvironment ??= $selectedProject['environments'][0] ?? null; - - return [$selectedProject, $selectedEnvironment]; - } - - private function selectedEnvironment(Project $project, ?string $environmentUuid): ?Environment - { - if ($environmentUuid === null) { - return $project->environments->first(); - } - - $environment = $project->environments->firstWhere('uuid', $environmentUuid); - - if (! $environment instanceof Environment) { - abort(422, 'The selected environment is not available for the selected project.'); - } - - return $environment; - } - - /** - * @return array}> - */ - private function projects(mixed $currentTeam): array - { - if (! $currentTeam instanceof Team) { - return []; - } - - return $this->projectQuery($currentTeam) - ->get() - ->map(fn (Project $project) => [ - 'uuid' => $project->uuid, - 'name' => $project->name, - 'environments' => $project->environments - ->map(fn ($environment) => [ - 'uuid' => $environment->uuid, - 'name' => $environment->name, - ]) - ->all(), ]) ->all(); } - - private function projectQuery(Team $currentTeam): Builder - { - return Project::query() - ->select(['id', 'uuid', 'name', 'team_id']) - ->where('team_id', $currentTeam->id) - ->with(['environments' => fn ($query) => $query - ->select(['id', 'uuid', 'name', 'project_id']) - ->orderByRaw("CASE WHEN LOWER(name) = 'production' THEN 0 ELSE 1 END") - ->orderByRaw('LOWER(name)')]) - ->orderByRaw('LOWER(name)'); - } } diff --git a/app/Http/Controllers/V5/ResourceConnectionController.php b/app/Http/Controllers/V5/ResourceConnectionController.php new file mode 100644 index 000000000..d901940d6 --- /dev/null +++ b/app/Http/Controllers/V5/ResourceConnectionController.php @@ -0,0 +1,330 @@ +currentTeamOrFail($request); + $projects = $this->projects($currentTeam); + [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); + + if ($selectedProject === null || $selectedEnvironment === null) { + return response()->json([ + 'message' => 'Select a project and environment before connecting resources.', + ], 422); + } + + $project = $this->projectQuery($currentTeam) + ->where('uuid', $selectedProject['uuid']) + ->first(); + + if (! $project instanceof Project) { + abort(403); + } + + $environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']); + + if (! $environment instanceof Environment) { + abort(403); + } + + $validated = $request->validate([ + 'resource_one' => ['required', 'array'], + 'resource_one.type' => ['required', 'string', Rule::in(['application'])], + 'resource_one.uuid' => ['required', 'string', 'max:255'], + 'resource_two' => ['required', 'array'], + 'resource_two.type' => ['required', 'string', Rule::in(['application'])], + 'resource_two.uuid' => ['required', 'string', 'max:255'], + ]); + + $resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']); + $resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']); + + if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) { + return response()->json([ + 'message' => 'A resource cannot connect to itself.', + ], 422); + } + + $connection = ResourceConnection::query()->firstOrCreate( + [ + 'team_id' => $currentTeam->id, + 'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo), + ], + [ + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'resource_one_type' => $resourceOne->getMorphClass(), + 'resource_one_id' => $resourceOne->getKey(), + 'resource_two_type' => $resourceTwo->getMorphClass(), + 'resource_two_id' => $resourceTwo->getKey(), + 'created_by_user_id' => $request->user()->id, + ], + ); + + return response()->json([ + 'connection' => $this->connectionSerializer->serialize($connection->load('rules')), + ], $connection->wasRecentlyCreated ? 201 : 200); + } + + /** + * Update the connection's rules, then converge the node firewalls. + * + * Ordering & failure semantics: + * 1. Snapshot the current DB rules and their firewall representation; abort + * with 502 before mutating anything when the snapshot cannot be built. + * 2. Commit the requested rules in a DB transaction — the DB always holds + * the desired state. + * 3. Converge the node firewalls through Flux. Nodes whose coold lacks the + * firewall verbs (UnsupportedCooldVerb) are tolerated: the committed + * rules are kept and the request succeeds. + * 4. On a real Flux failure the previous rules are restored in a second DB + * transaction, the node firewalls are rolled back to the restored rules + * best-effort (warning-logged when that also fails — deterministic rule + * ids keep a later re-sync idempotent), and the original error surfaces + * to the caller as a 502 {message, detail} response. + */ + public function update(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('update', [$connection, $currentTeam]); + + $validated = $request->validate([ + 'ports_by_direction' => ['present', 'array'], + 'ports_by_direction.*' => ['array'], + 'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'], + ]); + + $connection->load('rules'); + $oldRulePayloads = $connection->rules + ->map(fn ($rule): array => [ + 'source_resource_type' => $rule->source_resource_type, + 'source_resource_id' => $rule->source_resource_id, + 'target_resource_type' => $rule->target_resource_type, + 'target_resource_id' => $rule->target_resource_id, + 'protocol' => $rule->protocol, + 'port' => $rule->port, + ]) + ->all(); + + try { + $oldFirewallRules = $this->firewallSync->rulesFor($connection); + } catch (\RuntimeException $exception) { + report($exception); + Log::warning('V5 resource connection firewall snapshot failed', [ + 'connection_id' => $connection->id, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'message' => 'Could not sync firewall rules through Flux.', + 'detail' => 'The connection was left unchanged. Check the server diagnostics and try again.', + ], 502); + } + + DB::transaction(function () use ($connection, $validated): void { + $connection->rules()->delete(); + $resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection); + + foreach ($validated['ports_by_direction'] as $direction => $ports) { + [$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null); + $sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null; + $targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null; + + if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) { + continue; + } + + foreach (array_unique($ports) as $port) { + $connection->rules()->create([ + 'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid), + 'source_resource_id' => $sourceResource->id, + 'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid), + 'target_resource_id' => $targetResource->id, + 'protocol' => 'tcp', + 'port' => (int) $port, + ]); + } + } + }); + + $connection->refresh()->load('rules'); + + $newFirewallRules = null; + + try { + $newFirewallRules = $this->firewallSync->rulesFor($connection); + $this->firewallSync->sync($fluxClient, $oldFirewallRules, $newFirewallRules); + } catch (\RuntimeException $exception) { + $this->restoreConnectionRules($connection, $oldRulePayloads); + $this->rollBackFirewallRules($fluxClient, $connection, $newFirewallRules, $oldFirewallRules); + report($exception); + Log::warning('V5 resource connection firewall sync failed', [ + 'connection_id' => $connection->id, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'message' => 'Could not sync firewall rules through Flux.', + 'detail' => 'The previous rules were restored. Check the server diagnostics and try again.', + ], 502); + } + + return response()->json([ + 'connection' => $this->connectionSerializer->serialize($connection), + ]); + } + + /** + * Delete the connection using revoke-first ordering. + * + * The node firewall rules are revoked before any DB rows are removed. When + * a revoke fails with a real error the delete is aborted with a 502 + * {message, detail} response so the DB never loses track of rules that may + * still be open on a reachable node; UnsupportedCooldVerb and + * already-missing rules are tolerated. When the firewall snapshot cannot + * be built (an endpoint lost its server host id) the node cannot be + * addressed at all, so the failure is reported and the delete proceeds. + * Deterministic rule ids make a retried delete revoke the same node-side + * rules idempotently. + */ + public function destroy(Request $request, ResourceConnection $connection, FluxClient $fluxClient): Response|JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('delete', [$connection, $currentTeam]); + + try { + $oldFirewallRules = $this->firewallSync->rulesFor($connection->load('rules')); + } catch (\RuntimeException $exception) { + report($exception); + $oldFirewallRules = collect(); + } + + try { + $this->firewallSync->sync($fluxClient, $oldFirewallRules, collect()); + } catch (\RuntimeException $exception) { + report($exception); + Log::warning('V5 resource connection firewall revoke failed', [ + 'connection_id' => $connection->id, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'message' => 'Could not sync firewall rules through Flux.', + 'detail' => 'The connection was not deleted. Check the server diagnostics and try again.', + ], 502); + } + + $connection->delete(); + + return response()->noContent(); + } + + /** + * @param array{type: string, uuid: string} $resource + */ + private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model + { + return match ($resource['type']) { + 'application' => V5Application::query() + ->where('team_id', $team->id) + ->where('project_id', $project->id) + ->where('environment_id', $environment->id) + ->where('uuid', $resource['uuid']) + ->firstOrFail(), + }; + } + + private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string + { + return collect([ + $this->resourceIdentity($resourceOne), + $this->resourceIdentity($resourceTwo), + ])->sort()->implode('|'); + } + + private function resourceIdentity(Model $resource): string + { + return $resource->getMorphClass().':'.$resource->getKey(); + } + + private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string + { + $resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection); + $resource = $resourcesByUuid->get($resourceUuid); + + return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id + ? $connection->resource_one_type + : $connection->resource_two_type; + } + + /** + * @param array> $rulePayloads + */ + private function restoreConnectionRules(ResourceConnection $connection, array $rulePayloads): void + { + DB::transaction(function () use ($connection, $rulePayloads): void { + $connection->rules()->delete(); + + foreach ($rulePayloads as $rulePayload) { + $connection->rules()->create($rulePayload); + } + }); + } + + /** + * Best-effort roll back of a partially converged node firewall to the + * restored rules after a failed forward sync. Skipped when the forward + * sync never started (the node was not touched). Failures are only logged + * because the DB already holds the restored, authoritative rules and the + * deterministic rule ids keep a later re-sync idempotent. + * + * @param Collection|null $attemptedFirewallRules + * @param Collection $restoredFirewallRules + */ + private function rollBackFirewallRules(FluxClient $fluxClient, ResourceConnection $connection, ?Collection $attemptedFirewallRules, Collection $restoredFirewallRules): void + { + if (! $attemptedFirewallRules instanceof Collection) { + return; + } + + try { + $this->firewallSync->sync($fluxClient, $attemptedFirewallRules, $restoredFirewallRules); + } catch (\RuntimeException $exception) { + Log::warning('V5 resource connection firewall rollback failed; node firewall may diverge from the restored rules until the next sync', [ + 'connection_id' => $connection->id, + 'message' => $exception->getMessage(), + ]); + } + } +} diff --git a/app/Http/Controllers/V5/ServerController.php b/app/Http/Controllers/V5/ServerController.php new file mode 100644 index 000000000..6f92a844e --- /dev/null +++ b/app/Http/Controllers/V5/ServerController.php @@ -0,0 +1,761 @@ +currentTeamOrFail($request); + $this->authorize('create', [V5Server::class, $currentTeam, $cluster]); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'host' => [ + 'required', + 'string', + 'max:255', + $this->noControlCharactersRule(), + new ValidServerIp, + Rule::unique('v5_servers', 'host') + ->where('team_id', $currentTeam->id) + ->where('ssh_port', (int) $request->input('ssh_port', 22)), + ], + 'ssh_user' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._-]+$/', $this->noControlCharactersRule()], + 'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'], + 'private_key_uuid' => [ + 'required', + 'string', + Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id), + ], + 'node_address' => [ + 'nullable', + 'string', + 'max:255', + $this->noControlCharactersRule(), + new ValidServerIp, + Rule::unique('v5_servers', 'node_address')->where('team_id', $currentTeam->id), + ], + 'builder_enabled' => ['sometimes', 'boolean'], + 'builder_capacity' => $this->builderCapacityRules( + $this->requestedBuilderEnabled($request, $cluster->builder_enabled) + ), + 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], + 'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'wireguard_endpoint_override' => [ + 'nullable', + 'string', + 'max:255', + $this->noControlCharactersRule(), + $this->hostPortRule(), + Rule::unique('v5_servers', 'wireguard_endpoint_override')->where('cluster_id', $cluster->id), + ], + 'ingress_enabled' => ['sometimes', 'boolean'], + 'ingress_type' => [ + Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), + 'nullable', + 'string', + Rule::in(['caddy']), + ], + ]); + + $capacity = $this->clusterServerCapacity($cluster); + + if ($capacity !== null && $cluster->servers()->count() >= $capacity) { + return response()->json([ + 'message' => "This cluster's network pools are full ({$capacity} server(s) max). Grow the pools or remove a server first.", + ], 422); + } + + $builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled); + $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false); + $ingressType = $ingressEnabled ? $validated['ingress_type'] : null; + $builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity); + $builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota; + $devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']); + $privateKey = PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->where('uuid', $validated['private_key_uuid']) + ->firstOrFail(); + + V5Server::query()->create([ + 'team_id' => $currentTeam->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $request->user()->id, + 'name' => $validated['name'], + 'host' => $validated['host'], + 'ssh_user' => $validated['ssh_user'], + 'ssh_port' => $validated['ssh_port'], + 'private_key_id' => $privateKey->id, + 'status' => ServerStatus::Added->value, + 'ingress_type' => $ingressType, + 'is_ingress' => $ingressEnabled, + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => $builderCapacity, + 'builder_cpu_quota' => $builderCpuQuota, + 'node_address' => $validated['node_address'] ?? $validated['host'], + 'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'], + 'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'], + ]); + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + ], 201); + } + + public function update(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('update', [$server, $currentTeam, $cluster]); + + $validated = $request->validate([ + 'builder_enabled' => ['required', 'boolean'], + 'builder_capacity' => $this->builderCapacityRules( + $request->boolean('builder_enabled'), + required: true + ), + 'builder_cpu_quota' => ['required', 'string', 'max:32'], + 'ingress_enabled' => ['sometimes', 'boolean'], + 'ingress_type' => [ + Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), + 'nullable', + 'string', + Rule::in(['caddy']), + ], + ]); + + $wasIngress = $server->isIngress(); + $builderEnabled = (bool) $validated['builder_enabled']; + $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress); + $ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null; + $originalServerAttributes = $server->only([ + 'is_ingress', + 'ingress_type', + 'ingress_status', + 'builder_enabled', + 'builder_capacity', + 'builder_cpu_quota', + ]); + + // Stop the ingress before persisting the change: StopCaddyIngress needs + // the server's current ingress state, and a failed stop must leave the + // capability untouched. + if ($wasIngress && ! $ingressEnabled && $server->status === ServerStatus::Installed->value) { + try { + StopCaddyIngress::run($server); + } catch (\RuntimeException $exception) { + return $this->ingressSyncErrorResponse($exception); + } + } + + $server->update([ + 'is_ingress' => $ingressEnabled, + 'ingress_type' => $ingressType, + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => (int) $validated['builder_capacity'], + 'builder_cpu_quota' => $validated['builder_cpu_quota'], + ]); + + $server->refresh(); + + if (! $wasIngress && $ingressEnabled && $server->status === ServerStatus::Installed->value) { + try { + StartCaddyIngress::run($server); + } catch (\RuntimeException $exception) { + $server->update($originalServerAttributes); + + return $this->ingressSyncErrorResponse($exception); + } + } + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + ]); + } + + public function check(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('check', [$server, $currentTeam, $cluster]); + + if (! $server->privateKey instanceof PrivateKey) { + return response()->json([ + 'status' => 'failed', + 'output' => 'No private key is attached to this server.', + 'checkedAt' => now()->toJSON(), + ]); + } + + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); + if ($keyLocation === false) { + return response()->json([ + 'status' => 'failed', + 'output' => 'Could not create a temporary SSH key file.', + 'checkedAt' => now()->toJSON(), + ]); + } + + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + $target = "{$server->ssh_user}@{$server->host}"; + $command = [ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'LogLevel=ERROR', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'ConnectTimeout=10', + '-o', + 'IdentitiesOnly=yes', + '-i', + $keyLocation, + '-p', + (string) $server->ssh_port, + $target, + "printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true", + ]; + + try { + $result = Process::timeout(15)->run($command); + $output = trim($result->output()."\n".$result->errorOutput()); + $status = $result->successful() ? 'reachable' : 'failed'; + } catch (\Throwable $e) { + $output = $e->getMessage(); + $status = 'failed'; + } finally { + @unlink($keyLocation); + } + + return response()->json([ + 'status' => $status, + 'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(), + 'checkedAt' => now()->toJSON(), + ]); + } + + public function cooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]); + + $validated = $request->validate([ + 'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'], + ]); + + $hostId = $server->fluxHostId(); + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200)); + } catch (\Throwable $e) { + Log::warning('V5 coold logs request failed', [ + 'server_id' => $server->id, + 'message' => $e->getMessage(), + ]); + + if ($server->privateKey instanceof PrivateKey) { + try { + return response()->json([ + 'output' => $this->cooldLogsOverSsh($server, (int) ($validated['tail'] ?? 200)), + 'source' => 'ssh', + 'fetchedAt' => now()->toJSON(), + ]); + } catch (\Throwable $sshException) { + Log::warning('V5 coold logs SSH fallback failed', [ + 'server_id' => $server->id, + 'message' => $sshException->getMessage(), + ]); + } + } + + return response()->json([ + 'message' => 'Could not fetch coold logs through Flux. Check the Flux and coold status, then try again.', + ], 502); + } + + return response()->json([ + 'output' => $output, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + + private function cooldLogsOverSsh(V5Server $server, int $tail): string + { + return $this->runServerSshCommand( + $server, + 'sudo -n journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q || journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q', + 'SSH coold log command failed.', + ); + } + + private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage): string + { + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); + if ($keyLocation === false) { + throw new \RuntimeException('Could not create a temporary SSH key file.'); + } + + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + try { + $result = Process::timeout(15)->run([ + 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'LogLevel=ERROR', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'ConnectTimeout=10', + '-o', + 'IdentitiesOnly=yes', + '-i', + $keyLocation, + '-p', + (string) $server->ssh_port, + "{$server->ssh_user}@{$server->host}", + $remoteCommand, + ]); + $output = trim($result->output()."\n".$result->errorOutput()); + + if (! $result->successful()) { + throw new \RuntimeException($output !== '' ? $output : $failureMessage); + } + + return str($output)->limit(10000)->toString(); + } finally { + @unlink($keyLocation); + } + } + + public function corrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]); + + $validated = $request->validate([ + 'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'], + ]); + + $hostId = $server->fluxHostId(); + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200)); + } catch (\Throwable $e) { + Log::warning('V5 corrosion tables request failed', [ + 'server_id' => $server->id, + 'message' => $e->getMessage(), + ]); + + if ($server->privateKey instanceof PrivateKey) { + try { + return response()->json([ + 'output' => $this->corrosionTablesOverSsh($server, $cluster, (int) ($validated['limit'] ?? 200)), + 'source' => 'ssh', + 'fetchedAt' => now()->toJSON(), + ]); + } catch (\Throwable $sshException) { + Log::warning('V5 corrosion tables SSH fallback failed', [ + 'server_id' => $server->id, + 'message' => $sshException->getMessage(), + ]); + } + } + + return response()->json([ + 'message' => 'Could not fetch corrosion tables through Flux. Check the Flux and coold status, then try again.', + ], 502); + } + + return response()->json([ + 'output' => $output, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + + private function corrosionTablesOverSsh(V5Server $server, V5Cluster $cluster, int $limit): string + { + $limit = max(1, min($limit, 1000)); + $script = <<<'PYTHON' +python3 - <<'PY' +import json +import urllib.request + +limit = __LIMIT__ +url = "http://127.0.0.1:__PORT__/v1/queries" + +def query(sql): + request = urllib.request.Request( + url, + data=json.dumps([sql, []]).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + return json.loads(response.read().decode()) + +def quote_identifier(value): + return '"' + value.replace('"', '""') + '"' + +tables = [] +for row in query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"): + name = row[0] if row else None + if not isinstance(name, str): + continue + + identifier = quote_identifier(name) + columns = [column[1] for column in query(f"PRAGMA table_info({identifier})") if len(column) > 1] + rows = query(f"SELECT * FROM {identifier} LIMIT {limit}") + tables.append({"name": name, "columns": columns, "rows": rows}) + +print(json.dumps({"limit": limit, "tables": tables}, separators=(",", ":"))) +PY +PYTHON; + + return $this->runServerSshCommand($server, str_replace( + ['__LIMIT__', '__PORT__'], + [(string) $limit, (string) $cluster->corrosion_api_port], + $script, + ), 'SSH corrosion table command failed.'); + } + + public function firewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]); + + $validated = $request->validate([ + 'namespace' => ['sometimes', 'string', 'max:63'], + ]); + + $hostId = $server->fluxHostId(); + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? '')); + } catch (\Throwable $e) { + Log::warning('V5 firewall rules request failed', [ + 'server_id' => $server->id, + 'message' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => 'Could not fetch firewall rules through Flux. Check the Flux and coold status, then try again.', + ], 502); + } + + return response()->json([ + 'rules' => $rules, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + + public function bootstrap(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('bootstrap', [$server, $currentTeam, $cluster]); + + // Fail fast: the bootstrap job hard-fails on Flux enrollment (after + // the WireGuard mesh is already built) when no Flux URL is configured. + if (trim((string) config('coold.flux_url', '')) === '') { + return response()->json([ + 'message' => 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.', + ], 422); + } + + if ($server->last_bootstrapped_at !== null) { + return response()->json([ + 'message' => 'This server is already bootstrapped.', + ], 409); + } + + $installedServers = $cluster->servers() + ->with('privateKey') + ->whereNotNull('last_bootstrapped_at') + ->orderBy('name') + ->get(); + $server->load('privateKey'); + $servers = $installedServers->toBase() + ->push($server) + ->unique('id') + ->values(); + + if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) { + return response()->json([ + 'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.', + ], 422); + } + + $claim = DB::transaction(function () use ($cluster, $server, $installedServers): array { + $clusterServers = $cluster->servers()->lockForUpdate()->get(); + $activeServer = $clusterServers->first(fn (V5Server $candidate): bool => $this->hasActiveBootstrapClaim($candidate)); + + if ($activeServer instanceof V5Server) { + return ['claimed' => false, 'active_server_id' => $activeServer->id]; + } + + // Sweep provably dead claims (lost job or killed worker) so retries + // are possible and the UI reflects reality. + $clusterServers + ->filter(fn (V5Server $candidate): bool => in_array($candidate->last_bootstrap_status, ['queued', 'running'], true)) + ->each(fn (V5Server $candidate) => $candidate->update([ + 'last_bootstrap_status' => 'failed', + 'last_bootstrap_output' => 'The previous bootstrap attempt timed out or its worker died. Retry the bootstrap.', + ])); + + $server->update([ + 'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend', + 'last_bootstrap_status' => 'queued', + 'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.", + 'last_bootstrap_ran_at' => now(), + ]); + + return ['claimed' => true, 'active_server_id' => null]; + }); + + if (! $claim['claimed']) { + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + 'message' => $claim['active_server_id'] === $server->id + ? 'Bootstrap is already queued or running for this server.' + : 'Another server bootstrap is already queued or running for this cluster.', + ], 409); + } + + V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id); + V5BootstrapServerJob::dispatch($cluster->id, $server->id); + + return response()->json([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + 'message' => 'Bootstrap queued.', + ], 202); + } + + public function destroy(Request $request, V5Cluster $cluster, V5Server $server): Response|JsonResponse + { + $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('delete', [$server, $currentTeam, $cluster]); + + if (V5Application::query()->where('server_id', $server->id)->exists()) { + return response()->json([ + 'message' => 'Delete or move applications from this server before deleting it.', + ], 422); + } + + $warning = null; + + if ($server->last_bootstrapped_at !== null) { + if ($server->isIngress() && $server->status === ServerStatus::Installed->value) { + try { + StopCaddyIngress::run($server); + } catch (\Throwable $exception) { + report($exception); + $warning = 'Could not stop the Caddy ingress on the server before deleting it.'; + } + } + + if (! RemoveBootstrapMarker::run($server)) { + $warning = 'Could not clean up the server over SSH. Remove /etc/coolify/v5-node.json manually before re-adding this server.'; + } + } + + $server->delete(); + + return response()->json(array_filter([ + 'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster), + 'warning' => $warning, + ])); + } + + /** + * A queued claim is active while the job could still pick it up; a running + * claim is active until the job timeout (plus margin) has passed. Anything + * older is provably dead because the job runs with $tries = 1. + */ + private function hasActiveBootstrapClaim(V5Server $server): bool + { + $ranAt = $server->last_bootstrap_ran_at; + + return match ($server->last_bootstrap_status) { + 'queued' => $ranAt !== null && $ranAt->gt(now()->subMinutes(15)), + 'running' => $ranAt !== null && $ranAt->gt(now()->subSeconds(V5BootstrapServerJob::TIMEOUT_SECONDS + 300)), + default => false, + }; + } + + /** + * @return array{listen_port: int|null, endpoint: string|null} + */ + private function devLimaWireguardOverrides(string $host, int $sshPort): array + { + if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') { + return ['listen_port' => null, 'endpoint' => null]; + } + + if ($sshPort < 60001 || $sshPort > 60009) { + return ['listen_port' => null, 'endpoint' => null]; + } + + $wireguardPort = $sshPort - 8180; + + return [ + 'listen_port' => $wireguardPort, + 'endpoint' => "host.lima.internal:{$wireguardPort}", + ]; + } + + private function clusterServerCapacity(V5Cluster $cluster): ?int + { + $namespaceCount = max(1, count($cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES)); + + [, $poolPrefix] = array_pad(explode('/', (string) $cluster->container_network_pool, 2), 2, null); + $containerPrefix = (int) $cluster->container_network_prefix; + + if (! is_string($poolPrefix) || ! ctype_digit($poolPrefix) || $containerPrefix < (int) $poolPrefix || $containerPrefix > 32) { + return null; + } + + $containerCapacity = intdiv(2 ** ($containerPrefix - (int) $poolPrefix), $namespaceCount); + + [, $managementPrefix] = array_pad(explode('/', (string) $cluster->wireguard_management_pool, 2), 2, null); + $managementCapacity = is_string($managementPrefix) && ctype_digit($managementPrefix) && (int) $managementPrefix <= 30 + ? (2 ** (32 - (int) $managementPrefix)) - 2 + : null; + + return $managementCapacity === null ? $containerCapacity : min($containerCapacity, $managementCapacity); + } + + private function noControlCharactersRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value)) { + return; + } + + if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { + $fail('The :attribute contains invalid control characters.'); + } + }; + } + + private function hostPortRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + if (! is_string($value)) { + $fail('The :attribute must be in host:port format.'); + + return; + } + + $value = trim($value); + + if (preg_match('/^\[(?.+)]:(?\d+)$/', $value, $matches) === 1) { + $host = trim((string) $matches['host']); + $port = trim((string) $matches['port']); + } else { + $separatorPosition = strrpos($value, ':'); + + if ($separatorPosition === false) { + $fail('The :attribute must be in host:port format.'); + + return; + } + + $host = trim(substr($value, 0, $separatorPosition)); + $port = trim(substr($value, $separatorPosition + 1)); + + if (str_contains($host, ':')) { + $fail('The :attribute must use [ipv6]:port format for IPv6 addresses.'); + + return; + } + } + + if ($host === '' || $port === '' || ! ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) { + $fail('The :attribute must be in host:port format.'); + + return; + } + + $failed = false; + (new ValidServerIp)->validate($attribute, $host, function () use (&$failed): void { + $failed = true; + }); + + if ($failed) { + $fail('The :attribute must be in host:port format.'); + } + }; + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index c2b183cd4..146a493ad 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -92,6 +92,7 @@ class Kernel extends HttpKernel 'v5.authenticated' => [ 'auth', 'verified', + 'throttle:v5', V5EnsureCurrentTeam::class, ], diff --git a/app/Http/Middleware/V5/EnsureCurrentTeam.php b/app/Http/Middleware/V5/EnsureCurrentTeam.php index f8aa78f92..a8a05bf38 100644 --- a/app/Http/Middleware/V5/EnsureCurrentTeam.php +++ b/app/Http/Middleware/V5/EnsureCurrentTeam.php @@ -25,7 +25,13 @@ class EnsureCurrentTeam abort(403, 'No team available for this user.'); } - session(['currentTeam' => $currentTeam]); + // The v4 UI stores a full Team model under the same session key and + // reads arbitrary columns off it, so only rewrite the session when the + // resolved team actually changed — and always store the full model. + if (data_get(session('currentTeam'), 'id') !== $currentTeam->id) { + session(['currentTeam' => $currentTeam]); + } + $request->attributes->set('v5.currentTeam', $currentTeam); return $next($request); @@ -37,7 +43,6 @@ class EnsureCurrentTeam if ($sessionTeamId) { $sessionTeam = $user->teams() - ->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team') ->whereKey($sessionTeamId) ->first(); @@ -47,7 +52,6 @@ class EnsureCurrentTeam } return $user->teams() - ->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team') ->orderBy('teams.id') ->first(); } diff --git a/app/Jobs/V5BootstrapServerJob.php b/app/Jobs/V5BootstrapServerJob.php index d19ea4ccd..e8ed95674 100644 --- a/app/Jobs/V5BootstrapServerJob.php +++ b/app/Jobs/V5BootstrapServerJob.php @@ -3,39 +3,47 @@ namespace App\Jobs; use App\Actions\V5\Proxy\StartCaddyIngress; +use App\Enums\V5\ServerStatus; use App\Events\V5ClusterUpdated; use App\Models\PrivateKey; use App\Models\V5\Cluster as V5Cluster; use App\Models\V5\Server as V5Server; +use App\Services\Flux\AgentTokenIssuer; +use App\Services\Flux\FluxClient; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; -class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue +class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; private const BOOTSTRAP_MARKER_PATH = '/etc/coolify/v5-node.json'; + public const TIMEOUT_SECONDS = 7200; + public int $tries = 1; - public int $timeout = 7200; + public int $timeout = self::TIMEOUT_SECONDS; + + /** + * Second idempotency layer on top of the controller's DB bootstrap claim, + * aligned with its running-claim window (TIMEOUT_SECONDS plus margin). + */ + public int $uniqueFor = self::TIMEOUT_SECONDS + 300; public function __construct(public int $clusterId, public int $serverId) {} - /** - * @return array - */ - public function middleware(): array + public function uniqueId(): string { - return [(new WithoutOverlapping("v5-bootstrap-server-{$this->serverId}"))->expireAfter(7200)->dontRelease()]; + return (string) $this->serverId; } public function handle(): void @@ -58,18 +66,28 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue ->unique('id') ->values(); + $started = V5Server::query() + ->whereKey($server->id) + ->where('last_bootstrap_status', 'queued') + ->update([ + 'last_bootstrap_action' => $action, + 'last_bootstrap_status' => 'running', + 'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...", + 'last_bootstrap_ran_at' => now(), + ]); + + if ($started === 0) { + return; + } + + $server->refresh(); + if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) { $this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.'); return; } - $server->update([ - 'last_bootstrap_action' => $action, - 'last_bootstrap_status' => 'running', - 'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...", - 'last_bootstrap_ran_at' => now(), - ]); $this->broadcastClusterUpdated($server); $keyDirectory = storage_path('app/ssh/keys'); @@ -89,18 +107,23 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue $existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation); if (($existingBootstrap['cluster_id'] ?? null) !== null) { - if ((string) $existingBootstrap['cluster_id'] !== (string) $cluster->id) { + $markerClusterUuid = $existingBootstrap['cluster_uuid'] ?? null; + + if ( + (string) $existingBootstrap['cluster_id'] !== (string) $cluster->id + || (is_string($markerClusterUuid) && $markerClusterUuid !== $cluster->uuid) + ) { $this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.'); return; } - $this->adoptExistingBootstrap($server, $existingBootstrap); + $this->adoptExistingBootstrap($cluster, $server, $existingBootstrap, $sshConfigLocation); return; } - $result = Process::timeout(300) + $result = Process::timeout(7200) ->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action)); $output = trim($result->output()."\n".$result->errorOutput()); $successful = $result->successful(); @@ -117,22 +140,26 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue return; } - $capabilities = collect($server->capabilities ?? []) - ->push('coold') - ->when($server->isIngress(), fn ($capabilities) => $capabilities->push('ingress')) - ->unique() - ->values() - ->all(); + $this->persistBootstrapAssignments($cluster, $server, $result->output(), $sshConfigLocation); + $server->refresh(); + + // Resolve the coold version once so the on-host marker and the + // database row always agree. + $cooldVersion = $this->bootstrappedCooldVersion($cluster, $result->output()); + + $this->writeBootstrapMarker($cluster, $server, $sshConfigLocation, $cooldVersion); + + $this->enrollCooldIntoFlux($server, $sshConfigLocation); + $this->waitForFluxHostConnection($server); $server->update([ - 'status' => 'installed', - 'capabilities' => $capabilities, + 'status' => ServerStatus::Installed->value, + 'has_coold' => true, + 'coold_version' => $cooldVersion, 'last_bootstrapped_at' => now(), ]); $this->broadcastClusterUpdated($server); - $this->writeBootstrapMarker($cluster, $server, $sshConfigLocation); - if ($server->isIngress()) { StartCaddyIngress::run($server->fresh('privateKey')); } @@ -183,11 +210,13 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue 'init', $action, '--format', - 'table', + 'json', '--nodes', $servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','), '--ssh-config', $sshConfigLocation, + '--ssh-user', + $newServer->ssh_user, '--namespaces', implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES), '--container-pool', @@ -308,14 +337,17 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue /** * @param array $marker */ - private function adoptExistingBootstrap(V5Server $server, array $marker): void + private function adoptExistingBootstrap(V5Cluster $cluster, V5Server $server, array $marker, string $sshConfigLocation): void { + $bootstrapNode = $this->bootstrapNode($server); $serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null; $updates = [ 'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip, 'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key, + 'coold_version' => is_string($marker['coold_version'] ?? null) && trim($marker['coold_version']) !== '' ? trim($marker['coold_version']) : $cluster->coold_version, 'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets, - 'status' => 'installed', + 'has_coold' => true, + 'status' => ServerStatus::Installed->value, 'last_bootstrap_status' => 'succeeded', 'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.', 'last_bootstrap_ran_at' => now(), @@ -329,28 +361,399 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue $server->update($updates); $this->broadcastClusterUpdated($server); + $this->enrollCooldIntoFlux($server->fresh(), $sshConfigLocation, $bootstrapNode); + $this->waitForFluxHostConnection($server->fresh()); + if ($server->isIngress()) { StartCaddyIngress::run($server->fresh('privateKey')); } } - private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation): void + private function persistBootstrapAssignments(V5Cluster $cluster, V5Server $server, string $output, string $sshConfigLocation): void + { + $verifiedNode = $this->verifiedBootstrapNode($output, $server); + $wireguardManagementIp = is_array($verifiedNode) && is_string($verifiedNode['wireguard_ip'] ?? null) + ? $verifiedNode['wireguard_ip'] + : null; + $warnings = []; + + if (! is_string($wireguardManagementIp) || $wireguardManagementIp === '') { + $wireguardManagementIp = $this->readWireguardManagementIp($cluster, $server, $sshConfigLocation, $warnings); + } + + $wireguardPublicKey = $this->readWireguardPublicKey($cluster, $server, $sshConfigLocation, $warnings); + $containerSubnets = $this->readContainerSubnets($cluster, $server, $sshConfigLocation, $warnings); + $updates = []; + + if ($wireguardManagementIp !== null && $wireguardManagementIp !== '') { + $updates['wireguard_management_ip'] = $wireguardManagementIp; + + if (! is_string($server->node_address) || $server->node_address === '' || $server->node_address === $server->host) { + $updates['node_address'] = $wireguardManagementIp; + } + } else { + $warnings[] = 'Warning: could not determine the WireGuard management IP from the CLI output.'; + } + + if ($wireguardPublicKey !== null && $wireguardPublicKey !== '') { + $updates['wireguard_public_key'] = $wireguardPublicKey; + } + + if ($containerSubnets !== []) { + $updates['container_subnets'] = $containerSubnets; + } + + if ($warnings !== []) { + $updates['last_bootstrap_output'] = str(trim($server->last_bootstrap_output."\n".implode("\n", $warnings))) + ->limit(20000) + ->toString(); + } + + if ($updates !== []) { + $server->update($updates); + } + } + + /** + * @param array $warnings + */ + private function readWireguardManagementIp(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string + { + $interface = escapeshellarg($cluster->wireguard_interface); + $script = implode("\n", [ + "SUDO=''", + 'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi', + "\$SUDO ip -4 -o addr show dev {$interface} | awk '{print \$4}' | cut -d/ -f1 | head -n1", + ]); + + $result = Process::timeout(15)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $this->bootstrapNode($server), + $script, + ]); + + $ipAddress = trim($result->output()); + + if (! $result->successful() || filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + $warnings[] = 'Warning: could not read the WireGuard management IP from the server.'; + + return null; + } + + return $ipAddress; + } + + /** + * @return array|null + */ + private function verifiedBootstrapNode(string $output, V5Server $server): ?array + { + $decoded = $this->decodedBootstrapOutput($output); + + if (! is_array($decoded)) { + return null; + } + + $verifiedNodes = data_get($decoded, 'verified'); + + if (! is_array($verifiedNodes)) { + return null; + } + + $bootstrapNode = $this->bootstrapNode($server); + + foreach ($verifiedNodes as $verifiedNode) { + if (! is_array($verifiedNode)) { + continue; + } + + $host = $verifiedNode['host'] ?? $verifiedNode['node'] ?? $verifiedNode['name'] ?? null; + + if ($host === $bootstrapNode || $host === $server->uuid || $host === $server->name || $host === $server->host) { + return $verifiedNode; + } + } + + return null; + } + + /** + * @return array|null + */ + private function decodedBootstrapOutput(string $output): ?array + { + $output = trim($output); + + if ($output === '' || ! str_starts_with($output, '{')) { + return null; + } + + try { + $decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + /** + * The CLI init JSON output does not currently report the installed coold + * version, so fall back to the version the cluster asked the CLI to + * install (`--coold-version`). If a future CLI adds a `coold_version` key + * to its JSON output, prefer that. + */ + private function bootstrappedCooldVersion(V5Cluster $cluster, string $output): ?string + { + $reported = data_get($this->decodedBootstrapOutput($output), 'coold_version'); + + if (is_string($reported) && trim($reported) !== '') { + return trim($reported); + } + + return $cluster->coold_version; + } + + /** + * @param array $warnings + */ + private function readWireguardPublicKey(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string + { + $interface = escapeshellarg($cluster->wireguard_interface); + $script = implode("\n", [ + "SUDO=''", + 'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi', + "\$SUDO wg show {$interface} public-key", + ]); + + $result = Process::timeout(15)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $this->bootstrapNode($server), + $script, + ]); + + $publicKey = trim($result->output()); + + if (! $result->successful() || $publicKey === '') { + $warnings[] = 'Warning: could not read the WireGuard public key from the server.'; + + return null; + } + + return $publicKey; + } + + /** + * The container subnets are allocated by the coolify CLI on the host; the podman + * networks it creates are the source of truth, so read them back instead of + * re-deriving the allocation locally. + * + * @param array $warnings + * @return array + */ + private function readContainerSubnets(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): array + { + $namespaces = $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES; + + if ($namespaces === []) { + return []; + } + + $namespaceArguments = collect($namespaces) + ->map(fn (string $namespace): string => escapeshellarg($namespace)) + ->implode(' '); + $script = implode("\n", [ + "SUDO=''", + 'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi', + "for ns in {$namespaceArguments}; do", + ' printf \'%s=\' "$ns"', + ' $SUDO podman network inspect "coolify-${ns}-mesh" --format \'{{range .Subnets}}{{.Subnet}}{{end}}\' 2>/dev/null || true', + ' printf \'\n\'', + 'done', + ]); + + $result = Process::timeout(30)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $this->bootstrapNode($server), + $script, + ]); + + if (! $result->successful()) { + $warnings[] = 'Warning: could not read the container subnets from the server.'; + + return []; + } + + $subnets = []; + + foreach (preg_split('/\r?\n/', trim($result->output())) ?: [] as $line) { + [$namespace, $subnet] = array_pad(explode('=', trim($line), 2), 2, null); + + if (! is_string($namespace) || ! in_array($namespace, $namespaces, true) || ! $this->isIpv4Cidr($subnet)) { + continue; + } + + $subnets[$namespace] = $subnet; + } + + if (count($subnets) !== count($namespaces)) { + $warnings[] = 'Warning: could not read every container subnet from the server; the stored subnets may be incomplete.'; + } + + return $subnets; + } + + private function isIpv4Cidr(?string $value): bool + { + if (! is_string($value) || ! str_contains($value, '/')) { + return false; + } + + [$ip, $prefix] = explode('/', $value, 2); + + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false + && ctype_digit($prefix) + && (int) $prefix <= 32; + } + + private function enrollCooldIntoFlux(V5Server $server, string $sshConfigLocation, ?string $bootstrapNode = null): void + { + $fluxUrl = trim((string) config('coold.flux_url', '')); + + if ($fluxUrl === '') { + throw new \RuntimeException('COOLIFY_COOLD_FLUX_URL is not configured, so the server cannot be enrolled into Flux. Set it and retry the bootstrap.'); + } + + $jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt')); + + if ($jwtPath === '') { + $jwtPath = '/etc/coolify/host-jwt'; + } + + $fluxUrl = str_replace(["\r", "\n"], '', $fluxUrl); + $jwtPath = str_replace(["\r", "\n"], '', $jwtPath); + $hostId = $server->fluxHostId(); + $token = app(AgentTokenIssuer::class)->issueForServer($server); + $tokenArgument = $this->shellArg($token); + $hostId = str_replace(["\r", "\n"], '', $hostId); + $jwtPathArgument = $this->shellPathArg($jwtPath); + $dropInDirectory = '/etc/systemd/system/coold.service.d'; + $dropInPath = "{$dropInDirectory}/10-flux.conf"; + $script = <</dev/null +\$SUDO chmod 600 {$jwtPathArgument} +cat <<'COOLIFY_FLUX_ENV' | \$SUDO tee {$dropInPath} >/dev/null +[Service] +Environment=COOLIFY_COOLD_FLUX_URL={$fluxUrl} +Environment=COOLIFY_COOLD_HOST_ID={$hostId} +Environment=COOLIFY_COOLD_HOST_JWT_PATH={$jwtPath} +COOLIFY_FLUX_ENV +\$SUDO systemctl daemon-reload +\$SUDO systemctl restart coold.service +SH; + + $result = Process::timeout(60)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $bootstrapNode ?? $this->bootstrapNode($server), + $script, + ]); + + if (! $result->successful()) { + $output = trim($result->output()."\n".$result->errorOutput()); + + throw new \RuntimeException( + ($output !== '' ? $output : 'Could not enroll coold into Flux.') + ."\nThe WireGuard mesh was created successfully; retrying this bootstrap is safe and will resume from Flux enrollment." + ); + } + } + + private function waitForFluxHostConnection(V5Server $server): void + { + $timeoutSeconds = (int) config('flux.bootstrap_host_connection_timeout_seconds', 30); + + if ($timeoutSeconds <= 0) { + return; + } + + $hostId = $server->fluxHostId(); + + if (! is_string($hostId) || $hostId === '') { + throw new \RuntimeException('Server is missing its Flux host id after bootstrap.'); + } + + $deadline = time() + $timeoutSeconds; + $lastError = null; + + do { + try { + app(FluxClient::class)->cooldLogs($hostId, 1); + + return; + } catch (\Throwable $exception) { + $lastError = $exception->getMessage(); + sleep(1); + } + } while (time() < $deadline); + + throw new \RuntimeException( + 'The server was bootstrapped, but coold did not connect to Flux in time. ' + .'Wait a moment and retry the bootstrap before deploying applications.' + .($lastError !== null ? " Last Flux error: {$lastError}" : '') + ); + } + + private function shellArg(string $value): string + { + return escapeshellarg($value); + } + + private function shellPathArg(string $value): string + { + if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) { + return $value; + } + + return $this->shellArg($value); + } + + private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, ?string $cooldVersion = null): void { $payload = base64_encode(json_encode([ 'cluster_id' => $cluster->id, + 'cluster_uuid' => $cluster->uuid, 'server_uuid' => $server->uuid, 'wireguard_management_ip' => $server->wireguard_management_ip, 'wireguard_public_key' => $server->wireguard_public_key, + 'coold_version' => $cooldVersion ?? $server->coold_version ?? $cluster->coold_version, 'container_subnets' => $server->container_subnets ?? [], ], JSON_THROW_ON_ERROR)); - Process::timeout(15)->run([ + $result = Process::timeout(15)->run([ 'ssh', '-F', $sshConfigLocation, $this->bootstrapNode($server), "payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi', ]); + + if (! $result->successful()) { + $output = trim($result->output()."\n".$result->errorOutput()); + + throw new \RuntimeException('Could not write the bootstrap marker to the server: '.($output !== '' ? $output : 'the SSH command failed.')); + } } /** diff --git a/app/Jobs/V5DeployApplicationJob.php b/app/Jobs/V5DeployApplicationJob.php new file mode 100644 index 000000000..f81650ed6 --- /dev/null +++ b/app/Jobs/V5DeployApplicationJob.php @@ -0,0 +1,53 @@ +applicationId; + } + + public function handle(): void + { + $application = V5Application::query()->find($this->applicationId); + + if (! $application instanceof V5Application) { + return; + } + + DeployNginxApplication::run($application); + } + + public function failed(?\Throwable $exception): void + { + V5Application::query()->find($this->applicationId)?->update([ + 'status' => 'failed', + 'status_message' => str($exception?->getMessage() ?? 'The deploy job failed.')->limit(10000)->toString(), + ]); + } +} diff --git a/app/Jobs/V5ReconcileServerStateJob.php b/app/Jobs/V5ReconcileServerStateJob.php new file mode 100644 index 000000000..3b5d072a4 --- /dev/null +++ b/app/Jobs/V5ReconcileServerStateJob.php @@ -0,0 +1,255 @@ + flux -> webhook), so a + * dropped webhook leaves rows stale forever; this job is the pull-based + * safety net scheduled via V5ReconcileServersJob. + */ +class V5ReconcileServerStateJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + /** + * Reconcile runs on its own queue so the 5-minute fleet fan-out (one + * blocking flux call per server) can never starve user-triggered deploys + * and bootstraps sharing the default queue. Set via onQueue() in the + * constructor rather than a `$queue` property redeclaration, which the + * Queueable trait already defines (redeclaring with a default is an + * incompatible property composition and fatals on PHP 8.5). + */ + public int $tries = 1; + + public int $timeout = 120; + + public function __construct(public int $serverId) + { + $this->onQueue('v5-reconcile'); + } + + public function handle(FluxClient $fluxClient): void + { + $server = V5Server::query()->find($this->serverId); + + if (! $server instanceof V5Server) { + return; + } + + $hostId = $server->fluxHostId(); + + if ($hostId === '') { + Log::warning('V5 reconcile skipped: server is missing a Flux host id.', ['server_id' => $server->id]); + + return; + } + + // The moment we query coold is the observation time for every row this + // pass writes; a webhook that lands with a newer observation while this + // (possibly delayed) snapshot is processed must win the watermark. + $observedAt = CarbonImmutable::now(); + + try { + $containers = collect($fluxClient->listContainers($hostId)); + } catch (\Throwable $exception) { + $this->markServerUnreachable($server, $exception, $observedAt); + + return; + } + + $this->markServerReachable($server, $containers->count(), $observedAt); + $this->refreshContainerStatuses($server, $containers, $observedAt); + $this->reconcileApplications($server, $containers, $observedAt); + } + + private function markServerUnreachable(V5Server $server, \Throwable $exception, CarbonInterface $observedAt): void + { + Log::warning('V5 reconcile could not reach the server via flux.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + + $attributes = [ + 'last_status_check' => 'reconcile', + 'last_status_output' => str($exception->getMessage())->limit(1000)->toString(), + 'last_status_checked_at' => now(), + ]; + + if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) { + // Only an installed server can degrade to unreachable; added or + // failed servers keep their bootstrap-driven status. + $attributes['status'] = $server->status === ServerStatus::Installed->value + ? ServerStatus::Unreachable->value + : $server->status; + $attributes['status_observed_at'] = $observedAt; + } + + $server->update($attributes); + } + + private function markServerReachable(V5Server $server, int $containerCount, CarbonInterface $observedAt): void + { + $attributes = [ + 'last_status_check' => 'reconcile', + 'last_status_output' => "Reconciled {$containerCount} containers from coold.", + 'last_status_checked_at' => now(), + ]; + + if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) { + $attributes['status'] = $server->status === ServerStatus::Unreachable->value + ? ServerStatus::Installed->value + : $server->status; + $attributes['status_observed_at'] = $observedAt; + } + + $server->update($attributes); + } + + /** + * @param Collection $containers + */ + private function refreshContainerStatuses(V5Server $server, Collection $containers, CarbonInterface $observedAt): void + { + $containers->each(function (mixed $container) use ($server, $observedAt): void { + if (! is_array($container) || ! is_string($container['id'] ?? null) || $container['id'] === '') { + return; + } + + $existing = ContainerStatus::query() + ->where('server_id', $server->id) + ->where('container_id', $container['id']) + ->first(); + + if (StatusObservation::isStale($observedAt, $existing?->status_observed_at, 'container status', [ + 'server_id' => $server->id, + 'container_id' => $container['id'], + ])) { + return; + } + + ContainerStatus::query()->updateOrCreate([ + 'server_id' => $server->id, + 'container_id' => $container['id'], + ], [ + 'team_id' => $server->team_id, + 'container_name' => is_string($container['name'] ?? null) ? $container['name'] : null, + 'image' => is_string($container['image'] ?? null) ? $container['image'] : null, + 'status' => $this->containerState($container, ContainerState::class), + 'status_message' => 'Container state reconciled from coold.', + 'status_observed_at' => $observedAt, + 'last_seen_at' => now(), + ]); + }); + } + + /** + * @param Collection $containers + */ + private function reconcileApplications(V5Server $server, Collection $containers, CarbonInterface $observedAt): void + { + V5Application::query() + ->where('server_id', $server->id) + ->get() + ->each(function (V5Application $application) use ($containers, $observedAt): void { + try { + $this->reconcileApplication($application, $containers, $observedAt); + } catch (\Throwable $exception) { + Log::warning('V5 reconcile failed for an application.', [ + 'application_id' => $application->id, + 'error' => $exception->getMessage(), + ]); + } + }); + } + + /** + * @param Collection $containers + */ + private function reconcileApplication(V5Application $application, Collection $containers, CarbonInterface $observedAt): void + { + $container = $containers->first(function (mixed $container) use ($application): bool { + return is_array($container) + && (($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id) + || ($container['name'] ?? null) === $application->container_name); + }); + + if (! is_array($container)) { + // A creating application without a container id simply has not + // materialized yet; the deploy job will settle it. + if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) { + return; + } + + if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { + return; + } + + $attributes = [ + 'status' => ApplicationStatus::Exited->value, + 'status_observed_at' => $observedAt, + ]; + + if ($application->status !== ApplicationStatus::Exited->value) { + $attributes['status_message'] = 'Container not found on server during reconcile.'; + } + + $application->update($attributes); + + return; + } + + if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { + return; + } + + $status = $this->containerState($container, ApplicationStatus::class); + + $attributes = [ + 'status' => $status, + 'status_observed_at' => $observedAt, + 'runtime_container_id' => is_string($container['id'] ?? null) && $container['id'] !== '' + ? $container['id'] + : $application->runtime_container_id, + ]; + + // Only write status_message when the status actually changes: the + // status column is what a viewer cares about, and a constant message + // would otherwise fire a broadcast + full re-serialization every cycle. + if ($status !== $application->status) { + $attributes['status_message'] = 'Container state reconciled from coold.'; + } + + $application->update($attributes); + } + + /** + * @param array $container + * @param class-string $enumClass + */ + private function containerState(array $container, string $enumClass): string + { + $state = $container['state'] ?? null; + $raw = is_string($state) && $state !== '' ? $state : null; + + return StatusObservation::normalize($raw, $enumClass) ?? $enumClass::Unknown->value; + } +} diff --git a/app/Jobs/V5ReconcileServersJob.php b/app/Jobs/V5ReconcileServersJob.php new file mode 100644 index 000000000..5deae2e57 --- /dev/null +++ b/app/Jobs/V5ReconcileServersJob.php @@ -0,0 +1,89 @@ +onQueue('v5-reconcile'); + } + + public function handle(): void + { + $this->dispatchReconcileJobs(); + $this->pruneContainerStatuses(); + } + + private function dispatchReconcileJobs(): void + { + V5Server::query() + // Unreachable servers stay in the loop so a recovered node is + // restored to installed by its next successful reconcile. + ->whereIn('status', [ServerStatus::Installed->value, ServerStatus::Unreachable->value]) + ->where('has_coold', true) + ->get() + ->each(function (V5Server $server): void { + try { + V5ReconcileServerStateJob::dispatch($server->id); + } catch (\Throwable $exception) { + Log::warning('V5 reconcile dispatch failed for a server.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + } + }); + } + + private function pruneContainerStatuses(): void + { + $cutoff = now()->subHours(self::CONTAINER_STATUS_TTL_HOURS); + $liveContainerIds = V5Application::query() + ->whereNotNull('runtime_container_id') + ->pluck('runtime_container_id') + ->all(); + + ContainerStatus::query() + ->where(function ($query) use ($cutoff): void { + $query + ->where('last_seen_at', '<', $cutoff) + ->orWhere(function ($query) use ($cutoff): void { + $query->whereNull('last_seen_at')->where('created_at', '<', $cutoff); + }) + ->orWhereNotIn('server_id', V5Server::query()->select('id')); + }) + ->when($liveContainerIds !== [], fn ($query) => $query->whereNotIn('container_id', $liveContainerIds)) + ->delete(); + } +} diff --git a/app/Jobs/V5RotateAgentTokenJob.php b/app/Jobs/V5RotateAgentTokenJob.php new file mode 100644 index 000000000..c05f5be74 --- /dev/null +++ b/app/Jobs/V5RotateAgentTokenJob.php @@ -0,0 +1,150 @@ + flux UDS -> coold's `host.jwt.set` command), + * because that reuses the already authenticated flux<->coold channel and works + * while the CURRENT token is still valid — which is exactly when rotation runs + * (at ~12h remaining, well before the 24h exp). Only if the RPC push fails (the + * host's stream is down because its token already lapsed, flux rejects the verb, + * a timeout, etc.) do we fall back to the SSH push, which recovers a node whose + * token already expired and whose stream is therefore gone. + * + * PUSH-THEN-PERSIST: the new token is delivered to the host FIRST, and the + * server's jti/expires_at are only advanced AFTER a successful delivery via + * EITHER path. If both delivery paths fail the DB is left untouched, so the old + * expires_at keeps the server inside the dispatcher's rotation window and the + * next cycle simply retries — we never advance the watermark on a token the + * host never received (which would strand the host on the expiring old token + * until it fully lapsed). + * + * NO-REVOKE-ON-ROTATION: the previously issued jti is intentionally NOT revoked + * here. The old token is still legitimately valid until its own exp and coold + * may still be connected on it; revoking it would risk cutting the live stream. + * Revocation belongs to teardown/re-home (RemoveBootstrapMarker), not routine + * rotation — the old token simply ages out on its own exp. + */ +class V5RotateAgentTokenJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public int $tries = 3; + + public int $timeout = 60; + + /** + * Rotation shares the reconcile queue so the hourly fleet fan-out can never + * starve user-triggered deploys and bootstraps on the default queue. Set via + * onQueue() rather than a `$queue` property redeclaration, which the + * Queueable trait already defines (redeclaring with a default is an + * incompatible property composition and fatals on PHP 8.5). + */ + public function __construct(public int $serverId) + { + $this->onQueue('v5-reconcile'); + } + + public function handle(): void + { + $server = V5Server::query()->with('privateKey')->find($this->serverId); + + if (! $server instanceof V5Server) { + return; + } + + if (! $this->isEligible($server)) { + return; + } + + $hostId = $server->fluxHostId(); + + if ($hostId === '') { + Log::warning('V5 token rotation skipped: server is missing a Flux host id.', ['server_id' => $server->id]); + + return; + } + + $ttl = (int) config('flux.host_token_ttl'); + $jti = (string) Str::uuid(); + + $token = app(AgentTokenIssuer::class)->issue($hostId, null, $ttl, [ + 'jti' => $jti, + 'team_id' => (string) $server->team_id, + 'cluster_id' => (string) $server->cluster_id, + 'server_id' => $hostId, + 'wireguard_management_ip' => (string) $server->wireguard_management_ip, + ]); + + $delivery = $this->deliverToken($server, $hostId, $token); + + if ($delivery === null) { + Log::warning('V5 token rotation could not deliver the new host token; leaving the existing token in place.', [ + 'server_id' => $server->id, + 'host' => $server->host, + ]); + + return; + } + + $server->update([ + 'agent_token_jti' => $jti, + 'agent_token_expires_at' => now()->addSeconds($ttl), + ]); + + Log::debug('V5 token rotation delivered a fresh host token.', [ + 'server_id' => $server->id, + 'delivery' => $delivery, + ]); + } + + /** + * Deliver the freshly minted token to the host, preferring the live coold + * RPC stream and falling back to the SSH push on any RPC failure. + * + * @return 'rpc'|'ssh'|null The path that succeeded, or null if both failed. + */ + private function deliverToken(V5Server $server, string $hostId, string $token): ?string + { + try { + app(FluxClient::class)->pushHostToken($hostId, $token); + + return 'rpc'; + } catch (\Throwable $exception) { + Log::info('V5 token rotation RPC push failed; falling back to SSH.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + } + + if (PushHostAgentToken::run($server, $token)) { + return 'ssh'; + } + + return null; + } + + private function isEligible(V5Server $server): bool + { + return $server->status === ServerStatus::Installed->value + && (bool) $server->has_coold + && $server->last_bootstrapped_at !== null; + } +} diff --git a/app/Jobs/V5RotateAgentTokensJob.php b/app/Jobs/V5RotateAgentTokensJob.php new file mode 100644 index 000000000..537a6b474 --- /dev/null +++ b/app/Jobs/V5RotateAgentTokensJob.php @@ -0,0 +1,65 @@ +onQueue('v5-reconcile'); + } + + public function handle(): void + { + $threshold = now()->addSeconds((int) config('flux.host_token_refresh_threshold')); + + V5Server::query() + ->where('status', ServerStatus::Installed->value) + ->where('has_coold', true) + ->whereNotNull('last_bootstrapped_at') + ->where(function ($query) use ($threshold): void { + $query + ->whereNull('agent_token_expires_at') + ->orWhere('agent_token_expires_at', '<', $threshold); + }) + ->get() + ->each(function (V5Server $server): void { + try { + V5RotateAgentTokenJob::dispatch($server->id); + } catch (\Throwable $exception) { + Log::warning('V5 token rotation dispatch failed for a server.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + } + }); + } +} diff --git a/app/Jobs/V5TeardownTeamJob.php b/app/Jobs/V5TeardownTeamJob.php new file mode 100644 index 000000000..5f2a5eebd --- /dev/null +++ b/app/Jobs/V5TeardownTeamJob.php @@ -0,0 +1,325 @@ +> $servers Self-contained per-server teardown payload captured before the cascade. + */ + public function __construct( + public int $teamId, + public array $servers, + ) {} + + /** + * Collect the team's v5 servers (with their applications and SSH key + * material) into a self-contained payload and dispatch the teardown job. + * + * Must be called from the Team `deleting` hook, while the rows still exist. + * Returns without dispatching when the team owns no v5 servers. + */ + public static function dispatchForTeam(Team $team): void + { + // Guard against contexts where the v5 tables do not exist (e.g. v4-only + // schemas) so team deletion is never broken by this teardown. + if (! Schema::hasTable('v5_servers')) { + return; + } + + $servers = V5Server::query() + ->where('team_id', $team->id) + ->with('privateKey') + ->get(); + + if ($servers->isEmpty()) { + return; + } + + $applicationsByServer = V5Application::query() + ->where('team_id', $team->id) + ->whereNotNull('server_id') + ->get() + ->groupBy('server_id'); + + $payload = $servers->map(function (V5Server $server) use ($applicationsByServer): array { + return [ + 'id' => $server->id, + 'uuid' => $server->uuid, + 'name' => $server->name, + 'host' => $server->host, + 'ssh_user' => $server->ssh_user, + 'ssh_port' => (int) $server->ssh_port, + 'node_address' => $server->node_address, + 'wireguard_management_ip' => $server->wireguard_management_ip, + 'is_ingress' => (bool) $server->is_ingress, + 'ingress_type' => $server->ingress_type, + 'status' => $server->status, + 'last_bootstrapped_at' => $server->last_bootstrapped_at?->toISOString(), + // Captured before the cascade removes the row so the job can + // revoke the host token after the DB rows are gone. + 'agent_token_jti' => $server->agent_token_jti, + 'agent_token_expires_at' => $server->agent_token_expires_at?->toISOString(), + // Encrypted at rest on the model; needed to SSH into the host. + 'private_key' => $server->privateKey instanceof PrivateKey ? $server->privateKey->private_key : null, + 'applications' => ($applicationsByServer[$server->id] ?? collect()) + ->map(fn (V5Application $application): array => [ + 'id' => $application->id, + 'container_name' => $application->container_name, + 'runtime_container_id' => $application->runtime_container_id, + ]) + ->values() + ->all(), + ]; + })->all(); + + self::dispatch($team->id, $payload); + } + + public function handle(): void + { + $incompleteHosts = []; + + foreach ($this->servers as $serverPayload) { + if (! $this->teardownServer($serverPayload)) { + $incompleteHosts[] = [ + 'server_id' => $serverPayload['id'] ?? null, + 'host' => $serverPayload['host'] ?? null, + ]; + } + } + + // Teardown is best-effort and never fails the job (an unreachable host + // must not abort the others), so this is the single operator-facing + // signal that some hosts could not be reached and may now hold orphaned + // containers/mesh with no DB row left to reconcile them. + if ($incompleteHosts !== []) { + Log::error('v5 team teardown incomplete — '.count($incompleteHosts).' host(s) may have orphaned containers/mesh', [ + 'team_id' => $this->teamId, + 'hosts' => $incompleteHosts, + ]); + } + } + + /** + * Tear down a single host. Returns false when any on-host teardown step + * (container removal, ingress stop, bootstrap-marker removal) failed, so the + * caller can surface the host as potentially orphaned. Never throws: a + * single unreachable host must not abort teardown of the other hosts. + * + * @param array $serverPayload + */ + private function teardownServer(array $serverPayload): bool + { + $server = $this->reconstructServer($serverPayload); + $serverId = $serverPayload['id'] ?? null; + $host = $serverPayload['host'] ?? null; + $succeeded = true; + + foreach ($serverPayload['applications'] ?? [] as $applicationPayload) { + try { + $application = $this->reconstructApplication($applicationPayload, $server); + DestroyNginxApplication::run($application); + } catch (\Throwable $exception) { + $succeeded = false; + Log::warning('V5 team teardown: failed to remove application container', [ + 'team_id' => $this->teamId, + 'server_id' => $serverId, + 'host' => $host, + 'container_name' => $applicationPayload['container_name'] ?? null, + 'error' => $exception->getMessage(), + ]); + } + } + + if ($server->isIngress() && $server->status === ServerStatus::Installed->value) { + try { + StopCaddyIngress::run($server); + } catch (\Throwable $exception) { + $succeeded = false; + Log::warning('V5 team teardown: failed to stop Caddy ingress', [ + 'team_id' => $this->teamId, + 'server_id' => $serverId, + 'host' => $host, + 'error' => $exception->getMessage(), + ]); + } + } + + if (($serverPayload['last_bootstrapped_at'] ?? null) !== null) { + try { + if (! RemoveBootstrapMarker::run($server)) { + $succeeded = false; + Log::warning('V5 team teardown: could not remove on-host bootstrap identity over SSH', [ + 'team_id' => $this->teamId, + 'server_id' => $serverId, + 'host' => $host, + ]); + } + } catch (\Throwable $exception) { + $succeeded = false; + Log::warning('V5 team teardown: bootstrap marker removal threw', [ + 'team_id' => $this->teamId, + 'server_id' => $serverId, + 'host' => $host, + 'error' => $exception->getMessage(), + ]); + } + } + + // Revocation is best-effort and independent of the on-host cleanup: a + // failed flux push does not mean the host is orphaned, so it never flips + // $succeeded (it is logged separately inside AgentTokenIssuer::revoke). + $this->revokeAgentTokenIfSupported($server, $serverId, $host); + + return $succeeded; + } + + /** + * Reconstruct a non-persisted V5Server (with its private key relation + * pre-set) so the teardown actions never hit the deleted DB rows. + * + * @param array $serverPayload + */ + private function reconstructServer(array $serverPayload): V5Server + { + $server = new V5Server; + $server->forceFill([ + 'id' => $serverPayload['id'] ?? null, + 'uuid' => $serverPayload['uuid'] ?? null, + 'name' => $serverPayload['name'] ?? null, + 'host' => $serverPayload['host'] ?? null, + 'ssh_user' => $serverPayload['ssh_user'] ?? null, + 'ssh_port' => $serverPayload['ssh_port'] ?? 22, + 'node_address' => $serverPayload['node_address'] ?? null, + 'wireguard_management_ip' => $serverPayload['wireguard_management_ip'] ?? null, + 'is_ingress' => (bool) ($serverPayload['is_ingress'] ?? false), + 'ingress_type' => $serverPayload['ingress_type'] ?? null, + 'status' => $serverPayload['status'] ?? null, + 'agent_token_jti' => $serverPayload['agent_token_jti'] ?? null, + 'agent_token_expires_at' => $serverPayload['agent_token_expires_at'] ?? null, + ]); + // Non-persisted: StopCaddyIngress / the actions must not try to update a + // row that the cascade already removed. + $server->exists = false; + + $privateKeyMaterial = $serverPayload['private_key'] ?? null; + if (is_string($privateKeyMaterial) && $privateKeyMaterial !== '') { + $privateKey = new PrivateKey; + $privateKey->forceFill(['private_key' => $privateKeyMaterial]); + $server->setRelation('privateKey', $privateKey); + } else { + $server->setRelation('privateKey', null); + } + + return $server; + } + + /** + * @param array $applicationPayload + */ + private function reconstructApplication(array $applicationPayload, V5Server $server): V5Application + { + $application = new V5Application; + $application->forceFill([ + 'id' => $applicationPayload['id'] ?? null, + 'container_name' => $applicationPayload['container_name'] ?? null, + 'runtime_container_id' => $applicationPayload['runtime_container_id'] ?? null, + 'server_id' => $server->id, + ]); + $application->exists = false; + $application->setRelation('server', $server); + + return $application; + } + + /** + * If a coold-side agent-token revocation ever lands on AgentTokenIssuer, + * call it best-effort. Guarded so this job never hard-depends on a method + * that may not exist yet. + */ + private function revokeAgentTokenIfSupported(V5Server $server, mixed $serverId, mixed $host): void + { + if (! method_exists(AgentTokenIssuer::class, 'revokeForServer')) { + return; + } + + try { + app(AgentTokenIssuer::class)->revokeForServer($server); + } catch (\Throwable $exception) { + Log::warning('V5 team teardown: agent token revocation failed', [ + 'team_id' => $this->teamId, + 'server_id' => $serverId, + 'host' => $host, + 'error' => $exception->getMessage(), + ]); + } + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index a979b44fb..d01bfc45c 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Actions\User\RevokeUserTeamTokens; use App\Events\ServerReachabilityChanged; +use App\Jobs\V5TeardownTeamJob; use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; @@ -75,6 +76,17 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen }); static::deleting(function (Team $team) { + // Best-effort on-host teardown of this team's v5 resources BEFORE the + // DB cascade removes the servers/applications/private keys. Captured + // synchronously into a queued job so an unreachable host cannot block + // or fail the team deletion (see V5TeardownTeamJob). Guarded so a v5 + // teardown problem never breaks v4 team deletion. + try { + V5TeardownTeamJob::dispatchForTeam($team); + } catch (\Throwable $exception) { + report($exception); + } + RevokeUserTeamTokens::forTeam($team->id); foreach ($team->privateKeys as $key) { diff --git a/app/Models/V5/Application.php b/app/Models/V5/Application.php index 9052ef11a..9b673a97c 100644 --- a/app/Models/V5/Application.php +++ b/app/Models/V5/Application.php @@ -2,6 +2,7 @@ namespace App\Models\V5; +use App\Enums\V5\ApplicationStatus; use App\Events\V5CanvasResourceUpdated; use App\Models\Environment; use App\Models\Project; @@ -9,6 +10,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\DB; class Application extends V5Model { @@ -26,6 +28,7 @@ class Application extends V5Model 'container_name', 'status', 'status_message', + 'status_observed_at', 'runtime_container_id', 'mesh_namespace', 'ingress_enabled', @@ -35,7 +38,7 @@ class Application extends V5Model ]; protected $attributes = [ - 'status' => 'creating', + 'status' => ApplicationStatus::Creating->value, 'mesh_namespace' => 'default', 'ingress_enabled' => false, 'canvas_x' => 0, @@ -46,7 +49,7 @@ class Application extends V5Model { static::updated(function (self $application): void { if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) { - V5CanvasResourceUpdated::dispatch($application->team_id, $application->id); + DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($application->team_id, $application->id)); } }); } @@ -54,6 +57,7 @@ class Application extends V5Model protected function casts(): array { return [ + 'status_observed_at' => 'datetime', 'ingress_enabled' => 'boolean', 'internal_port' => 'integer', 'canvas_x' => 'integer', diff --git a/app/Models/V5/ApplicationDomain.php b/app/Models/V5/ApplicationDomain.php index 5cabe6207..4a518dbfa 100644 --- a/app/Models/V5/ApplicationDomain.php +++ b/app/Models/V5/ApplicationDomain.php @@ -8,6 +8,8 @@ class ApplicationDomain extends V5Model { protected $table = 'v5_application_domains'; + protected bool $hasUuidColumn = false; + protected $fillable = [ 'application_id', 'domain', diff --git a/app/Models/V5/Cluster.php b/app/Models/V5/Cluster.php index d910ae698..213b492ea 100644 --- a/app/Models/V5/Cluster.php +++ b/app/Models/V5/Cluster.php @@ -11,6 +11,12 @@ class Cluster extends V5Model { protected $table = 'v5_clusters'; + /** + * Single source of truth for cluster defaults: `$attributes` below is + * built from these consts, and the column defaults in + * 2026_06_16_130649_v5_create_clusters_table mirror them (kept there for + * historical rows only — update both when changing a default). + */ public const DEFAULT_WIREGUARD_INTERFACE = 'wg0'; public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16'; diff --git a/app/Models/V5/ContainerStatus.php b/app/Models/V5/ContainerStatus.php index 6985ccda8..ccea59618 100644 --- a/app/Models/V5/ContainerStatus.php +++ b/app/Models/V5/ContainerStatus.php @@ -9,6 +9,8 @@ class ContainerStatus extends V5Model { protected $table = 'v5_container_statuses'; + protected bool $hasUuidColumn = false; + protected $fillable = [ 'team_id', 'server_id', @@ -17,12 +19,14 @@ class ContainerStatus extends V5Model 'image', 'status', 'status_message', + 'status_observed_at', 'last_seen_at', ]; protected function casts(): array { return [ + 'status_observed_at' => 'datetime', 'last_seen_at' => 'datetime', ]; } diff --git a/app/Models/V5/ResourceConnectionRule.php b/app/Models/V5/ResourceConnectionRule.php index 10fe290ee..8369c316d 100644 --- a/app/Models/V5/ResourceConnectionRule.php +++ b/app/Models/V5/ResourceConnectionRule.php @@ -9,6 +9,8 @@ class ResourceConnectionRule extends V5Model { protected $table = 'v5_resource_connection_rules'; + protected bool $hasUuidColumn = false; + protected $fillable = [ 'connection_id', 'source_resource_type', diff --git a/app/Models/V5/RevokedAgentToken.php b/app/Models/V5/RevokedAgentToken.php new file mode 100644 index 000000000..c60523829 --- /dev/null +++ b/app/Models/V5/RevokedAgentToken.php @@ -0,0 +1,45 @@ + + */ + protected function casts(): array + { + return [ + 'revoked_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } +} diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index 040618c5c..9fc85bb74 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -2,12 +2,15 @@ namespace App\Models\V5; +use App\Enums\V5\IngressStatus; use App\Events\V5CanvasResourceUpdated; use App\Events\V5ClusterUpdated; use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\DB; class Server extends V5Model { @@ -24,9 +27,12 @@ class Server extends V5Model 'ssh_user', 'ssh_port', 'status', + 'status_observed_at', 'ingress_type', 'ingress_status', 'capabilities', + 'has_coold', + 'is_ingress', 'builder_enabled', 'builder_capacity', 'builder_cpu_quota', @@ -35,6 +41,9 @@ class Server extends V5Model 'wireguard_endpoint_override', 'wireguard_management_ip', 'wireguard_public_key', + 'coold_version', + 'agent_token_jti', + 'agent_token_expires_at', 'container_subnets', 'canvas_x', 'canvas_y', @@ -60,22 +69,22 @@ class Server extends V5Model } if ($server->wasChanged('status') && $server->cluster_id !== null) { - V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id); + DB::afterCommit(fn () => V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id)); } if ($server->wasChanged('status')) { - V5CanvasResourceUpdated::dispatch( + DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch( $server->team_id, null, $server->isIngress() ? $server->id : null, $server->id, - ); + )); return; } if ($server->isIngress()) { - V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id); + DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id)); } }); } @@ -83,20 +92,56 @@ class Server extends V5Model protected function casts(): array { return [ - 'capabilities' => 'array', + 'has_coold' => 'boolean', + 'is_ingress' => 'boolean', 'builder_enabled' => 'boolean', 'container_subnets' => 'array', 'canvas_x' => 'integer', 'canvas_y' => 'integer', + 'status_observed_at' => 'datetime', + 'agent_token_expires_at' => 'datetime', 'last_bootstrapped_at' => 'datetime', 'last_bootstrap_ran_at' => 'datetime', 'last_status_checked_at' => 'datetime', ]; } + public function fluxHostId(): string + { + return (string) $this->uuid; + } + + /** + * Virtual attribute kept for wire-format compatibility: capabilities are + * stored as the indexed has_coold / is_ingress booleans, but reads and + * writes of `capabilities` keep working with the historical string array. + * Unknown capability names are dropped on write. + * + * The dropped `capabilities` column intentionally stays in `$fillable`: + * call sites still mass-assign it, and this mutator maps those writes + * onto the boolean columns. + */ + protected function capabilities(): Attribute + { + return Attribute::make( + get: fn () => array_values(array_filter([ + $this->has_coold ? 'coold' : null, + $this->is_ingress ? 'ingress' : null, + ])), + set: fn (?array $capabilities) => [ + 'has_coold' => in_array('coold', $capabilities ?? [], true), + 'is_ingress' => in_array('ingress', $capabilities ?? [], true), + ], + ); + } + public function hasCapability(string $capability): bool { - return in_array($capability, $this->capabilities ?? [], true); + return match ($capability) { + 'coold' => (bool) $this->has_coold, + 'ingress' => (bool) $this->is_ingress, + default => false, + }; } /** @@ -104,7 +149,7 @@ class Server extends V5Model */ public function withCapability(string $capability): array { - return collect($this->capabilities ?? []) + return collect($this->capabilities) ->push($capability) ->unique() ->values() @@ -116,7 +161,7 @@ class Server extends V5Model */ public function withoutCapability(string $capability): array { - return collect($this->capabilities ?? []) + return collect($this->capabilities) ->reject(fn (string $existingCapability) => $existingCapability === $capability) ->values() ->all(); @@ -124,16 +169,12 @@ class Server extends V5Model public function isIngress(): bool { - return $this->hasCapability('ingress'); + return (bool) $this->is_ingress; } public function ingressStatus(): string { - if ($this->ingress_status !== null) { - return $this->ingress_status; - } - - return $this->status === 'installed' ? 'running' : 'unknown'; + return $this->ingress_status ?? IngressStatus::Unknown->value; } public function ingressType(): string diff --git a/app/Models/V5/V5Model.php b/app/Models/V5/V5Model.php index d1df6e12b..935bd20ac 100644 --- a/app/Models/V5/V5Model.php +++ b/app/Models/V5/V5Model.php @@ -3,26 +3,55 @@ namespace App\Models\V5; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Schema; abstract class V5Model extends Model { + /** + * Whether the model's table has a `uuid` column. Models without one (set + * this to false there) skip public-id generation and route on the primary + * key instead. + */ + protected bool $hasUuidColumn = true; + public function getRouteKeyName(): string { - return 'uuid'; + return $this->hasUuidColumn ? 'uuid' : $this->getKeyName(); } protected static function boot(): void { parent::boot(); - static::creating(function (Model $model): void { - if ( - Schema::hasColumn($model->getTable(), 'uuid') - && ! $model->getAttribute('uuid') - ) { - $model->setAttribute('uuid', new_public_id()); + static::creating(function (self $model): void { + if ($model->hasUuidColumn && ! $model->getAttribute('uuid')) { + $model->setAttribute('uuid', $model->newUniquePublicId()); } }); } + + /** + * Generate a public id, regenerating (up to three candidates) when one is + * already taken. A concurrent insert between this exists() check and our + * own insert can still collide; the unique index then rejects the insert, + * which is an acceptable residual race for these cheap, retryable writes. + */ + protected function newUniquePublicId(): string + { + $attempts = 0; + + do { + $candidate = $this->newPublicIdCandidate(); + $attempts++; + } while ( + $attempts < 3 + && $this->newModelQuery()->where('uuid', $candidate)->exists() + ); + + return $candidate; + } + + protected function newPublicIdCandidate(): string + { + return new_public_id(); + } } diff --git a/app/Policies/V5/ApplicationPolicy.php b/app/Policies/V5/ApplicationPolicy.php new file mode 100644 index 000000000..5b1877a00 --- /dev/null +++ b/app/Policies/V5/ApplicationPolicy.php @@ -0,0 +1,74 @@ +belongsToTeam($application, $team); + } + + /** + * Determine whether the user can update the application within the current team. + */ + public function update(User $user, Application $application, Team $team): Response + { + return $this->allowIfAdminAndScoped($user, $application, $team); + } + + /** + * Determine whether the user can update the application's ingress configuration. + */ + public function updateIngress(User $user, Application $application, Team $team): Response + { + return $this->allowIfAdminAndScoped($user, $application, $team); + } + + /** + * Determine whether the user can delete the application within the current team. + */ + public function delete(User $user, Application $application, Team $team): Response + { + return $this->allowIfAdminAndScoped($user, $application, $team); + } + + /** + * Run the team scoping check first (mismatch stays hidden as a 404) and + * only then the role check (403 for members on their own team's app). + */ + private function allowIfAdminAndScoped(User $user, Application $application, Team $team): Response + { + $scope = $this->belongsToTeam($application, $team); + + if ($scope->denied()) { + return $scope; + } + + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage applications in this team.'); + } + + /** + * Applications outside the current team must stay invisible, so + * mismatches deny as not found instead of forbidden. + */ + private function belongsToTeam(Application $application, Team $team): Response + { + return $application->team_id === $team->id + ? Response::allow() + : Response::denyAsNotFound(); + } +} diff --git a/app/Policies/V5/ClusterPolicy.php b/app/Policies/V5/ClusterPolicy.php new file mode 100644 index 000000000..dec9e1c35 --- /dev/null +++ b/app/Policies/V5/ClusterPolicy.php @@ -0,0 +1,64 @@ +belongsToTeam($cluster, $team); + } + + /** + * Determine whether the user can create a cluster in the current team. + * There is no model to scope yet, so this is a pure role gate. + */ + public function create(User $user, Team $team): Response + { + return $this->allowIfAdmin($user, $team); + } + + /** + * Determine whether the user can delete the cluster within the current team. + */ + public function delete(User $user, Cluster $cluster, Team $team): Response + { + $scope = $this->belongsToTeam($cluster, $team); + + if ($scope->denied()) { + return $scope; + } + + return $this->allowIfAdmin($user, $team); + } + + /** + * Members may read but not mutate; only admins/owners of the team pass. + */ + private function allowIfAdmin(User $user, Team $team): Response + { + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage clusters in this team.'); + } + + /** + * Clusters outside the current team must stay invisible, so mismatches + * deny as not found instead of forbidden. + */ + private function belongsToTeam(Cluster $cluster, Team $team): Response + { + return $cluster->team_id === $team->id + ? Response::allow() + : Response::denyAsNotFound(); + } +} diff --git a/app/Policies/V5/ResourceConnectionPolicy.php b/app/Policies/V5/ResourceConnectionPolicy.php new file mode 100644 index 000000000..6b3dcde79 --- /dev/null +++ b/app/Policies/V5/ResourceConnectionPolicy.php @@ -0,0 +1,55 @@ +allowIfAdminAndScoped($user, $connection, $team); + } + + /** + * Determine whether the user can delete the connection within the current team. + */ + public function delete(User $user, ResourceConnection $connection, Team $team): Response + { + return $this->allowIfAdminAndScoped($user, $connection, $team); + } + + /** + * Run the team scoping check first (mismatch stays hidden as a 404) and + * only then the role check (403 for members on their own team's connection). + */ + private function allowIfAdminAndScoped(User $user, ResourceConnection $connection, Team $team): Response + { + $scope = $this->belongsToTeam($connection, $team); + + if ($scope->denied()) { + return $scope; + } + + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage resource connections in this team.'); + } + + /** + * Connections outside the current team must stay invisible, so + * mismatches deny as not found instead of forbidden. + */ + private function belongsToTeam(ResourceConnection $connection, Team $team): Response + { + return $connection->team_id === $team->id + ? Response::allow() + : Response::denyAsNotFound(); + } +} diff --git a/app/Policies/V5/ServerPolicy.php b/app/Policies/V5/ServerPolicy.php new file mode 100644 index 000000000..af828b398 --- /dev/null +++ b/app/Policies/V5/ServerPolicy.php @@ -0,0 +1,121 @@ +team_id !== $team->id) { + return Response::deny(); + } + + return $this->allowIfAdmin($user, $team); + } + + /** + * Determine whether the user can update the server within the current team. + */ + public function update(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); + } + + /** + * Determine whether the user can delete the server within the current team. + */ + public function delete(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); + } + + /** + * Determine whether the user can run a connectivity check against the server. + */ + public function check(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); + } + + /** + * Determine whether the user can bootstrap the server. + */ + public function bootstrap(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->allowIfAdminAndScoped($user, $server, $team, $cluster); + } + + /** + * Determine whether the user can view server diagnostics (coold logs, + * corrosion tables, firewall rules). Read-only, so gated on team + * membership alone. + */ + public function viewDiagnostics(User $user, Server $server, Team $team, Cluster $cluster): Response + { + return $this->belongsToClusterInTeam($server, $team, $cluster); + } + + /** + * Determine whether the user can move the server's Caddy ingress card on + * the canvas. Non-ingress servers must stay invisible on the canvas, and + * moving a card mutates persisted layout so it requires an admin/owner. + */ + public function updateCanvasPosition(User $user, Server $server, Team $team): Response + { + if (! ($server->team_id === $team->id && $server->isIngress())) { + return Response::denyAsNotFound(); + } + + return $this->allowIfAdmin($user, $team); + } + + /** + * Run the team/cluster scoping check first (mismatch stays hidden as a 404) + * and only then the role check (403 for members on their own team's server). + */ + private function allowIfAdminAndScoped(User $user, Server $server, Team $team, Cluster $cluster): Response + { + $scope = $this->belongsToClusterInTeam($server, $team, $cluster); + + if ($scope->denied()) { + return $scope; + } + + return $this->allowIfAdmin($user, $team); + } + + /** + * Members may read but not mutate; only admins/owners of the team pass. + */ + private function allowIfAdmin(User $user, Team $team): Response + { + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage servers in this team.'); + } + + /** + * Servers outside the current team (or outside the addressed cluster) + * must stay invisible, so mismatches deny as not found. + */ + private function belongsToClusterInTeam(Server $server, Team $team, Cluster $cluster): Response + { + return $cluster->team_id === $team->id + && $server->team_id === $team->id + && $server->cluster_id === $cluster->id + ? Response::allow() + : Response::denyAsNotFound(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2d0094f33..9e9bb1c6c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,7 +3,9 @@ namespace App\Providers; use App\Models\PersonalAccessToken; +use App\Models\V5\Application; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -27,6 +29,7 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); + $this->configureMorphMap(); $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); @@ -41,6 +44,18 @@ class AppServiceProvider extends ServiceProvider } } + /** + * Map v5 models to stable morph aliases so polymorphic rows survive class + * renames. Deliberately NOT enforced: v4 polymorphic relations store FQCNs + * and must keep resolving them. + */ + private function configureMorphMap(): void + { + Relation::morphMap([ + 'v5.application' => Application::class, + ]); + } + private function configureModels(): void { // Disabled because it's causing issues with the application diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 5c1a79cf7..8903e750b 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -36,6 +36,10 @@ use App\Models\StandaloneRedis; use App\Models\SwarmDocker; use App\Models\Team; use App\Models\TelegramNotificationSettings; +use App\Models\V5\Application as V5Application; +use App\Models\V5\Cluster as V5Cluster; +use App\Models\V5\ResourceConnection as V5ResourceConnection; +use App\Models\V5\Server as V5Server; use App\Models\WebhookNotificationSettings; use App\Policies\ApiTokenPolicy; use App\Policies\ApplicationPolicy; @@ -61,6 +65,10 @@ use App\Policies\SharedEnvironmentVariablePolicy; use App\Policies\StandaloneDockerPolicy; use App\Policies\SwarmDockerPolicy; use App\Policies\TeamPolicy; +use App\Policies\V5\ApplicationPolicy as V5ApplicationPolicy; +use App\Policies\V5\ClusterPolicy as V5ClusterPolicy; +use App\Policies\V5\ResourceConnectionPolicy as V5ResourceConnectionPolicy; +use App\Policies\V5\ServerPolicy as V5ServerPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; use Laravel\Sanctum\PersonalAccessToken; @@ -122,6 +130,12 @@ class AuthServiceProvider extends ServiceProvider CloudProviderToken::class => CloudProviderTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, + // V5 policies - scoped to the current team resolved from the request + V5Application::class => V5ApplicationPolicy::class, + V5Cluster::class => V5ClusterPolicy::class, + V5ResourceConnection::class => V5ResourceConnectionPolicy::class, + V5Server::class => V5ServerPolicy::class, + ]; /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2bb5b40bd..7c1969395 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -60,6 +60,14 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); }); + // v5 authenticated web endpoints run synchronous SSH/Flux work per + // request (connectivity checks, bootstrap, diagnostics). Throttle per + // user so a single member cannot pin FPM workers by hammering them, + // while leaving ample headroom for the canvas's 3s cluster polling. + RateLimiter::for('v5', function (Request $request) { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); + RateLimiter::for('feedback', function (Request $request) { return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); }); diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php index 89b68663b..c8e09d043 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -33,10 +33,19 @@ class ValidHostname implements ValidationRule return; } + // Reject ASCII control characters (including embedded newlines, which + // would otherwise slip through the trailing-newline-tolerant `$` anchor + // in the per-label regex below). + if (preg_match('/[\x00-\x1f\x7f]/', $hostname) === 1) { + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + + return; + } + // Check for dangerous shell metacharacters $dangerousChars = [ ';', '|', '&', '$', '`', '(', ')', '{', '}', - '<', '>', '\n', '\r', '\0', '"', "'", '\\', + '<', '>', "\n", "\r", "\0", '"', "'", '\\', '!', '*', '?', '[', ']', '~', '^', ':', '#', '@', '%', '=', '+', ',', ' ', ]; @@ -104,7 +113,7 @@ class ValidHostname implements ValidationRule } // Check if label contains only valid characters (letters, digits, hyphens) - if (! preg_match('/^[a-z0-9-]+$/', $label)) { + if (! preg_match('/^[a-z0-9-]+$/D', $label)) { $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php index 270ff1c34..bb642e18a 100644 --- a/app/Rules/ValidServerIp.php +++ b/app/Rules/ValidServerIp.php @@ -9,6 +9,12 @@ class ValidServerIp implements ValidationRule { /** * Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname. + * + * IP literals in private/reserved ranges (loopback, link-local, RFC 1918, + * etc.) are rejected by default to stop a member from pointing a server at + * the Coolify host's internal network and abusing the synchronous SSH check + * to probe it. Self-hosters on private LANs can allow them via + * config('coold.allow_private_server_ips'). */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -19,10 +25,14 @@ class ValidServerIp implements ValidationRule $trimmed = trim($value); if (filter_var($trimmed, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $this->failIfDisallowedRange($trimmed, $fail); + return; } if (filter_var($trimmed, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $this->failIfDisallowedRange($trimmed, $fail); + return; } @@ -37,4 +47,25 @@ class ValidServerIp implements ValidationRule $fail('The :attribute must be a valid IPv4 address, IPv6 address, or hostname.'); } } + + /** + * Reject IPs in private/reserved ranges unless the operator has explicitly + * opted in. The IP is already known to be a valid literal here. + */ + private function failIfDisallowedRange(string $ip, Closure $fail): void + { + if (config('coold.allow_private_server_ips')) { + return; + } + + $isPublic = filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + + if ($isPublic === false) { + $fail('The :attribute must not be a private or reserved IP address.'); + } + } } diff --git a/app/Services/Flux/AgentTokenIssuer.php b/app/Services/Flux/AgentTokenIssuer.php index 4a06a6735..51aa320a4 100644 --- a/app/Services/Flux/AgentTokenIssuer.php +++ b/app/Services/Flux/AgentTokenIssuer.php @@ -2,20 +2,41 @@ namespace App\Services\Flux; +use App\Models\V5\RevokedAgentToken; use App\Models\V5\Server as V5Server; use Firebase\JWT\JWT; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use RuntimeException; +/** + * Mints the per-host ES256 JWT that authorizes a coold host agent against flux. + * + * Capability scoping: by default the token carries the EXPLICIT list of + * primitive capability strings coold advertises (config('flux.host_capabilities'), + * mirroring coold/coold/src/grpc/client.rs:204-231) rather than the + * `host-agent:default` wildcard profile. flux intersects the jwt `caps` with + * coold's advertised set (flux/src/main.rs:128-141), so the effective power is + * unchanged, but the token no longer depends on flux's + * `capability_profile_authorizes_all` wildcard bypass (main.rs:124-126). + * + * @see config/flux.php for the capability list, escape hatch, TTL and kid config. + */ class AgentTokenIssuer { public const DEFAULT_PROFILE = 'host-agent:default'; + private const TTL_FLOOR_SECONDS = 60; + /** - * @param array $capabilities - * @param array $extraClaims + * Mint a host JWT. + * + * @param array|null $capabilities Explicit caps; null resolves the configured default set (or escape-hatch profile). + * @param int|null $ttl Lifetime in seconds; null resolves config('flux.host_token_ttl'). Clamped to a 60s floor. + * @param array $extraClaims Extra claims merged in (a `jti` here is honored, otherwise one is generated). */ - public function issue(string $hostId, array $capabilities = [self::DEFAULT_PROFILE], int $ttl = 86400, array $extraClaims = []): string + public function issue(string $hostId, ?array $capabilities = null, ?int $ttl = null, array $extraClaims = []): string { if ($hostId === '') { throw new RuntimeException('Flux host id is required.'); @@ -27,30 +48,186 @@ class AgentTokenIssuer throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}."); } + $this->assertPrivateKeyPermissions($privateKeyPath); + + $capabilities ??= $this->defaultCapabilities(); + $ttl ??= (int) config('flux.host_token_ttl', 3600); + + $jti = $extraClaims['jti'] ?? (string) Str::uuid(); + unset($extraClaims['jti']); + $now = time(); + $keyId = (string) config('flux.jwt_kid', 'flux-default'); return JWT::encode(array_merge($extraClaims, [ 'sub' => $hostId, 'aud' => 'coold', 'caps' => $this->normalizeCapabilities($capabilities), + 'jti' => $jti, 'iat' => $now, - 'exp' => $now + max(60, $ttl), - ]), File::get($privateKeyPath), 'ES256'); + 'exp' => $now + max(self::TTL_FLOOR_SECONDS, $ttl), + ]), File::get($privateKeyPath), 'ES256', $keyId !== '' ? $keyId : null); } - public function issueForServer(V5Server $server, int $ttl = 86400): string + public function issueForServer(V5Server $server, ?int $ttl = null): string { - $hostId = $server->wireguard_management_ip ?: $server->node_address; + $hostId = $server->fluxHostId(); - if (! is_string($hostId) || $hostId === '') { - throw new RuntimeException('Server is missing its Flux host id.'); + if ($hostId === '') { + throw new RuntimeException('Server is missing a valid Flux host id.'); } - return $this->issue($hostId, [self::DEFAULT_PROFILE], $ttl, [ - 'team_id' => $server->team_id, - 'cluster_id' => $server->cluster_id, - 'server_id' => $server->id, + $jti = (string) Str::uuid(); + $ttl ??= (int) config('flux.host_token_ttl', 3600); + + // team_id/cluster_id/server_id are minted as STRINGS: flux deserializes + // the `team_id` claim as a string (coold/flux/src/auth.rs Claims), and + // rejects the whole token with a JSON type error if it arrives as a JSON + // integer. Keep the sibling ids string-typed for consistency. + $token = $this->issue($hostId, $this->defaultCapabilities(), $ttl, [ + 'jti' => $jti, + 'team_id' => (string) $server->team_id, + 'cluster_id' => (string) $server->cluster_id, + 'server_id' => $hostId, + 'wireguard_management_ip' => (string) $server->wireguard_management_ip, ]); + + // Persist the freshly issued jti (so a later destroy/re-home knows which + // token to revoke) and its expiry (so the scheduled rotation loop knows + // when to re-mint). Use a targeted update keyed by id so this neither + // inserts an unsaved model nor flushes unrelated dirty attributes, and + // does not depend on the Server model's $fillable. + if ($server->exists) { + $expiresAt = now()->addSeconds(max(self::TTL_FLOOR_SECONDS, $ttl)); + + V5Server::query()->whereKey($server->getKey())->update([ + 'agent_token_jti' => $jti, + 'agent_token_expires_at' => $expiresAt, + ]); + $server->setAttribute('agent_token_jti', $jti); + $server->setAttribute('agent_token_expires_at', $expiresAt); + $server->syncOriginalAttribute('agent_token_jti'); + $server->syncOriginalAttribute('agent_token_expires_at'); + } + + return $token; + } + + /** + * Record the server's currently-issued host token jti as revoked AND push + * the revocation to flux so it rejects the jti at verify immediately. + * + * flux now consults a revocation denylist (flux/src/auth.rs `is_revoked`, + * fed by `POST /v1/tokens/revoke` on the flux UDS), and Laravel pushes to it + * here. The local `RevokedAgentToken` record remains the source of truth + * Laravel owns; the flux push is best-effort — if flux is unreachable the + * revocation is logged and the local record still stands, with the short TTL + * and hourly rotation bounding the exposure until flux is reachable again. + */ + public function revoke(V5Server $server): void + { + $jti = $server->agent_token_jti; + + if (! is_string($jti) || $jti === '') { + return; + } + + $expiresAt = $server->agent_token_expires_at; + $expiresAtUnix = $expiresAt instanceof \DateTimeInterface ? $expiresAt->getTimestamp() : null; + + RevokedAgentToken::query()->updateOrCreate( + ['jti' => $jti], + [ + 'server_id' => $server->id, + 'revoked_at' => now(), + 'expires_at' => $expiresAt, + ] + ); + + // Best-effort: a destroy/teardown must never fail because flux is down. + try { + app(FluxClient::class)->revokeToken($jti, $expiresAtUnix); + } catch (\Throwable $exception) { + Log::warning('Failed to push agent token revocation to Flux.', [ + 'server_id' => $server->id, + 'jti' => $jti, + 'error' => $exception->getMessage(), + ]); + } + + if ($server->exists) { + V5Server::query()->whereKey($server->getKey())->update(['agent_token_jti' => null]); + $server->setAttribute('agent_token_jti', null); + $server->syncOriginalAttribute('agent_token_jti'); + } + } + + /** + * Revoke the server's currently-issued host token. Alias of {@see revoke()} + * kept as the name the team-teardown job resolves via `method_exists`. + */ + public function revokeForServer(V5Server $server): void + { + $this->revoke($server); + } + + public function isRevoked(string $jti): bool + { + if ($jti === '') { + return false; + } + + return RevokedAgentToken::query()->where('jti', $jti)->exists(); + } + + /** + * The default capability set for production host tokens: the explicit + * advertised primitive list, unless the emergency escape hatch profile is + * configured (then that single profile is minted instead). + * + * @return array + */ + private function defaultCapabilities(): array + { + $profile = config('flux.host_capability_profile'); + + if (is_string($profile) && trim($profile) !== '') { + return [trim($profile)]; + } + + $configured = config('flux.host_capabilities'); + + if (is_array($configured) && $configured !== []) { + return array_values($configured); + } + + return [self::DEFAULT_PROFILE]; + } + + /** + * Warn (but do not hard-fail — that could break existing installs) when the + * private key file is readable by group/other or is not owner-readable. The + * key should be generated 0600, e.g.: + * openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \ + * -out storage/app/flux/jwt.priv && chmod 600 storage/app/flux/jwt.priv + */ + private function assertPrivateKeyPermissions(string $path): void + { + $perms = @fileperms($path); + + if ($perms === false) { + return; + } + + $mode = $perms & 0777; + + if (($mode & 0077) !== 0 || ($mode & 0400) === 0) { + Log::warning('Flux JWT private key has insecure permissions.', [ + 'path' => $path, + 'mode' => sprintf('%04o', $mode), + 'expected' => '0600', + ]); + } } /** diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index fa5caacd5..22f46db45 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -2,6 +2,7 @@ namespace App\Services\Flux; +use App\Exceptions\V5\UnsupportedCooldVerb; use Illuminate\Support\Str; use RuntimeException; @@ -60,6 +61,28 @@ class FluxClient return $this->output($payload, 'Container started.'); } + public function stopContainer(string $hostId, string $id, int $timeoutSeconds = 10): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.stop', + 'id' => $id, + 'timeout_seconds' => max(0, $timeoutSeconds), + ]); + + return $this->output($payload, 'Container stopped.'); + } + + public function removeContainer(string $hostId, string $id, bool $force = false): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.delete', + 'id' => $id, + 'force' => $force, + ]); + + return $this->output($payload, 'Container removed.'); + } + /** * @return array */ @@ -151,6 +174,19 @@ class FluxClient return $this->output($payload, 'No coold logs returned.'); } + public function containerLogs(string $hostId, string $containerId, int $tail = 200): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'containers.logs', + 'id' => $containerId, + 'tail' => max(1, min($tail, 1000)), + 'stdout' => true, + 'stderr' => true, + ]); + + return $this->output($payload, 'No container logs returned.'); + } + public function corrosionTables(string $hostId, int $limit = 200): string { $payload = $this->dispatch($hostId, [ @@ -161,11 +197,107 @@ class FluxClient return $this->output($payload, '{"limit":200,"tables":[]}'); } + /** + * Deliver a freshly minted host JWT to the node over the live coold RPC + * stream (flux gates the `host.jwt.set` capability; the token must carry + * it). Preferred over the SSH push because it reuses the already + * authenticated flux<->coold channel and works while the current token is + * still valid — exactly the rotation window. Throws like the sibling + * dispatch methods (host not connected / UnsupportedCooldVerb / generic + * failure) so the caller can catch and fall back to SSH. + */ + public function pushHostToken(string $hostId, string $token): void + { + $this->dispatch($hostId, [ + 'type' => 'host.jwt.set', + 'jwt' => $token, + ]); + } + + /** + * Revoke a host token by its `jti` on the flux revocation store so flux + * rejects it at verify immediately, instead of waiting for the token's TTL + * to lapse (flux/src/unix_bridge.rs `POST /v1/tokens/revoke`, + * flux/src/auth.rs `is_revoked`). The optional `expiresAt` (the token `exp`, + * unix seconds) lets flux prune the denylist entry once it can no longer + * matter. + * + * Best-effort like the sibling dispatch methods: throws a RuntimeException on + * connection failure / timeout / non-2xx so the caller can catch and treat + * an unreachable flux as non-fatal (the local revocation record still + * stands and the short TTL + rotation bound the exposure). + */ + public function revokeToken(string $jti, ?int $expiresAt = null): void + { + if (trim($jti) === '') { + return; + } + + $requestBody = ['jti' => $jti]; + + if ($expiresAt !== null) { + $requestBody['expires_at'] = $expiresAt; + } + + $body = json_encode($requestBody, JSON_THROW_ON_ERROR); + $response = $this->sendOverSocket('/v1/tokens/revoke', $body); + $statusCode = $this->statusCode($response); + + if ($statusCode < 200 || $statusCode >= 300) { + $responseBody = $this->responseBody($response); + $payload = $responseBody === '' ? null : json_decode($responseBody, true); + + throw new RuntimeException( + $this->errorMessage($payload, $responseBody) ?? "Flux token revocation returned HTTP {$statusCode}." + ); + } + } + /** * @param array $command * @return array */ private function dispatch(string $hostId, array $command): array + { + $body = json_encode([ + 'host_id' => $hostId, + 'request_id' => (string) Str::uuid(), + 'command' => $command, + ], JSON_THROW_ON_ERROR); + + $response = $this->sendOverSocket('/v1/coold/dispatch', $body); + + $statusCode = $this->statusCode($response); + $responseBody = $this->responseBody($response); + $payload = $responseBody === '' ? null : json_decode($responseBody, true); + + if ($statusCode < 200 || $statusCode >= 300) { + throw $this->dispatchException( + $command, + $statusCode, + $this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}." + ); + } + + if (! is_array($payload)) { + throw new RuntimeException('Flux dispatch returned an invalid response.'); + } + + if (($payload['status'] ?? null) === 'error') { + $message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.'; + + throw $this->dispatchException($command, $statusCode, $message); + } + + return $payload; + } + + /** + * Send a single HTTP/1.1 request over the flux Unix-domain socket and return + * the raw response. Shared by every flux verb (coold dispatch, host token + * rotation, token revocation) — only the request path and JSON body differ. + */ + private function sendOverSocket(string $path, string $body): string { $socketPath = config('flux.unix_socket_path'); @@ -177,11 +309,6 @@ class FluxClient throw new RuntimeException('Flux socket was not found.'); } - $body = json_encode([ - 'host_id' => $hostId, - 'request_id' => (string) Str::uuid(), - 'command' => $command, - ], JSON_THROW_ON_ERROR); $connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0); $dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0); $stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout); @@ -193,7 +320,7 @@ class FluxClient stream_set_timeout($stream, (int) ceil($dispatchTimeout)); fwrite($stream, implode("\r\n", [ - 'POST /v1/coold/dispatch HTTP/1.1', + "POST {$path} HTTP/1.1", 'Host: flux', 'Accept: application/json', 'Content-Type: application/json', @@ -206,25 +333,27 @@ class FluxClient $response = stream_get_contents($stream) ?: ''; fclose($stream); - $statusCode = $this->statusCode($response); - $responseBody = $this->responseBody($response); - $payload = $responseBody === '' ? null : json_decode($responseBody, true); + return $response; + } - if ($statusCode < 200 || $statusCode >= 300) { - throw new RuntimeException($this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}."); + /** + * Flux answers a verb the node's coold did not advertise with HTTP 501 and + * the message "primitive is not supported by host" (coold repo: + * flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245). Anything + * else — including coold-side command failures relayed with their own + * status code — is a generic dispatch failure. + * + * @param array $command + */ + private function dispatchException(array $command, int $statusCode, string $message): RuntimeException + { + $verb = is_string($command['type'] ?? null) ? $command['type'] : 'unknown'; + + if ($statusCode === 501 || preg_match('/primitive .+ is not supported by host/i', $message) === 1) { + return new UnsupportedCooldVerb($verb, $message); } - if (! is_array($payload)) { - throw new RuntimeException('Flux dispatch returned an invalid response.'); - } - - if (($payload['status'] ?? null) === 'error') { - $message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.'; - - throw new RuntimeException($message); - } - - return $payload; + return new RuntimeException($message); } private function statusCode(string $response): int diff --git a/app/Support/V5/CanvasResourceSerializer.php b/app/Support/V5/CanvasResourceSerializer.php new file mode 100644 index 000000000..3a2ec7b7d --- /dev/null +++ b/app/Support/V5/CanvasResourceSerializer.php @@ -0,0 +1,87 @@ + + */ + public function serializeApplication(V5Application $application): array + { + $application->loadMissing(['server', 'domains', 'project', 'environment']); + $server = $application->server; + $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); + + return [ + 'id' => $application->uuid, + 'name' => $application->name, + 'image' => $application->image, + 'containerName' => $application->container_name, + 'status' => $application->status, + 'statusMessage' => $application->status_message, + 'effectiveStatus' => $isServerReachable ? $application->status : 'unknown', + 'effectiveStatusMessage' => $isServerReachable + ? $application->status_message + : $this->serverStatusMessage($server), + 'runtimeContainerId' => $application->runtime_container_id, + 'serverName' => $server?->name, + 'serverStatus' => $server?->status, + 'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null, + 'isServerReachable' => $isServerReachable, + 'serverIngressEnabled' => (bool) $server?->isIngress(), + 'meshNamespace' => $application->mesh_namespace, + 'ingressEnabled' => $application->ingress_enabled, + 'internalPort' => $application->internal_port, + 'domains' => $application->domains->pluck('domain')->values()->all(), + 'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal', + 'projectUuid' => $application->project?->uuid, + 'environmentUuid' => $application->environment?->uuid, + 'canvasX' => $application->canvas_x, + 'canvasY' => $application->canvas_y, + ]; + } + + /** + * @return array + */ + public function serializeCaddyIngress(V5Server $server, int $index = 0): array + { + $isServerReachable = $this->isServerReachable($server); + + return [ + 'id' => $server->uuid, + 'name' => $server->name, + 'host' => $server->host, + 'type' => $server->ingressType(), + 'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable', + 'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server), + 'canvasX' => $server->canvas_x ?? -(self::CARD_WIDTH + self::CARD_GAP), + 'canvasY' => $server->canvas_y ?? $index * (self::CARD_HEIGHT + self::CARD_GAP), + ]; + } + + private function isServerReachable(V5Server $server): bool + { + return $server->status !== 'unreachable'; + } + + private function serverStatusMessage(?V5Server $server): ?string + { + return $server?->last_status_output ?: null; + } +} diff --git a/app/Support/V5/ClusterSerializer.php b/app/Support/V5/ClusterSerializer.php new file mode 100644 index 000000000..4b94fd8dd --- /dev/null +++ b/app/Support/V5/ClusterSerializer.php @@ -0,0 +1,90 @@ + + */ + public function serialize(V5Cluster $cluster): array + { + return [ + 'id' => $cluster->uuid, + 'name' => $cluster->name, + 'description' => $cluster->description, + 'wireguardInterface' => $cluster->wireguard_interface, + 'wireguardManagementPool' => $cluster->wireguard_management_pool, + 'wireguardListenPort' => $cluster->wireguard_listen_port, + 'containerNetworkPool' => $cluster->container_network_pool, + 'containerNetworkPrefix' => $cluster->container_network_prefix, + 'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES, + 'defaultDenyContainers' => $cluster->default_deny_containers, + 'cooldVersion' => $cluster->coold_version, + 'corrosionVersion' => $cluster->corrosion_version, + 'corrosionGossipPort' => $cluster->corrosion_gossip_port, + 'corrosionApiPort' => $cluster->corrosion_api_port, + 'builderEnabled' => $cluster->builder_enabled, + 'builderCapacity' => $cluster->builder_capacity, + 'builderCpuQuota' => $cluster->builder_cpu_quota, + 'builderMemoryMax' => $cluster->builder_memory_max, + 'builderTimeoutSecs' => $cluster->builder_timeout_secs, + 'lastCliAction' => $cluster->last_cli_action, + 'lastCliStatus' => $cluster->last_cli_status, + 'lastCliSummary' => $cluster->last_cli_summary, + 'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(), + 'serversCount' => $cluster->servers_count ?? $cluster->servers->count(), + 'servers' => $cluster->servers->map(fn (V5Server $server) => [ + 'id' => $server->uuid, + 'name' => $server->name, + 'host' => $server->host, + 'status' => $server->status, + 'capabilities' => $server->capabilities ?? [], + 'builderEnabled' => $server->builder_enabled, + 'builderCapacity' => $server->builder_capacity, + 'builderCpuQuota' => $server->builder_cpu_quota, + 'ingressEnabled' => $server->isIngress(), + 'ingressType' => $server->ingress_type, + 'uuid' => $server->uuid, + 'nodeAddress' => $server->node_address, + 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, + 'wireguardEndpointOverride' => $server->wireguard_endpoint_override, + 'wireguardManagementIp' => $server->wireguard_management_ip, + 'wireguardPublicKey' => $server->wireguard_public_key, + 'containerSubnets' => $server->container_subnets ?? [], + 'privateKeyName' => $server->privateKey?->name, + 'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(), + 'lastBootstrapAction' => $server->last_bootstrap_action, + 'lastBootstrapStatus' => $server->last_bootstrap_status, + 'lastBootstrapOutput' => $server->last_bootstrap_output, + 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), + 'lastStatusOutput' => $server->last_status_output, + 'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(), + ])->all(), + ]; + } + + /** + * Reload servers (with keys) and counts before serializing so the payload + * always reflects the latest database state. + * + * @return array + */ + public function serializeFresh(V5Cluster $cluster): array + { + $cluster->load(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]); + $cluster->loadCount('servers'); + + return $this->serialize($cluster); + } +} diff --git a/app/Support/V5/ConnectionFirewallSync.php b/app/Support/V5/ConnectionFirewallSync.php new file mode 100644 index 000000000..04241db14 --- /dev/null +++ b/app/Support/V5/ConnectionFirewallSync.php @@ -0,0 +1,157 @@ + + */ + public function rulesFor(ResourceConnection $connection): Collection + { + $applicationIds = $connection->rules + ->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id]) + ->unique() + ->values(); + + $applications = V5Application::query() + ->whereIn('id', $applicationIds) + ->with('server') + ->get() + ->keyBy('id'); + + return $connection->rules + ->flatMap(function ($rule) use ($applications, $connection): Collection { + $source = $applications->get($rule->source_resource_id); + $target = $applications->get($rule->target_resource_id); + + if (! $source instanceof V5Application || ! $target instanceof V5Application) { + return collect(); + } + + $missingHost = collect([$source, $target]) + ->first(function (V5Application $application): bool { + $hostId = $application->server?->fluxHostId(); + + return ! is_string($hostId) || $hostId === ''; + }); + + if ($missingHost instanceof V5Application) { + throw new \RuntimeException("Application {$missingHost->name} has no reachable server host id, so its firewall rules cannot be synced."); + } + + $hostIds = collect([$source->server, $target->server]) + ->map(fn (V5Server $server) => $server->fluxHostId()) + ->unique() + ->values(); + + $firewallRule = [ + 'id' => $this->ruleId($connection, $rule), + 'namespace' => $target->mesh_namespace ?: 'default', + 'src' => $source->container_name, + 'dst' => $target->container_name, + 'proto' => $rule->protocol ?: 'tcp', + 'port' => (int) $rule->port, + ]; + + return $hostIds->map(fn (string $hostId): array => [ + 'id' => $firewallRule['id'], + 'hostId' => $hostId, + 'rule' => $firewallRule, + ]); + }) + ->values(); + } + + /** + * @param Collection $oldRules + * @param Collection $newRules + */ + public function sync(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void + { + $newRuleKeys = $newRules->map(fn (array $rule): string => $this->syncKey($rule))->all(); + $oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->syncKey($rule))->all(); + + $oldRules + ->reject(fn (array $oldRule): bool => in_array($this->syncKey($oldRule), $newRuleKeys, true)) + ->each(fn (array $oldRule): ?string => $this->revokeRuleIfPresent($fluxClient, $oldRule['hostId'], $oldRule['id'])); + + $newRules + ->reject(fn (array $newRule): bool => in_array($this->syncKey($newRule), $oldRuleKeys, true)) + ->each(function (array $newRule) use ($fluxClient): void { + try { + $fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']); + } catch (UnsupportedCooldVerb $exception) { + Log::warning('V5 resource connection firewall rule skipped: coold verb unsupported', [ + 'host_id' => $newRule['hostId'], + 'rule_id' => $newRule['id'], + 'verb' => $exception->verb, + 'message' => $exception->getMessage(), + ]); + } + }); + } + + public function revokeRuleIfPresent(FluxClient $fluxClient, string $hostId, string $ruleId): ?string + { + try { + return $fluxClient->revokeFirewallRule($hostId, $ruleId); + } catch (UnsupportedCooldVerb $exception) { + Log::warning('V5 resource connection firewall revoke skipped: coold verb unsupported', [ + 'host_id' => $hostId, + 'rule_id' => $ruleId, + 'verb' => $exception->verb, + 'message' => $exception->getMessage(), + ]); + + return null; + } catch (\RuntimeException $exception) { + if (str_contains(Str::lower($exception->getMessage()), 'not found')) { + return null; + } + + throw $exception; + } + } + + /** + * Deterministic node-side rule id derived only from the connection id and + * the rule's stable attributes — never from the rule row's primary key — + * so rewritten or restored DB rows resolve to the same firewall rule ids + * and compensating re-syncs stay idempotent. + */ + public function ruleId(ResourceConnection $connection, mixed $rule): string + { + return implode(':', [ + 'v5-resource-connection', + $connection->id, + $rule->source_resource_id, + $rule->target_resource_id, + $rule->protocol ?: 'tcp', + (int) $rule->port, + ]); + } + + /** + * @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule + */ + private function syncKey(array $rule): string + { + return $rule['hostId'].'|'.$rule['id']; + } +} diff --git a/app/Support/V5/ResourceConnectionSerializer.php b/app/Support/V5/ResourceConnectionSerializer.php new file mode 100644 index 000000000..db89a897b --- /dev/null +++ b/app/Support/V5/ResourceConnectionSerializer.php @@ -0,0 +1,73 @@ + + */ + public function serialize(ResourceConnection $connection): array + { + $applications = $this->applicationsById($connection); + $resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid; + $resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid; + $applicationsById = $applications; + + return [ + 'id' => $connection->uuid, + 'applicationIds' => array_values(array_filter([ + $resourceOneUuid, + $resourceTwoUuid, + ])), + 'fromApplicationId' => $resourceOneUuid, + 'toApplicationId' => $resourceTwoUuid, + 'portsByDirection' => $connection->rules + ->groupBy(function ($rule) use ($applicationsById): string { + $sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid; + $targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid; + + return "{$sourceUuid}->{$targetUuid}"; + }) + ->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->')) + ->map(fn (Collection $rules) => $rules + ->sortBy('port') + ->pluck('port') + ->map(fn ($port) => (string) $port) + ->values() + ->all()) + ->all(), + ]; + } + + /** + * @return Collection + */ + public function applicationsByUuid(ResourceConnection $connection): Collection + { + return $this->applicationsById($connection)->keyBy('uuid'); + } + + /** + * @return Collection + */ + public function applicationsById(ResourceConnection $connection): Collection + { + return V5Application::query() + ->whereIn('id', [ + (int) $connection->resource_one_id, + (int) $connection->resource_two_id, + ]) + ->get() + ->keyBy('id'); + } +} diff --git a/app/Support/V5/StatusObservation.php b/app/Support/V5/StatusObservation.php new file mode 100644 index 000000000..877435ad1 --- /dev/null +++ b/app/Support/V5/StatusObservation.php @@ -0,0 +1,68 @@ + $logContext + */ + public static function isStale(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool + { + if ($observedAt === null || $currentObservedAt === null || ! $observedAt->lt($currentObservedAt)) { + return false; + } + + Log::debug("Dropping stale flux {$context} update.", [ + ...$logContext, + 'observed_at' => $observedAt->toIso8601String(), + 'current_status_observed_at' => $currentObservedAt->toIso8601String(), + ]); + + return true; + } + + /** + * Map a raw status string onto the given status enum. Unknown values are + * never written to the database: they fall back to the enum's Unknown case + * and are logged. Returns null only when no raw value is supplied. + * + * @param class-string $enumClass + */ + public static function normalize(?string $raw, string $enumClass): ?string + { + if ($raw === null || $raw === '') { + return null; + } + + $status = $enumClass::tryFrom(strtolower($raw)); + + if ($status === null) { + Log::warning('Received unknown flux resource status; falling back to unknown.', [ + 'raw_status' => $raw, + 'status_enum' => $enumClass, + ]); + + return $enumClass::Unknown->value; + } + + return $status->value; + } +} diff --git a/config/coold.php b/config/coold.php index 06b47083d..cdd5b082e 100644 --- a/config/coold.php +++ b/config/coold.php @@ -5,4 +5,21 @@ return [ 'coold_version' => env('COOLIFY_COOLD_VERSION', 'nightly'), 'corrosion_version' => env('COOLIFY_CORROSION_VERSION', 'v1.0.0'), 'dev_ssh_user' => env('COOLIFY_CLI_SSH_USER', 'coolify'), + 'flux_url' => env('COOLIFY_COOLD_FLUX_URL', env('COOLIFY_COOLD_VM_FLUX_URL')), + 'flux_host_jwt_path' => env('COOLIFY_COOLD_HOST_JWT_PATH', '/etc/coolify/host-jwt'), + + /* + * When false (the default), v5 server hosts/node addresses may not point at + * private or reserved IP ranges (loopback, link-local, RFC 1918, CGNAT is + * still allowed as it is the WireGuard mesh space). This blocks a team + * member from adding a server that targets the Coolify host's internal + * network and abusing the synchronous SSH connectivity check to probe it. + * + * Self-hosters running Coolify on a private LAN can opt back in by setting + * COOLIFY_ALLOW_PRIVATE_SERVER_IPS=true. + */ + 'allow_private_server_ips' => filter_var( + env('COOLIFY_ALLOW_PRIVATE_SERVER_IPS', false), + FILTER_VALIDATE_BOOLEAN + ), ]; diff --git a/config/flux.php b/config/flux.php index 955559edd..adf011afe 100644 --- a/config/flux.php +++ b/config/flux.php @@ -7,5 +7,105 @@ return [ 'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0), 'connection_timeout_seconds' => (float) env('COOLIFY_FLUX_CONNECTION_TIMEOUT_SECONDS', 1.0), 'dispatch_timeout_seconds' => (float) env('COOLIFY_FLUX_DISPATCH_TIMEOUT_SECONDS', 35.0), + 'bootstrap_host_connection_timeout_seconds' => (int) env('COOLIFY_FLUX_BOOTSTRAP_HOST_CONNECTION_TIMEOUT_SECONDS', 30), + + /* + |-------------------------------------------------------------------------- + | Host agent (coold) token capabilities + |-------------------------------------------------------------------------- + | + | The EXACT set of primitive capability strings coold advertises to flux on + | connect (coold/coold/src/grpc/client.rs, `primitive_capabilities`). Minting + | these explicit strings — instead of the `host-agent:default` wildcard + | profile — means the token no longer relies on flux's + | `capability_profile_authorizes_all` bypass. flux INTERSECTS the jwt `caps` + | with coold's advertised set, so as long as this list matches coold's + | advertised primitives the host retains exactly the same effective power. + | + | SAFETY: keep this list byte-for-byte in sync with coold's + | `primitive_capabilities()`. A string here that coold does not advertise is + | silently dropped by flux's intersection; a verb coold needs that is missing + | here means the host loses that ability. + | + | `host.jwt.set` authorizes RPC-delivered host-JWT rotation (the token can + | authorize its own replacement over the live stream; Laravel is the root of + | trust that holds the signing key). + */ + 'host_capabilities' => [ + 'images.pull', + 'images.list', + 'images.delete', + 'containers.create', + 'containers.start', + 'containers.stop', + 'containers.restart', + 'containers.delete', + 'containers.inspect', + 'containers.list', + 'containers.logs', + 'containers.exec', + 'containers.healthcheck.run', + 'ingress.apply', + 'ingress.stop', + 'firewall.allow', + 'firewall.revoke', + 'firewall.list', + 'firewall.reconcile', + 'coold.logs', + 'corrosion.tables', + 'host.jwt.set', + ], + + /* + | Emergency escape hatch: when set (e.g. to `host-agent:default`), minted + | host tokens carry ONLY this single capability profile instead of the + | explicit list above. This re-enables flux's wildcard bypass and is meant + | purely for rollback without a code change if the explicit list ever drifts + | from coold's advertised set and breaks the data plane. Leave NULL in + | production so tokens are explicitly scoped. + */ + 'host_capability_profile' => env('COOLIFY_FLUX_HOST_CAPABILITY_PROFILE'), + + /* + | Host JWT lifetime and rotation. + | + | `host_token_ttl` is the token `exp` window (default 1h) — the maximum time + | a leaked/undetected token stays valid if BOTH rotation and revocation fail. + | Keep this at or below flux's `COOLIFY_FLUX_MAX_TOKEN_LIFETIME_SECS` + | default (3600), otherwise flux rejects coold streams at connect. + | `host_token_refresh_threshold` is the remaining-lifetime below which the + | rotation job re-mints and re-delivers a fresh token (default 30m). + | Keep the threshold below the TTL. + */ + 'host_token_ttl' => (int) env('COOLIFY_FLUX_HOST_TOKEN_TTL', 3600), + 'host_token_refresh_threshold' => (int) env('COOLIFY_FLUX_HOST_TOKEN_REFRESH_THRESHOLD', 1800), + + /* + | JWT header `kid` minted into host tokens. flux selects the verification key + | by this id (single default key today; a per-cluster keys directory can map + | `kid = cluster-` to `.pub` for per-tenant signing keys later). + */ + 'jwt_kid' => env('COOLIFY_FLUX_JWT_KID', 'flux-default'), + + /* + |-------------------------------------------------------------------------- + | Inbound flux -> Laravel API token(s) + |-------------------------------------------------------------------------- + | + | flux authenticates to Laravel's internal status-ingest endpoint with a + | bearer token. `laravel_api_tokens` accepts SEVERAL tokens at once so an + | operator can rotate with zero downtime: + | 1. add the new token alongside the old: + | COOLIFY_FLUX_LARAVEL_API_TOKENS=, + | then `php artisan config:clear` + | 2. cut every flux instance over to + | 3. drop from the list and `config:clear` again + | Generate tokens with `openssl rand -hex 32`. The single `laravel_api_token` + | remains as a fallback so existing single-token installs keep working. + */ 'laravel_api_token' => env('COOLIFY_FLUX_LARAVEL_API_TOKEN'), + 'laravel_api_tokens' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('COOLIFY_FLUX_LARAVEL_API_TOKENS', '')) + ))), ]; diff --git a/config/horizon.php b/config/horizon.php index 0423f1549..71f9a45f8 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -192,6 +192,24 @@ return [ 'sleep' => 3, 'timeout' => env('HORIZON_TIMEOUT', 36000), ], + + // Dedicated low-priority pool for the v5 reconcile + host-token rotation + // jobs (queue `v5-reconcile`, set via onQueue()). Isolated from the + // user-facing high/default deploy pool so a starved rotation cannot let + // host tokens drift to expiry, and so the 5-minute fleet fan-out never + // blocks deploys. + 'v5reconcile' => [ + 'connection' => 'redis', + 'balance' => env('HORIZON_V5_RECONCILE_BALANCE', 'false'), + 'queue' => 'v5-reconcile', + 'maxTime' => env('HORIZON_V5_RECONCILE_MAX_TIME', 0), + 'maxJobs' => 200, + 'memory' => 128, + 'tries' => 1, + 'nice' => 10, + 'sleep' => 3, + 'timeout' => env('HORIZON_V5_RECONCILE_TIMEOUT', 300), + ], ], 'environments' => [ @@ -203,7 +221,11 @@ return [ 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), ], - + 'v5reconcile' => [ + 'autoScalingStrategy' => 'size', + 'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1), + 'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 2), + ], ], 'local' => [ 's6' => [ @@ -213,6 +235,11 @@ return [ 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), ], + 'v5reconcile' => [ + 'autoScalingStrategy' => 'size', + 'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1), + 'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 1), + ], ], ], ]; diff --git a/database/migrations/2026_07_05_215736_v5_add_status_lookup_indexes.php b/database/migrations/2026_07_05_215736_v5_add_status_lookup_indexes.php new file mode 100644 index 000000000..09c8bd093 --- /dev/null +++ b/database/migrations/2026_07_05_215736_v5_add_status_lookup_indexes.php @@ -0,0 +1,48 @@ +index('wireguard_management_ip'); + $table->index('node_address'); + $table->index('host'); + }); + + Schema::table('v5_applications', function (Blueprint $table) { + $table->index('runtime_container_id'); + }); + + Schema::table('v5_container_statuses', function (Blueprint $table) { + $table->index('last_seen_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropIndex(['wireguard_management_ip']); + $table->dropIndex(['node_address']); + $table->dropIndex(['host']); + }); + + Schema::table('v5_applications', function (Blueprint $table) { + $table->dropIndex(['runtime_container_id']); + }); + + Schema::table('v5_container_statuses', function (Blueprint $table) { + $table->dropIndex(['last_seen_at']); + }); + } +}; diff --git a/database/migrations/2026_07_05_215736_v5_make_servers_uuid_not_null.php b/database/migrations/2026_07_05_215736_v5_make_servers_uuid_not_null.php new file mode 100644 index 000000000..fcd8a7afe --- /dev/null +++ b/database/migrations/2026_07_05_215736_v5_make_servers_uuid_not_null.php @@ -0,0 +1,39 @@ +whereNull('uuid') + ->pluck('id') + ->each(function (int $id): void { + DB::table('v5_servers') + ->where('id', $id) + ->update(['uuid' => Str::lower(Str::random(24))]); + }); + + Schema::table('v5_servers', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->string('uuid')->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2026_07_05_222616_v5_add_status_observed_at_columns.php b/database/migrations/2026_07_05_222616_v5_add_status_observed_at_columns.php new file mode 100644 index 000000000..2189edbb8 --- /dev/null +++ b/database/migrations/2026_07_05_222616_v5_add_status_observed_at_columns.php @@ -0,0 +1,44 @@ +timestamp('status_observed_at')->nullable()->after('status'); + }); + + Schema::table('v5_applications', function (Blueprint $table) { + $table->timestamp('status_observed_at')->nullable()->after('status_message'); + }); + + Schema::table('v5_container_statuses', function (Blueprint $table) { + $table->timestamp('status_observed_at')->nullable()->after('status_message'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('status_observed_at'); + }); + + Schema::table('v5_applications', function (Blueprint $table) { + $table->dropColumn('status_observed_at'); + }); + + Schema::table('v5_container_statuses', function (Blueprint $table) { + $table->dropColumn('status_observed_at'); + }); + } +}; diff --git a/database/migrations/2026_07_05_222940_v5_add_coold_version_to_servers_table.php b/database/migrations/2026_07_05_222940_v5_add_coold_version_to_servers_table.php new file mode 100644 index 000000000..1d42ef4ab --- /dev/null +++ b/database/migrations/2026_07_05_222940_v5_add_coold_version_to_servers_table.php @@ -0,0 +1,28 @@ +string('coold_version')->nullable()->after('wireguard_public_key'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('coold_version'); + }); + } +}; diff --git a/database/migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php b/database/migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php new file mode 100644 index 000000000..e81623fe1 --- /dev/null +++ b/database/migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php @@ -0,0 +1,74 @@ +rewriteMorphTypes(self::FQCN, self::ALIAS); + $this->rewritePairKeys(self::FQCN, self::ALIAS); + } + + public function down(): void + { + $this->rewriteMorphTypes(self::ALIAS, self::FQCN); + $this->rewritePairKeys(self::ALIAS, self::FQCN); + } + + private function rewriteMorphTypes(string $from, string $to): void + { + if (Schema::hasTable('v5_resource_connections')) { + foreach (['resource_one_type', 'resource_two_type'] as $column) { + DB::table('v5_resource_connections') + ->where($column, $from) + ->update([$column => $to]); + } + } + + if (Schema::hasTable('v5_resource_connection_rules')) { + foreach (['source_resource_type', 'target_resource_type'] as $column) { + DB::table('v5_resource_connection_rules') + ->where($column, $from) + ->update([$column => $to]); + } + } + } + + private function rewritePairKeys(string $from, string $to): void + { + if (! Schema::hasTable('v5_resource_connections')) { + return; + } + + // Backslash escaping in LIKE patterns differs between Postgres and + // SQLite, so match the FQCN in PHP instead of in SQL. + DB::table('v5_resource_connections') + ->select(['id', 'resource_pair_key']) + ->orderBy('id') + ->chunkById(100, function ($connections) use ($from, $to): void { + foreach ($connections as $connection) { + if (! str_contains((string) $connection->resource_pair_key, $from)) { + continue; + } + + DB::table('v5_resource_connections') + ->where('id', $connection->id) + ->update([ + 'resource_pair_key' => str_replace($from, $to, $connection->resource_pair_key), + ]); + } + }); + } +}; diff --git a/database/migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php b/database/migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php new file mode 100644 index 000000000..80ab529f4 --- /dev/null +++ b/database/migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php @@ -0,0 +1,74 @@ +boolean('has_coold')->default(false)->index(); + $table->boolean('is_ingress')->default(false)->index(); + }); + + DB::table('v5_servers') + ->select(['id', 'capabilities']) + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + $capabilities = json_decode($server->capabilities ?? '[]', true); + + if (! is_array($capabilities) || $capabilities === []) { + continue; + } + + DB::table('v5_servers')->where('id', $server->id)->update([ + 'has_coold' => in_array('coold', $capabilities, true), + 'is_ingress' => in_array('ingress', $capabilities, true), + ]); + } + }); + + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('capabilities'); + }); + } + + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->json('capabilities')->nullable(); + }); + + DB::table('v5_servers') + ->select(['id', 'has_coold', 'is_ingress']) + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + $capabilities = array_values(array_filter([ + $server->has_coold ? 'coold' : null, + $server->is_ingress ? 'ingress' : null, + ])); + + DB::table('v5_servers')->where('id', $server->id)->update([ + 'capabilities' => json_encode($capabilities), + ]); + } + }); + + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropIndex(['has_coold']); + $table->dropIndex(['is_ingress']); + $table->dropColumn(['has_coold', 'is_ingress']); + }); + } +}; diff --git a/database/migrations/2026_07_06_100000_v5_add_agent_token_jti_to_servers_table.php b/database/migrations/2026_07_06_100000_v5_add_agent_token_jti_to_servers_table.php new file mode 100644 index 000000000..edfaa4f96 --- /dev/null +++ b/database/migrations/2026_07_06_100000_v5_add_agent_token_jti_to_servers_table.php @@ -0,0 +1,22 @@ +string('agent_token_jti')->nullable()->after('coold_version'); + }); + } + + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('agent_token_jti'); + }); + } +}; diff --git a/database/migrations/2026_07_06_100100_v5_create_revoked_agent_tokens_table.php b/database/migrations/2026_07_06_100100_v5_create_revoked_agent_tokens_table.php new file mode 100644 index 000000000..7136ee4ac --- /dev/null +++ b/database/migrations/2026_07_06_100100_v5_create_revoked_agent_tokens_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('jti')->unique(); + $table->foreignId('server_id')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('v5_revoked_agent_tokens'); + } +}; diff --git a/database/migrations/2026_07_06_110000_v5_add_agent_token_expires_at_to_servers_table.php b/database/migrations/2026_07_06_110000_v5_add_agent_token_expires_at_to_servers_table.php new file mode 100644 index 000000000..08cfe07fa --- /dev/null +++ b/database/migrations/2026_07_06_110000_v5_add_agent_token_expires_at_to_servers_table.php @@ -0,0 +1,22 @@ +timestamp('agent_token_expires_at')->nullable()->after('agent_token_jti'); + }); + } + + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('agent_token_expires_at'); + }); + } +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 555c8660c..f4753465f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,7 @@ services: COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}" COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}" COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}" + COOLIFY_FLUX_REQUIRE_HOST_BINDING: "${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}" COOLIFY_CLI_VERSION: "${COOLIFY_CLI_VERSION:-nightly}" COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}" COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}" diff --git a/docker/development/etc/s6-overlay/s6-rc.d/flux/run b/docker/development/etc/s6-overlay/s6-rc.d/flux/run index 9d8598cf9..876013750 100755 --- a/docker/development/etc/s6-overlay/s6-rc.d/flux/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/flux/run @@ -20,6 +20,7 @@ export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/cool export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}" export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}" export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}" +export COOLIFY_FLUX_REQUIRE_HOST_BINDING="${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}" export COOLIFY_FLUX_LARAVEL_API_URL="${COOLIFY_FLUX_LARAVEL_API_URL:-http://127.0.0.1:8080}" if [ -z "${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}" ] && [ -f .env ]; then COOLIFY_FLUX_LARAVEL_API_TOKEN="$(grep -E '^COOLIFY_FLUX_LARAVEL_API_TOKEN=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")" diff --git a/package-lock.json b/package-lock.json index 360382669..6a3fc221a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -788,12 +787,36 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1017,7 +1040,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1852,7 +1874,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1908,8 +1929,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -2128,7 +2148,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2949,7 +2968,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3399,7 +3417,6 @@ "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -5056,7 +5073,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5066,7 +5082,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5732,8 +5747,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -5876,7 +5890,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5999,7 +6012,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6451,7 +6463,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/phpunit.xml b/phpunit.xml index 5d55acf75..a516aa4ac 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,9 @@ + + diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 5f87bd307..7e5989336 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { AppNavbar } from '@/components/app-navbar'; +import { CanvasNotice } from '@/components/canvas/canvas-notice'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -25,8 +26,9 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { csrfToken } from '@/lib/csrf'; +import { apiRequest } from '@/lib/api'; import { usePendingIds } from '@/lib/use-pending-ids'; +import { useTeamChannel } from '@/lib/use-team-channel'; import type { V5Cluster, V5DashboardProps, V5Server } from '@/types'; type ClusterFormErrors = { @@ -90,11 +92,13 @@ type DeleteServerResponse = { type CooldLogsResponse = { output: string; fetchedAt: string; + source: 'flux' | 'ssh'; }; type CorrosionTablesResponse = { output: string; fetchedAt: string; + source: 'flux' | 'ssh'; }; type FirewallRule = { @@ -127,16 +131,28 @@ type BootstrapServerResponse = { message?: string; }; -type ServerSshCheck = { - status: string; - output: string; - checkedAt: string; +type ServerConnectionNotice = { + message: string; + description: string; + variant: 'danger' | 'success'; }; type V5ClusterUpdatedEvent = { cluster: V5Cluster | null; }; +type ParsedBootstrapLogSummary = { + label: string; + value: string; + tone: 'success' | 'muted'; +}; + +type ParsedBootstrapLogs = { + summary: ParsedBootstrapLogSummary[]; + visibleOutput: string; + rawOutput: string; +}; + function formatCorrosionCell(value: unknown): string { if (value === null || value === undefined) { return 'null'; @@ -163,6 +179,147 @@ function parseCorrosionTables(output: string): CorrosionTableDump | null { } } +function jsonValueToString(value: unknown): string { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +function initialJsonEnd(output: string): number | null { + const trimmedStart = output.search(/\S/); + + if (trimmedStart === -1 || output[trimmedStart] !== '{') { + return null; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let index = trimmedStart; index < output.length; index += 1) { + const character = output[index]; + + if (isEscaped) { + isEscaped = false; + continue; + } + + if (character === '\\') { + isEscaped = inString; + continue; + } + + if (character === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (character === '{') { + depth += 1; + } + + if (character === '}') { + depth -= 1; + } + + if (depth === 0) { + return index + 1; + } + } + + return null; +} + +function hideRawBootstrapPlan(output: string): string { + const visibleLines: string[] = []; + let isSkippingPlan = false; + + output.split('\n').forEach((line) => { + if (line.trim() === 'Plan:') { + isSkippingPlan = true; + + return; + } + + if (isSkippingPlan) { + const trimmedLine = line.trim(); + + if (trimmedLine === '' || trimmedLine.startsWith('[') || line.startsWith(' ')) { + return; + } + + isSkippingPlan = false; + } + + visibleLines.push(line); + }); + + return visibleLines.join('\n').trim(); +} + +function parseBootstrapOutput(output: string | null): ParsedBootstrapLogs { + const rawOutput = output?.trim() || 'No install logs captured yet.'; + const jsonEnd = initialJsonEnd(rawOutput); + + if (jsonEnd === null) { + return { + summary: [], + visibleOutput: rawOutput, + rawOutput, + }; + } + + try { + const parsed = JSON.parse(rawOutput.slice(0, jsonEnd)) as { + results?: Array<{ + action?: { action?: unknown; host?: unknown }; + status?: unknown; + detail?: unknown; + }>; + verified?: Array>; + }; + const summary = [ + ...(parsed.results ?? []).map((result) => ({ + label: jsonValueToString(result.action?.action ?? result.action?.host ?? 'Action'), + value: `${jsonValueToString(result.status)}${result.detail ? ` · ${jsonValueToString(result.detail)}` : ''}`, + tone: result.status === 'ok' ? ('success' as const) : ('muted' as const), + })), + ...(parsed.verified ?? []).map((node) => ({ + label: `Verified ${jsonValueToString(node.host)}`, + value: [ + `status ${jsonValueToString(node.status)}`, + `wg ${jsonValueToString(node.wireguard_ip)}`, + `peers ${jsonValueToString(node.peer_count)}`, + ].join(' · '), + tone: node.status === 'ok' ? ('success' as const) : ('muted' as const), + })), + ]; + const remainingOutput = rawOutput.slice(jsonEnd).trim(); + + return { + summary, + visibleOutput: hideRawBootstrapPlan(remainingOutput), + rawOutput, + }; + } catch { + return { + summary: [], + visibleOutput: rawOutput, + rawOutput, + }; + } +} + function statusLabel(status: string): string { return status .split(/[-_\s]+/) @@ -187,24 +344,6 @@ function statusBadgeClass(status: string): string { return 'border-border bg-muted/40 text-muted-foreground'; } -type EchoChannel = { - listen: (event: string, callback: (payload: unknown) => void) => EchoChannel; - subscribed?: (callback: () => void) => EchoChannel; - error?: (callback: (error: unknown) => void) => EchoChannel; -}; - -type EchoClient = { - private: (channel: string) => EchoChannel; - leave?: (channel: string) => void; - leaveChannel?: (channel: string) => void; -}; - -declare global { - interface Window { - Echo?: EchoClient; - } -} - const clusterDefaults = { wireguardInterface: 'wg0', wireguardManagementPool: '100.64.0.0/16', @@ -242,6 +381,18 @@ function formatDate(value: string | null): string { }).format(new Date(value)); } +function diagnosticsSourceLabel(source: 'flux' | 'ssh' | null): string { + if (source === 'ssh') { + return 'SSH'; + } + + if (source === 'flux') { + return 'Flux'; + } + + return 'Unknown'; +} + export default function Clusters({ flux, currentTeam = null, @@ -297,8 +448,9 @@ export default function Clusters({ const [isServerSubmitting, setIsServerSubmitting] = useState(false); const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false); const checkingServers = usePendingIds(); - const [sshChecks, setSshChecks] = useState>({}); - const [visibleBootstrapLogs, setVisibleBootstrapLogs] = useState>({}); + const [serverConnectionNotice, setServerConnectionNotice] = useState(null); + const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false); + const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState(null); const bootstrappingServers = usePendingIds(); const [bootstrapServerError, setBootstrapServerError] = useState(null); const deletingServers = usePendingIds(); @@ -314,12 +466,14 @@ export default function Clusters({ const [cooldLogsServer, setCooldLogsServer] = useState(null); const [cooldLogsOutput, setCooldLogsOutput] = useState(''); const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState(null); + const [cooldLogsSource, setCooldLogsSource] = useState<'flux' | 'ssh' | null>(null); const [cooldLogsError, setCooldLogsError] = useState(null); const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false); const [isCorrosionTablesDialogOpen, setIsCorrosionTablesDialogOpen] = useState(false); const [corrosionTablesServer, setCorrosionTablesServer] = useState(null); const [corrosionTablesOutput, setCorrosionTablesOutput] = useState(''); const [corrosionTablesFetchedAt, setCorrosionTablesFetchedAt] = useState(null); + const [corrosionTablesSource, setCorrosionTablesSource] = useState<'flux' | 'ssh' | null>(null); const [corrosionTablesError, setCorrosionTablesError] = useState(null); const [isLoadingCorrosionTables, setIsLoadingCorrosionTables] = useState(false); const [isFirewallRulesDialogOpen, setIsFirewallRulesDialogOpen] = useState(false); @@ -340,60 +494,23 @@ export default function Clusters({ const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []; const hasBootstrapInProgress = selectedCluster?.servers.some((server) => ['queued', 'running'].includes(server.lastBootstrapStatus ?? '')) ?? false; + const bootstrapLogsServer = useMemo( + () => clusterList.flatMap((cluster) => cluster.servers).find((server) => server.id === bootstrapLogsServerId) ?? null, + [bootstrapLogsServerId, clusterList], + ); + const parsedBootstrapLogs = parseBootstrapOutput(bootstrapLogsServer?.lastBootstrapOutput ?? null); - useEffect(() => { - if (!currentTeam) { + useTeamChannel(currentTeam?.id ?? null, '.v5.cluster.updated', (payload) => { + const event = payload as V5ClusterUpdatedEvent; + + if (!event.cluster) { return; } - let isCancelled = false; - let attempts = 0; - const channelName = `team.${currentTeam.id}`; - - const interval = window.setInterval(() => { - attempts += 1; - - if (!window.Echo) { - if (attempts === 1) { - console.debug('Waiting for window.Echo before subscribing to cluster updates'); - } - - if (attempts >= 20) { - window.clearInterval(interval); - } - - return; - } - - window.clearInterval(interval); - - if (isCancelled) { - return; - } - - const channel = window.Echo.private(channelName); - - channel.subscribed?.(() => console.debug(`Subscribed to private-${channelName} for cluster updates`)); - channel.error?.((error) => console.error(`Subscription error on private-${channelName}`, error)); - channel.listen('.v5.cluster.updated', (payload) => { - const event = payload as V5ClusterUpdatedEvent; - - if (!event.cluster) { - return; - } - - setClusterList((currentClusters) => - currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)), - ); - }); - }, 500); - - return () => { - isCancelled = true; - window.clearInterval(interval); - window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`); - }; - }, [currentTeam]); + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)), + ); + }); useEffect(() => { if (!selectedCluster || !hasBootstrapInProgress) { @@ -407,15 +524,9 @@ export default function Clusters({ return; } - const response = await fetch(`/v5/clusters/${selectedCluster.id}`, { - method: 'GET', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - }, - }); + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}`, { method: 'GET' }).catch(() => null); - if (!response.ok || isCancelled) { + if (!response?.ok || isCancelled) { return; } @@ -441,15 +552,9 @@ export default function Clusters({ setIsSubmitting(true); setErrors({}); - const response = await fetch('/v5/clusters', { + const response = await apiRequest('/v5/clusters', { method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - body: JSON.stringify({ + body: { name, description: description.trim() === '' ? null : description, wireguard_interface: wireguardInterface, @@ -471,10 +576,10 @@ export default function Clusters({ builder_cpu_quota: builderCpuQuota, builder_memory_max: builderMemoryMax, builder_timeout_secs: Number(builderTimeoutSecs), - }), - }); + }, + }).catch(() => null); - if (response.status === 422) { + if (response?.status === 422) { const payload = (await response.json()) as { errors?: ClusterFormErrors; }; @@ -484,7 +589,7 @@ export default function Clusters({ return; } - if (!response.ok) { + if (!response?.ok) { setErrors({ name: ['Unable to create this cluster. Please try again.'], }); @@ -517,15 +622,9 @@ export default function Clusters({ setIsServerSubmitting(true); setServerErrors({}); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers`, { method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - body: JSON.stringify({ + body: { name: serverName, host: serverHost, ssh_user: serverSshUser, @@ -540,10 +639,10 @@ export default function Clusters({ wireguard_listen_port_override: wireguardListenPortOverride.trim() === '' ? null : Number(wireguardListenPortOverride), wireguard_endpoint_override: wireguardEndpointOverride.trim() === '' ? null : wireguardEndpointOverride, - }), - }); + }, + }).catch(() => null); - if (response.status === 422) { + if (response?.status === 422) { const payload = (await response.json()) as { errors?: ServerFormErrors; }; @@ -553,7 +652,7 @@ export default function Clusters({ return; } - if (!response.ok) { + if (!response?.ok) { setServerErrors({ name: ['Unable to add this server. Please try again.'], }); @@ -582,24 +681,18 @@ export default function Clusters({ setIsServerUpdateSubmitting(true); setEditServerErrors({}); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, { method: 'PATCH', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - body: JSON.stringify({ + body: { builder_enabled: editServerBuilderEnabled, ingress_enabled: editServerIngressEnabled, ingress_type: editServerIngressEnabled ? editServerIngressType : null, builder_capacity: Number(editServerBuilderCapacity), builder_cpu_quota: editServerBuilderCpuQuota, - }), - }); + }, + }).catch(() => null); - if (response.status === 422) { + if (response?.status === 422) { const payload = (await response.json()) as { errors?: ServerFormErrors; }; @@ -609,8 +702,8 @@ export default function Clusters({ return; } - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { message?: string } | null; + if (!response?.ok) { + const payload = (await response?.json().catch(() => null)) as { message?: string } | null; setEditServerErrors({ builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'], @@ -637,23 +730,20 @@ export default function Clusters({ checkingServers.start(server.id); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, { method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - }); + }).catch(() => null); + const payload = (await response?.json().catch(() => null)) as (CheckServerResponse & { message?: string }) | null; - if (response.ok) { - const payload = (await response.json()) as CheckServerResponse; - setSshChecks((currentChecks) => ({ - ...currentChecks, - [server.id]: payload, - })); - } + setServerConnectionNotice({ + message: response?.ok + ? `Connection check for ${server.name}: ${payload?.status ?? 'unknown'}` + : `Connection check failed for ${server.name}`, + description: response?.ok + ? (payload?.output ?? 'No output returned.') + : (payload?.message ?? 'Unable to check server connection.'), + variant: response?.ok ? 'success' : 'danger', + }); checkingServers.finish(server.id); } @@ -665,17 +755,12 @@ export default function Clusters({ bootstrappingServers.start(server.id); setBootstrapServerError(null); + openBootstrapLogs(server); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, { method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - }); - const payload = (await response.json()) as BootstrapServerResponse; + }).catch(() => null); + const payload = ((await response?.json().catch(() => null)) ?? {}) as BootstrapServerResponse; if (payload.cluster) { setClusterList((currentClusters) => @@ -683,13 +768,17 @@ export default function Clusters({ ); } - if (!response.ok) { + if (!response?.ok) { setBootstrapServerError(payload.message ?? 'Unable to queue bootstrap for this server.'); } bootstrappingServers.finish(server.id); } + function openBootstrapLogs(server: V5Server): void { + setBootstrapLogsServerId(server.id); + setIsBootstrapLogsDialogOpen(true); + } async function loadCooldLogs(server: V5Server): Promise { if (!selectedCluster) { @@ -702,18 +791,15 @@ export default function Clusters({ setCooldLogsError(null); setCooldLogsOutput(''); setCooldLogsFetchedAt(null); + setCooldLogsSource(null); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, { method: 'GET', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - }, - }); + }).catch(() => null); - const payload = (await response.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null; + const payload = (await response?.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null; - if (!response.ok) { + if (!response?.ok) { setCooldLogsError(payload?.message ?? 'Unable to load coold logs.'); setIsLoadingCooldLogs(false); @@ -722,6 +808,7 @@ export default function Clusters({ setCooldLogsOutput(payload?.output ?? ''); setCooldLogsFetchedAt(payload?.fetchedAt ?? null); + setCooldLogsSource(payload?.source ?? null); setIsLoadingCooldLogs(false); } @@ -736,18 +823,15 @@ export default function Clusters({ setCorrosionTablesError(null); setCorrosionTablesOutput(''); setCorrosionTablesFetchedAt(null); + setCorrosionTablesSource(null); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, { method: 'GET', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - }, - }); + }).catch(() => null); - const payload = (await response.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null; + const payload = (await response?.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null; - if (!response.ok) { + if (!response?.ok) { setCorrosionTablesError(payload?.message ?? 'Unable to load Corrosion tables.'); setIsLoadingCorrosionTables(false); @@ -756,6 +840,7 @@ export default function Clusters({ setCorrosionTablesOutput(payload?.output ?? ''); setCorrosionTablesFetchedAt(payload?.fetchedAt ?? null); + setCorrosionTablesSource(payload?.source ?? null); setIsLoadingCorrosionTables(false); } @@ -772,17 +857,13 @@ export default function Clusters({ setFirewallRules([]); setFirewallRulesFetchedAt(null); - const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, { + const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, { method: 'GET', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - }, - }); + }).catch(() => null); - const payload = (await response.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null; + const payload = (await response?.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null; - if (!response.ok) { + if (!response?.ok) { setFirewallRulesError(payload?.message ?? 'Unable to load firewall rules.'); setIsLoadingFirewallRules(false); @@ -820,25 +901,32 @@ export default function Clusters({ async function deleteServer(cluster: V5Cluster, server: V5Server): Promise { deletingServers.start(server.id); + setDeleteClusterError(null); - const response = await fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`, { + const response = await apiRequest(`/v5/clusters/${cluster.id}/servers/${server.id}`, { method: 'DELETE', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - }); + }).catch(() => null); - if (response.ok) { - const payload = (await response.json()) as DeleteServerResponse; + if (!response?.ok) { + const payload = (await response?.json().catch(() => null)) as { message?: string } | null; - setClusterList((currentClusters) => - currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + setDeleteClusterError( + payload?.message ?? + (response?.status === 422 + ? 'Delete or move applications from this server before deleting it.' + : 'Unable to delete this server. Please try again.'), ); + deletingServers.finish(server.id); + + return; } + const payload = (await response.json()) as DeleteServerResponse; + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + ); + deletingServers.finish(server.id); setIsDeleteDialogOpen(false); setClusterPendingDelete(null); @@ -866,16 +954,11 @@ export default function Clusters({ setIsDeletingCluster(true); setDeleteClusterError(null); - const response = await fetch(`/v5/clusters/${cluster.id}`, { + const response = await apiRequest(`/v5/clusters/${cluster.id}`, { method: 'DELETE', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - }); + }).catch(() => null); - if (response.status === 422) { + if (response?.status === 422) { const payload = (await response.json()) as { message?: string }; setDeleteClusterError(payload.message ?? 'Only empty clusters can be deleted.'); setIsDeletingCluster(false); @@ -883,14 +966,14 @@ export default function Clusters({ return; } - if (!response.ok) { + if (!response?.ok) { setDeleteClusterError('Unable to delete this cluster. Please try again.'); setIsDeletingCluster(false); return; } - const nextClusters = clusterList.filter((cluster) => cluster.id !== selectedCluster.id); + const nextClusters = clusterList.filter((remainingCluster) => remainingCluster.id !== cluster.id); setClusterList(nextClusters); setSelectedClusterId(nextClusters[0]?.id ?? ''); @@ -969,11 +1052,8 @@ export default function Clusters({ const isBootstrappingServer = bootstrappingServers.has(server.id) || isBootstrapInProgress; const isDeletingServer = deletingServers.has(server.id); const isServerInitialized = server.lastBootstrappedAt !== null; - const latestSshCheck = sshChecks[server.id] ?? null; const hasBootstrapLogs = server.lastBootstrapOutput !== null && server.lastBootstrapOutput.trim() !== ''; const canShowBootstrapLogs = hasBootstrapLogs || isBootstrapInProgress; - const isBootstrapLogVisible = - isBootstrapInProgress || (canShowBootstrapLogs && (visibleBootstrapLogs[server.id] ?? false)); return (
@@ -1035,20 +1115,8 @@ export default function Clusters({ {isCheckingServer ? 'Checking...' : 'Check connection'} {canShowBootstrapLogs ? ( - - setVisibleBootstrapLogs((currentLogs) => ({ - ...currentLogs, - [server.id]: !isBootstrapLogVisible, - })) - } - > - {isBootstrapInProgress - ? 'Install logs shown' - : isBootstrapLogVisible - ? 'Hide install logs' - : 'Show install logs'} + openBootstrapLogs(server)}> + View install logs ) : null} void loadCooldLogs(server)}> @@ -1113,37 +1181,6 @@ export default function Clusters({ - - {latestSshCheck ? ( -
-
- Latest SSH check: {latestSshCheck.status} - {formatDate(latestSshCheck.checkedAt)} -
-
-                            {latestSshCheck.output}
-                        
-
- ) : null} - - {isBootstrapLogVisible ? ( -
-
- - Install logs - {server.lastBootstrapStatus ? `: ${server.lastBootstrapStatus}` : ''} - - {formatDate(server.lastBootstrapRanAt)} -
- {server.lastBootstrapOutput ? ( -
-                                {server.lastBootstrapOutput}
-                            
- ) : ( -

No install logs captured yet.

- )} -
- ) : null}
); } @@ -1161,6 +1198,15 @@ export default function Clusters({ selectedEnvironmentUuid={selectedEnvironmentUuid} /> + {serverConnectionNotice ? ( + setServerConnectionNotice(null)} + /> + ) : null} +
@@ -1487,6 +1533,7 @@ export default function Clusters({ setIsDeleteDialogOpen(open); if (!open) { + setDeleteClusterError(null); setClusterPendingDelete(null); setServerPendingDelete(null); } @@ -1501,6 +1548,14 @@ export default function Clusters({ : `Delete cluster ${clusterPendingDelete?.name ?? ''}? This cannot be undone.`} + {deleteClusterError ? ( +

+ {deleteClusterError} +

+ ) : null} - ); - - if (!isDisabled) { - return button; - } - - return ( - - }>{button} - -

You need to enable ingress in server settings first.

-
-
- ); - } - - async function addNginx(): Promise { + const addNginx = useCallback(async (): Promise => { setIsCreating(true); setNotice(null); - try { - const response = await fetch('/v5/applications/nginx', { + const response = await canvasRequest('/v5/applications/nginx', { method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - body: JSON.stringify({ + body: { server_uuid: selectedNginxServerId || null, image: nginxImage.trim() || DEFAULT_NGINX_IMAGE, - }), + }, }); const payload = (await response.json()) as { application?: V5Application; message?: string }; - if (payload.application) { - const settledResources = settleCanvasResources([...applications, payload.application], ingresses); - const settledApplication = settledResources.applications.find( - (application) => application.id === payload.application?.id, - ); + if (!response.ok || !payload.application) { + setNotice(payload.message ?? 'Could not deploy nginx.'); - setApplications(settledResources.applications); - setIngresses(settledResources.ingresses); - centerOnCanvasNodes(settledResources.applications, settledResources.ingresses); - - if ( - settledApplication && - (settledApplication.canvasX !== payload.application.canvasX || - settledApplication.canvasY !== payload.application.canvasY) - ) { - void persistApplicationPosition(settledApplication); - } + return; } - if (!response.ok) { - setNotice(payload.application?.statusMessage ?? payload.message ?? 'Could not deploy nginx.'); + const settledResources = settleCanvasResources([...applicationsRef.current, payload.application], ingresses); + const settledApplication = settledResources.applications.find( + (application) => application.id === payload.application?.id, + ); + + setApplications(settledResources.applications); + setIngresses(settledResources.ingresses); + centerOnCanvasNodes(settledResources.applications, settledResources.ingresses); + + if ( + settledApplication && + (settledApplication.canvasX !== payload.application.canvasX || + settledApplication.canvasY !== payload.application.canvasY) + ) { + locallyPositionedApplicationIdsRef.current.add(settledApplication.id); + void persistApplicationPosition(settledApplication, { + canvasX: payload.application.canvasX, + canvasY: payload.application.canvasY, + }); } } catch (error) { setNotice(error instanceof Error ? error.message : 'Could not deploy nginx.'); } finally { setIsCreating(false); } - } + }, [selectedNginxServerId, nginxImage, ingresses, centerOnCanvasNodes, persistApplicationPosition]); - async function refreshApplications(): Promise { + const refreshApplications = useCallback(async (): Promise => { setIsRefreshing(true); setNotice(null); - try { - const response = await fetch('/v5/applications/refresh', { - method: 'POST', - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken(), - }, - }); + const response = await canvasRequest('/v5/applications/refresh', { method: 'POST' }); const payload = (await response.json()) as { applications?: V5Application[]; errors?: string[]; @@ -969,45 +355,79 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection } finally { setIsRefreshing(false); } - } + }, [ingresses]); - function centerOnCanvasNodes( - nextApplications = applications, - nextCaddyIngresses: V5CaddyIngress[] = ingresses, - ): void { - const canvas = canvasRef.current; - const nodes = [...nextApplications, ...nextCaddyIngresses]; + const startApplicationDrag = useCallback( + (event: PointerEvent, application: V5Application): void => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setSelectedConnectionId(null); + setSelectedApplicationId(application.id); + locallyPositionedApplicationIdsRef.current.add(application.id); + setPointerState({ + type: 'app', + pointerId: event.pointerId, + applicationId: application.id, + startClientX: event.clientX, + startClientY: event.clientY, + startX: application.canvasX, + startY: application.canvasY, + }); + }, + [setSelectedConnectionId], + ); - if (!canvas || nodes.length === 0) { - setViewport((currentViewport) => ({ x: 0, y: 0, zoom: currentViewport.zoom })); + const startIngressDrag = useCallback((event: PointerEvent, ingress: V5CaddyIngress): void => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + locallyPositionedIngressIdsRef.current.add(ingress.id); + setPointerState({ + type: 'ingress', + pointerId: event.pointerId, + ingressId: ingress.id, + startClientX: event.clientX, + startClientY: event.clientY, + startX: ingress.canvasX, + startY: ingress.canvasY, + }); + }, []); - return; - } + const startConnectionDrag = useCallback( + (event: PointerEvent, applicationId: string, side: ConnectorSide): void => { + event.stopPropagation(); - const bounds = nodes.reduce( - (currentBounds, node) => ({ - minX: Math.min(currentBounds.minX, node.canvasX), - maxX: Math.max(currentBounds.maxX, node.canvasX), - minY: Math.min(currentBounds.minY, node.canvasY), - maxY: Math.max(currentBounds.maxY, node.canvasY), - }), - { - minX: nodes[0]?.canvasX ?? 0, - maxX: nodes[0]?.canvasX ?? 0, - minY: nodes[0]?.canvasY ?? 0, - maxY: nodes[0]?.canvasY ?? 0, - }, - ); - const centerX = (bounds.minX + bounds.maxX) / 2; - const centerY = (bounds.minY + bounds.maxY) / 2; - const rect = canvas.getBoundingClientRect(); + const from = { applicationId, side }; + const fromApplication = applicationsRef.current.find((candidate) => candidate.id === applicationId); + const startPoint = fromApplication ? connectorPoint(fromApplication, side) : canvasPointFromPointer(event); - setViewport((currentViewport) => ({ - x: rect.width / 2 - (centerX + APPLICATION_CARD_WIDTH / 2) * currentViewport.zoom, - y: rect.height / 2 - (centerY + APPLICATION_CARD_HEIGHT / 2) * currentViewport.zoom, - zoom: currentViewport.zoom, - })); - } + setDraftConnection({ + from, + toX: startPoint.x, + toY: startPoint.y, + }); + setPointerState({ + type: 'connection', + pointerId: event.pointerId, + from, + }); + }, + [canvasPointFromPointer], + ); + + const selectConnection = useCallback( + (event: MouseEvent, connectionId: string): void => { + event.stopPropagation(); + setSelectedConnectionId(connectionId); + setSelectedApplicationId(null); + }, + [setSelectedConnectionId], + ); + + const openApplicationInspector = useCallback((event: MouseEvent, application: V5Application): void => { + event.stopPropagation(); + setSelectedApplicationId(application.id); + setSelectedInspectorApplicationId(application.id); + }, []); function startPan(event: PointerEvent): void { if (event.target !== event.currentTarget) { @@ -1024,64 +444,6 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection }); } - function startApplicationDrag(event: PointerEvent, application: V5Application): void { - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - setSelectedConnectionId(null); - setSelectedApplicationId(application.id); - setPointerState({ - type: 'app', - pointerId: event.pointerId, - applicationId: application.id, - startClientX: event.clientX, - startClientY: event.clientY, - startX: application.canvasX, - startY: application.canvasY, - }); - } - - function startIngressDrag(event: PointerEvent, ingress: V5CaddyIngress): void { - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - setPointerState({ - type: 'ingress', - pointerId: event.pointerId, - ingressId: ingress.id, - startClientX: event.clientX, - startClientY: event.clientY, - startX: ingress.canvasX, - startY: ingress.canvasY, - }); - } - - function startConnectionDrag( - event: PointerEvent, - applicationId: string, - side: ConnectorSide, - ): void { - event.stopPropagation(); - - const from = { applicationId, side }; - const startPoint = connectorPoint(from) ?? canvasPointFromPointer(event); - - setDraftConnection({ - from, - toX: startPoint.x, - toY: startPoint.y, - }); - setPointerState({ - type: 'connection', - pointerId: event.pointerId, - from, - }); - } - - function selectConnection(event: MouseEvent, connectionId: string): void { - event.stopPropagation(); - setSelectedConnectionId(connectionId); - setSelectedApplicationId(null); - } - function clearCanvasSelection(event: MouseEvent): void { if (event.target !== event.currentTarget) { return; @@ -1091,12 +453,6 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection setSelectedApplicationId(null); } - function openApplicationInspector(event: MouseEvent, application: V5Application): void { - event.stopPropagation(); - setSelectedApplicationId(application.id); - setSelectedInspectorApplicationId(application.id); - } - function connectionTargetFromPointer(event: PointerEvent): HTMLElement | null { const pointerTarget = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; @@ -1163,47 +519,6 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection ); } - function clampCanvasZoom(zoom: number): number { - return Math.min(MAX_CANVAS_ZOOM, Math.max(MIN_CANVAS_ZOOM, zoom)); - } - - function zoomCanvas(direction: 1 | -1, step = CANVAS_ZOOM_STEP, origin?: { x: number; y: number }): void { - const rect = canvasRef.current?.getBoundingClientRect(); - - setViewport((currentViewport) => { - const nextZoom = clampCanvasZoom(currentViewport.zoom + direction * step); - - if (!rect || nextZoom === currentViewport.zoom) { - return currentViewport; - } - - const originX = origin?.x ?? rect.width / 2; - const originY = origin?.y ?? rect.height / 2; - const canvasX = (originX - currentViewport.x) / currentViewport.zoom; - const canvasY = (originY - currentViewport.y) / currentViewport.zoom; - - return { - x: originX - canvasX * nextZoom, - y: originY - canvasY * nextZoom, - zoom: nextZoom, - }; - }); - } - - function handleCanvasWheel(event: WheelEvent): void { - if (!event.ctrlKey) { - return; - } - - const rect = event.currentTarget.getBoundingClientRect(); - - event.preventDefault(); - zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP, { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }); - } - function stopPointer(event: PointerEvent): void { if (!pointerState || pointerState.pointerId !== event.pointerId) { return; @@ -1234,16 +549,23 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection const application = applications.find((candidate) => candidate.id === pointerState.applicationId); if (application) { - const updatedApplication = resolveApplicationPosition({ - ...application, - canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), - canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), - }); + const updatedApplication = resolveApplicationPosition( + { + ...application, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + }, + applications, + ingresses, + ); setApplications((currentApplications) => currentApplications.map((candidate) => (candidate.id === updatedApplication.id ? updatedApplication : candidate)), ); - void persistApplicationPosition(updatedApplication); + void persistApplicationPosition(updatedApplication, { + canvasX: pointerState.startX, + canvasY: pointerState.startY, + }); } } @@ -1251,22 +573,46 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection const ingress = ingresses.find((candidate) => candidate.id === pointerState.ingressId); if (ingress) { - const updatedIngress = resolveIngressPosition({ - ...ingress, - canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), - canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), - }); + const updatedIngress = resolveIngressPosition( + { + ...ingress, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + }, + applications, + ingresses, + ); setIngresses((currentIngresses) => currentIngresses.map((candidate) => (candidate.id === updatedIngress.id ? updatedIngress : candidate)), ); - void persistCaddyIngressPosition(updatedIngress); + void persistCaddyIngressPosition(updatedIngress, { + canvasX: pointerState.startX, + canvasY: pointerState.startY, + }); } } setPointerState(null); } + const deployNginx = useCallback((): void => { + void addNginx(); + }, [addNginx]); + const refreshCanvas = useCallback((): void => { + void refreshApplications(); + }, [refreshApplications]); + const centerCanvas = useCallback((): void => { + centerOnCanvasNodes(applications, ingresses); + }, [applications, ingresses, centerOnCanvasNodes]); + const zoomIn = useCallback((): void => zoomCanvas(1), [zoomCanvas]); + const zoomOut = useCallback((): void => zoomCanvas(-1), [zoomCanvas]); + const dismissNotice = useCallback((): void => setNotice(null), []); + const closeInspector = useCallback((): void => setSelectedInspectorApplicationId(null), []); + const submitIngress = useCallback((): void => { + void submitApplicationIngress(); + }, [submitApplicationIngress]); + return ( @@ -1280,111 +626,25 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection />
-
- - setNginxImage(event.target.value)} - disabled={isCreating} - className="w-72 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60" - /> - - -
- - - {Math.round(viewport.zoom * 100)}% - - -
- -
- {applications.length} apps - - {statusCounts.running} running - {statusCounts.failed > 0 && ( - <> - - {statusCounts.failed} failed - - )} - {statusCounts.unknown > 0 && ( - <> - - {statusCounts.unknown} unknown - - )} -
-
+ - {notice && ( -
- {notice} - -
- )} + {notice && }
{ingresses.map((ingress) => ( -
startIngressDrag(event, ingress)} - > -
-
-
Caddy ingress
-
{ingress.name}
-
- - {ingress.status} - -
- -
-
-
Server
-
{ingress.name}
-
-
-
Host
-
- {ingress.host} -
-
-
-
+ ))} - - - - - - - {connections.map((connection) => { - const points = shortestConnectionPoints(connection); + - if (!points) { - return null; - } + {selectedConnection && ( + + )} - return ( - - event.stopPropagation()} - onClick={(event) => selectConnection(event, connection.id)} - /> - - - ); - })} - {draftConnection && - (() => { - const from = connectorPoint(draftConnection.from); - - if (!from) { - return null; - } - - return ( - - ); - })()} - - - {connections.map((connection) => { - if (connection.id !== selectedConnectionId) { - return null; - } - - const points = shortestConnectionPoints(connection); - - if (!points) { - return null; - } - - const activePorts = activeConnectionPorts(connection); - const firstApplicationId = connection.applicationIds[0]; - const secondApplicationId = connection.applicationIds[1]; - const isForwardDirection = - connection.fromApplicationId === firstApplicationId && - connection.toApplicationId === secondApplicationId; - - return ( -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > -
-
- Firewall -
-
- - -
-
- -
-
- Allowed ports -
-
- {activePorts.length === 0 && ( - No ports yet. - )} - {activePorts.map((port) => ( - - ))} -
-
- - setConnectionPortInput((currentInputs) => ({ - ...currentInputs, - [connection.id]: event.target.value, - })) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - addConnectionPort(connection.id); - } - }} - className="min-w-0 flex-1 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground outline-none transition focus:border-warning" - /> - -
-
- - -
- ); - })} - - {applications.map((application) => { - const isDeletingApplication = deletingApplicationIds.has(application.id); - - return ( -
startApplicationDrag(event, application)} - onDoubleClick={(event) => openApplicationInspector(event, application)} - > - {CONNECTOR_SIDES.map((side) => ( - - ))} - -
-
-
{application.name}
-
{application.image}
-
-
- - {application.effectiveStatus} - - - -
-
- -
-
-
Server
-
- {application.serverName ?? 'Unknown'} - {!application.isServerReachable && ( - (unreachable) - )} -
-
-
-
Container
-
- {application.containerName} -
-
-
-
Ingress
-
- - {application.ingressEnabled - ? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}` - : 'Private'} - - {renderIngressButton(application)} -
-
-
-
- ); - })} + {applications.map((application) => ( + + ))}
- { - if (!open) { - setSelectedInspectorApplicationId(null); - } - }} - > - - {selectedInspectorApplication && ( - <> - - App configuration - - Double-click an application card to open configuration. Review runtime, networking, and advanced settings for{' '} - {selectedInspectorApplication.name}. - - + -
- - - Overview - Networking - Advanced - + !open && setPendingLocalDelete(null)}> + + + Delete from Coolify only? + + Coolify could not reach the server to clean up containers, volumes, networks, or ingress config. + + - -
- - Name - - - - - Status - - - - {selectedInspectorApplication.effectiveStatus !== selectedInspectorApplication.status && ( - - Last known container status - - - )} - - - Image - - - - - Server - - - - - Container - - - - - Runtime container ID - - -
- - - Status message -