diff --git a/.ai/lessons.md b/.ai/lessons.md deleted file mode 100644 index 0c08f5d495..0000000000 --- a/.ai/lessons.md +++ /dev/null @@ -1,7 +0,0 @@ -# Lessons - -## Alpine x-transition + tw-animate-css exit animations flash at the end -- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears. -- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. -- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. -- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/.github/workflows/coolify-next-build.yml b/.github/workflows/coolify-next-build.yml new file mode 100644 index 0000000000..9985edb411 --- /dev/null +++ b/.github/workflows/coolify-next-build.yml @@ -0,0 +1,152 @@ +name: Build Coolify Next + +on: + push: + branches: [next] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml + - docker/coolify-helper/Dockerfile + - docker/coolify-realtime/Dockerfile + - docker/testing-host/Dockerfile + - templates/** + - CHANGELOG.md + +permissions: + contents: read + packages: write + +concurrency: + group: coolify-next-build + 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: + rc_version: ${{ steps.version.outputs.rc_version }} + short_sha: ${{ steps.version.outputs.short_sha }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Resolve next version + id: version + run: | + RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Invalid next RC version: ${RC_VERSION}" + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${RC_VERSION}.${SHORT_SHA}" + echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$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 next 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 }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + steps: + - 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: Publish next manifest on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish next manifest on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" diff --git a/.github/workflows/coolify-rc-release.yml b/.github/workflows/coolify-rc-release.yml new file mode 100644 index 0000000000..d7f4dc830f --- /dev/null +++ b/.github/workflows/coolify-rc-release.yml @@ -0,0 +1,304 @@ +name: Release Coolify RC +run-name: ${{ inputs.tag }} + +on: + workflow_dispatch: + inputs: + tag: + description: Existing draft prerelease tag (for example, v4.4-rc.1) + required: true + type: string + +permissions: {} + +concurrency: + group: coolify-rc-release + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + validate: + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + release_id: ${{ steps.draft.outputs.release_id }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Reject releases outside next + if: ${{ github.ref != 'refs/heads/next' }} + run: | + echo "RC releases must run from the next branch, not ${{ github.ref }}." + exit 1 + + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate version + id: version + env: + TAG_NAME: ${{ inputs.tag }} + run: | + if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Unsupported RC tag: ${TAG_NAME}" + exit 1 + fi + + VERSION="${TAG_NAME#v}" + CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then + echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}." + exit 1 + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Validate and pin draft prerelease + id: draft + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME); + + if (!release) { + core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`); + return; + } + if (!release.draft) { + core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`); + return; + } + if (!release.prerelease) { + core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + }); + core.setOutput('release_id', release.id); + + build: + needs: validate + permissions: + contents: read + packages: write + 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 RC 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.validate.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + + revalidate: + needs: [validate, build] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Revalidate draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + } catch (error) { + if (error.status !== 404) throw error; + } + + publish: + needs: [validate, build, revalidate] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - 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: Publish RC and next on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish RC and next on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish reviewed draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: Number(process.env.RELEASE_ID), + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + draft: false, + }); diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml deleted file mode 100644 index 254440436c..0000000000 --- a/.github/workflows/coolify-staging-build.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Staging Build - -on: - push: - branches-ignore: - - main - - v3.x - - '**v5.x**' - 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 - -env: - GITHUB_REGISTRY: ghcr.io - DOCKER_REGISTRY: docker.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - build-push: - 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 - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - 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 Image (${{ matrix.arch }}) - uses: docker/build-push-action@v6 - with: - context: . - file: docker/production/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - cache-from: | - type=gha,scope=build-${{ matrix.arch }} - type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }} - - merge-manifest: - runs-on: ubuntu-24.04 - needs: build-push - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - 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: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 614175d9b4..595a21e799 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -45,15 +45,17 @@ jobs: exit 1 fi - existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url') + 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 echo "A main to next pull request already exists: $existing_pr" else + git push --force origin origin/main:"refs/heads/$sync_branch" gh pr create \ --base next \ - --head main \ + --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.' + --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.' fi echo 'main could not be merged into next without conflicts.' diff --git a/AGENTS.md b/AGENTS.md index 5563a18ec1..4d78dfd938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,9 +99,30 @@ function loginAsRoot(): mixed ``` - See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. -- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). - Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. +### How Browser Tests Actually Run (no Docker, no display needed) + +`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin: + +1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`). +2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself. + +Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres. + +`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements). + +### Browser Test Gotchas + +- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`. +- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache. +- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`. +- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up. +- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds. +- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions. +- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible. +- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner. + ## Architecture ### Backend Structure (app/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b6..626f6d7b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/RELEASE.md b/RELEASE.md index d278d76902..3733392a48 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,7 +9,7 @@ | `feature/*` | New features based on and merged into `next` | | `hotfix/X.Y.Z` | Production fixes based on `main` | -Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. +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 @@ -25,11 +25,12 @@ feature/* → next → RC ``` 1. Merge feature branches into `next`. -2. Set the intended RC version on `next`, such as `4.4-rc.1`. -3. Regular builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. +2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`. +3. Regular `next` builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag. 4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. -5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft. -6. Advance `next` to the next intended RC version. +5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`. +6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease. +7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 10f5b85f21..b25eb481b6 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -29,7 +29,7 @@ class StopApplicationOneServer instant_remote_process( [ dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", + dockerRemoveCommand($containerName), ], $server ); diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..f9e92e08f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -3,12 +3,14 @@ namespace App\Actions\Database; use App\Models\StandaloneClickhouse; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartClickhouse { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneClickhouse $database; @@ -16,7 +18,11 @@ class StartClickhouse public string $configuration_dir; - public function handle(StandaloneClickhouse $database) + private string $resolvedClickhouseUser; + + private string $resolvedClickhousePassword; + + public function handle(StandaloneClickhouse $database, ?Activity $activity = null) { $this->database = $database; @@ -51,7 +57,7 @@ class StartClickhouse ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1', + 'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -109,7 +115,7 @@ class StartClickhouse $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -147,8 +153,17 @@ class StartClickhouse private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user; + $this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'CLICKHOUSE_USER') { + $this->resolvedClickhouseUser = $rawValue; + } elseif ($env->key === 'CLICKHOUSE_PASSWORD') { + $this->resolvedClickhousePassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 4b55b0c1df..3487bc9a42 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -2,6 +2,9 @@ namespace App\Actions\Database; +use App\Enums\ActivityTypes; +use App\Enums\ProcessStatus; +use App\Jobs\DatabaseStartJob; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; +use Spatie\Activitylog\Models\Activity; class StartDatabase { @@ -22,38 +26,38 @@ class StartDatabase $job->onQueue(deployment_queue()); } - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - switch ($database->getMorphClass()) { - case StandalonePostgresql::class: - $activity = StartPostgresql::run($database); - break; - case StandaloneRedis::class: - $activity = StartRedis::run($database); - break; - case StandaloneMongodb::class: - $activity = StartMongodb::run($database); - break; - case StandaloneMysql::class: - $activity = StartMysql::run($database); - break; - case StandaloneMariadb::class: - $activity = StartMariadb::run($database); - break; - case StandaloneKeydb::class: - $activity = StartKeydb::run($database); - break; - case StandaloneDragonfly::class: - $activity = StartDragonfly::run($database); - break; - case StandaloneClickhouse::class: - $activity = StartClickhouse::run($database); - break; + + $activity = activity() + ->withProperties([ + 'server_uuid' => $server->uuid, + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $database->uuid, + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $server->team_id, + 'operation' => 'database-start', + ]) + ->performedOn($database) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); + + if ($activity === null) { + return 'Database start could not be queued because activity logging is disabled.'; } + + DatabaseStartJob::dispatch( + $database->getMorphClass(), + (int) $database->getKey(), + (int) $database->team()->id, + (int) $activity->getKey(), + auth()->id(), + ); + if ($database->is_public && $database->public_port) { StartDatabaseProxy::dispatch($database); } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..078d557f57 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartDragonfly { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneDragonfly $database; @@ -20,7 +22,9 @@ class StartDragonfly private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneDragonfly $database) + private string $resolvedRedisPassword; + + public function handle(StandaloneDragonfly $database, ?Activity $activity = null) { $this->database = $database; @@ -107,7 +111,7 @@ class StartDragonfly ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping', + 'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -196,12 +200,13 @@ class StartDragonfly $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function buildStartCommand(): string { - $command = "dragonfly --requirepass {$this->database->dragonfly_password}"; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); + $command = "dragonfly --requirepass {$escapedRedisPassword}"; if ($this->database->enable_ssl) { $sslArgs = [ @@ -251,8 +256,14 @@ class StartDragonfly private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedRedisPassword = (string) $this->database->dragonfly_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..3b9cba28f4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneKeydb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartKeydb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneKeydb $database; @@ -20,7 +22,9 @@ class StartKeydb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneKeydb $database) + private string $resolvedRedisPassword; + + public function handle(StandaloneKeydb $database, ?Activity $activity = null) { $this->database = $database; @@ -109,7 +113,7 @@ class StartKeydb ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping', + 'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -214,7 +218,7 @@ class StartKeydb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -252,8 +256,14 @@ class StartKeydb private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedRedisPassword = (string) $this->database->keydb_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { @@ -280,6 +290,7 @@ class StartKeydb { $hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf); $keydbConfPath = '/etc/keydb/keydb.conf'; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); if ($hasKeydbConf) { $confContent = $this->database->keydb_conf; @@ -288,10 +299,10 @@ class StartKeydb if ($hasRequirePass) { $command = "keydb-server $keydbConfPath"; } else { - $command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}"; + $command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}"; } } else { - $command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; + $command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes"; } if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..a05da25efd 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMariadb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMariadb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMariadb $database; @@ -20,7 +22,7 @@ class StartMariadb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMariadb $database) + public function handle(StandaloneMariadb $database, ?Activity $activity = null) { $this->database = $database; @@ -216,7 +218,7 @@ class StartMariadb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -255,7 +257,7 @@ class StartMariadb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..ff338aa99f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMongodb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMongodb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMongodb $database; @@ -20,7 +22,13 @@ class StartMongodb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMongodb $database) + private string $resolvedMongoUsername; + + private string $resolvedMongoPassword; + + private string $resolvedMongoDatabase; + + public function handle(StandaloneMongodb $database, ?Activity $activity = null) { $this->database = $database; @@ -265,7 +273,7 @@ class StartMongodb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -303,8 +311,20 @@ class StartMongodb private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username; + $this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password; + $this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') { + $this->resolvedMongoUsername = $rawValue; + } elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') { + $this->resolvedMongoPassword = $rawValue; + } elseif ($env->key === 'MONGO_INITDB_DATABASE') { + $this->resolvedMongoDatabase = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { @@ -337,9 +357,9 @@ class StartMongodb private function add_default_database() { - $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES); - $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES); - $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES); + $dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES); + $userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES); + $pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES); $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});"; $content_base64 = base64_encode($content); $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..cff8d0b363 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMysql; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMysql { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMysql $database; @@ -20,7 +22,9 @@ class StartMysql private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMysql $database) + private string $resolvedMysqlRootPassword; + + public function handle(StandaloneMysql $database, ?Activity $activity = null) { $this->database = $database; @@ -104,7 +108,7 @@ class StartMysql ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}", + 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}", ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -218,7 +222,7 @@ class StartMysql $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -256,8 +260,14 @@ class StartMysql private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'MYSQL_ROOT_PASSWORD') { + $this->resolvedMysqlRootPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..f9dd7a3c4f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandalonePostgresql; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartPostgresql { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandalonePostgresql $database; @@ -22,7 +24,11 @@ class StartPostgresql private ?SslCertificate $ssl_certificate = null; - public function handle(StandalonePostgresql $database) + private string $resolvedPostgresUser; + + private string $resolvedPostgresDatabase; + + public function handle(StandalonePostgresql $database, ?Activity $activity = null) { $this->database = $database; $container_name = $this->database->uuid; @@ -111,7 +117,7 @@ class StartPostgresql ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1', + 'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -227,7 +233,7 @@ class StartPostgresql $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -265,8 +271,17 @@ class StartPostgresql private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedPostgresUser = (string) $this->database->postgres_user; + $this->resolvedPostgresDatabase = (string) $this->database->postgres_db; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'POSTGRES_USER') { + $this->resolvedPostgresUser = $rawValue; + } elseif ($env->key === 'POSTGRES_DB') { + $this->resolvedPostgresDatabase = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..41ece532b1 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartRedis { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneRedis $database; @@ -20,7 +22,11 @@ class StartRedis private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneRedis $database) + private ?string $resolvedRedisPassword = null; + + private ?string $resolvedRedisUsername = null; + + public function handle(StandaloneRedis $database, ?Activity $activity = null) { $this->database = $database; @@ -209,7 +215,7 @@ class StartRedis $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -249,23 +255,40 @@ class StartRedis $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { + $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); + if ($env->is_shared) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); if ($env->key === 'REDIS_PASSWORD') { - $this->database->update(['redis_password' => $env->real_value]); + $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + + if (! $usesSecretManager) { + $this->database->update(['redis_password' => $this->resolvedRedisPassword]); + } } if ($env->key === 'REDIS_USERNAME') { - $this->database->update(['redis_username' => $env->real_value]); + $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + + if (! $usesSecretManager) { + $this->database->update(['redis_username' => $this->resolvedRedisUsername]); + } } } else { - if ($env->key === 'REDIS_PASSWORD') { + if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) { $env->update(['value' => $this->database->redis_password]); - } elseif ($env->key === 'REDIS_USERNAME') { + } elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) { $env->update(['value' => $this->database->redis_username]); } - $environment_variables->push("$env->key=$env->real_value"); + + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + } elseif ($env->key === 'REDIS_USERNAME') { + $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + } + + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } } @@ -276,6 +299,7 @@ class StartRedis private function buildStartCommand(): string { + $redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password; $hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf); $redisConfPath = '/usr/local/etc/redis/redis.conf'; @@ -286,10 +310,10 @@ class StartRedis if ($hasRequirePass) { $command = "redis-server $redisConfPath"; } else { - $command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}"; + $command = "redis-server $redisConfPath --requirepass {$redisPassword}"; } } else { - $command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; + $command = "redis-server --requirepass {$redisPassword} --appendonly yes"; } if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a1097662..e6789202f3 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -24,10 +24,10 @@ class StopDatabaseProxy { $server = data_get($database, 'destination.server'); $uuid = $database->uuid; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $server = data_get($database, 'service.server'); } - instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); + instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server); $database->save(); diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php index 21c40a50ad..3e1b5380b6 100644 --- a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork $safeNetwork = escapeshellarg($destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server); } } diff --git a/app/Actions/Development/ConfigureDevelopmentQemuHost.php b/app/Actions/Development/ConfigureDevelopmentQemuHost.php new file mode 100644 index 0000000000..a999b77ada --- /dev/null +++ b/app/Actions/Development/ConfigureDevelopmentQemuHost.php @@ -0,0 +1,122 @@ +ensureDevelopmentEnvironment(); + $this->installDependencies(); + $this->runOrFail('systemctl enable --now libvirtd'); + $this->configureLibvirtNetwork(); + $this->configureIpForwarding(); + $this->configureStorage(); + $this->configureDockerForwarding(); + } + + private function installDependencies(): void + { + $binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install']; + $check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && '); + + if (Process::run($check)->successful()) { + return; + } + + if (! File::exists('/usr/bin/apt-get')) { + throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.'); + } + + $this->runOrFail('apt-get update'); + $this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst'); + } + + private function configureLibvirtNetwork(): void + { + $network = config('development-qemu.libvirt_network'); + $networkInfo = Process::run('virsh net-info '.escapeshellarg($network)); + + if ($networkInfo->failed()) { + $networkXml = config('development-qemu.storage_path').'/libvirt-network.xml'; + File::ensureDirectoryExists(dirname($networkXml), 0777, true); + File::put($networkXml, $this->libvirtNetworkXml($network)); + $this->runOrFail('virsh net-define '.escapeshellarg($networkXml)); + $networkInfo = Process::result(output: 'Active: no'); + } + + if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) { + $this->runOrFail('virsh net-start '.escapeshellarg($network)); + } + + $this->runOrFail('virsh net-autostart '.escapeshellarg($network)); + } + + private function configureIpForwarding(): void + { + $this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf"); + $this->runOrFail('sysctl -w net.ipv4.ip_forward=1'); + } + + private function configureStorage(): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory, 0777, true); + File::chmod($directory, 0777); + } + + private function configureDockerForwarding(): void + { + $dockerNetwork = escapeshellarg(config('development-qemu.docker_network')); + $subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}')); + $subnet = trim($subnetResult->output()); + + if ($subnetResult->failed() || $subnet === '') { + throw new RuntimeException('Unable to determine the Coolify Docker network subnet.'); + } + + $rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet'))); + + Process::run("iptables -D LIBVIRT_FWI {$rule}"); + $this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}"); + } + + private function libvirtNetworkXml(string $network): string + { + return << + {$network} + + + + + + + + +XML; + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU host configuration may only run in development environments.'); + } + } +} diff --git a/app/Actions/Development/ManageDevelopmentQemuVm.php b/app/Actions/Development/ManageDevelopmentQemuVm.php new file mode 100644 index 0000000000..a1257cbda1 --- /dev/null +++ b/app/Actions/Development/ManageDevelopmentQemuVm.php @@ -0,0 +1,33 @@ + $profileNames */ + public function handle(string|array $profileNames): void + { + $profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames]; + + foreach ($profileNames as $index => $profileName) { + StartDevelopmentQemuVm::run($profileName, $index === 0); + + try { + SeedDevelopmentQemuServer::run($profileName, $index === 0); + } catch (QueryException $exception) { + $keepOthers = $index === 0 ? '' : ' --keep-others'; + $result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers); + + if ($result->failed()) { + throw $exception; + } + } + } + } +} diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php new file mode 100644 index 0000000000..10cb0b3c7d --- /dev/null +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -0,0 +1,60 @@ +ensureDevelopmentEnvironment(); + $profile = config("development-qemu.profiles.{$profileName}"); + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + $privateKey = PrivateKey::query()->find(1); + + if (! $privateKey) { + throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.'); + } + + if ($removeOtherServers) { + Server::query() + ->where('uuid', 'like', 'development-qemu-%') + ->where('uuid', '!=', $profile['uuid']) + ->delete(); + } + + $server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server; + $server->forceFill(['uuid' => $profile['uuid']]); + $server->fill([ + 'name' => $profile['name'], + 'description' => 'Development-only QEMU virtual machine managed by dev:qemu.', + 'ip' => $profile['ip'], + 'port' => 22, + 'user' => $profile['user'], + 'team_id' => 0, + 'private_key_id' => $privateKey->id, + ]); + $server->deleted_at = null; + $server->save(); + + return $server->fresh(); + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VM servers may only be seeded in development environments.'); + } + } +} diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php new file mode 100644 index 0000000000..d6d9c00b2f --- /dev/null +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -0,0 +1,219 @@ +ensureDevelopmentEnvironment(); + $profiles = config('development-qemu.profiles'); + $profile = $profiles[$profileName] ?? null; + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + ConfigureDevelopmentQemuHost::run(); + $this->configureDhcpReservation($profile); + + if ($resetManagedVms) { + foreach ($profiles as $managedProfile) { + Process::run('virsh destroy '.escapeshellarg($managedProfile['domain'])); + Process::run('virsh undefine '.escapeshellarg($managedProfile['domain'])); + $this->deleteVmData($managedProfile['domain']); + } + } + + $this->createVm($profile); + + ConfigureDevelopmentQemuHost::run(); + $this->waitForSsh($profile['ip']); + } + + /** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ + private function createVm(array $profile): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory); + File::chmod($directory, 0777); + $this->moveLegacyFiles($directory); + $baseImage = "{$directory}/{$profile['image']}"; + $disk = "{$directory}/{$profile['domain']}.qcow2"; + $userData = "{$directory}/{$profile['domain']}-user-data.yaml"; + $networkConfig = "{$directory}/{$profile['domain']}-network.yaml"; + + if (! File::exists($baseImage)) { + $this->runOrFail(sprintf( + 'curl --fail --location --output %s %s', + escapeshellarg($baseImage), + escapeshellarg($profile['image_url']), + )); + } + + if (! File::exists($disk)) { + $this->runOrFail(sprintf( + 'qemu-img create -f qcow2 -F qcow2 -b %s %s %s', + escapeshellarg($baseImage), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.disk_size')), + )); + } + + if (File::exists($baseImage)) { + File::chmod($baseImage, 0644); + } + + if (File::exists($disk)) { + File::chmod($disk, 0666); + } + + File::put($userData, $this->userData($profile)); + File::put($networkConfig, $this->networkConfig($profile)); + + $this->runOrFail(sprintf( + 'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole', + escapeshellarg($profile['domain']), + config('development-qemu.memory'), + config('development-qemu.vcpus'), + escapeshellarg($profile['os_variant']), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.libvirt_network')), + escapeshellarg($profile['mac']), + escapeshellarg($userData), + escapeshellarg($networkConfig), + )); + } + + private function moveLegacyFiles(string $directory): void + { + $legacyDirectory = storage_path('app/development-qemu'); + + if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) { + return; + } + + foreach (File::files($legacyDirectory) as $file) { + $destination = "{$directory}/{$file->getFilename()}"; + + if (! File::exists($destination)) { + File::move($file->getPathname(), $destination); + } + } + } + + private function deleteVmData(string $domain): void + { + $directory = config('development-qemu.storage_path'); + File::delete([ + "{$directory}/{$domain}.qcow2", + "{$directory}/{$domain}-user-data.yaml", + "{$directory}/{$domain}-network.yaml", + ]); + } + + /** @param array{user: string, provisioner: string} $profile */ + private function userData(array $profile): string + { + $publicKey = config('development-qemu.public_key'); + $adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel'; + $sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n"; + + [$packages, $startDocker] = match ($profile['provisioner']) { + 'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'], + 'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'], + default => [" - docker.io\n - sudo", 'systemctl enable --now docker'], + }; + $addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}"; + + return <<failed()) { + throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.'); + } + + if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) { + return; + } + + $host = sprintf("", $profile['mac'], $profile['domain'], $profile['ip']); + $this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config'); + } + + private function waitForSsh(string $ip): void + { + $container = escapeshellarg(config('development-qemu.coolify_container')); + $probe = <<<'PHP' +$deadline = time() + 120; +do { + $socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1); + if (is_resource($socket)) { + fclose($socket); + exit(0); + } + sleep(1); +} while (time() < $deadline); +exit(1); +PHP; + $this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip)); + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VMs may only be managed in development environments.'); + } + } +} diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index e801a1c997..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -275,7 +276,7 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 460600d699..2def132804 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -2,77 +2,52 @@ namespace App\Actions\Service; -use App\Actions\Server\CleanupDocker; use App\Models\Service; -use Illuminate\Support\Facades\Log; -use Lorisleiva\Actions\Concerns\AsAction; class DeleteService { - use AsAction; - - public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup) + public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void { - try { - $server = data_get($service, 'server'); - if ($deleteVolumes && $server->isFunctional()) { - $storagesToDelete = collect([]); - - $service->environment_variables()->delete(); - $commands = []; - foreach ($service->applications()->get() as $application) { - $storages = $application->persistentStorages()->get(); - foreach ($storages as $storage) { - $storagesToDelete->push($storage); - } - } - foreach ($service->databases()->get() as $database) { - $storages = $database->persistentStorages()->get(); - foreach ($storages as $storage) { - $storagesToDelete->push($storage); - } - } - foreach ($storagesToDelete as $storage) { + $server = data_get($service, 'server'); + if ($deleteVolumes && $server->isFunctional()) { + $commands = []; + foreach ($service->applications()->get() as $application) { + foreach ($application->persistentStorages()->get() as $storage) { $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); } - - // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. - if (! empty($commands)) { - foreach ($commands as $command) { - $result = instant_remote_process([$command], $server, false); - if ($result !== null && $result !== 0) { - Log::error('Error deleting volumes: '.$result); - } - } - } - } - - if ($deleteConnectedNetworks) { - $service->deleteConnectedNetworks(); - } - - instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); - } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); - } finally { - if ($deleteConfigurations) { - $service->deleteConfigurations(); - } - foreach ($service->applications()->get() as $application) { - $application->forceDelete(); } foreach ($service->databases()->get() as $database) { - $database->forceDelete(); + foreach ($database->persistentStorages()->get() as $storage) { + $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); + } } - foreach ($service->scheduled_tasks as $task) { - $task->delete(); - } - $service->tags()->detach(); - $service->forceDelete(); - - if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + foreach ($commands as $command) { + instant_remote_process([$command], $server, false); } } + + if ($deleteConnectedNetworks) { + $service->deleteConnectedNetworks(); + } + if ($deleteConfigurations) { + $service->deleteConfigurations(); + } + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); + } + + public function deleteLocal(Service $service): void + { + foreach ($service->applications()->get() as $application) { + $application->forceDelete(); + } + foreach ($service->databases()->get() as $database) { + $database->forceDelete(); + } + foreach ($service->scheduled_tasks as $task) { + $task->delete(); + } + $service->environment_variables()->delete(); + $service->tags()->detach(); + $service->forceDelete(); } } diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 0000000000..d0cea0fb1c --- /dev/null +++ b/app/Actions/Shared/CheckDomainDns.php @@ -0,0 +1,142 @@ + $entries + * @return array + */ + public function handle( + array $entries, + ?Server $server, + ?string $expectedIp, + bool $skipForMultipleServers = false, + int $timeoutSeconds = 5, + ): array { + if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) { + return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp); + } + + if (! $server) { + return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null); + } + + if ($skipForMultipleServers) { + return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp); + } + + $deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000); + $dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers')) + ->explode(',') + ->map(fn ($dnsServer) => trim((string) $dnsServer)) + ->filter() + ->values(); + $results = []; + + foreach ($entries as $key => $url) { + $results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline); + } + + return $results; + } + + /** + * @param array $dnsServers + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array + { + try { + $host = Url::fromString($url)->getHost(); + } catch (\Throwable) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + if (str($host)->contains('sslip.io')) { + return $this->result('ok', 'DNS looks correct.', $expectedIp); + } + + $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + + foreach ($dnsServers as $dnsServer) { + $remainingNanoseconds = $deadline - hrtime(true); + if ($remainingNanoseconds < 1_000_000_000) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + + try { + $query = app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), + ]); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() !== $type) { + continue; + } + + if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } catch (\Throwable) { + continue; + } + } + + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + + private function successMessage(Server $server, ?string $expectedIp): string + { + if ( + filled($expectedIp) + && filled($server->ip) + && $server->ip !== $expectedIp + && filter_var($server->ip, FILTER_VALIDATE_IP) === false + ) { + return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare)."; + } + + return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.'; + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function result(string $status, string $message, ?string $expectedIp): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $entries + * @return array + */ + private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array + { + $result = $this->result($status, $message, $expectedIp); + + return array_fill_keys(array_keys($entries), $result); + } +} diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php new file mode 100644 index 0000000000..be880b7e78 --- /dev/null +++ b/app/Actions/Team/DeleteTeam.php @@ -0,0 +1,65 @@ +lockForUpdate()->findOrFail($team->id); + + $role = DB::table('team_user') + ->where('team_id', $team->id) + ->where('user_id', $user->id) + ->lockForUpdate() + ->value('role'); + + if ($role !== 'owner') { + throw new AuthorizationException('Only team owners can delete a team.'); + } + + $hasRunningApplications = Application::query() + ->whereHas('environment.project', fn ($query) => $query->where('team_id', $team->id)) + ->lockForUpdate() + ->get(['id', 'status']) + ->contains(fn (Application $application): bool => $application->isRunning()); + + if ($hasRunningApplications) { + throw new RuntimeException('Stop all running applications before deleting this team.'); + } + + if ($team->servers()->lockForUpdate()->get(['servers.id'])->isNotEmpty()) { + throw new RuntimeException('Delete all team servers before deleting this team.'); + } + + if (! $team->isEmpty()) { + throw new RuntimeException('Delete all team resources before deleting this team.'); + } + + $team->members() + ->where('users.id', '!=', $user->id) + ->get() + ->each(function (User $member) use ($team): void { + $member->teams()->detach($team); + DB::table('sessions')->where('user_id', $member->id)->delete(); + }); + + $team->delete(); + + return $user->teams()->first(); + }); + + Cache::forget("user:{$user->id}:team:{$team->id}"); + + return $newTeam; + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Models\AuditEvent; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -49,6 +50,12 @@ class CleanupDatabase extends Command $activity_log->delete(); } + $count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count(); + echo "Delete $count entries from audit_events.\n"; + if ($this->option('yes')) { + AuditEvent::pruneExpired(); + } + // Cleanup application_deployment_queues table $application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10); $count = $application_deployment_queues->count(); diff --git a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php new file mode 100644 index 0000000000..a93e1779b9 --- /dev/null +++ b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php @@ -0,0 +1,40 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $profiles = config('development-qemu.profiles'); + $profileNames = $this->argument('profiles') ?: multiselect( + label: 'Which QEMU servers should be started and seeded?', + options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(), + required: true, + ); + + ManageDevelopmentQemuVm::run($profileNames); + + foreach ($profileNames as $profileName) { + $profile = $profiles[$profileName]; + $this->info("Started and seeded {$profile['label']} at {$profile['ip']}."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php new file mode 100644 index 0000000000..4772317c39 --- /dev/null +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -0,0 +1,27 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others')); + $this->info("Seeded {$server->name} at {$server->ip}."); + + return self::SUCCESS; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 137f50fbbd..e7d6d071b4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -87,15 +87,48 @@ class SshMultiplexingHelper return false; } - self::storeConnectionMetadata($server); - return true; } public static function removeMuxFile(Server $server): void { - Process::run(self::muxControlCommand($server, 'exit')); - self::clearConnectionMetadata($server); + $checkProcess = Process::run(self::muxControlCommand($server, 'check')); + $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches) + ? $matches[1] + : null; + + if ($pid !== null) { + self::markMuxProcessAsRetiring($pid, self::muxSocket($server)); + } + + $stopProcess = Process::run(self::muxControlCommand($server, 'stop')); + + if ($pid !== null && ! $stopProcess->successful()) { + self::unmarkMuxProcessAsRetiring($pid, self::muxSocket($server)); + } + } + + public static function markMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void + { + $processStartTime ??= self::processStartTime($pid); + Cache::forever(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime), true); + } + + public static function isMuxProcessRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): bool + { + $processStartTime ??= self::processStartTime($pid); + $key = self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime); + if (! Cache::has($key)) { + return false; + } + + return true; + } + + public static function unmarkMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void + { + $processStartTime ??= self::processStartTime($pid); + Cache::forget(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime)); } public static function generateScpCommand(Server $server, string $source, string $dest): string @@ -248,25 +281,6 @@ class SshMultiplexingHelper return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); } - public static function isConnectionExpired(Server $server): bool - { - $connectionAge = self::getConnectionAge($server); - $maxAge = config('constants.ssh.mux_max_age'); - - return $connectionAge !== null && $connectionAge > $maxAge; - } - - public static function getConnectionAge(Server $server): ?int - { - $connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}"); - - if ($connectionTime === null) { - return null; - } - - return time() - $connectionTime; - } - public static function refreshMultiplexedConnection(Server $server): bool { self::removeMuxFile($server); @@ -279,6 +293,28 @@ class SshMultiplexingHelper return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; } + private static function muxProcessRetirementKey(string $pid, string $muxSocket, ?string $processStartTime): string + { + return 'ssh_mux_retiring_'.hash('sha256', self::processScope().'|'.$pid.'|'.$processStartTime.'|'.$muxSocket); + } + + private static function processScope(): string + { + return (gethostname() ?: 'unknown').'|'.(@readlink('/proc/self/ns/pid') ?: 'unknown'); + } + + private static function processStartTime(string $pid): ?string + { + $stat = @file_get_contents("/proc/{$pid}/stat"); + if ($stat === false || ! preg_match('/^\d+ \(.*\) (.*)$/', trim($stat), $matches)) { + return null; + } + + $fields = preg_split('/\s+/', $matches[1]); + + return $fields[19] ?? null; + } + private static function masterConnectionExists(Server $server): bool { return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0; @@ -290,14 +326,6 @@ class SshMultiplexingHelper return false; } - if (self::getConnectionAge($server) === null) { - self::storeConnectionMetadata($server); - } - - if (self::isConnectionExpired($server)) { - return false; - } - if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { return false; } @@ -388,14 +416,4 @@ class SshMultiplexingHelper return $options.'-p '.escapeshellarg((string) $server->port).' '; } - - private static function storeConnectionMetadata(Server $server): void - { - Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300); - } - - private static function clearConnectionMetadata(Server $server): void - { - Cache::forget("ssh_mux_connection_time_{$server->uuid}"); - } } diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php new file mode 100644 index 0000000000..c8c311766d --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php @@ -0,0 +1,123 @@ + []]], + tags: ['Secret Managers'], + parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['integration_token_uuid'], + properties: [ + new OA\Property(property: 'integration_token_uuid', type: 'string'), + new OA\Property(property: 'settings', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Secret manager configured.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $request->route('uuid')) + ->first(); + + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $body = $request->json()->all(); + $token = IntegrationToken::query() + ->where('team_id', $teamId) + ->where('uuid', $body['integration_token_uuid'] ?? '') + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->first(); + + if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) { + return response()->json(['message' => 'Secret manager integration token not found.'], 404); + } + + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + 'settings' => ['sometimes', 'array'], + ]; + $rules += match ($token->provider) { + 'doppler' => $token->dopplerTokenType() === 'service_account' ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value)); + $application->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + + auditLog('api.application.secret_manager.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'integration_token_uuid' => $token->uuid, + ]); + + return response()->json([ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + 'settings' => $settings ?: null, + ]); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..784751caf0 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); + $githubApp = GithubApp::where('uuid', $githubAppUuid) + ->where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + }) + ->first(); if (! $githubApp) { return response()->json(['message' => 'Github App not found.'], 404); } @@ -3122,7 +3127,7 @@ class ApplicationsController extends Controller if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); } - $application->save(); + $application->withoutAuditLogging(fn () => $application->save()); auditLog('api.application.updated', [ 'team_id' => $teamId, @@ -5630,14 +5635,6 @@ class ApplicationsController extends Controller return response()->json(['message' => $result['message']], 200); } - auditLog('api.application.rollback', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid, - 'commit' => $commit, - ]); - return response()->json([ 'message' => 'Rollback deployment queued.', 'deployment_uuid' => $deployment_uuid, diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php new file mode 100644 index 0000000000..da452bb303 --- /dev/null +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -0,0 +1,80 @@ +user()->isAdminOfTeam($teamId)) { + return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403); + } + + $validator = Validator::make($request->all(), [ + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'search' => ['sometimes', 'nullable', 'string', 'max:255'], + 'action' => ['sometimes', 'nullable', 'string', 'max:255'], + 'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])], + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $validated = $validator->validated(); + $perPage = (int) ($validated['per_page'] ?? 25); + $search = trim((string) ($validated['search'] ?? '')); + $canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true; + $events = AuditEvent::query() + ->select([ + 'id', + 'team_id', + 'event', + 'source', + 'action', + 'actor_type', + 'actor_id', + 'actor_name', + 'resource_type', + 'resource_uuid', + 'resource_name', + 'description', + 'created_at', + ]) + ->when($canReadSensitive, fn ($query) => $query->addSelect([ + 'actor_email', + 'actor_token_id', + 'actor_token_name', + 'metadata', + 'ip_address', + 'user_agent', + ])) + ->visibleToTeam($teamId) + ->filtered( + search: $search, + action: (string) ($validated['action'] ?? 'all'), + source: (string) ($validated['source'] ?? 'all'), + searchSensitiveFields: $canReadSensitive, + ) + ->latestFirst() + ->paginate($perPage); + + return response()->json(serializeApiResponse($events)); + } +} diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5c073e9c0a..5a0e74d0d9 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -15,9 +15,9 @@ use OpenApi\Attributes as OA; class GithubController extends Controller { - private function removeSensitiveData($githubApp) + private function removeSensitiveData(GithubApp $githubApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) { $githubApp->makeVisible([ 'client_secret', 'webhook_secret', @@ -97,8 +97,8 @@ class GithubController extends Controller ->orWhere('is_system_wide', true); })->get(); - $githubApps = $githubApps->map(function ($app) { - return $this->removeSensitiveData($app); + $githubApps = $githubApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($githubApps); @@ -642,7 +642,7 @@ class GithubController extends Controller $rules['webhook_secret'] = 'string'; } if (isset($payload['private_key_uuid'])) { - $rules['private_key_uuid'] = 'string|uuid'; + $rules['private_key_uuid'] = 'string'; } if (! isCloud() && isset($payload['is_system_wide'])) { $rules['is_system_wide'] = 'boolean'; diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php index c907af46f3..959a3067aa 100644 --- a/app/Http/Controllers/Api/GitlabController.php +++ b/app/Http/Controllers/Api/GitlabController.php @@ -13,9 +13,9 @@ use OpenApi\Attributes as OA; class GitlabController extends Controller { - private function removeSensitiveData(GitlabApp $gitlabApp) + private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) { $gitlabApp->makeVisible([ 'client_secret', 'webhook_token', @@ -108,8 +108,8 @@ class GitlabController extends Controller ->orWhere('is_system_wide', true); })->get(); - $gitlabApps = $gitlabApps->map(function ($app) { - return $this->removeSensitiveData($app); + $gitlabApps = $gitlabApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($gitlabApps); @@ -280,7 +280,7 @@ class GitlabController extends Controller 'gitlab_app_name' => $gitlabApp->name, ]); - return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201); } catch (\Throwable $e) { return handleError($e); } @@ -441,7 +441,7 @@ class GitlabController extends Controller return response()->json([ 'message' => 'GitLab app updated successfully', - 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + 'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId), ]); } catch (ModelNotFoundException $e) { return response()->json([ diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php new file mode 100644 index 0000000000..ad84b6629d --- /dev/null +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -0,0 +1,97 @@ + []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + ] + )] + public function show(): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('view', $settings); + + return response()->json($this->serialize($settings)); + } + + #[OA\Patch( + summary: 'Update instance email settings', + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.', + path: '/settings/email', operationId: 'update-instance-email-settings', + security: [['bearerAuth' => []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Updated instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('update', $settings); + + $validator = customApiValidator($request->json()->all(), [ + 'smtp_enabled' => 'sometimes|boolean', + 'smtp_from_address' => 'sometimes|nullable|email', + 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'smtp_host' => 'sometimes|nullable|string|max:255', + 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', + 'smtp_username' => 'sometimes|nullable|string|max:255', + 'smtp_password' => 'sometimes|nullable|string|max:255', + 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], + 'resend_enabled' => 'sometimes|boolean', + 'resend_api_key' => 'sometimes|nullable|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $settings->fill($validator->validated()); + $settings->save(); + + auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]); + + return response()->json($this->serialize($settings->refresh())); + } + + private function authorizeRootTeam(string $ability, InstanceSettings $settings): void + { + $teamId = getTeamIdFromToken(); + abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.'); + $this->authorize($ability, $settings); + } + + private function serialize(InstanceSettings $settings): array + { + exposeSensitiveFields($settings); + + return Arr::only($settings->toArray(), self::FIELDS); + } +} diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php new file mode 100644 index 0000000000..13a225a107 --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -0,0 +1,108 @@ + []]], + tags: ['Secret Managers'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['provider', 'name', 'token'], + properties: [ + new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'metadata', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 201, description: 'Integration token created.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', IntegrationToken::class); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $body = $request->json()->all(); + $rules = [ + 'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'metadata' => ['sometimes', 'array'], + ]; + + if (($body['provider'] ?? null) === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } elseif (($body['provider'] ?? null) === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.client_id'] = ['required', 'string']; + } elseif (($body['provider'] ?? null) === 'vault') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $validated = $validator->validated(); + $metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value)); + + if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) { + return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400); + } + + $integrationToken = IntegrationToken::query()->create([ + 'team_id' => $teamId, + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => ['secrets'], + 'metadata' => $metadata ?: null, + ]); + + auditLog('api.integration_token.created', [ + 'team_id' => $teamId, + 'integration_token_uuid' => $integrationToken->uuid, + 'provider' => $integrationToken->provider, + ]); + + return response()->json(['uuid' => $integrationToken->uuid], 201); + } +} diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index 99d0cb8971..f5493d0249 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -11,6 +11,7 @@ use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Models\WebhookNotificationSettings; use App\Rules\SafeWebhookUrl; +use App\Rules\ValidHostname; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -37,6 +38,7 @@ class NotificationsController extends Controller 'smtp_username' => 'sometimes|nullable|string|max:255', 'smtp_password' => 'sometimes|nullable|string|max:255', 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], 'resend_enabled' => 'sometimes|boolean', 'resend_api_key' => 'sometimes|nullable|string|max:255', 'use_instance_email_settings' => 'sometimes|boolean', @@ -283,7 +285,7 @@ class NotificationsController extends Controller #[OA\Get( summary: 'Get email notification settings', - description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', path: '/notifications/email', operationId: 'get-current-team-email-notifications', security: [['bearerAuth' => []]], @@ -301,7 +303,7 @@ class NotificationsController extends Controller #[OA\Patch( summary: 'Update email notification settings', - description: 'Update the current team email notification settings.', + description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.', path: '/notifications/email', operationId: 'update-current-team-email-notifications', security: [['bearerAuth' => []]], diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 64bf26c1bb..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -158,6 +158,8 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); if (! $environment) { $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); @@ -269,12 +271,6 @@ class ProjectController extends Controller 'team_id' => $teamId, ]); - auditLog('api.project.created', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - ]); - return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -394,13 +390,6 @@ class ProjectController extends Controller $project->update($request->only($allowedFields)); - auditLog('api.project.updated', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), - ]); - return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -480,16 +469,8 @@ class ProjectController extends Controller return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } - $projectUuid = $project->uuid; - $projectName = $project->name; $project->delete(); - auditLog('api.project.deleted', [ - 'team_id' => $teamId, - 'project_uuid' => $projectUuid, - 'project_name' => $projectName, - ]); - return response()->json(['message' => 'Project deleted.']); } diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index d50a5226a9..f7966c71f1 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -550,11 +550,7 @@ class ServersController extends Controller } $foundServer = ModelsServer::whereIp($request->ip)->first(); if ($foundServer) { - if ($foundServer->team_id === $teamId) { - return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); - } - - return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); + return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 35e01c8314..b9f8572673 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -56,7 +56,7 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams->sortBy('id'); + $teams = auth()->user()->teams->where('id', $teamId)->values(); $teams = $teams->map(function ($team) { return $this->removeSensitiveData($team); }); @@ -100,13 +100,14 @@ class TeamController extends Controller )] public function team_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } @@ -159,13 +160,14 @@ class TeamController extends Controller )] public function members_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index c723d811a4..9b6b315cee 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -8,12 +8,15 @@ use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Auth\Events\Verified; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; @@ -95,61 +98,105 @@ class Controller extends BaseController return response()->json(['message' => 'Transactional emails are not active'], 400); } - public function link() + public function link(): View|RedirectResponse { $token = request()->get('token'); - if (is_string($token) && $token !== '') { - try { - $decrypted = Crypt::decryptString($token); - } catch (DecryptException) { - return redirect()->route('login')->with('error', 'Invalid credentials.'); - } - - if (! str_contains($decrypted, '@@@')) { - return redirect()->route('login')->with('error', 'Invalid credentials.'); - } - - $payload = explode('@@@', $decrypted, 3); - if (count($payload) === 3) { - [$email, $invitationUuid, $password] = $payload; - } else { - [$email, $password] = $payload; - $invitationUuid = null; - } - - $email = Str::lower($email); - $user = User::whereEmail($email)->first(); - if (! $user) { - return redirect()->route('login'); - } - - $invitation = TeamInvitation::query() - ->where('email', $email) - ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) - ->first(); - if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) { - return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); - } - - if (Hash::check($password, $user->password)) { - $team = $invitation->team; - if (! $user->teams()->where('team_id', $team->id)->exists()) { - $user->teams()->attach($team->id, ['role' => $invitation->role]); - } - $invitation->delete(); - - $user->forceFill([ - 'password' => Hash::make(Str::random(64)), - ])->save(); - - Auth::login($user); - session(['currentTeam' => $team]); - - return redirect()->route('dashboard'); - } + $credentials = is_string($token) ? $this->magicLinkCredentials($token) : null; + if (! $credentials) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } - return redirect()->route('login')->with('error', 'Invalid credentials.'); + [$user, $invitation] = $credentials; + + return view('invitation.accept', [ + 'invitation' => $invitation, + 'team' => $invitation->team, + 'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(), + 'formAction' => route('auth.link.accept'), + 'token' => $token, + ]); + } + + public function acceptLink(Request $request): RedirectResponse + { + $token = $request->input('token'); + if (! is_string($token)) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); + } + + $acceptedInvitation = DB::transaction(function () use ($token) { + $credentials = $this->magicLinkCredentials($token, lockForUpdate: true); + if (! $credentials) { + return null; + } + + [$user, $invitation] = $credentials; + $team = $invitation->team; + if (! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team->id, ['role' => $invitation->role]); + } + + $user->forceFill([ + 'password' => Hash::make(Str::random(64)), + ])->save(); + $invitation->delete(); + + return [$user, $team]; + }); + + if (! $acceptedInvitation) { + return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); + } + + [$user, $team] = $acceptedInvitation; + + Auth::login($user); + session(['currentTeam' => $team]); + + return redirect()->route('dashboard'); + } + + /** + * @return array{0: User, 1: TeamInvitation}|null + */ + private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array + { + if ($token === '') { + return null; + } + + try { + $decrypted = Crypt::decryptString($token); + } catch (DecryptException) { + return null; + } + + $payload = explode('@@@', $decrypted, 3); + if (count($payload) === 3) { + [$email, $invitationUuid, $password] = $payload; + } elseif (count($payload) === 2) { + [$email, $password] = $payload; + $invitationUuid = null; + } else { + return null; + } + + $email = Str::lower($email); + $user = User::query()->where('email', $email)->first(); + $invitationQuery = TeamInvitation::query() + ->where('email', $email) + ->when($lockForUpdate, fn ($query) => $query->lockForUpdate()); + $invitation = $invitationUuid + ? $invitationQuery->where('uuid', $invitationUuid)->first() + : $invitationQuery->get()->first( + fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token) + ); + + if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) { + return null; + } + + return Hash::check($password, $user->password) ? [$user, $invitation] : null; } private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool @@ -185,6 +232,7 @@ class Controller extends BaseController 'invitation' => $invitation, 'team' => $invitation->team, 'alreadyMember' => $alreadyMember, + 'formAction' => route('team.invitation.accept', $invitation->uuid), ]); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\SetCacheHeaders; @@ -59,6 +60,7 @@ class Kernel extends HttpKernel ValidatePostSize::class, TrimStrings::class, ConvertEmptyStringsToNull::class, + InvokeDeferredCallbacks::class, ]; diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php index e7b34248ed..f59b1a0b17 100644 --- a/app/Jobs/ApiTokenExpirationWarningJob.php +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -41,6 +41,10 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si continue; } + if (! $team->members()->whereKey($token->tokenable_id)->exists()) { + continue; + } + $warningSentAt = now(); $team->notify(new ApiTokenExpiringNotification($token)); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1e8450c1b9..a70d917815 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Support\RemoteSecretReferences; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; @@ -143,6 +144,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; + /** @var array|null */ + private ?array $remote_secrets_cache = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -614,6 +618,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->dockerImagePreviewTag; } + if ($this->rollback && str($this->commit)->isNotEmpty()) { + return $this->commit; + } + if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { return $this->application->docker_registry_image_tag; } @@ -1275,6 +1283,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return true; } + if ($this->has_remote_buildtime_secret_references()) { + $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.'); + + return false; + } $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); if (! $configurationDiff->requiresBuild()) { $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); @@ -1302,6 +1315,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return false; } + private function has_remote_buildtime_secret_references(): bool + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview(); + + return $environmentVariables + ->where('is_buildtime', true) + ->get(['value']) + ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + } + private function check_image_locally_or_remotely() { $this->execute_remote_command([ @@ -1323,6 +1348,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Fetch the secrets from the application's secret manager source. Values + * live only in memory during the deployment and in the generated .env on + * the server — they are never persisted in the Coolify database. Fetched + * lazily (only when a variable references a secret), once per deployment. + * A fetch failure fails the deployment. + * + * @return array + */ + private function remote_secrets(): array + { + if ($this->remote_secrets_cache !== null) { + return $this->remote_secrets_cache; + } + + $link = $this->application->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.'); + } + + $provider = $link->integrationToken->providerName(); + $tokenName = $link->integrationToken->name; + + try { + $secrets = $link->fetchSecrets(); + } catch (Throwable $e) { + $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr'); + + throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); + } + + $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); + + return $this->remote_secrets_cache = $secrets; + } + + /** + * Replace {{vault.KEY}} references with values from the configured secret + * manager source. Missing keys fail the deployment with a + * list — changing the source never re-checks references, so this is the + * moment problems surface. + */ + private function substitute_remote_secrets(string $value, string $envKey): string + { + $secrets = $this->remote_secrets(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey})."; + $this->application_deployment_queue->addLogEntry($message, 'stderr'); + + throw new DeploymentException($message.' Check the secret manager source of this application.'); + } + + return RemoteSecretReferences::substitute($value, $secrets); + } + + /** + * Resolve shared variables, then secret references, in a raw variable value. + */ + private function resolve_environment_variable_raw(EnvironmentVariable $env): string + { + $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + + return $this->substitute_remote_secrets($value ?? '', $env->key); + } + + /** + * Resolve a runtime variable to its dotenv representation. Values with + * secret references are substituted and written as literals. + */ + private function resolve_environment_variable(EnvironmentVariable $env): ?string + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return $env->getResolvedValueWithServer($this->mainServer); + } + + return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env)); + } + + /** + * Format a remote secret value for the runtime .env file (dotenv syntax read + * by docker compose). Values are treated as literals — no interpolation. + */ + private function format_remote_secret_value(string $value): string + { + if (! str_contains($value, "'")) { + return "'".$value."'"; + } + + // Fall back to double quotes; $$ escapes compose interpolation. + return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"'; + } + private function generate_runtime_environment_variables() { $envs = collect([]); @@ -1391,7 +1511,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1458,7 +1578,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Fall back to production env vars for keys not overridden by preview vars, @@ -1472,7 +1592,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } } @@ -1580,6 +1700,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + 'skip_command_log' => true, ] ); @@ -1598,6 +1719,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + 'skip_command_log' => true, ] ); $this->server = $this->build_server; @@ -1605,6 +1727,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + 'skip_command_log' => true, ] ); } @@ -1728,6 +1851,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1783,6 +1912,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1853,6 +1988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + 'skip_command_log' => true, ] ); @@ -2651,6 +2787,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { + if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + $resolved = $this->resolve_environment_variable_raw($environmentVariable); + + return $resolved === '' ? null : $resolved; + } + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); if (is_null($resolvedValue) || $resolvedValue === '') { return null; @@ -3194,7 +3336,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -3210,7 +3354,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -4268,7 +4414,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { $secrets_string = $variables ->map(function ($env) { - return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"; + return "{$env->key}={$this->resolve_environment_variable($env)}"; }) ->sort() ->implode('|'); @@ -4334,7 +4480,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); } } // Add Coolify variables as ARGs @@ -4356,7 +4502,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); } } // Add Coolify variables as ARGs @@ -4370,6 +4516,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + if ($argsToInsert->isNotEmpty()) { + $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) { + return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))]; + }); + $secretsHash = $this->generate_secrets_hash($environmentVariables); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"); + } + // Development logging to show what ARGs are being injected if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); @@ -4391,11 +4545,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); } } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->getResolvedValueWithServer($this->mainServer)]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); @@ -4404,11 +4553,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'ignore_errors' => true, + 'skip_command_log' => true, ]); } diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 0000000000..1a7ceaeabc --- /dev/null +++ b/app/Jobs/CheckDomainDnsJob.php @@ -0,0 +1,90 @@ +persistResults(CheckDomainDns::run( + [$this->statusKey => $this->url], + $this->server, + $this->expectedIp, + $this->skipForMultipleServers, + )); + } + + public function failed(?\Throwable $exception): void + { + $this->persistResults([ + $this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'), + ]); + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function status(string $status, string $message): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $this->expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $results + */ + private function persistResults(array $results): void + { + DB::transaction(function () use ($results): void { + $resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey()); + if (! $resource) { + return; + } + + $statuses = $resource->domain_dns_statuses ?? []; + + foreach ($results as $key => $result) { + if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) { + continue; + } + + $statuses[$key] = $result; + } + + $resource->domain_dns_statuses = $statuses === [] ? null : $statuses; + $resource->save(); + }); + } +} diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index 0d3029c668..69e151d24f 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -2,8 +2,8 @@ namespace App\Jobs; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; -use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -51,7 +51,9 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue continue; } - if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { + if ($process['etimes'] >= $minAge + && ! file_exists($pathMatch[1]) + && ! SshMultiplexingHelper::isMuxProcessRetiring($process['pid'], $pathMatch[1])) { $this->reapOrphan('ssh', $process); } } @@ -169,14 +171,6 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue if ($checkProcess->exitCode() !== 0) { $this->removeMultiplexFile($muxFile, 'connection_check_failed'); - } else { - $muxContent = Storage::disk('ssh-mux')->get($muxFile); - $establishedAt = Carbon::parse(substr($muxContent, 37)); - $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); - - if (Carbon::now()->isAfter($expirationTime)) { - $this->removeMultiplexFile($muxFile, 'expired'); - } } } } @@ -216,8 +210,20 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue } $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; - $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; - Process::run($closeCommand); + $checkProcess = Process::run("ssh -O check -o ControlPath={$muxSocket} localhost"); + $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches) + ? $matches[1] + : null; + + if ($pid !== null) { + SshMultiplexingHelper::markMuxProcessAsRetiring($pid, $muxSocket); + } + + $closeCommand = "ssh -O stop -o ControlPath={$muxSocket} localhost 2>/dev/null"; + $stopProcess = Process::run($closeCommand); + if ($pid !== null && ! $stopProcess->successful()) { + SshMultiplexingHelper::unmarkMuxProcessAsRetiring($pid, $muxSocket); + } Storage::disk('ssh-mux')->delete($muxFile); Log::info('Removed stale mux file', [ diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 1838feb9e7..0b73ed0cf5 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; + $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}"); instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php new file mode 100644 index 0000000000..e21ee38c61 --- /dev/null +++ b/app/Jobs/DatabaseStartJob.php @@ -0,0 +1,88 @@ +onQueue(deployment_queue()); + } + + public function handle(): void + { + $database = $this->databaseClass::query()->findOrFail($this->databaseId); + abort_unless((int) $database->team()->id === $this->teamId, 403); + $activity = Activity::query()->findOrFail($this->activityId); + + match ($database->getMorphClass()) { + StandalonePostgresql::class => StartPostgresql::run($database, $activity), + StandaloneRedis::class => StartRedis::run($database, $activity), + StandaloneMongodb::class => StartMongodb::run($database, $activity), + StandaloneMysql::class => StartMysql::run($database, $activity), + StandaloneMariadb::class => StartMariadb::run($database, $activity), + StandaloneKeydb::class => StartKeydb::run($database, $activity), + StandaloneDragonfly::class => StartDragonfly::run($database, $activity), + StandaloneClickhouse::class => StartClickhouse::run($database, $activity), + }; + + event(new DatabaseStatusChanged($this->userId)); + } + + public function failed(?Throwable $exception): void + { + try { + $activity = Activity::query()->find($this->activityId); + if (! $activity) { + return; + } + + $activity->properties = $activity->properties->merge([ + 'status' => ProcessStatus::ERROR->value, + 'error' => 'Database start failed.', + 'failed_at' => now()->toIso8601String(), + ]); + $activity->save(); + } finally { + event(new DatabaseStatusChanged($this->userId)); + } + } +} diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 124cc16cca..dff7d88de1 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,7 +4,6 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; -use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Actions\Shared\DeleteScheduledVolumeBackup; @@ -28,6 +27,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue { @@ -43,20 +44,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue('high'); } - public function handle() + public function handle(): void { - if (! $this->resource instanceof ApplicationPreview) { - $this->deleteScheduledVolumeBackups(); + if ($this->resource instanceof ApplicationPreview) { + DB::transaction(function (): void { + $this->deleteApplicationPreview(); + }); + + return; } try { - // Handle ApplicationPreview instances separately - if ($this->resource instanceof ApplicationPreview) { - $this->deleteApplicationPreview(); - - return; - } - switch ($this->resource->type()) { case 'application': StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup); @@ -73,21 +71,71 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue break; case 'service': StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); - DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup); - - return; + app(DeleteService::class)->cleanupRemote( + $this->resource, + $this->deleteVolumes, + $this->deleteConnectedNetworks, + $this->deleteConfigurations, + ); + break; } - if ($this->deleteConfigurations) { - $this->resource->deleteConfigurations(); + if (! $this->resource instanceof Service) { + if ($this->deleteConfigurations) { + $this->resource->deleteConfigurations(); + } + if ($this->deleteVolumes) { + $this->resource->deleteVolumes(); + } + if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') { + $this->resource->deleteConnectedNetworks(); + } } + } catch (\Throwable $e) { + Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + + DB::transaction(function (): void { + try { + $this->deleteScheduledVolumeBackups(); + } catch (\Throwable $e) { + Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + + if ($this->resource instanceof Service) { + app(DeleteService::class)->deleteLocal($this->resource); + + return; + } + if ($this->deleteVolumes) { - $this->resource->deleteVolumes(); $this->resource->persistentStorages()->delete(); } - $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag + $this->resource->fileStorages()->delete(); - $isDatabase = $this->resource instanceof StandalonePostgresql + if ($this->isDatabase()) { + $this->resource->sslCertificates()->delete(); + $this->resource->scheduledBackups()->delete(); + $this->resource->tags()->detach(); + } + $this->resource->environment_variables()->delete(); + $this->resource->forceDelete(); + }); + + Artisan::queue('cleanup:stucked-resources'); + } + + private function isDatabase(): bool + { + return $this->resource instanceof StandalonePostgresql || $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneMongodb || $this->resource instanceof StandaloneMysql @@ -95,29 +143,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue || $this->resource instanceof StandaloneKeydb || $this->resource instanceof StandaloneDragonfly || $this->resource instanceof StandaloneClickhouse; - - if ($isDatabase) { - $this->resource->sslCertificates()->delete(); - $this->resource->scheduledBackups()->delete(); - $this->resource->tags()->detach(); - } - $this->resource->environment_variables()->delete(); - - if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') { - $this->resource->deleteConnectedNetworks(); - } - } catch (\Throwable $e) { - throw $e; - } finally { - $this->resource->forceDelete(); - if ($this->dockerCleanup) { - $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); - if ($server) { - CleanupDocker::dispatch($server, false, false); - } - } - Artisan::queue('cleanup:stucked-resources'); - } } private function deleteScheduledVolumeBackups(): void diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 16f3d88ad9..5a7627e0a9 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -155,4 +155,38 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } } } + + public function failed(?\Throwable $exception): void + { + $execution = DockerCleanupExecution::query() + ->where('server_id', $this->server->id) + ->where('status', 'running') + ->whereNull('finished_at') + ->latest('id') + ->first(); + + if (! $execution) { + return; + } + + $message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.'; + + $updated = DockerCleanupExecution::query() + ->whereKey($execution->id) + ->where('status', 'running') + ->whereNull('finished_at') + ->update([ + 'status' => 'failed', + 'message' => $message, + 'finished_at' => Carbon::now()->toImmutable(), + ]); + + if ($updated === 0) { + return; + } + + $execution->refresh(); + event(new DockerCleanupDone($execution)); + $this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$message)); + } } diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 30ecb2d8d5..9b117d4e13 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'created') { instant_remote_process([ - 'docker rm -f coolify-proxy', + dockerRemoveCommand('coolify-proxy'), ], $server); } } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 1b344c9056..03fa2b5109 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -81,7 +81,7 @@ class Show extends Component } $safeNetwork = escapeshellarg($this->destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); + instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server); } $this->destination->delete(); diff --git a/app/Livewire/Dev/LivewireRequestFailurePreview.php b/app/Livewire/Dev/LivewireRequestFailurePreview.php new file mode 100644 index 0000000000..5cdda5d781 --- /dev/null +++ b/app/Livewire/Dev/LivewireRequestFailurePreview.php @@ -0,0 +1,31 @@ + + */ + public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]; + + public function fail(int $status): never + { + abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND); + + throw new HttpResponseException(response( + '

Gateway time-out

cloudflare proxy error '.$status.'

', + $status, + ['Content-Type' => 'text/html'] + )); + } + + public function render(): mixed + { + return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple'); + } +} diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index 52e4460add..e28cefba48 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,10 +2,8 @@ namespace App\Livewire; +use App\Actions\Team\DeleteTeam; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Livewire\Component; class NavbarDeleteTeam extends Component @@ -28,22 +26,7 @@ class NavbarDeleteTeam extends Component $currentTeam = currentTeam(); $this->authorize('delete', $currentTeam); - - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); - $currentTeam->delete(); - - $newTeam = Auth::user()->teams()->first(); + $newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user()); refreshSession($newTeam); return redirect()->route('team.index'); diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 11280799e3..2a373a5065 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -5,6 +5,7 @@ namespace App\Livewire\Notifications; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use App\Rules\ValidHostname; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; @@ -56,6 +57,9 @@ class Email extends Component #[Validate(['nullable', 'numeric'])] public ?string $smtpTimeout = null; + #[Validate(['nullable', 'string'])] + public ?string $smtpEhloDomain = null; + #[Validate(['boolean'])] public bool $resendEnabled = false; @@ -128,6 +132,7 @@ class Email extends Component { if ($toModel) { $this->validate(); + $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; @@ -139,6 +144,7 @@ class Email extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -174,6 +180,7 @@ class Email extends Component ? $this->settings->smtp_password : null; $this->smtpTimeout = $this->settings->smtp_timeout; + $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->resendEnabled = $this->settings->resend_enabled; $this->resendApiKey = auth()->user()->can('update', $this->settings) @@ -311,6 +318,7 @@ class Email extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->save(); $this->dispatch('success', 'SMTP settings updated.'); @@ -356,6 +364,7 @@ class Email extends Component 'smtpUsername' => 'nullable|string', 'smtpPassword' => 'nullable|string', 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ 'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.email' => 'Please enter a valid email address.', @@ -430,6 +439,7 @@ class Email extends Component $this->smtpUsername = $settings->smtp_username; $this->smtpPassword = $settings->smtp_password; $this->smtpTimeout = $settings->smtp_timeout; + $this->smtpEhloDomain = $settings->smtp_ehlo_domain; if ($settings->resend_enabled) { $this->resendEnabled = true; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -104,7 +104,6 @@ class DeploymentNavbar extends Component $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); - try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 45a76a4c33..2f9370871c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Application; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; @@ -10,6 +12,7 @@ use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Domains extends Component @@ -141,6 +144,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->application); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); @@ -464,6 +500,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -478,6 +515,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -533,6 +571,8 @@ class Domains extends Component || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +588,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +619,50 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; + } + + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + + // Keep suggested-row copy short after DNS checks (no role badge). + if ($this->domainRows[$index]['is_suggested'] ?? false) { + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; - } - // Keep suggested-row copy short after DNS checks (no role badge). - if ($this->domainRows[$index]['is_suggested'] ?? false) { - $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $serviceName = $this->domainRows[$index]['service'] ?? null; - $meta = $this->suggestedDomainMeta( - $isWww, - $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) - ); - $this->domainRows[$index]['dns_message'] = $meta['pending_message']; - $this->domainRows[$index]['suggestion_label'] = null; - $this->domainRows[$index]['suggestion_role'] = $meta['role']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; } - - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** @@ -647,11 +696,34 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } + DB::transaction(function () use (&$statuses): void { + $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; - $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void @@ -804,16 +876,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -825,14 +887,110 @@ class Domains extends Component $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + ->map(fn (string $url, string $statusKey) => [ + 'status_key' => $statusKey, + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + $this->persistDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $this->application, + $dnsCheck['status_key'], + $dnsCheck['url'], + $this->application->destination?->server, + $this->serverIp, + $dnsCheck['check_id'], + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $service, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + + /** + * @param array $urls + * @return array + */ + protected function dnsEntriesForUrls(array $urls, ?string $service = null): array + { + $entries = []; + + foreach ($urls as $url) { + $entries[$this->domainDnsStatusKey($url, $service)] = $url; + } + + return $entries; + } + /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * @@ -875,7 +1033,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1071,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); StopApplication::dispatch($this->application, false, $this->docker_cleanup); + auditLog('ui.application.stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b40..3944bbe09d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -377,6 +377,12 @@ class Previews extends Component ApplicationPreview::where('application_id', $this->application->id) ->where('pull_request_id', $pull_request_id) ->update(['status' => 'exited']); + auditLog('ui.application.preview_stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'pull_request_id' => $pull_request_id, + ]); ServiceStatusChanged::dispatch($this->application->environment->project->team->id); GetContainersStatus::run($server); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -102,6 +102,14 @@ class CloneMe extends Component if (! $selectedDestination) { throw new \Exception('Destination not found.'); } + auditLog('ui.project.clone_started', [ + 'team_id' => $this->project->team_id, + 'project_uuid' => $this->project->uuid, + 'project_name' => $this->project->name, + 'clone_type' => $type, + 'new_name' => $this->newName, + 'destination_uuid' => $selectedDestination->uuid, + ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); if ($foundProject) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 2c04f5ba9b..608d153ebc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -207,10 +207,18 @@ class BackupEdit extends Component } } + $database = $this->backup->database; + $backupUuid = $this->backup->uuid; $this->backup->delete(); + auditLog('ui.database.backup_schedule_deleted', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $backupUuid, + ]); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; + if ($database->getMorphClass() === ServiceDatabase::class) { + $serviceDatabase = $database; return redirect()->route('project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], @@ -238,9 +246,14 @@ class BackupEdit extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); - $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); if ($database instanceof ServiceDatabase) { return redirect()->route('project.service.database.backup.executions', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..e45c797d1e 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -18,6 +18,13 @@ class BackupNow extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); + $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f227021..4b34c5e4ee 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -83,6 +83,7 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); + $this->auditDatabaseAction('ui.database.stopped'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } @@ -94,6 +95,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -107,6 +109,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.started'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -122,4 +125,13 @@ class Heading extends Component ], ]); } + + private function auditDatabaseAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index ccd3435106..87c9328eb3 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -510,6 +510,12 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.import_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 'file', + ]); } } catch (\Throwable $e) { handleError($e, $this); @@ -768,6 +774,13 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.restore_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 's3', + 'storage_id' => $this->s3StorageId, + ]); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; @@ -796,6 +809,8 @@ EOD; * * Hardened against bypasses: * - decompresses gzip backups before scanning, + * - converts custom-format (PGDMP) archives to SQL with pg_restore + * before scanning, and rejects archives that cannot be inspected, * - strips `--` line comments and flattens newlines so multi-line and * comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught, * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects. @@ -817,8 +832,30 @@ EOD; $escapedSqlPattern = escapeshellarg($sqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern); $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; + $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; + $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); + $sqlScan = $scan($contents); + $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; + $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; - return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; + return << "\$inspect"; then + {$blockedInspect} + fi + if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then + {$blockedInspect} + fi + if {$customScan}; then + {$blockedProgram} + fi +elif {$sqlScan}; then + {$blockedProgram} +fi +SH; } private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void @@ -883,7 +920,7 @@ EOD; case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi"; } else { $restoreCommand .= " {$escapedTmpPath}"; } diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index caa19042b8..f3ec3ba4df 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -26,10 +26,17 @@ class Configuration extends Component public array $parameters; - protected $listeners = [ - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; + public function getListeners(): array + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + 'configurationChanged' => 'refreshServices', + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices', + ]; + } public function render() { diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4690335d86..d932e76494 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Service; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; @@ -131,6 +133,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->service); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void { $application = $this->service->applications()->findOrFail($serviceApplicationId); @@ -282,6 +317,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -298,6 +334,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -404,6 +441,8 @@ class Domains extends Component $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $this->domainRows[$index]['dns_status'] = 'skipped'; @@ -415,7 +454,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +486,37 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistAllDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; } - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; + $this->decorateSuggestedDomainAfterDnsCheck($index); + } } /** @@ -516,6 +563,7 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } @@ -528,8 +576,30 @@ class Domains extends Component ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); + DB::transaction(function () use ($app, &$statuses): void { + $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $app->domain_dns_statuses = $statuses === [] ? null : $statuses; - $app->save(); } $this->service->load('applications'); @@ -928,16 +998,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; @@ -955,14 +1015,93 @@ class Domains extends Component $this->forceRemovePort = false; $this->pendingAction = null; $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $serviceApplicationId = (int) $app->id; + $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + $this->persistAllDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $app, + $dnsCheck['url'], + $dnsCheck['url'], + $this->service->server, + $this->serverIp, + $dnsCheck['check_id'], + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { @@ -1309,7 +1448,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1473,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 34bb46ff19..072a56e002 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -113,6 +113,7 @@ class Heading extends Component try { $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); + $this->auditServiceAction('ui.service.started'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -146,6 +147,7 @@ class Heading extends Component try { $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); + $this->auditServiceAction('ui.service.stopped'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -162,6 +164,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -180,6 +183,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -196,6 +200,15 @@ class Heading extends Component $this->authorize($ability, $this->service); } + private function auditServiceAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->service->team()?->id, + 'service_uuid' => $this->service->uuid, + 'service_name' => $this->service->name, + ]); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b6..6880b5ab09 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,13 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + $name = str($this->resource->name)->slug()->value(); + + return ($name ?: 'volume').'-data'; + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index adb1234e69..430e89a2c1 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ class Create extends Component $label = str($resource->name)->headline(); $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ 'key' => 'volume:'.$volume->id, - 'type' => 'Volume · '.$label, - 'name' => $volume->name, + 'type' => $label, + 'name' => str($volume->name)->after($this->service->uuid.'_')->value(), ])); $targets->push(...$resource->fileStorages() ->where('is_directory', true) @@ -109,8 +109,8 @@ class Create extends Component ->get() ->map(fn (LocalFileVolume $directory): array => [ 'key' => 'directory:'.$directory->id, - 'type' => 'Directory · '.$label, - 'name' => $directory->fs_path, + 'type' => $label, + 'name' => $directory->fs_path.' (directory)', ])); } diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -64,6 +64,13 @@ class Destination extends Component $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); + auditLog('ui.application.destination_stopped', [ + 'team_id' => $this->resource->team()?->id, + 'application_uuid' => $this->resource->uuid, + 'application_name' => $this->resource->name, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); $this->refreshServers(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..15b4410a5f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -9,14 +9,27 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component { - use AuthorizesRequests, EnvironmentVariableAnalyzer; + use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete; + + protected function secretManagerResource(): ?Model + { + if ($this->shared || ! $this->resource) { + return null; + } + + return $this->resource; + } + + public $resource; public $parameters; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..4b68c4d8f1 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Events\ApplicationConfigurationChanged; use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; @@ -12,7 +13,9 @@ use App\Models\SharedEnvironmentVariable; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -21,7 +24,12 @@ class Show extends Component { public bool $showEnvironmentType = true; - use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; + + protected function secretManagerResource(): ?Model + { + return $this->isSharedVariable ? null : $this->env->resourceable; + } public $parameters; @@ -161,6 +169,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -204,7 +228,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -231,12 +255,12 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || auth()->user()?->isMember()) { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; } public function checkEnvs() @@ -298,6 +322,10 @@ class Show extends Component $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); + + if ($this->is_required && $this->resource instanceof Service) { + event(new ApplicationConfigurationChanged($this->resource->team()->id)); + } } catch (\Exception $e) { return handleError($e); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..61b4b2d2ed 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -86,6 +86,14 @@ class ResourceOperations extends Component if (! $server->canHostResources()) { return $this->addError('destination_id', 'The selected server cannot host resources.'); } + auditLog('ui.resource.clone_started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'resource_type' => class_basename($this->resource), + 'destination_uuid' => $new_destination->uuid, + 'environment_id' => $new_environment->id, + ]); if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 2d6b76c25f..61bc6b0fbc 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; +use App\Models\Service; +use App\Models\StandalonePostgresql; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -59,13 +62,13 @@ class Add extends Component // Get the resource based on type and id switch ($this->type) { case 'application': - $this->resource = \App\Models\Application::findOrFail($this->id); + $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id); break; case 'service': - $this->resource = \App\Models\Service::findOrFail($this->id); + $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id); break; case 'standalone-postgresql': - $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id); break; default: throw new \Exception('Invalid resource type'); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 11df001531..14777724e5 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -184,6 +184,13 @@ class Show extends Component $this->authorize('update', $this->resource); $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); + auditLog('ui.scheduled_task.executed', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'scheduled_task_uuid' => $this->task->uuid, + 'scheduled_task_name' => $this->task->name, + ]); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..c0641b56c5 --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,290 @@ + Remote key names only — values are never stored. */ + public array $keys = []; + + public bool $keysLoaded = false; + + public string $search = ''; + + public function mount(): void + { + $this->loadData(); + } + + private function loadData(): void + { + $this->link = $this->resource->secretManagerLink()->with('integrationToken')->first(); + $this->availableTokens = IntegrationToken::ownedByCurrentTeam() + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->get() + ->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true)) + ->values(); + + if ($this->link) { + $this->integration_token_uuid = $this->link->integrationToken->uuid; + $this->settings = $this->link->settings ?? []; + } + } + + public function getSelectedTokenProperty(): ?IntegrationToken + { + if (blank($this->integration_token_uuid)) { + return null; + } + + return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid); + } + + protected function rules(): array + { + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + ]; + + $rules += match ($this->selectedToken?->provider) { + 'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account' + ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] + : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + return $rules; + } + + /** + * Auto-save when a token is selected in the dropdown. Existing {{vault.*}} + * references are intentionally NOT re-checked — missing keys surface at + * the next deployment. + */ + public function updatedIntegrationTokenUuid(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->selectedToken; + + if (! $token) { + return; + } + + if ($this->link?->integrationToken?->provider !== $token->provider + || $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) { + $this->settings = []; + } + + $settings = array_filter($this->settings, fn ($value) => filled($value)); + + $this->resource->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + $this->auditSecretManagerAction('source_updated', [ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + ]); + + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Auto-save of the provider-specific settings fields (called on blur). + */ + public function saveSettings(): void + { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $validated = $this->validate(); + + try { + + $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); + + $this->link->update(['settings' => $settings ?: null]); + $this->auditSecretManagerAction('settings_updated'); + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager settings saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeSource(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->link?->integrationToken; + $this->resource->secretManagerLink()->delete(); + $this->auditSecretManagerAction('source_removed', [ + 'integration_token_uuid' => $token?->uuid, + 'provider' => $token?->provider, + ]); + $this->link = null; + $this->integration_token_uuid = ''; + $this->settings = []; + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function loadKeys(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + // Values are fetched into memory, reduced to key names, and discarded. + $keys = array_keys($this->link->fetchSecrets()); + sort($keys); + $this->keys = $keys; + $this->keysLoaded = true; + $this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]); + } catch (\Throwable $e) { + $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage()); + } + } + + public function addReference(string $key): void + { + try { + $this->authorize('update', $this->resource); + + if (! in_array($key, $this->keys, true)) { + return; + } + + if ($this->resource->environment_variables()->where('key', $key)->exists()) { + $this->dispatch('error', "A variable with the key {$key} already exists."); + + return; + } + + $this->resource->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $this->auditSecretManagerAction('reference_created', ['secret_key' => $key]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', "Added {$key} as {{vault.{$key}}}."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function importAll(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $imported = $this->link->importMissingReferences(); + $this->auditSecretManagerAction('references_imported', [ + 'key_count' => count($imported), + 'secret_keys' => $imported, + ]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', $imported === [] + ? 'All remote keys already exist as variables.' + : 'Imported '.count($imported).' keys as {{vault.KEY}} references.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function resetKeys(): void + { + $this->keys = []; + $this->keysLoaded = false; + $this->search = ''; + } + + /** @param array $context */ + private function auditSecretManagerAction(string $action, array $context = []): void + { + $resourceType = str(class_basename($this->resource))->snake()->value(); + + auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([ + 'team_id' => $this->resource->team()?->id, + "{$resourceType}_uuid" => $this->resource->uuid, + "{$resourceType}_name" => $this->resource->name, + ], $context)); + } + + public function getFilteredKeysProperty(): array + { + if (blank($this->search)) { + return $this->keys; + } + + return array_values(array_filter( + $this->keys, + fn (string $key) => stripos($key, $this->search) !== false, + )); + } + + public function render(): View + { + return view('livewire.project.shared.secret-manager-links', [ + 'selectedToken' => $this->selectedToken, + 'filteredKeys' => $this->filteredKeys, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a4..efe54a6a7d 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ class All extends Component $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..ef7b36ff72 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -204,6 +204,12 @@ class VolumeBackups extends Component } VolumeBackupJob::dispatch($this->backup); + auditLog('ui.volume_backup.started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Storage backup queued.'); return redirect()->route($this->routeName('executions'), $this->routeParameters()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index d6bd6e54bf..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -59,7 +59,10 @@ class ApiTokens extends Component private function getTokens() { - $this->tokens = auth()->user()->tokens->sortByDesc('created_at'); + $this->tokens = auth()->user()->tokens() + ->where('team_id', currentTeam()->id) + ->latest() + ->get(); } public function updatedPermissions($permissionToUpdate) @@ -137,6 +140,12 @@ class ApiTokens extends Component ]); $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); + auditLog('ui.api_token.created', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $this->description, + 'abilities' => array_values($this->permissions), + 'expires_at' => $expiresAt?->toIso8601String(), + ]); $this->getTokens(); // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. session()->flash('token', $token->plainTextToken); @@ -148,9 +157,17 @@ class ApiTokens extends Component public function revoke(int $id) { try { - $token = auth()->user()->tokens()->where('id', $id)->firstOrFail(); + $token = auth()->user()->tokens() + ->where('team_id', currentTeam()->id) + ->where('id', $id) + ->firstOrFail(); $this->authorize('delete', $token); + $tokenName = $token->name; $token->delete(); + auditLog('ui.api_token.revoked', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $tokenName, + ]); $this->getTokens(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 453a7e8ae8..2a33591822 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component public array $capabilities = []; + public array $metadata = []; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component $this->name = $this->integrationToken->name; $this->capabilities = $this->integrationToken->capabilities; + $this->metadata = $this->integrationToken->metadata ?? []; } protected function rules(): array { - return [ + $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->integrationToken->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->integrationToken->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component ]; } - public function save(CloudflareTokenValidator $validator): void + public function save(IntegrationTokenValidator $validator): void { $this->authorize('update', $this->integrationToken); $validated = $this->validate(); + $provider = $this->integrationToken->provider; $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); try { - if ((filled($validated['newToken']) || $capabilitiesChanged) - && ! $validator->validate($token, $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) + && ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($provider)); return; } @@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component $updates = [ 'name' => $validated['name'], 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, ]; if (filled($validated['newToken'])) { @@ -100,8 +121,25 @@ class IntegrationTokenEditor extends Component public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); + + if ($this->integrationToken->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + + $uuid = $this->integrationToken->uuid; + $name = $this->integrationToken->name; + $provider = $this->integrationToken->provider; $this->integrationToken->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $uuid, + 'integration_token_name' => $name, + 'provider' => $provider, + ]); + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); $this->dispatch('close-modal'); $this->dispatch('success', 'Integration token deleted successfully.'); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 7a7637bf5e..26ecce1e14 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component public array $capabilities = ['dns']; + public array $metadata = []; + public function mount(): void { $this->authorize('create', IntegrationToken::class); } + public function updatedProvider(): void + { + if ($this->provider === 'cloudflare') { + $this->capabilities = ['dns']; + $this->metadata = []; + } else { + $this->capabilities = ['secrets']; + $this->metadata = $this->provider === 'infisical' + ? ['base_url' => 'https://app.infisical.com'] + : []; + } + } + protected function rules(): array { - return [ - 'provider' => ['required', 'in:cloudflare'], + $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ + 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->provider === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } + + if ($this->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -42,25 +75,38 @@ class IntegrationTokenForm extends Component return [ 'capabilities.required' => 'Select at least one capability.', 'capabilities.min' => 'Select at least one capability.', + 'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).', ]; } - public function addToken(CloudflareTokenValidator $validator): void + public function addToken(IntegrationTokenValidator $validator): void { $validated = $this->validate(); + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($validated['provider'])); return; } - IntegrationToken::query()->create([ - ...$validated, + $integrationToken = IntegrationToken::query()->create([ + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id, ]); + auditLog('ui.integration_token.created', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $integrationToken->uuid, + 'integration_token_name' => $integrationToken->name, + 'provider' => $integrationToken->provider, + ]); + $this->reset(['name', 'token']); $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 39db135b38..71805fb841 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -29,7 +29,23 @@ class IntegrationTokens extends Component { $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + + if ($token->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $provider = $token->provider; $token->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $tokenUuid, + 'integration_token_name' => $tokenName, + 'provider' => $provider, + ]); $this->loadTokens(); $this->dispatch('success', 'Integration token deleted successfully.'); } diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 12d111d219..24acdecad1 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -134,6 +134,13 @@ class DockerCleanup extends Component try { $this->authorize('update', $this->server); DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); + auditLog('ui.server.docker_cleanup_started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'delete_unused_volumes' => $this->deleteUnusedVolumes, + 'delete_unused_networks' => $this->deleteUnusedNetworks, + ]); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 56d6130644..6a739bc84c 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Models\DockerCleanupExecution; use App\Models\Server; use Illuminate\Support\Collection; use Livewire\Component; @@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component ->get(); if ($this->selectedKey) { - $this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey); if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { $this->isPollingActive = false; } @@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = DockerCleanupExecution::find($key); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key); $this->currentPage = 1; if ($this->selectedExecution && $this->selectedExecution->status === 'running') { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -101,6 +101,11 @@ class Navbar extends Component // Always use background job for all servers RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); } catch (\Throwable $e) { $this->restartInitiated = false; @@ -125,6 +130,11 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); $activity = StartProxy::run($this->server, force: true); + auditLog('ui.proxy.started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -136,6 +146,12 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); StopProxy::dispatch($this->server, $forceStop); + auditLog('ui.proxy.stopped', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'force' => $forceStop, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -123,6 +123,15 @@ class TransferImport extends Component $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + if (! $dryRun) { + auditLog('ui.server.imported', [ + 'team_id' => $teamId, + 'server_uuid' => $this->importedServerUuid, + 'claimed' => (bool) data_get($result, 'claimed'), + 'adopt_mode' => $this->adoptMode, + ]); + } + if ($dryRun) { $this->dispatch('success', 'Dry run completed — nothing was written.'); } elseif (data_get($result, 'claimed')) { diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 40705617f1..829fbda800 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -212,7 +212,7 @@ class Index extends Component return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index c6e9be3e4f..1426f61f02 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\InstanceSettings; use App\Models\Team; use App\Notifications\TransactionalEmails\Test; +use App\Rules\ValidHostname; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; @@ -50,6 +51,9 @@ class SettingsEmail extends Component #[Validate(['nullable', 'numeric'])] public ?string $smtpTimeout = null; + #[Validate(['nullable', 'string'])] + public ?string $smtpEhloDomain = null; + #[Validate(['boolean'])] public bool $resendEnabled = false; @@ -74,6 +78,7 @@ class SettingsEmail extends Component { if ($toModel) { $this->validate(); + $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; $this->settings->smtp_port = $this->smtpPort; @@ -81,6 +86,7 @@ class SettingsEmail extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -95,6 +101,7 @@ class SettingsEmail extends Component $this->smtpUsername = $this->settings->smtp_username; $this->smtpPassword = $this->settings->smtp_password; $this->smtpTimeout = $this->settings->smtp_timeout; + $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->smtpFromAddress = $this->settings->smtp_from_address; $this->smtpFromName = $this->settings->smtp_from_name; @@ -214,6 +221,7 @@ class SettingsEmail extends Component $this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -264,6 +272,7 @@ class SettingsEmail extends Component 'smtpUsername' => 'nullable|string', 'smtpPassword' => 'nullable|string', 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ 'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.email' => 'Please enter a valid email address.', @@ -296,11 +305,20 @@ class SettingsEmail extends Component $this->authorize('update', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', ], [ 'testEmailAddress.required' => 'Test email address is required.', 'testEmailAddress.email' => 'Please enter a valid email address.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', ]); + $this->settings->smtp_from_address = $this->smtpFromAddress; + $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->save(); + $executed = RateLimiter::attempt( 'test-email:'.$this->team->id, $perMinute = 0, diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index 145c285ab5..d50f57c141 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -19,7 +19,7 @@ class SwitchTeam extends Component $this->switch_to($this->selectedTeamId); } - public function switch_to($team_id) + public function switch_to($team_id, ?string $currentUrl = null) { if (! auth()->user()->teams->contains($team_id)) { return; @@ -30,6 +30,12 @@ class SwitchTeam extends Component } refreshSession($team_to_switch_to); - return redirect('dashboard'); + $parsedUrl = parse_url($currentUrl ?? '/dashboard'); + $redirectUrl = data_get($parsedUrl, 'path', '/dashboard'); + if ($query = data_get($parsedUrl, 'query')) { + $redirectUrl .= '?'.$query; + } + + return redirect($redirectUrl); } } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..53cb203eff --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,73 @@ +user()->isAdminOfTeam(currentTeam()->id), 403); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedAction(): void + { + $this->resetPage(); + } + + public function updatedSource(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(10, min(100, $this->perPage)); + $this->resetPage(); + } + + public function render(): View + { + $search = trim($this->search); + $teamId = currentTeam()->id; + $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin(); + $visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents); + $actionOptions = [ + ['value' => 'all', 'label' => 'All actions'], + ...$visibleEvents->clone() + ->select('action') + ->distinct() + ->orderBy('action') + ->pluck('action') + ->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)]) + ->all(), + ]; + $events = AuditEvent::query() + ->visibleToTeam($teamId, $canViewInstanceEvents) + ->filtered($search, $this->action, $this->source) + ->latestFirst() + ->paginate($this->perPage); + + return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]); + } +} diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index c459626482..1f191ec6b1 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -35,7 +35,7 @@ class Create extends Component 'personal_team' => false, 'is_mcp_server_enabled' => true, ]); - auth()->user()->teams()->attach($team, ['role' => 'admin']); + auth()->user()->teams()->attach($team, ['role' => 'owner']); refreshSession($team); return redirectRoute($this, 'team.index'); diff --git a/app/Livewire/Team/DangerZone.php b/app/Livewire/Team/DangerZone.php index a3f73a44f1..74cf3d7ef8 100644 --- a/app/Livewire/Team/DangerZone.php +++ b/app/Livewire/Team/DangerZone.php @@ -2,11 +2,9 @@ namespace App\Livewire\Team; +use App\Actions\Team\DeleteTeam; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Livewire\Component; class DangerZone extends Component @@ -25,22 +23,7 @@ class DangerZone extends Component try { $currentTeam = currentTeam(); $this->authorize('delete', $currentTeam); - $currentTeam->members->each(function ($user) use ($currentTeam): void { - if ($user->id === Auth::id()) { - return; - } - - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); - $currentTeam->delete(); - - $newTeam = Auth::user()->teams()->first(); + $newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user()); refreshSession($newTeam); return redirect()->route('team.index'); @@ -49,6 +32,12 @@ class DangerZone extends Component } } + public function refreshResources(): void + { + $this->team = Team::query()->findOrFail($this->team->id); + refreshSession($this->team); + } + public function render(): mixed { return view('livewire.team.danger-zone'); diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 523f640b96..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -5,6 +5,7 @@ namespace App\Livewire\Team; use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Invitations extends Component @@ -21,12 +22,21 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); - $user = User::whereEmail($invitation->email)->first(); - if (filled($user)) { - $user->deleteIfNotVerifiedAndForcePasswordReset(); - } + $invitationEmail = $invitation->email; + $invitationUuid = $invitation->uuid; + DB::transaction(function () use ($invitation): void { + $user = User::whereEmail($invitation->email)->first(); + if (filled($user)) { + $user->deleteIfNotVerifiedAndForcePasswordReset(); + } - $invitation->delete(); + $invitation->delete(); + }); + auditLog('ui.team_invitation.revoked', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitationUuid, + 'invitation_email' => $invitationEmail, + ]); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -103,6 +103,13 @@ class InviteLink extends Component 'link' => $link, 'via' => $sendEmail ? 'email' : 'link', ]); + auditLog('ui.team_invitation.created', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitation->uuid, + 'invitation_email' => $invitation->email, + 'role' => $invitation->role, + 'via' => $invitation->via, + ]); if ($sendEmail) { $mail = new MailMessage; $mail->view('emails.invitation-link', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 97d492d700..d99fd2eb1b 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -7,6 +7,7 @@ use App\Enums\Role; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Member extends Component @@ -25,8 +26,11 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); + $this->auditRoleUpdate($teamId, Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -43,8 +47,11 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); + $this->auditRoleUpdate($teamId, Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -61,8 +68,11 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); + $this->auditRoleUpdate($teamId, Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -79,8 +89,16 @@ class Member extends Component throw new \Exception('You are not authorized to perform this action.'); } $teamId = currentTeam()->id; - $this->member->teams()->detach(currentTeam()); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + DB::transaction(function () use ($teamId): void { + $this->member->teams()->detach($teamId); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + }); + auditLog('ui.team_member.removed', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + ]); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); @@ -94,4 +112,15 @@ class Member extends Component { return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } + + private function auditRoleUpdate(int $teamId, Role $role): void + { + auditLog('ui.team_member.role_updated', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + 'role' => $role->value, + ]); + } } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e908..116db1eed1 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -47,7 +47,7 @@ class Index extends Component return [ 'name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'), - 'uuid' => data_get($container, 'Names'), + 'uuid' => $server->uuid.':'.data_get($container, 'Names'), 'status' => data_get_str($container, 'State')->lower(), 'server' => $server, 'server_uuid' => $server->uuid, diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index aad02c7801..d548b37249 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -20,6 +20,8 @@ class Upgrade extends Component public bool $devMode = false; + public bool $fullButton = false; + protected $listeners = ['updateAvailable' => 'checkUpdate']; public function mount() diff --git a/app/Models/Application.php b/app/Models/Application.php index fef76cd393..2fa1cff990 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,11 +7,14 @@ use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; +use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -120,7 +123,8 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + /** @use HasFactory */ + use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -378,6 +382,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->secretManagerLink()->delete(); foreach ($application->scheduled_tasks as $task) { $task->delete(); } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; +use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -44,6 +45,44 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + protected static function booted(): void + { + static::created(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() || ! $deployment->rollback) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.application.rollback", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + 'commit' => $deployment->commit, + ]); + }); + + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() + || ! $deployment->wasChanged('status') + || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.deployment.cancelled", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + }); + } + protected $fillable = [ 'application_id', 'deployment_uuid', diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6998211eea..0905242753 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; +use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel @@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel 'pull_request_id' => 'integer', ]; - protected static function booted() + protected static function booted(): void { - static::forceDeleting(function ($preview) { + static::forceDeleting(function (ApplicationPreview $preview): void { $server = $preview->application->destination->server; $application = $preview->application; @@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel }); } else { // Regular application volume cleanup - $persistentStorages = $preview->persistentStorages()->get() ?? collect(); - if ($persistentStorages->count() > 0) { - foreach ($persistentStorages as $storage) { - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $application->persistentStorages() + ->get() + ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path) + && $storage->is_preview_suffix_enabled); + + foreach ($persistentStorages as $storage) { + $volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id); + try { + instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server); + } catch (RuntimeException $exception) { + if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) { + throw $exception; + } } } } diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..2383dee267 --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,211 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder + { + return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void { + $query->where('team_id', $teamId) + ->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id')); + }); + } + + public function scopeFiltered( + Builder $query, + string $search = '', + string $action = 'all', + string $source = 'all', + bool $searchSensitiveFields = true, + ): Builder { + return $query + ->when($action !== 'all', fn (Builder $query) => $query->where('action', $action)) + ->when($source !== 'all', fn (Builder $query) => $query->where('source', $source)) + ->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where(function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where('event', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%")); + }); + }); + } + + public function scopeLatestFirst(Builder $query): Builder + { + return $query->latest('created_at')->latest('id'); + } + + /** + * @param array $context + */ + public static function record(string $event, array $context = []): void + { + try { + $attributes = self::attributesFor($event, $context); + + DB::afterCommit(function () use ($attributes): void { + defer(function () use ($attributes): void { + try { + self::query()->create($attributes); + } catch (Throwable $exception) { + Log::warning('Audit event persistence failed', [ + 'event' => $attributes['event'], + 'exception' => $exception::class, + ]); + } + })->always(); + }); + } catch (Throwable $exception) { + Log::warning('Audit event preparation failed', [ + 'event' => $event, + 'exception' => $exception::class, + ]); + } + } + + /** + * @param array $context + * @return array + */ + private static function attributesFor(string $event, array $context): array + { + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') + ?? data_get($context, 'team_id') + ?? currentTeam()?->id + ?? self::teamIdFromContext($context); + + $parts = explode('.', $event); + $source = $parts[0] ?? 'system'; + $resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null); + $action = data_get($context, 'action') ?? (end($parts) ?: 'event'); + $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); + $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $actorType = match (true) { + in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, + $token !== null => 'api_token', + $user !== null => 'user', + default => 'system', + }; + + return [ + 'team_id' => $teamId, + 'event' => $event, + 'source' => $source, + 'action' => $action, + 'actor_type' => $actorType, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + 'actor_token_id' => $token?->id, + 'actor_token_name' => $token?->name, + 'resource_type' => $resourceType, + 'resource_uuid' => $resourceUuid, + 'resource_name' => $resourceName, + 'description' => data_get($context, 'audit_description') + ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), + 'metadata' => self::redact($context), + 'ip_address' => app()->bound('request') ? request()->ip() : null, + 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, + ]; + } + + /** + * @param array $context + */ + private static function teamIdFromContext(array $context): ?int + { + $applicationUuid = data_get($context, 'application_uuid'); + if (! is_string($applicationUuid) || $applicationUuid === '') { + return null; + } + + return Application::query() + ->where('uuid', $applicationUuid) + ->first()?->team()?->id; + } + + public static function pruneExpired(): int + { + return self::query() + ->where('created_at', '<', now()->subDays(90)) + ->delete(); + } + + /** + * @param array $context + */ + private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed + { + if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { + return data_get($context, $preferredKey); + } + + $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); + + return $key ? data_get($context, $key) : null; + } + + private static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { + return '[REDACTED]'; + } + + if (! is_array($value)) { + return $value; + } + + return collect($value) + ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ + $itemKey => self::redact($item, (string) $itemKey), + ]) + ->all(); + } +} diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 7368bafbf0..814d053395 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model 'smtp_username', 'smtp_password', 'smtp_timeout', + 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,8 +22,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..e7dd8564bc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -34,6 +35,8 @@ use OpenApi\Attributes as OA; )] class EnvironmentVariable extends BaseModel { + use Auditable; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; protected $attributes = [ @@ -249,17 +252,21 @@ class EnvironmentVariable extends BaseModel protected function isShared(): Attribute { return Attribute::make( - get: function () { - $type = str($this->value)->after('{{')->before('.')->value; - if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) { - return true; - } - - return false; - } + get: fn () => $this->isSharedReference(), ); } + private function isSharedReference(): bool + { + if (blank($this->value)) { + return false; + } + + $types = implode('|', SHARED_VARIABLE_TYPES); + + return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; + } + public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource, $server); @@ -302,6 +309,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); @@ -389,8 +413,6 @@ class EnvironmentVariable extends BaseModel protected function updateIsShared(): void { - $type = str($this->value)->after('{{')->before('.')->value; - $isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}'); - $this->is_shared = $isShared; + $this->is_shared = $this->isSharedReference(); } } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 7c2f8c0628..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -2,10 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + use Auditable; + + public function delete(): ?bool + { + return DB::transaction(fn () => parent::delete()); + } + protected $fillable = [ 'team_id', 'private_key_id', diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 09a48e8b91..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\Crypt; class GitlabApp extends BaseModel { + use Auditable; + protected $fillable = [ 'name', 'organization', @@ -100,6 +103,7 @@ class GitlabApp extends BaseModel if ($gitlabApp->applications()->count() > 0) { throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); } + }); } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 35683fbf4a..02f3e7ed50 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -34,6 +34,7 @@ class InstanceSettings extends Model 'smtp_username', 'smtp_password', 'smtp_timeout', + 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', 'is_dns_validation_enabled', diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 20541f6139..53b4dd6f4a 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -3,15 +3,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class IntegrationToken extends BaseModel { + public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault']; + + public const PROVIDER_NAMES = [ + 'cloudflare' => 'Cloudflare', + 'doppler' => 'Doppler', + 'infisical' => 'Infisical', + 'vault' => 'HashiCorp Vault', + ]; + protected $fillable = [ 'team_id', 'provider', 'name', 'token', 'capabilities', + 'metadata', ]; protected $hidden = [ @@ -23,6 +34,7 @@ class IntegrationToken extends BaseModel return [ 'token' => 'encrypted', 'capabilities' => 'array', + 'metadata' => 'array', ]; } @@ -31,6 +43,34 @@ class IntegrationToken extends BaseModel return $this->belongsTo(Team::class); } + public function secretManagerLinks(): HasMany + { + return $this->hasMany(SecretManagerLink::class); + } + + public function isSecretManager(): bool + { + return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); + } + + public function providerName(): string + { + return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); + } + + public function dopplerTokenType(): ?string + { + if ($this->provider !== 'doppler') { + return null; + } + + return match (true) { + str_starts_with($this->token, 'dp.st.') => 'service', + str_starts_with($this->token, 'dp.sa.') => 'service_account', + default => null, + }; + } + public static function ownedByCurrentTeam() { return self::query()->where('team_id', currentTeam()->id); diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasFactory, HasSafeStringAttribute, WithRateLimiting; + use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..65c21c1e78 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,8 +21,8 @@ use OpenApi\Attributes as OA; )] class Project extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ @@ -63,7 +64,9 @@ class Project extends BaseModel ]); }); static::deleting(function ($project) { - $project->environments()->delete(); + foreach ($project->environments()->get() as $environment) { + $environment->delete(); + } $project->settings()->delete(); $shared_variables = $project->environment_variables(); foreach ($shared_variables as $shared_variable) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { - use HasFactory, HasSafeStringAttribute; + use Auditable, HasFactory, HasSafeStringAttribute; private const CONNECTION_TIMEOUT_SECONDS = 15; diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + 'array', + ]; + } + + public function resourceable(): MorphTo + { + return $this->morphTo(); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + /** + * Fetch the secrets from the remote manager. Values live only in memory. + * + * @return array + */ + public function fetchSecrets(): array + { + $token = $this->integrationToken; + $settings = $this->settings ?? []; + $metadata = $token->metadata ?? []; + + return match ($token->provider) { + 'doppler' => (new DopplerService($token->token))->fetchSecrets( + data_get($settings, 'project'), + data_get($settings, 'config'), + ), + 'infisical' => (new InfisicalService( + data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token->token, + ))->fetchSecrets( + (string) data_get($settings, 'project_id'), + (string) data_get($settings, 'environment'), + (string) data_get($settings, 'secret_path', '/'), + ), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token->token, + data_get($metadata, 'namespace'), + ))->fetchSecrets( + (string) data_get($settings, 'mount', 'secret'), + (string) data_get($settings, 'path'), + ), + default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."), + }; + } + + /** + * Create one {{vault.KEY}} reference variable per remote key that has no + * variable with that key yet. Only key names touch the database. + * + * @return list The keys that were imported + */ + public function importMissingReferences(): array + { + $keys = array_keys($this->fetchSecrets()); + sort($keys); + + $existing = $this->resourceable->environment_variables()->pluck('key')->flip(); + $imported = []; + + foreach ($keys as $key) { + if (isset($existing[$key])) { + continue; + } + + $this->resourceable->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $imported[] = $key; + } + + return $imported; + } + + /** Short human-readable description of the remote source for the UI. */ + public function sourceSummary(): string + { + $settings = $this->settings ?? []; + + return match ($this->integrationToken->provider) { + 'doppler' => trim(implode('/', array_filter([ + data_get($settings, 'project'), + data_get($settings, 'config'), + ])), '/') ?: 'token scope', + 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'), + 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'), + default => '', + }; + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 9736406428..738bcbfec5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..429422b90e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,8 +4,10 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +45,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -1631,7 +1633,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$env->real_value}"); + $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { $commands[] = 'touch .env'; diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model { + use Auditable; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..6265345ee9 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..da4804dd2d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..f4dbaec210 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..c923b489bd 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..70b108087a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..6a08a4dc45 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..f8dc5c0caa 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..3bfcc5434e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..12998be165 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', @@ -86,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen } // Transfer instance-wide sources to root team so they remain available - GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); - GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() + ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); + foreach ($systemWideSources as $source) { + $source->update(['team_id' => 0]); + } // Delete non-instance-wide sources owned by this team $teamSources = GithubApp::where('team_id', $team->id)->get() diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index c322982ede..4258a82b8a 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -33,11 +33,9 @@ class TeamInvitation extends Model return TeamInvitation::whereTeamId(currentTeam()->id); } - public function isValid() + public function isValid(): bool { - $createdAt = $this->created_at; - $diff = $createdAt->diffInDays(now()); - if ($diff <= config('constants.invitation.link.expiration_days')) { + if (! $this->hasExpired()) { return true; } else { $this->delete(); @@ -49,4 +47,9 @@ class TeamInvitation extends Model return false; } } + + public function hasExpired(): bool + { + return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days'); + } } diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index abd1155502..fd62a90720 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,9 +4,14 @@ namespace App\Notifications\Channels; use App\Exceptions\NonReportableException; use App\Models\Team; +use App\Support\SmtpTransportFactory; use Exception; use Illuminate\Notifications\Notification; use Resend; +use Resend\Exceptions\ErrorException; +use Resend\Exceptions\TransporterException; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mime\Email; class EmailChannel { @@ -70,43 +75,27 @@ class EmailChannel if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); - $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; $resend->emails->send([ - 'from' => $from, + 'from' => mail_from_formatted($settings), 'to' => $recipients, 'subject' => $mailMessage->subject, 'html' => (string) $mailMessage->render(), ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; - - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( - $settings->smtp_host, - $settings->smtp_port, - $encryption + $transport = SmtpTransportFactory::fromSettings( + $settings, + config('mail.mailers.smtp.local_domain') ); - $transport->setUsername($settings->smtp_username ?? ''); - $transport->setPassword($settings->smtp_password ?? ''); + $mailer = new Mailer($transport); - $mailer = new \Symfony\Component\Mailer\Mailer($transport); - - $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; - $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) - ->from($from) + $email = mail_from_email(new Email, $settings) ->to(...$recipients) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()); $mailer->send($email); } - } catch (\Resend\Exceptions\ErrorException $e) { + } catch (ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -131,13 +120,13 @@ class EmailChannel // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); } - throw new \Exception($userMessage, $e->getCode(), $e); - } catch (\Resend\Exceptions\TransporterException $e) { + throw new Exception($userMessage, $e->getCode(), $e); + } catch (TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 8ab74a60b1..f4e8b294a4 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -30,7 +30,7 @@ class TransactionalEmailChannel Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()) diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 511818e215..cb65391068 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -54,7 +54,10 @@ class ResetPassword extends Notification protected function buildMailMessage($url) { + $from = mail_from_identity($this->settings); $mail = new MailMessage; + $mail->from($from['address'], $from['name']); + $mail->withSymfonyMessage(fn ($message) => prevent_mail_from_header_folding($message, $this->settings)); $mail->subject('Coolify: Reset Password'); $mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]); diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index ba9bade012..e9ef4d7c9e 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -20,7 +20,7 @@ class ApiTokenPolicy */ public function view(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -36,7 +36,7 @@ class ApiTokenPolicy */ public function update(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -44,7 +44,7 @@ class ApiTokenPolicy */ public function delete(User $user, PersonalAccessToken $token): bool { - return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + return $this->belongsToUserAndCurrentTeam($user, $token); } /** @@ -86,4 +86,19 @@ class ApiTokenPolicy { return $user->isAdmin() || $user->isOwner(); } + + private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool + { + if ($user->id !== $token->tokenable_id || $token->tokenable_type !== User::class) { + return false; + } + + $currentTeamId = $user->currentTeam()?->id; + + if ($currentTeamId === null || $token->team_id === null) { + return false; + } + + return (string) $currentTeamId === (string) $token->team_id; + } } diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php index cc7745b648..5b97927601 100644 --- a/app/Policies/TeamPolicy.php +++ b/app/Policies/TeamPolicy.php @@ -53,7 +53,7 @@ class TeamPolicy return false; } - return $user->isAdminOfTeam($team->id); + return $user->roleInTeam($team->id) === 'owner'; } /** diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index bf6fa4c4bf..dfa3bb3314 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($email.'|'.$realIp); }); + RateLimiter::for('magic-link', function (Request $request) { + $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); + $token = (string) $request->input('token'); + + return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp)); + }); + RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); diff --git a/app/Services/ConfigurationRepository.php b/app/Services/ConfigurationRepository.php index ff2e73eed6..ead3e4a21c 100644 --- a/app/Services/ConfigurationRepository.php +++ b/app/Services/ConfigurationRepository.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Support\SmtpTransportFactory; use Illuminate\Config\Repository; +use Illuminate\Support\Facades\Mail; class ConfigurationRepository { @@ -15,40 +17,51 @@ class ConfigurationRepository public function updateMailConfig($settings): void { + $from = mail_from_identity($settings); + if ($settings->resend_enabled) { $this->config->set('mail.default', 'resend'); - $this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); - $this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test'); + $this->applyMailFrom($from); $this->config->set('resend.api_key', $settings->resend_api_key); return; } if ($settings->smtp_enabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; + $mailerOptions = SmtpTransportFactory::mailerOptions($settings); + $localDomain = $settings->smtp_ehlo_domain + ?? $this->config->get('mail.mailers.smtp.local_domain'); $this->config->set('mail.default', 'smtp'); - $this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); - $this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test'); + $this->applyMailFrom($from); $this->config->set('mail.mailers.smtp', [ 'transport' => 'smtp', + 'scheme' => $mailerOptions['scheme'], 'host' => $settings->smtp_host, 'port' => $settings->smtp_port, - 'encryption' => $encryption, + 'encryption' => $mailerOptions['encryption'], 'username' => $settings->smtp_username, 'password' => $settings->smtp_password, 'timeout' => $settings->smtp_timeout, - 'local_domain' => null, - 'auto_tls' => $settings->smtp_encryption === 'none' ? '0' : '', + 'local_domain' => $localDomain, + 'auto_tls' => $mailerOptions['auto_tls'], ]); } } + /** + * @param array{address: string, name: string} $from + */ + private function applyMailFrom(array $from): void + { + $this->config->set('mail.from.address', $from['address']); + $this->config->set('mail.from.name', $from['name']); + + if (app()->bound('mail.manager')) { + Mail::purge(); + } + } + public function disableSshMux(): void { $this->config->set('constants.ssh.mux_enabled', false); diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php new file mode 100644 index 0000000000..dab3599101 --- /dev/null +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -0,0 +1,77 @@ +destination->server; + if ($server->isNonRoot()) { + $commands = parseCommandsByLineForSudo(collect($commands), $server)->all(); + } + + $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') + ? $database->resolvedSecretManagerValuesForRedaction() + : []; + $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + + $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); + $activity->save(); + + $process = Process::timeout(config('constants.ssh.command_timeout')) + ->idleTimeout(3600) + ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void { + $this->appendOutput($activity, $type, $this->redact($output, $secrets)); + }); + + $result = $process->wait(); + $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR; + $activity->properties = $activity->properties->merge([ + 'status' => $status->value, + 'exitCode' => $result->exitCode(), + ]); + $activity->save(); + + if (! $result->successful()) { + throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode()); + } + + return $activity; + } + + private function redact(string $value, array $secrets): string + { + foreach ($secrets as $secret) { + if (is_string($secret) && $secret !== '') { + $value = str_replace($secret, REDACTED, $value); + } + } + + return sanitize_utf8_text(remove_iip($value)); + } + + private function appendOutput(Activity $activity, string $type, string $output): void + { + if ($output === '') { + return; + } + + $entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR); + $entries[] = [ + 'type' => $type, + 'output' => $output, + 'timestamp' => hrtime(true), + 'batch' => 1, + 'order' => count($entries) + 1, + ]; + $activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $activity->save(); + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 9833b5be45..ace4888338 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,28 +17,8 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; - /** - * Defaults for fields introduced after configuration snapshots were first - * stored. Older snapshots omitted these keys, which should not make an - * unchanged default look like a pending configuration change. - * - * @var array - */ - private const INTRODUCED_DEFAULTS = [ - 'build.is_static' => false, - 'build.is_spa' => false, - 'build.is_git_submodules_enabled' => true, - 'build.is_git_lfs_enabled' => true, - 'build.is_git_shallow_clone_enabled' => true, - 'build.is_env_sorting_enabled' => [false, true], - 'runtime.is_consistent_container_name_enabled' => false, - 'runtime.is_container_label_escape_enabled' => true, - 'runtime.is_container_label_readonly_enabled' => true, - 'runtime.is_log_drain_enabled' => false, - 'runtime.is_swarm_only_worker_nodes' => true, - 'runtime.is_preserve_repository_enabled' => false, - 'domains.noindex_domains' => [], - ]; + /** @var array */ + private const DYNAMIC_SECTIONS = ['environment', 'storage']; /** * @param array $previousSnapshot @@ -59,11 +39,7 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; - if ( - $previous === null - && array_key_exists($key, self::INTRODUCED_DEFAULTS) - && $this->matchesIntroducedDefault($key, data_get($current, 'compare_value')) - ) { + if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { continue; } @@ -127,17 +103,6 @@ class ConfigurationDiffer return ConfigurationDiff::fromChanges($changes); } - private function matchesIntroducedDefault(string $key, mixed $value): bool - { - $default = self::INTRODUCED_DEFAULTS[$key]; - - if (is_array($default) && $default !== [] && array_is_list($default)) { - return in_array($value, $default, true); - } - - return $value === $default; - } - /** * Reduce two multi-line values to only the lines that differ, so the modal * shows just the changed container labels instead of the whole block. diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..06f1e5d49f --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,89 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..e41652cd54 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,68 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 2c1de948ba..84e629fe1a 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,8 +90,11 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { + $requireStatementBoundary = true; + if (str_starts_with($sql, 'PGDMP')) { - return false; + $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; + $requireStatementBoundary = false; } $withoutComments = self::stripSqlComments($sql); @@ -100,7 +103,9 @@ class DatabaseBackupFileValidator return true; } - return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; + + return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index 2e6da7868c..c86d5fa9a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -4,11 +4,11 @@ namespace App\Support; class DomainUrlParts { - public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string + public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string { $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $host = trim($host); - $port = trim($port); + $port = trim((string) $port); $path = trim($path); if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { diff --git a/app/Support/RemoteSecretReferences.php b/app/Support/RemoteSecretReferences.php new file mode 100644 index 0000000000..530c29a28c --- /dev/null +++ b/app/Support/RemoteSecretReferences.php @@ -0,0 +1,64 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is — collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Support/SmtpTransportFactory.php b/app/Support/SmtpTransportFactory.php new file mode 100644 index 0000000000..43a0c5c065 --- /dev/null +++ b/app/Support/SmtpTransportFactory.php @@ -0,0 +1,68 @@ +smtp_host, + (int) $settings->smtp_port, + match ($mode) { + 'none' => false, + 'tls' => true, + default => null, + } + ); + + if ($mode === 'none') { + $transport->setAutoTls(false); + } + + $transport->setUsername($settings->smtp_username ?? ''); + $transport->setPassword($settings->smtp_password ?? ''); + + $localDomain = $settings->smtp_ehlo_domain ?? $localDomain; + if ($localDomain !== null && $localDomain !== '') { + $transport->setLocalDomain($localDomain); + } + + $stream = $transport->getStream(); + if (isset($settings->smtp_timeout) && $stream instanceof SocketStream) { + $stream->setTimeout((float) $settings->smtp_timeout); + } + + return $transport; + } + + /** + * @return array{scheme: ?string, encryption: ?string, auto_tls: string} + */ + public static function mailerOptions(object $settings): array + { + $mode = self::encryptionMode($settings); + + return [ + 'scheme' => match ($mode) { + 'none', 'starttls' => 'smtp', + 'tls' => 'smtps', + default => null, + }, + 'encryption' => $mode === 'tls' ? 'tls' : null, + 'auto_tls' => $mode === 'none' ? '0' : '', + ]; + } + + private static function encryptionMode(object $settings): ?string + { + return $settings->smtp_encryption === null + ? null + : strtolower((string) $settings->smtp_encryption); + } +} diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..878d46c1e4 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,102 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! $this->auditLoggingEnabled || ! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, [ + 'updated_at', + 'order', + 'status', + ...($this->auditExclude ?? []), + ], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + public function withoutAuditLogging(Closure $callback): mixed + { + $wasAuditLoggingEnabled = $this->auditLoggingEnabled; + $this->auditLoggingEnabled = false; + + try { + return $callback(); + } finally { + $this->auditLoggingEnabled = $wasAuditLoggingEnabled; + } + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a2c3d06da9..b8ff5df14b 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,13 @@ trait ExecuteRemoteCommand ); } + if (isset($this->remote_secrets_cache)) { + $lockedVars = $lockedVars->merge(array_values(array_filter( + $this->remote_secrets_cache, + static fn (mixed $value): bool => is_string($value) && $value !== '' + ))); + } + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php new file mode 100644 index 0000000000..d267a8b1b2 --- /dev/null +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -0,0 +1,19 @@ +execute($commands, $database, $activity); + } + + return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..8b3e50b7bd --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,107 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + return $this->formatEnvironmentVariableValue($environmentVariable, $value); + } + + public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { + if ($value === null) { + return null; + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + if ($value === null) { + return null; + } + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + return $value; + } + + public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool + { + return RemoteSecretReferences::containsReference( + $this->resolvedEnvironmentVariableValue($environmentVariable), + ); + } + + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + return $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } + + /** @return array */ + public function resolvedSecretManagerValuesForRedaction(): array + { + return $this->resolvedSecretManagerValues ?? []; + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..1b46ca2dd5 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + throw new \RuntimeException('Unable to fetch secret manager keys.'); + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..2fb0bb3f53 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,15 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index a60ba675be..00300d26a2 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -166,11 +166,13 @@ function format_docker_labels_to_json(string|array $rawOutput): Collection $outputArray = explode(',', $outputLine); return collect($outputArray) - ->map(function ($outputLine) { - return explode('=', $outputLine); - }) ->mapWithKeys(function ($outputLine) { - return [$outputLine[0] => $outputLine[1]]; + $label = explode('=', $outputLine, 2); + if (count($label) !== 2) { + return []; + } + + return [$label[0] => $label[1]]; }); })[0]; } @@ -267,7 +269,28 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string { $container = escapeShellValue($container); - $script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code"; + $script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + +function dockerRemoveCommand(string $container): string +{ + $command = 'docker rm -f '.escapeShellValue($container); + + return dockerCommandIgnoringError($command, 'No such container:'); +} + +function dockerNetworkRemoveCommand(string $network): string +{ + $command = 'docker network rm '.escapeShellValue($network); + + return dockerCommandIgnoringError($command, 'network .* not found'); +} + +function dockerCommandIgnoringError(string $command, string $ignoredError): string +{ + $script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; return 'bash -c '.escapeShellValue($script); } @@ -891,7 +914,6 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, - escape_redirect_replacement_for_compose: false, )); break; } diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index f64535ea82..e76487383d 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -5,6 +5,8 @@ use App\Notifications\Internal\GeneralNotification; use Illuminate\Mail\Message; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Mail; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; function is_transactional_emails_enabled(): bool { @@ -13,6 +15,63 @@ function is_transactional_emails_enabled(): bool return $settings->smtp_enabled || $settings->resend_enabled; } +/** + * @return array{address: string, name: string} + */ +function mail_from_identity(object $settings): array +{ + if (blank($settings->smtp_from_address ?? null)) { + throw new InvalidArgumentException('Transactional email sender address is not configured.'); + } + + $address = (string) $settings->smtp_from_address; + + $name = trim((string) ($settings->smtp_from_name ?? '')); + + return [ + 'address' => $address, + 'name' => $name !== '' ? $name : 'Coolify', + ]; +} + +function mail_from_address(object $settings): Address +{ + $identity = mail_from_identity($settings); + + return new Address($identity['address'], $identity['name']); +} + +function mail_from_formatted(object $settings): string +{ + return mail_from_address($settings)->toString(); +} + +function mail_from_email(Email $email, object $settings): Email +{ + $email->from(mail_from_address($settings)); + prevent_mail_from_header_folding($email, $settings); + + return $email; +} + +function mail_from_message(Message $message, object $settings): Message +{ + $identity = mail_from_identity($settings); + $message->from($identity['address'], $identity['name']); + prevent_mail_from_header_folding($message->getSymfonyMessage(), $settings); + + return $message; +} + +function prevent_mail_from_header_folding(Email $email, object $settings): void +{ + if (strtolower(trim((string) ($settings->smtp_host ?? ''))) !== 'smtp.protonmail.ch') { + return; + } + + $email->getHeaders()->get('From')?->setMaxLineLength(998); +} + function send_internal_notification(string $message): void { try { @@ -33,7 +92,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->replyTo($email) ->cc($cc) @@ -44,7 +103,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => $message + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mail->subject) ->html((string) $mail->render()) diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 31e55da336..fe639950be 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -123,7 +123,7 @@ function connectProxyToNetworks(Server $server) } return collect([ - 'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', + 'for network in $(docker inspect $(docker ps -a --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', ' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then', ' continue', ' fi', diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 9cabe84f1b..07fdeb086f 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -233,7 +233,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) // IMPORTANT: Only extract variables that are DIRECTLY DECLARED for this service, // not variables that are merely referenced from other services $serviceConfig = data_get($dockerCompose, "services.{$name}"); - $environment = data_get($serviceConfig, 'environment', []); + $environment = data_get($serviceConfig, 'environment') ?? []; $templateVariableNames = []; foreach ($environment as $key => $value) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 6d7c312b85..8d0ab9b8c5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2068,7 +2068,7 @@ function validateDNSEntry(string $fqdn, Server $server) $type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { - $query = new DNSQuery($dns_server); + $query = createDnsQuery($dns_server); $results = $query->query($host, $type); if ($results === false || $query->hasError()) { } else { @@ -2076,11 +2076,11 @@ function validateDNSEntry(string $fqdn, Server $server) if ($result->getType() == $type) { if (isCloudflareIp($result->getData())) { $found_matching_ip = true; - break; + break 2; } if ($ip && $result->getData() === $ip) { $found_matching_ip = true; - break; + break 2; } } } @@ -2092,6 +2092,15 @@ function validateDNSEntry(string $fqdn, Server $server) return $found_matching_ip; } +function createDnsQuery(string $dnsServer): DNSQuery +{ + return app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => 5, + ]); +} + function isCloudflareIp(string $ip): bool { // https://www.cloudflare.com/ips/ @@ -4143,11 +4152,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/composer.json b/composer.json index 65d6d39fbf..5f51f4da1a 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index 18a1bf9b90..aa82aa12e8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3a54b3e333f74090e8222240cf6db305", + "content-hash": "ebe25735ad92bf75fc528fc0de1d894b", "packages": [ { "name": "aws/aws-crt-php", @@ -14543,80 +14543,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -15662,72 +15588,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/config/constants.php b/config/constants.php index 4e73bd09c1..44c2e7c7f6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.9', + 'version' => env('COOLIFY_VERSION') ?: '4.3.11', 'helper_version' => '1.0.15', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), @@ -69,7 +69,6 @@ return [ 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), - 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds diff --git a/config/development-qemu.php b/config/development-qemu.php new file mode 100644 index 0000000000..ae77c8341e --- /dev/null +++ b/config/development-qemu.php @@ -0,0 +1,124 @@ + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68', + 'storage_path' => env('DEVELOPMENT_QEMU_STORAGE_PATH', '/var/lib/libvirt/images/coolify-development'), + 'gateway' => '192.168.122.1', + 'subnet' => '192.168.122.0/24', + 'prefix' => 24, + 'dns' => '1.1.1.1', + 'memory' => 2048, + 'vcpus' => 2, + 'disk_size' => '20G', + 'libvirt_network' => 'default', + 'docker_network' => 'coolify', + 'coolify_container' => 'coolify', + 'profiles' => [ + 'ubuntu-root' => [ + 'label' => 'Ubuntu 24.04 (root)', + 'domain' => 'coolify-dev-ubuntu-root', + 'uuid' => 'development-qemu-ubuntu-root', + 'name' => 'QEMU Ubuntu (root)', + 'ip' => '192.168.122.10', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:01', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'ubuntu-non-root' => [ + 'label' => 'Ubuntu 24.04 (non-root)', + 'domain' => 'coolify-dev-ubuntu-non-root', + 'uuid' => 'development-qemu-ubuntu-non-root', + 'name' => 'QEMU Ubuntu (non-root)', + 'ip' => '192.168.122.11', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:02', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'debian-root' => [ + 'label' => 'Debian 12 (root)', + 'domain' => 'coolify-dev-debian-root', + 'uuid' => 'development-qemu-debian-root', + 'name' => 'QEMU Debian (root)', + 'ip' => '192.168.122.20', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:03', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'debian-non-root' => [ + 'label' => 'Debian 12 (non-root)', + 'domain' => 'coolify-dev-debian-non-root', + 'uuid' => 'development-qemu-debian-non-root', + 'name' => 'QEMU Debian (non-root)', + 'ip' => '192.168.122.21', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:04', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'centos-root' => [ + 'label' => 'CentOS Stream 9 (root)', + 'domain' => 'coolify-dev-centos-root', + 'uuid' => 'development-qemu-centos-root', + 'name' => 'QEMU CentOS Stream (root)', + 'ip' => '192.168.122.30', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:05', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'centos-non-root' => [ + 'label' => 'CentOS Stream 9 (non-root)', + 'domain' => 'coolify-dev-centos-non-root', + 'uuid' => 'development-qemu-centos-non-root', + 'name' => 'QEMU CentOS Stream (non-root)', + 'ip' => '192.168.122.31', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:06', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'alpine-root' => [ + 'label' => 'Alpine Linux 3.24 (root)', + 'domain' => 'coolify-dev-alpine-root', + 'uuid' => 'development-qemu-alpine-root', + 'name' => 'QEMU Alpine (root)', + 'ip' => '192.168.122.40', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:07', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + 'alpine-non-root' => [ + 'label' => 'Alpine Linux 3.24 (non-root)', + 'domain' => 'coolify-dev-alpine-non-root', + 'uuid' => 'development-qemu-alpine-non-root', + 'name' => 'QEMU Alpine (non-root)', + 'ip' => '192.168.122.41', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:08', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + ], +]; diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index ded507c56d..188d32954b 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -2,8 +2,12 @@ namespace Database\Factories; +use App\Models\Application; use Illuminate\Database\Eloquent\Factories\Factory; +/** + * @extends Factory + */ class ApplicationFactory extends Factory { public function definition(): array diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php b/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php new file mode 100644 index 0000000000..8a421156f2 --- /dev/null +++ b/database/migrations/2026_08_19_090916_add_smtp_ehlo_domain_to_email_settings.php @@ -0,0 +1,36 @@ +string('smtp_ehlo_domain')->nullable(); + }); + + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->string('smtp_ehlo_domain')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('smtp_ehlo_domain'); + }); + + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('smtp_ehlo_domain'); + }); + } +}; diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..0ace21f229 --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,45 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('created_at'); + $table->index(['team_id', 'created_at', 'id']); + $table->index(['team_id', 'action', 'created_at', 'id']); + $table->index(['team_id', 'source', 'created_at', 'id']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php b/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php new file mode 100644 index 0000000000..1d6d5694d4 --- /dev/null +++ b/database/migrations/2026_08_20_070831_promote_first_team_member_when_team_has_no_owner.php @@ -0,0 +1,52 @@ +select('teams.id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('team_user as owners') + ->whereColumn('owners.team_id', 'teams.id') + ->where('owners.role', 'owner'); + }) + ->orderBy('teams.id') + ->chunkById(100, function ($teams): void { + foreach ($teams as $team) { + $firstMember = DB::table('team_user') + ->where('team_id', $team->id) + ->orderByRaw("CASE WHEN role = 'admin' THEN 0 ELSE 1 END") + ->orderBy('created_at') + ->orderBy('id') + ->first(); + + if ($firstMember === null) { + continue; + } + + DB::table('team_user') + ->where('id', $firstMember->id) + ->update([ + 'role' => 'owner', + 'updated_at' => now(), + ]); + } + }, 'teams.id', 'id'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // This data migration cannot identify which owners were promoted safely. + } +}; diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..744697628f --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,36 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('resourceable_type'); + $table->unsignedBigInteger('resourceable_id'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeebe..94330bbcec 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index 8769d62d9d..61f82f6265 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -20,7 +20,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index bf863099b4..d3b639ba5f 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -34,6 +34,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index bc0841e1ef..9487125cc4 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -226,6 +226,9 @@ done log "All images pulled successfully" echo " All images pulled successfully." +set_env_var "LATEST_IMAGE" "$LATEST_IMAGE" +set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE" + log_section "Step 4/6: Stopping and restarting containers" write_status "4" "Stopping containers" echo "" diff --git a/other/nightly/versions.json b/other/nightly/versions.json index d868ff224e..0e62c0b7e4 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.9" + "version": "4.3.11" }, "nightly": { - "version": "4.3.10" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15" diff --git a/resources/css/app.css b/resources/css/app.css index 9dc39b7cc5..30c9147f44 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1870,6 +1870,61 @@ html[data-theme="custom"] textarea:disabled { flex-shrink: 0; } +.split-action { + display: inline-flex; +} + +.split-action-main, +.split-action-caret { + @apply button-highlighted; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.split-action-main { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.625rem; + border-radius: 6px 0 0 6px; +} + +.split-action-caret { + flex-shrink: 0; + width: 1.75rem; + border-left: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0 6px 6px 0; +} + +.split-action > .split-action-main:only-of-type { + border-radius: 6px; +} + +.split-action-main:disabled, +.split-action-caret:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.split-action-main:focus-visible, +.split-action-caret:focus-visible { + outline: none; + position: relative; + z-index: 1; + box-shadow: 0 0 0 1px var(--color-accent); +} + +.application-heading-actions .split-action-main, +.application-heading-actions .split-action-caret { + height: 1.75rem; +} + /* Custom listbox (replaces native - - - diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php new file mode 100644 index 0000000000..d31fac0bca --- /dev/null +++ b/resources/views/components/forms/copy-input.blade.php @@ -0,0 +1,15 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 378a3947e3..41a29fbbdb 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -20,13 +20,32 @@ cursorPosition: 0, currentScope: null, availableVars: @js($availableVars), + hasVaultSource: @js($hasVaultSource), + vaultKeysLoading: false, get availableScopes() { // Only include scopes that have at least one variable const allScopes = ['team', 'project', 'environment', 'server']; - return allScopes.filter(scope => { + const scopes = allScopes.filter(scope => { const vars = this.availableVars[scope]; return vars && vars.length > 0; }); + // The vault scope is offered whenever a secret manager source is + // configured; its keys are fetched lazily on first use. + if (this.hasVaultSource) { + scopes.push('vault'); + } + return scopes; + }, + loadVaultKeys() { + if (this.vaultKeysLoading) return; + this.vaultKeysLoading = true; + this.$wire.fetchSecretManagerKeys().then(keys => { + this.availableVars['vault'] = keys || []; + this.vaultKeysLoading = false; + this.handleInput(); + }).catch(() => { + this.vaultKeysLoading = false; + }); }, scopeUrls: @js($scopeUrls), @@ -84,6 +103,15 @@ } this.currentScope = scope; + + // Vault keys are fetched from the secret manager on first use. + if (scope === 'vault' && this.availableVars['vault'] === undefined) { + this.loadVaultKeys(); + this.suggestions = []; + this.showDropdown = true; + return; + } + const scopeVars = this.availableVars[scope] || []; const filtered = scopeVars.filter(v => v.toLowerCase().includes((partial || '').toLowerCase()) @@ -214,6 +242,7 @@ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" + wire:target.except="fetchSecretManagerKeys" @disabled($disabled) @if ($type !== 'password') type="{{ $type }}" @@ -236,7 +265,14 @@
-