From 87dade45178a82131ea7b3c6087ebcba30f23a86 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:38 +0200 Subject: [PATCH 1/6] fix(secret-manager): add configurable request timeout for Infisical and Vault --- .../Api/IntegrationTokensController.php | 2 ++ .../Security/IntegrationTokenEditor.php | 2 ++ .../Security/IntegrationTokenForm.php | 2 ++ app/Models/IntegrationToken.php | 10 ++++++ app/Models/SecretManagerLink.php | 2 ++ app/Services/InfisicalService.php | 6 ++-- app/Services/IntegrationTokenValidator.php | 4 +++ app/Services/VaultService.php | 6 ++-- .../integration-token-editor.blade.php | 4 +++ .../security/integration-token-form.blade.php | 4 +++ .../SecretManagers/SecretManagerLinkTest.php | 19 +++++++++++ .../SecretManagerServicesTest.php | 15 +++++++++ .../Security/IntegrationTokenFormTest.php | 33 +++++++++++++++++++ 13 files changed, 103 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php index 13a225a107..afd31bc699 100644 --- a/app/Http/Controllers/Api/IntegrationTokensController.php +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -64,9 +64,11 @@ class IntegrationTokensController extends Controller } elseif (($body['provider'] ?? null) === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } elseif (($body['provider'] ?? null) === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } $validator = customApiValidator($body, $rules); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 16a7f0ad9b..81db162a6f 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -64,11 +64,13 @@ class IntegrationTokenEditor extends Component if ($this->integrationToken->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->integrationToken->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 3cfbc75aa7..238c0a0c73 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -66,6 +66,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->provider === 'doppler') { @@ -75,6 +76,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 119c8d95df..437aa9b5b9 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -82,6 +82,16 @@ class IntegrationToken extends BaseModel return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; } + /** + * Optional request timeout in seconds for self-hosted secret managers. + * + * @param array $metadata + */ + public static function requestTimeout(array $metadata): ?int + { + return filled($metadata['timeout'] ?? null) ? (int) $metadata['timeout'] : null; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php index 0e47cf63ed..20a8ff55d6 100644 --- a/app/Models/SecretManagerLink.php +++ b/app/Models/SecretManagerLink.php @@ -63,6 +63,7 @@ class SecretManagerLink extends BaseModel data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token->token, + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'project_id'), (string) data_get($settings, 'environment'), @@ -72,6 +73,7 @@ class SecretManagerLink extends BaseModel (string) data_get($metadata, 'base_url'), $token->token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'mount', 'secret'), (string) data_get($settings, 'path'), diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 1d3e87e48c..3be8de7728 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -14,7 +14,7 @@ class InfisicalService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -87,7 +87,7 @@ class InfisicalService { return Http::acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); } } diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php index 6033ce98f7..2872a83a91 100644 --- a/app/Services/IntegrationTokenValidator.php +++ b/app/Services/IntegrationTokenValidator.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\IntegrationToken; + /** * Validates an integration token against its provider API before it is saved. */ @@ -16,11 +18,13 @@ class IntegrationTokenValidator (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token, + IntegrationToken::requestTimeout($metadata), ))->validate(), 'vault' => (new VaultService( (string) data_get($metadata, 'base_url'), $token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->validate(), default => false, }; diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php index e41652cd54..852b749303 100644 --- a/app/Services/VaultService.php +++ b/app/Services/VaultService.php @@ -14,7 +14,7 @@ class VaultService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -56,8 +56,8 @@ class VaultService $client = Http::withHeaders(['X-Vault-Token' => $this->token]) ->acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); if (filled($this->namespace)) { $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index ed7d108aed..5f9a01f374 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -20,12 +20,16 @@ + @elseif ($integrationToken->provider === 'vault')
+
@endif diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index a085ebbe1a..a0c3566025 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -17,6 +17,8 @@ + @else
@@ -34,6 +36,8 @@ placeholder="https://vault.example.com:8200" /> +
@endif diff --git a/tests/Feature/SecretManagers/SecretManagerLinkTest.php b/tests/Feature/SecretManagers/SecretManagerLinkTest.php index acaa33f7a7..c7074ceb8d 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinkTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinkTest.php @@ -187,6 +187,25 @@ test('a vault link uses the base url and namespace from the token metadata', fun Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a')); }); +test('an infisical link uses the request timeout from the token metadata', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return str_contains($request->url(), '/auth/universal-auth/login') + ? Http::response(['accessToken' => 'short-lived-token']) + : Http::response(['secrets' => [['secretKey' => 'KEY', 'secretValue' => 'value']]]); + }); + + $link = createSecretManagerLink('infisical', + ['project_id' => 'project-1', 'environment' => 'prod'], + ['base_url' => 'https://example.com', 'client_id' => 'client-id', 'timeout' => '30'], + ); + + expect($link->fetchSecrets())->toBe(['KEY' => 'value']) + ->and($timeouts)->toBe([[30, 30], [30, 30]]); +}); + test('services resolve environment variables from their configured secret manager', function () { Http::fake([ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([ diff --git a/tests/Feature/SecretManagers/SecretManagerServicesTest.php b/tests/Feature/SecretManagers/SecretManagerServicesTest.php index f6a2c34ac3..7618c6cb96 100644 --- a/tests/Feature/SecretManagers/SecretManagerServicesTest.php +++ b/tests/Feature/SecretManagers/SecretManagerServicesTest.php @@ -125,6 +125,21 @@ describe('InfisicalService', function () { && $request['expandSecretReferences'] === 'true'); }); + test('uses a 5 second connect timeout and a 10 second request timeout by default', function (?int $timeout, array $expected) { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return Http::response(['accessToken' => 'short-lived-token']); + }); + + expect((new InfisicalService('https://example.com', 'client-id', 'client-secret', $timeout))->validate())->toBeTrue() + ->and($timeouts)->toBe([$expected]); + })->with([ + 'default' => [null, [5, 10]], + 'custom' => [60, [60, 60]], + ]); + test('throws when the login fails', function () { Http::fake([ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([ diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 41d18784ee..b46c6d1364 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -469,3 +469,36 @@ test('saving a vault token with unchanged connection settings and a blank token ->and($savedToken->token)->toBe('hvs.stored-secret') ->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); }); + +test('changing the vault request timeout keeps the stored token and validates with the new timeout', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = $options['timeout']; + + return Http::response(['data' => []]); + }); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', '45') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $savedToken->refresh(); + expect($timeouts)->toBe([45]) + ->and($savedToken->token)->toBe('hvs.stored-secret') + ->and(IntegrationToken::requestTimeout($savedToken->metadata))->toBe(45); +}); + +test('a request timeout outside 1 to 300 seconds is rejected', function (string $timeout) { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', $timeout) + ->call('save') + ->assertHasErrors(['metadata.timeout']); + + Http::assertNothingSent(); +})->with(['0', '301', 'abc']); From 408ee23936c5f83f287dc2aecbbbb92d1c0faa4b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:44:26 +0200 Subject: [PATCH 2/6] chore(release): use next as the development branch Sync main into next automatically and update release workflows and contributor docs for the long-lived next branch. --- .../coolify-release-branch-build.yml | 144 ------------------ .github/workflows/sync-main-to-next.yml | 65 ++++++++ .github/workflows/sync-main-to-release.yml | 81 ---------- AGENTS.md | 4 +- CONTRIBUTING.md | 7 +- RELEASE.md | 39 +++-- 6 files changed, 89 insertions(+), 251 deletions(-) delete mode 100644 .github/workflows/coolify-release-branch-build.yml create mode 100644 .github/workflows/sync-main-to-next.yml delete mode 100644 .github/workflows/sync-main-to-release.yml diff --git a/.github/workflows/coolify-release-branch-build.yml b/.github/workflows/coolify-release-branch-build.yml deleted file mode 100644 index 8b9e4bb4e6..0000000000 --- a/.github/workflows/coolify-release-branch-build.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Build Coolify Release Branch - -on: - push: - branches: ["release/v*"] - paths-ignore: - - .github/workflows/coolify-helper.yml - - .github/workflows/coolify-helper-next.yml - - .github/workflows/pr-quality.yaml - - docker/coolify-helper/Dockerfile - - docker/testing-host/Dockerfile - - templates/** - - CHANGELOG.md - -permissions: - contents: read - packages: write - -concurrency: - group: coolify-release-branch-build-${{ github.ref_name }} - cancel-in-progress: false - -env: - GITHUB_REGISTRY: ghcr.io - DOCKER_REGISTRY: docker.io - IMAGE_NAME: coollabsio/coolify - -jobs: - prepare: - runs-on: ubuntu-24.04 - outputs: - dev_version: ${{ steps.version.outputs.dev_version }} - short_sha: ${{ steps.version.outputs.short_sha }} - version: ${{ steps.version.outputs.version }} - steps: - - name: Resolve version from branch name - id: version - env: - BRANCH: ${{ github.ref_name }} - run: | - if [[ ! "${BRANCH}" =~ ^release/v([0-9]+)\.([0-9]+)$ ]]; then - echo "Invalid release branch: ${BRANCH}. Expected release/vX.Y, such as release/v4.5." - exit 1 - fi - - DEV_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}-dev" - SHORT_SHA="${GITHUB_SHA::7}" - echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" - echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "version=${DEV_VERSION}.${SHORT_SHA}" >> "$GITHUB_OUTPUT" - - build: - needs: prepare - strategy: - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - - arch: aarch64 - platform: linux/aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push release branch image (${{ matrix.arch }}) - uses: docker/build-push-action@v6 - with: - context: . - file: docker/production/Dockerfile - platforms: ${{ matrix.platform }} - push: true - build-args: | - COOLIFY_VERSION=${{ needs.prepare.outputs.version }} - tags: | - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} - - publish: - needs: [prepare, build] - runs-on: ubuntu-24.04 - strategy: - matrix: - registry: [ghcr.io, docker.io] - steps: - - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - if: matrix.registry == 'ghcr.io' - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - if: matrix.registry == 'docker.io' - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Publish release branch manifest on ${{ matrix.registry }} - env: - REGISTRY: ${{ matrix.registry }} - DEV_VERSION: ${{ needs.prepare.outputs.dev_version }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - docker buildx imagetools create \ - "${IMAGE}:${VERSION}-amd64" \ - "${IMAGE}:${VERSION}-aarch64" \ - --tag "${IMAGE}:${VERSION}" \ - --tag "${IMAGE}:${DEV_VERSION}" - - notify: - needs: [prepare, build, publish] - if: always() - runs-on: ubuntu-24.04 - steps: - - uses: sarisia/actions-status-discord@v1 - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} - status: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml new file mode 100644 index 0000000000..6e520633ae --- /dev/null +++ b/.github/workflows/sync-main-to-next.yml @@ -0,0 +1,65 @@ +name: Sync main to next + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-next + cancel-in-progress: false + +jobs: + sync: + name: Merge main into next + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Merge main into next + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main next + git checkout --quiet -B next origin/next + + if git merge --no-edit origin/main; then + git push origin HEAD:refs/heads/next + exit 0 + fi + + conflicts=$(git diff --name-only --diff-filter=U) + git merge --abort + + if [ -z "$conflicts" ]; then + echo '::error title=main to next sync failed::The merge failed without conflicts, so no pull request was created.' + exit 1 + fi + + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') + if [ -n "$existing_pr" ]; then + # The conflict was already reported when this pull request was opened. + # Stay green until it is merged instead of failing every scheduled run. + echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr" + exit 0 + fi + + git push --force origin origin/main:"refs/heads/$sync_branch" + gh pr create \ + --base next \ + --head "$sync_branch" \ + --title 'chore: merge main into next' \ + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' + + echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.' + exit 1 diff --git a/.github/workflows/sync-main-to-release.yml b/.github/workflows/sync-main-to-release.yml deleted file mode 100644 index 6ea7ea0768..0000000000 --- a/.github/workflows/sync-main-to-release.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Sync main to release branches - -on: - schedule: - - cron: '*/10 * * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-main-to-release - cancel-in-progress: false - -jobs: - sync: - name: Merge main into release branches - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Merge main into every release/v* branch - env: - GH_TOKEN: ${{ github.token }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch origin main '+refs/heads/release/v*:refs/remotes/origin/release/v*' - - branches=$(git for-each-ref --format='%(refname:lstrip=3)' 'refs/remotes/origin/release/v*') - if [ -z "$branches" ]; then - echo 'No release/v* branch exists, so there is nothing to sync.' - exit 0 - fi - - failed=0 - for branch in $branches; do - echo "::group::Sync main into $branch" - git checkout --quiet -B "$branch" "origin/$branch" - - if git merge --no-edit origin/main; then - git push origin "HEAD:refs/heads/$branch" - echo '::endgroup::' - continue - fi - - conflicts=$(git diff --name-only --diff-filter=U) - git merge --abort - echo '::endgroup::' - - if [ -z "$conflicts" ]; then - echo "::error title=main to $branch sync failed::The merge failed without conflicts, so no pull request was created." - failed=1 - continue - fi - - sync_branch="automation/sync-main-to-${branch//\//-}" - existing_pr=$(gh pr list --base "$branch" --head "$sync_branch" --state open --json url --jq '.[0].url') - if [ -n "$existing_pr" ]; then - # The conflict was already reported when this pull request was opened. - # Stay green until it is merged instead of failing every scheduled run. - echo "::notice title=main to $branch sync blocked::main still conflicts with $branch; resolve the open pull request: $existing_pr" - continue - fi - - git push --force origin origin/main:"refs/heads/$sync_branch" - gh pr create \ - --base "$branch" \ - --head "$sync_branch" \ - --title "chore: merge main into $branch" \ - --body "This pull request was created automatically because main could not be merged into $branch without conflicts. Resolve conflicts on this temporary branch; never update main with $branch." - - echo "::error title=main to $branch sync blocked::main could not be merged into $branch without conflicts; a pull request was opened to resolve them." - failed=1 - done - - exit "$failed" diff --git a/AGENTS.md b/AGENTS.md index ce9efdde49..9d5fe44b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,8 +251,8 @@ The persistent-storage implementation is the reference pattern: `Project\Service ## Git Workflow - Production branch: `main` -- Development branch: `release/v4.x`, where `x` is the next minor release (for example, `release/v4.5` while production is `4.4.x`). Do not use `next`. -- Fix PRs should target the current production branch; feature PRs should target the current `release/v4.x` branch +- Development branch: `next`. Do not create `release/v*` branches. +- Fix PRs should target the current production branch; feature PRs should target `next` === foundation rules === diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 977b90919a..d93150c6bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,12 +193,11 @@ Choose the branch based on the type of change: | --- | --- | --- | | Fixes and small improvements | `main` | `main` | | Security fixes | `main` | `main` | -| New features and larger changes | `release/v4.x` | `release/v4.x` | +| New features and larger changes | `next` | `next` | - For a fix, branch from `main` and target `main`. -- For a feature, branch from `release/v4.x` and target `release/v4.x`. -- `x` is the number of the next minor release. For example, when the production version is `4.4.x`, use `release/v4.5`. There is no `next` branch. -- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `release/v4.x` so the fix is included there too. +- For a feature, branch from `next` and target `next`. +- If a fix is discovered while developing a feature, submit it separately to `main`. `main` is merged into `next` automatically, so the fix is included there too. - Pull requests targeting the wrong branch may be closed or asked to retarget. diff --git a/RELEASE.md b/RELEASE.md index 15cc64527d..e0847d2cd8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,54 +5,54 @@ | Branch | Purpose | | --- | --- | | `main` | Latest production source | -| `release/v4.x` | Feature integration and RC releases for the next minor release, such as `release/v4.5` | -| `feature/*` | New features based on and merged into the current `release/v4.x` branch | +| `next` | Feature integration and RC releases for the next version | +| `feature/*` | New features based on and merged into `next` | | `hotfix/X.Y.Z` | Production fixes based on `main` | -`x` is the number of the next minor release. When `4.4.x` is the production version, the release branch is `release/v4.5`. Create a new `release/v4.x` branch from `main` after each stable minor release. Do not use a long-lived `next` branch. +`next` is the only long-lived development branch. Do not create `release/v*` branches. Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go - Fixes, security updates, and small improvements target `main`. -- New features and larger changes target the current `release/v4.x` branch. -- **Sync main to release branches** merges `main` into every `release/v*` branch every 10 minutes, so every production fix is included in the next release. If a merge conflicts, it opens a `chore: merge main into release/vX.Y` pull request to resolve. -- Do not merge `release/v4.x` into `main` until an RC is approved for a stable release. +- New features and larger changes target `next`. +- **Sync main to next** merges `main` into `next` every 10 minutes, so every production fix is included in the next release. If the merge conflicts, it opens a `chore: merge main into next` pull request to resolve. +- Do not merge `next` into `main` until an RC is approved for a stable release. ## Feature and RC flow ```text -feature/* → release/v4.x → RC +feature/* → next → RC ``` -1. Merge feature branches into `release/v4.x`. +1. Merge feature branches into `next`. 2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`. -3. Every push to `release/v4.x` runs **Build Coolify Release Branch**. It calculates the version from the branch name and publishes `4.5-dev.` and the moving `4.5-dev` tag. It never publishes an RC or stable tag. +3. Every push to `next` runs **Build Coolify Next**. It publishes `sha-`, `4.5-rc.1.`, and the moving `next` tag. It never publishes the exact `4.5-rc.1` tag. 4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease. -5. Run **Release Coolify RC** manually from `release/v4.x` and enter `v4.5-rc.1`. +5. Run **Release Coolify RC** manually from `next` and enter `v4.5-rc.1`. 6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease. 7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow ```text -release/v4.x → main → stable release +next → main → stable release ``` -1. Temporarily stop merging features into `release/v4.x`. -2. Change the version on `release/v4.x` from the approved RC to the stable version, such as `4.5.0`. -3. Merge `release/v4.x` into `main`. +1. Temporarily stop merging features into `next`. +2. Change the version on `next` from the approved RC to the stable version, such as `4.5.0`. +3. Merge `next` into `main`. 4. Create a reviewed draft GitHub Release named `v4.5.0`. 5. Run the stable release workflow from `main`. 6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft. 7. Update the CDN only after the release is approved. -8. Create the next release branch from `main`, such as `release/v4.6`, and set its development version. Delete the old release branch. +8. Advance `next` to the next development version, such as `4.6-rc.1`. ## Hotfix flow ```text -main → hotfix/X.Y.Z → main → release/v4.x +main → hotfix/X.Y.Z → main → next ``` 1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead. @@ -61,7 +61,7 @@ main → hotfix/X.Y.Z → main → release/v4.x 4. Merge the fix into `main`. 5. Create a reviewed draft GitHub Release named `vX.Y.Z`. 6. Run the stable release workflow from `main`. -7. Merge `main` into `release/v4.x`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. +7. **Sync main to next** merges `main` into `next`. If the version conflicts, resolve it in favor of the next intended RC in the sync pull request. Delete the hotfix branch if one was used. 8. Update the CDN only after the release is approved. ## Image tags @@ -69,9 +69,8 @@ main → hotfix/X.Y.Z → main → release/v4.x | Tag | Meaning | | --- | --- | | `latest` | Latest stable release | -| `next` | Latest successful `next` build (legacy branch) | -| `X.Y-dev` | Latest successful `release/vX.Y` build | -| `X.Y-dev.` | Exact `release/vX.Y` commit build | +| `next` | Latest successful `next` build or RC release | +| `X.Y-rc.N.` | Exact `next` commit build | | `X.Y.Z` | Exact stable release | | `X.Y-rc.N` | Exact RC release | | `sha-` | Exact commit build | From ef27d1a94d7b9154674f43787a5278522501b444 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:46:02 +0200 Subject: [PATCH 3/6] fix(backup): restore Cloud backups in non-self-hosted mode --- scripts/restore-backup-vm.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/restore-backup-vm.sh b/scripts/restore-backup-vm.sh index 7de0291c13..856e8fca1c 100755 --- a/scripts/restore-backup-vm.sh +++ b/scripts/restore-backup-vm.sh @@ -9,6 +9,9 @@ # Backup file pg_dump custom-format backup (.dmp), optionally gzipped (.gz) # APP_KEY APP_KEY of the instance that created the backup (base64:...), not shown # Version Coolify version or image tag, e.g. 4.3.23 or sha-99c8409 (empty: latest stable) +# Cloud y for a Coolify Cloud backup: starts Coolify with SELF_HOSTED=false, as Cloud runs. +# Self-hosted mode on a Cloud backup adds the localhost server and makes app:init +# try SSH to every server, so Reverb does not start and the container stays unhealthy. # # Flow: # 1. Base image (once per version, until destroy): a separate builder VM with internet access installs @@ -52,7 +55,7 @@ die() { } usage() { - sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' exit 1 } @@ -204,6 +207,7 @@ BACKUP_FILE=${BACKUP_FILE/#\~/$HOME} read -r -s -p "APP_KEY of the backed-up instance (input is hidden): " APP_KEY echo read -r -p "Coolify version or image tag (empty for latest stable): " VERSION +read -r -p "Is this a Coolify Cloud backup? [y/N]: " CLOUD_BACKUP [ -s "$BACKUP_FILE" ] || die "Backup file '$BACKUP_FILE' does not exist or is empty." [[ "$APP_KEY" == base64:* ]] || die "APP_KEY must start with 'base64:'." @@ -216,6 +220,7 @@ if [ -z "$VERSION" ]; then VERSION=$(curl -fsSL https://cdn.coollabs.io/coolify/versions.json | jq -r '.coolify.v4.version') echo "Latest stable version: $VERSION" fi +[[ "${CLOUD_BACKUP,,}" =~ ^(y|yes)$ ]] && SELF_HOSTED=false || SELF_HOSTED=true [[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "Version '$VERSION' contains characters that are not allowed." BASE_IMAGE="$IMAGE_DIR/coolify-base-$VERSION.qcow2" @@ -272,6 +277,7 @@ log "Restoring the backup and starting Coolify (migrations run on start)" # Send APP_KEY through stdin, so it does not show in the process list of this host or the VM. { printf 'APP_KEY=%q\n' "$APP_KEY" + printf 'SELF_HOSTED=%q\n' "$SELF_HOSTED" cat <<'REMOTE' set -euo pipefail cd /data/coolify/source @@ -295,6 +301,8 @@ for flag in HORIZON_ENABLED SCHEDULER_ENABLED; do sed -i "/^${flag}=/d" .env echo "${flag}=false" >>.env done +sed -i '/^SELF_HOSTED=/d' .env +echo "SELF_HOSTED=${SELF_HOSTED}" >>.env # Recreate the database: tables from a newer fresh install must not remain. docker exec coolify-db psql -q -U coolify -d postgres -v ON_ERROR_STOP=1 \ From 64b748008e568f5c0879e9cffed0afe24bef1406 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:28:48 +0200 Subject: [PATCH 4/6] fix(server): list the active private key first Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Server/PrivateKey/Show.php | 6 +++++- tests/Feature/ServerPrivateKeyDropdownTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 7bc02aa03d..97eb760d2c 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -30,7 +30,10 @@ class Show extends Component private function loadPrivateKeys(): void { - $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get()->where('is_git_related', false); + $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get() + ->where('is_git_related', false) + ->sortBy(fn (PrivateKey $privateKey) => $privateKey->id !== $this->server->private_key_id) + ->values(); } public function setPrivateKey($privateKeyId) @@ -51,6 +54,7 @@ class Show extends Component throw new \Exception($error); } }); + $this->loadPrivateKeys(); $this->dispatch('success', 'Private key updated successfully.'); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php index 8951491a97..a8d9aaeced 100644 --- a/tests/Feature/ServerPrivateKeyDropdownTest.php +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -135,4 +135,13 @@ describe('after the user switches the session team', function () { expect($this->server->fresh()->private_key_id)->toBe($this->sameTeamKey->id); }); + + test('lists the active key first', function () { + expect($this->component->get('privateKeys')->first()->id)->toBe($this->currentPrivateKey->id); + + $keys = $this->component->call('setPrivateKey', $this->sameTeamKey->id)->get('privateKeys'); + + expect($keys->first()->id)->toBe($this->sameTeamKey->id) + ->and($keys->pluck('id'))->toContain($this->currentPrivateKey->id); + }); }); From 8742d0014aef14d55ec52af516bd57c47afe338f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:56:23 +0200 Subject: [PATCH 5/6] chore(versions): bump Sentinel images to 1.1.2 --- other/nightly/versions.json | 6 +++--- versions.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index c8ee5c91c9..bb54ed9c01 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -10,11 +10,11 @@ "version": "1.0.17" }, "sentinel": { - "version": "1.0.2" + "version": "1.1.2" }, "sentinel-host": { - "version": "1.0.2", - "digest": "sha256:ef4a41f2dfaf20c91c165244508bda12f9b66eb046fd8ae8b575158ccd61262e" + "version": "1.1.2", + "digest": "sha256:149d2097943716d30af9659eeeb11259601335442242ecb0b0331982a82a3bd2" } }, "traefik": { diff --git a/versions.json b/versions.json index c8ee5c91c9..bb54ed9c01 100644 --- a/versions.json +++ b/versions.json @@ -10,11 +10,11 @@ "version": "1.0.17" }, "sentinel": { - "version": "1.0.2" + "version": "1.1.2" }, "sentinel-host": { - "version": "1.0.2", - "digest": "sha256:ef4a41f2dfaf20c91c165244508bda12f9b66eb046fd8ae8b575158ccd61262e" + "version": "1.1.2", + "digest": "sha256:149d2097943716d30af9659eeeb11259601335442242ecb0b0331982a82a3bd2" } }, "traefik": { From d730ab48fd8a6d8a2721fed7368772d109a312cd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:47:40 +0200 Subject: [PATCH 6/6] feat(security): add command to re-encrypt secrets after key rotation --- app/Console/Commands/ReencryptWithAppKey.php | 216 ++++++++++++++++++ .../ReencryptWithAppKeyCommandTest.php | 142 ++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 app/Console/Commands/ReencryptWithAppKey.php create mode 100644 tests/Feature/ReencryptWithAppKeyCommandTest.php diff --git a/app/Console/Commands/ReencryptWithAppKey.php b/app/Console/Commands/ReencryptWithAppKey.php new file mode 100644 index 0000000000..68ae2acf4d --- /dev/null +++ b/app/Console/Commands/ReencryptWithAppKey.php @@ -0,0 +1,216 @@ +, list> + */ + public const ACCESSOR_ENCRYPTED_COLUMNS = [ + GitlabApp::class => ['webhook_token'], + OauthSetting::class => ['client_secret'], + ]; + + private Encrypter $currentKeyOnly; + + public function handle(): int + { + $dryRun = (bool) $this->option('dry-run'); + $this->currentKeyOnly = new Encrypter(Crypt::getKey(), config('app.cipher')); + + if (Crypt::getPreviousKeys() === []) { + $this->warn('APP_PREVIOUS_KEYS is empty. Values encrypted with an old key cannot be decrypted.'); + } + + if (! $dryRun && ! $this->option('force') && ! $this->confirm('Re-encrypt all stored secrets with the current APP_KEY?')) { + return self::FAILURE; + } + + $rows = []; + $reencrypted = 0; + $undecryptable = 0; + foreach (self::encryptedColumns() as $class => $columns) { + foreach ($columns as $column) { + [$changed, $failed] = $this->reencryptColumn(new $class, $column, $dryRun); + $reencrypted += $changed; + $undecryptable += $failed; + if ($changed + $failed > 0) { + $rows[] = [class_basename($class), $column, $changed, $failed]; + } + } + } + $sentinelTokens = $this->regenerateSentinelTokens($dryRun); + + if ($rows !== []) { + $this->table(['Model', 'Column', $dryRun ? 'Uses an old key' : 'Re-encrypted', 'Undecryptable'], $rows); + } + + if ($dryRun) { + $this->info("{$reencrypted} values and {$sentinelTokens} Sentinel tokens use an old key. Nothing was changed."); + } else { + $this->info("Re-encrypted {$reencrypted} values."); + $this->info("Regenerated {$sentinelTokens} Sentinel tokens. Sentinel restarts on the servers where it is enabled."); + } + + if ($undecryptable > 0) { + $this->error("{$undecryptable} values cannot be decrypted with APP_KEY or APP_PREVIOUS_KEYS. Do not remove APP_PREVIOUS_KEYS until you fix them."); + + return self::FAILURE; + } + + if (! $dryRun) { + $this->info('You can remove APP_PREVIOUS_KEYS when queued jobs finished. Sessions, invitation links, and old backups still need the previous key.'); + } + + return self::SUCCESS; + } + + /** + * Every model column that holds a Laravel encrypted payload. + * + * @return array, list> + */ + public static function encryptedColumns(): array + { + $columns = []; + foreach (Finder::create()->files()->in(app_path('Models'))->name('*.php') as $file) { + $class = 'App\\Models\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()); + if (! is_subclass_of($class, Model::class) || (new ReflectionClass($class))->isAbstract()) { + continue; + } + + $castColumns = collect((new $class)->getCasts()) + ->filter(fn ($cast) => is_string($cast) && Str::contains($cast, 'encrypted', ignoreCase: true)) + ->keys() + ->all(); + $encrypted = array_values(array_unique([...$castColumns, ...(self::ACCESSOR_ENCRYPTED_COLUMNS[$class] ?? [])])); + if ($encrypted !== []) { + $columns[$class] = $encrypted; + } + } + ksort($columns); + + return $columns; + } + + /** + * Decrypts and encrypts the raw string, so serialized (encrypt()) and plain (encryptString()) + * payloads keep their format. Plaintext legacy values are left as they are. + * + * @return array{int, int} The re-encrypted and the undecryptable value counts. + */ + private function reencryptColumn(Model $model, string $column, bool $dryRun): array + { + $schema = Schema::connection($model->getConnectionName()); + if (! $schema->hasTable($model->getTable()) || ! $schema->hasColumn($model->getTable(), $column)) { + return [0, 0]; + } + + $keyName = $model->getKeyName(); + $changed = 0; + $failed = 0; + $model->newQueryWithoutScopes()->toBase() + ->select([$keyName, $column]) + ->whereNotNull($column) + ->chunkById(500, function ($rows) use ($model, $keyName, $column, $dryRun, &$changed, &$failed) { + foreach ($rows as $row) { + $payload = $row->{$column}; + if (! $this->usesOldKey($payload)) { + continue; + } + + try { + $plaintext = Crypt::decryptString($payload); + } catch (DecryptException) { + $failed++; + + continue; + } + + if (! $dryRun) { + // Compare-and-set, so a value saved during the run is not overwritten. + $model->newQueryWithoutScopes()->toBase() + ->where($keyName, $row->{$keyName}) + ->where($column, $payload) + ->update([$column => Crypt::encryptString($plaintext)]); + } + $changed++; + } + }, $keyName); + + return [$changed, $failed]; + } + + /** + * A Sentinel token is an encrypted payload itself, and Sentinel sends it back on every push. + * Saving a new token restarts Sentinel with it (see ServerSetting::booted()). + */ + private function regenerateSentinelTokens(bool $dryRun): int + { + $count = 0; + ServerSetting::query()->with('server')->chunkById(100, function (Collection $settings) use ($dryRun, &$count) { + foreach ($settings as $setting) { + try { + $token = $setting->sentinel_token; + } catch (DecryptException) { + continue; + } + + if ($setting->server === null || ! $this->usesOldKey($token)) { + continue; + } + + if (! $dryRun) { + $setting->generateSentinelToken(); + } + $count++; + } + }); + + return $count; + } + + private function usesOldKey(mixed $value): bool + { + if (! is_string($value)) { + return false; + } + + $payload = json_decode(base64_decode($value, true) ?: '', true); + if (! is_array($payload) || ! isset($payload['iv'], $payload['value'], $payload['mac'])) { + return false; + } + + try { + $this->currentKeyOnly->decryptString($value); + + return false; + } catch (DecryptException) { + return true; + } + } +} diff --git a/tests/Feature/ReencryptWithAppKeyCommandTest.php b/tests/Feature/ReencryptWithAppKeyCommandTest.php new file mode 100644 index 0000000000..02c6ab7713 --- /dev/null +++ b/tests/Feature/ReencryptWithAppKeyCommandTest.php @@ -0,0 +1,142 @@ + $key, 'app.previous_keys' => $previousKeys]); + app()->forgetInstance('encrypter'); + Crypt::clearResolvedInstance('encrypter'); +} + +function newAppKey(): string +{ + return 'base64:'.base64_encode(random_bytes(32)); +} + +beforeEach(function () { + Queue::fake(); + $this->oldKey = newAppKey(); + $this->newKey = newAppKey(); + useAppKeys($this->oldKey); + + InstanceSettings::forceCreate(['id' => 0, 'smtp_password' => 'instance-smtp-password']); + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $this->server->settings->forceFill(['is_sentinel_enabled' => true])->saveQuietly(); + $this->environmentVariable = EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://user:secret@db:5432/app', + 'resourceable_type' => Application::class, + 'resourceable_id' => Application::factory()->create()->id, + ]); + $this->gitlabApp = GitlabApp::create([ + 'name' => 'GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'secret-webhook-token', + 'team_id' => $this->team->id, + ]); + $this->oldSentinelToken = $this->server->settings->sentinel_token; + $this->privateKeyValue = $this->privateKey->private_key; +}); + +it('re-encrypts stored secrets so they decrypt without the previous key', function () { + useAppKeys($this->newKey, [$this->oldKey]); + + $this->artisan('app-key:reencrypt --force') + ->expectsOutputToContain('Regenerated 1 Sentinel tokens.') + ->assertSuccessful(); + StartSentinel::assertPushed(); + + useAppKeys($this->newKey); + + expect(InstanceSettings::query()->find(0)->smtp_password)->toBe('instance-smtp-password') + ->and($this->privateKey->fresh()->private_key)->toBe($this->privateKeyValue) + ->and($this->environmentVariable->fresh()->value)->toBe('postgres://user:secret@db:5432/app') + ->and($this->gitlabApp->fresh()->webhook_token)->toBe('secret-webhook-token'); + + $sentinelToken = $this->server->settings->fresh()->sentinel_token; + expect($sentinelToken)->not->toBe($this->oldSentinelToken) + ->and(json_decode(decrypt($sentinelToken), true))->toBe(['server_uuid' => $this->server->uuid]); +}); + +it('changes nothing when every secret already uses the current key', function () { + useAppKeys($this->newKey, [$this->oldKey]); + $this->artisan('app-key:reencrypt --force')->assertSuccessful(); + $rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'); + + $this->artisan('app-key:reencrypt --force') + ->expectsOutputToContain('Re-encrypted 0 values.') + ->expectsOutputToContain('Regenerated 0 Sentinel tokens.') + ->assertSuccessful(); + + expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey); +}); + +it('reports old-key values in a dry run without changing them', function () { + $rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'); + useAppKeys($this->newKey, [$this->oldKey]); + + $this->artisan('app-key:reencrypt --dry-run') + ->expectsOutputToContain('values and 1 Sentinel tokens use an old key. Nothing was changed.') + ->assertSuccessful(); + + expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey) + ->and($this->server->settings->fresh()->sentinel_token)->toBe($this->oldSentinelToken); + StartSentinel::assertNotPushed(); +}); + +it('keeps legacy plaintext values as they are', function () { + DB::table('gitlab_apps')->where('id', $this->gitlabApp->id)->update(['webhook_token' => 'legacy-plaintext-token']); + useAppKeys($this->newKey, [$this->oldKey]); + + $this->artisan('app-key:reencrypt --force')->assertSuccessful(); + + expect(DB::table('gitlab_apps')->where('id', $this->gitlabApp->id)->value('webhook_token'))->toBe('legacy-plaintext-token'); +}); + +it('fails and keeps values that no configured key can decrypt', function () { + $rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'); + useAppKeys($this->newKey); + + $this->artisan('app-key:reencrypt --force') + ->expectsOutputToContain('cannot be decrypted with APP_KEY or APP_PREVIOUS_KEYS') + ->assertFailed(); + + expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey); +}); + +it('knows every model that encrypts data without an encrypted cast', function () { + // Reviewed: EnvironmentVariable encrypts its "value" column, which also has an encrypted cast. + // ServerSetting encrypts the Sentinel token, which the command regenerates. + $reviewed = [EnvironmentVariable::class, ServerSetting::class]; + + $manualEncryption = collect(Finder::create()->files()->in(app_path('Models'))->name('*.php')) + ->filter(fn ($file) => preg_match('/\bCrypt::|(?:$])(en|de)crypt\(/', $file->getContents()) === 1) + ->map(fn ($file) => 'App\\Models\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname())) + ->values() + ->all(); + + expect(array_diff($manualEncryption, [...array_keys(ReencryptWithAppKey::ACCESSOR_ENCRYPTED_COLUMNS), ...$reviewed])) + ->toBe([], 'Add the encrypted columns of these models to ReencryptWithAppKey::ACCESSOR_ENCRYPTED_COLUMNS.'); +});