'array', 'session.driver' => 'array', 'queue.default' => 'sync', 'app.maintenance.driver' => 'file', ]); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( ['id' => 0], ['is_api_enabled' => true], )); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->bearerToken = serverProxyApiToken($this->user, $this->team, ['*']); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->server->proxy->set('type', ProxyTypes::TRAEFIK->value); $this->server->proxy->set('status', 'exited'); $this->server->proxy->redirect_enabled = true; $this->server->save(); }); function serverProxyApiHeaders(string $bearerToken): array { return [ 'Authorization' => 'Bearer '.$bearerToken, 'Content-Type' => 'application/json', ]; } function serverProxyApiToken(User $user, Team $team, array $abilities): string { $plainTextToken = Str::random(40); $token = $user->tokens()->create([ 'name' => 'server-proxy-api-test-'.Str::random(6), 'token' => hash('sha256', $plainTextToken), 'abilities' => $abilities, 'team_id' => $team->id, ]); return $token->getKey().'|'.$plainTextToken; } test('GET /api/v1/servers/{uuid}/proxy returns proxy settings without configuration when none is stored', function () { $sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']); $this->withHeaders(serverProxyApiHeaders($sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") ->assertOk() ->assertJsonPath('proxy_type', ProxyTypes::TRAEFIK->value) ->assertJsonPath('redirect_enabled', true) ->assertJsonPath('redirect_url', null) ->assertJsonPath('generate_exact_labels', false) ->assertJsonPath('configuration', null); }); test('GET /api/v1/servers/{uuid}/proxy omits stored configuration without read:sensitive', function () { $compose = "services:\n traefik:\n image: traefik:v3.5\n"; $this->server->proxy->set('last_saved_proxy_configuration', $compose); $this->server->save(); // '*' tokens grant all abilities including read:sensitive; use a read-only token. $readToken = serverProxyApiToken($this->user, $this->team, ['read']); $this->withHeaders(serverProxyApiHeaders($readToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") ->assertOk() ->assertJsonMissingPath('configuration'); }); test('GET /api/v1/servers/{uuid}/proxy returns stored configuration with read:sensitive for admins', function () { $compose = "services:\n traefik:\n image: traefik:v3.5\n"; $this->server->proxy->set('last_saved_proxy_configuration', $compose); $this->server->save(); $sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']); $this->withHeaders(serverProxyApiHeaders($sensitiveToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") ->assertOk() ->assertJsonPath('configuration', $compose); }); test('GET /api/v1/servers/{uuid}/proxy hides configuration from non-admin users with read:sensitive', function () { $compose = "services:\n traefik:\n image: traefik:v3.5\n"; $this->server->proxy->set('last_saved_proxy_configuration', $compose); $this->server->save(); // ApiSensitiveData requires admin/owner of the token team even with read:sensitive. $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); session(['currentTeam' => $this->team]); $memberToken = serverProxyApiToken($member, $this->team, ['read', 'read:sensitive']); // Members may be forbidden from viewing servers via policy; when allowed, config must still be hidden. $response = $this->withHeaders(serverProxyApiHeaders($memberToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/proxy"); if ($response->status() === 200) { $response->assertJsonMissingPath('configuration'); } else { $response->assertForbidden(); } }); test('GET /api/v1/servers/{uuid}/proxy does not expose another team server', function () { $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->getJson("/api/v1/servers/{$otherServer->uuid}/proxy") ->assertNotFound(); }); test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings without SSH', function () { // Factory servers are not reachable, so setupDefaultRedirect is skipped. $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'redirect_enabled' => false, 'redirect_url' => null, 'generate_exact_labels' => true, ]) ->assertOk() ->assertJsonPath('redirect_enabled', false) ->assertJsonPath('redirect_url', null) ->assertJsonPath('generate_exact_labels', true); $server = $this->server->fresh(); expect((bool) data_get($server->proxy, 'redirect_enabled'))->toBeFalse() ->and(data_get($server->proxy, 'redirect_url'))->toBeNull() ->and((bool) $server->settings->generate_exact_labels)->toBeTrue(); }); test('PATCH /api/v1/servers/{uuid}/proxy updates the Traefik certificate resolver', function (?string $input, ?string $expected) { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") ->assertOk() ->assertJsonPath('traefik_certificate_resolver', 'letsencrypt'); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'traefik_certificate_resolver' => $input, ]) ->assertOk() ->assertJsonPath('traefik_certificate_resolver', $expected); expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe($expected); })->with([ 'custom resolver' => ['cloudflare', 'cloudflare'], 'no resolver' => [null, null], 'empty resolver' => ['', null], ]); test('PATCH /api/v1/servers/{uuid}/proxy rejects unsafe certificate resolver names', function () { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'traefik_certificate_resolver' => 'letsencrypt.acme=x', ]) ->assertUnprocessable() ->assertJsonValidationErrors('traefik_certificate_resolver'); expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); }); test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'redirect_enabled' => true, 'unknown_field' => 'nope', ]) ->assertUnprocessable() ->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.'); }); test('PATCH /api/v1/servers/{uuid}/proxy rejects invalid proxy type', function () { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'proxy_type' => 'haproxy', ]) ->assertUnprocessable() ->assertJsonPath('errors.proxy_type.0', 'Invalid proxy type.'); }); test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously', function () { StartProxy::shouldRun()->andReturn('OK'); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ 'proxy_type' => 'caddy', ]) ->assertOk() ->assertJsonPath('proxy_type', ProxyTypes::CADDY->value); expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $compose = "services:\n traefik:\n image: traefik:v3.5\n"; SaveProxyConfiguration::shouldRun() ->once() ->withArgs(function (Server $server, string $configuration) use ($compose) { return $server->is($this->server) && $configuration === $compose; }); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode($compose), ]) ->assertOk() ->assertJsonPath('message', 'Proxy configuration saved.'); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configuration', function () { $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'foo' => 'bar', ]) ->assertUnprocessable(); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects command injection without mutation', function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $originalProxy = $this->server->proxy->toArray(); $configuration = "services:\n traefik:\n ports:\n - '`id>/tmp/pwned`:443'\n"; $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode($configuration), ]) ->assertUnprocessable() ->assertJsonPath('errors.configuration.0', 'Proxy ports must use Docker Compose port syntax with ports from 1 through 65535.'); expect($this->server->fresh()->proxy->toArray())->toBe($originalProxy); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration forbids normal members', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); $memberToken = serverProxyApiToken($member, $this->team, ['*']); SaveProxyConfiguration::shouldNotRun(); $this->withHeaders(serverProxyApiHeaders($memberToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode("services:\n traefik:\n ports: ['80:80']\n"), ]) ->assertForbidden(); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permission', function () { $writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']); $configuration = "services:\n traefik:\n image: traefik:v3.5\n"; SaveProxyConfiguration::shouldNotRun(); $this->withHeaders(serverProxyApiHeaders($writeOnlyToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode($configuration), ]) ->assertForbidden() ->assertJsonPath('message', 'Missing required permissions: deploy. Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.'); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $deployToken = serverProxyApiToken($this->user, $this->team, ['write', 'deploy']); SaveProxyConfiguration::shouldRun()->once(); $this->withHeaders(serverProxyApiHeaders($deployToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"), ]) ->assertOk(); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the write permission', function () { $deployOnlyToken = serverProxyApiToken($this->user, $this->team, ['deploy']); SaveProxyConfiguration::shouldNotRun(); $this->withHeaders(serverProxyApiHeaders($deployOnlyToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"), ]) ->assertForbidden(); }); test('POST /api/v1/servers/{uuid}/proxy/restart requires the deploy permission', function () { Queue::fake(); $writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']); $this->withHeaders(serverProxyApiHeaders($writeOnlyToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") ->assertForbidden(); Queue::assertNotPushed(RestartProxyJob::class); }); test('POST /api/v1/servers/{uuid}/proxy/restart works with the deploy permission', function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); Queue::fake(); $deployToken = serverProxyApiToken($this->user, $this->team, ['deploy']); $this->withHeaders(serverProxyApiHeaders($deployToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") ->assertOk(); Queue::assertPushed(RestartProxyJob::class); }); test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); Queue::fake(); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") ->assertOk() ->assertJsonPath('message', 'Proxy restart queued.'); Queue::assertPushed( RestartProxyJob::class, fn (RestartProxyJob $job): bool => $job->server->is($this->server) ); }); test('proxy configuration and restart endpoints do not connect to a server that is not validated', function () { Queue::fake(); SaveProxyConfiguration::shouldNotRun(); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"), ]) ->assertStatus(400) ->assertJsonPath('message', 'Server is not functional.'); $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") ->assertStatus(400) ->assertJsonPath('message', 'Server is not functional.'); Queue::assertNotPushed(RestartProxyJob::class); }); test('proxy endpoints require authentication', function () { $this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized(); $this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized(); $this->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", ['configuration' => 'x'])->assertUnauthorized(); $this->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")->assertUnauthorized(); });