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 ccbd141295..0000000000 --- a/.github/workflows/coolify-staging-build.yml +++ /dev/null @@ -1,134 +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/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 - -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/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/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/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/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/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/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/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/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/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index d6bd6e54bf..5a978ac84f 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) @@ -148,7 +151,10 @@ 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); $token->delete(); $this->getTokens(); 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/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..0868bdf9cd 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -12,6 +12,7 @@ use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -120,7 +121,10 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + + /** @use HasFactory */ + use HasFactory; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; 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/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/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/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/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/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index a60ba675be..210c84a86a 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -891,7 +891,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/shared.php b/bootstrap/helpers/shared.php index 6d7c312b85..461e7c2669 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/ diff --git a/config/constants.php b/config/constants.php index e0aefe8e2b..a406dd0ea7 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.10', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', @@ -71,7 +71,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/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/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/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 94fb77607f..c5f38df6e0 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -216,6 +216,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 92a88ea023..440ad36160 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.9" + "version": "4.3.10" }, "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..30ccf4ac02 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2121,6 +2121,36 @@ input[type="search"]::-webkit-search-results-decoration { width: 100%; } +.validation-installation-logs { + border: 1px solid var(--coollabs-fill); +} + +.checkpoint-scroll-fade::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 1; + width: 2rem; + background: linear-gradient(to left, var(--coollabs-base), transparent); + pointer-events: none; +} + +@media (max-width: 639px) { + .process-dialog-mobile-fullscreen { + height: 100dvh !important; + min-height: 100dvh; + max-height: 100dvh; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important; + } + + .process-dialog-mobile-fullscreen .process-dialog-body { + border-radius: 0; + } +} + /* Data table (layer-card body, full-bleed) */ .data-table-header { display: grid; diff --git a/resources/views/components/icon-tooltip.blade.php b/resources/views/components/icon-tooltip.blade.php index f492d971b6..de62e8813d 100644 --- a/resources/views/components/icon-tooltip.blade.php +++ b/resources/views/components/icon-tooltip.blade.php @@ -38,11 +38,11 @@ this.visible = true; const rect = target.getBoundingClientRect(); this.below = rect.top < 48; - this.x = rect.left; + this.x = rect.left + rect.width / 2; this.y = this.below ? rect.bottom + 8 : rect.top - 8; this.$nextTick(() => { const width = this.$refs.tooltip?.offsetWidth || 0; - this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x)); + this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x - width / 2)); this.$nextTick(() => this.positioned = true); }); }, diff --git a/resources/views/components/process-dialog.blade.php b/resources/views/components/process-dialog.blade.php index dc4f9c3b59..193e5680ce 100644 --- a/resources/views/components/process-dialog.blade.php +++ b/resources/views/components/process-dialog.blade.php @@ -1,5 +1,6 @@ @props([ 'closeWithX' => false, + 'mobileFullscreen' => false, 'open' => false, 'size' => 'lg', ]) @@ -40,7 +41,11 @@
+ @class([ + 'flex min-h-full items-center justify-center', + 'p-4 sm:p-6' => ! $mobileFullscreen, + 'p-0 sm:p-6' => $mobileFullscreen, + ])>
$mobileFullscreen, $panelWidth, // Fixed shell size so empty “waiting for process” state does not collapse. 'min-h-[min(70dvh,28rem)] h-[min(85dvh,52rem)] max-h-[calc(100dvh-2rem)]', diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 41cbd7ce9d..46e433e8a5 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -70,6 +70,7 @@ 'icon' => 'shield-star', 'group' => 'Platform', 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server), + 'warning' => $server->isSentinelEnabled() && ! $server->isSentinelLive(), 'children' => [ ['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'], ['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'], diff --git a/resources/views/components/server/status-summary.blade.php b/resources/views/components/server/status-summary.blade.php index 7a5522b541..5e732fade8 100644 --- a/resources/views/components/server/status-summary.blade.php +++ b/resources/views/components/server/status-summary.blade.php @@ -6,7 +6,10 @@ @php $serverReady = $server->isFunctional(); - $proxyNeedsAttention = $server->proxySet() && ! in_array($proxyStatus, ['running'], true); + $proxyUpdateAvailable = $server->proxySet() + && ($server->hasCurrentTraefikOutdatedInfo() || $server->hasPendingProxyConfiguration()); + $proxyNeedsAttention = $server->proxySet() + && (! in_array($proxyStatus, ['running'], true) || $proxyUpdateAvailable); $sentinelNeedsAttention = $showSentinelStatus && ! $server->isSentinelLive(); [$summaryLabel, $summaryType] = match (true) { @@ -58,9 +61,9 @@ class="listbox-option gap-2.5!" @click="open = false" role="menuitem"> $proxyStatus === 'running', - 'bg-warning' => in_array($proxyStatus, ['starting', 'restarting', 'stopping'], true), - 'bg-error' => ! in_array($proxyStatus, ['running', 'starting', 'restarting', 'stopping'], true), + 'bg-success' => $proxyStatus === 'running' && ! $proxyUpdateAvailable, + 'bg-warning' => $proxyNeedsAttention && ($proxyUpdateAvailable || in_array($proxyStatus, ['starting', 'restarting', 'stopping'], true)), + 'bg-error' => $proxyNeedsAttention && ! $proxyUpdateAvailable && ! in_array($proxyStatus, ['starting', 'restarting', 'stopping'], true), ])> Proxy {{ str($proxyStatus ?: 'unknown')->headline() }} @@ -69,7 +72,11 @@ @if ($showSentinelStatus) - + ! $sentinelNeedsAttention, + 'bg-warning' => $sentinelNeedsAttention, + ])> Sentinel {{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }} diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index 8b9d42e73e..b176f4f219 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -16,13 +16,19 @@ themeColor: localStorage.getItem('themeColor') || '#6b16ed', themeColorFrame: null, avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null), + openPanel() { + this.appearanceOpen = false; + this.open = true; + }, + closePanel() { + this.open = false; + }, setTheme(type, closeMenu = true) { this.theme = type; localStorage.setItem('theme', type); if (closeMenu) { - this.appearanceOpen = false; - this.open = false; + this.closePanel(); } const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; @@ -62,9 +68,9 @@ localStorage.setItem('themeColor', color); localStorage.setItem('theme', 'custom'); }, -}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false" - @click.outside="open = false; appearanceOpen = false"> -
diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php index d89e8b891f..112e052094 100644 --- a/resources/views/livewire/server/index.blade.php +++ b/resources/views/livewire/server/index.blade.php @@ -33,7 +33,8 @@ && $server->settings->is_usable && ! $server->settings->force_disabled && ! $isTransferredAway; - $proxyNeedsAttention = $isReady && $server->proxySet() && $server->proxy->status !== 'running'; + $proxyNeedsAttention = $isReady && $server->proxySet() + && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); $sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive(); $status = match (true) { diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d2d28f0955..1ee4279bd4 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -184,7 +184,7 @@
@endif - + Validate and configure @else
+ class="shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">

Validation checkpoints

-
- @foreach ($checkpoints as $checkpoint) - - @endforeach +
+
+ @foreach ($checkpoints as $checkpoint) + + @endforeach +
@@ -107,7 +118,7 @@
@elseif ($isInstalling) -
+
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index 8866b66b02..dcec396ae7 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -8,7 +8,7 @@ {{-- One bar for the whole page. Three stacked bars made Save run submitResend(), which required an API key even when Resend was off. --}} + targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,smtpEhloDomain,resendApiKey" />
@@ -48,6 +48,9 @@ autocomplete="new-password" /> +
diff --git a/resources/views/livewire/settings/updates.blade.php b/resources/views/livewire/settings/updates.blade.php index 32d0bd185c..aaea04e655 100644 --- a/resources/views/livewire/settings/updates.blade.php +++ b/resources/views/livewire/settings/updates.blade.php @@ -8,6 +8,11 @@ {{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}} + + + + diff --git a/resources/views/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php index 31ea8d3086..fb429e24f4 100644 --- a/resources/views/livewire/switch-team.blade.php +++ b/resources/views/livewire/switch-team.blade.php @@ -22,7 +22,7 @@ Teams @foreach (auth()->user()->teams as $team) - @@ -66,7 +66,7 @@ Teams @foreach (auth()->user()->teams as $team) - diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php index 97c08c0357..b1ad048e1c 100644 --- a/resources/views/livewire/upgrade.blade.php +++ b/resources/views/livewire/upgrade.blade.php @@ -6,6 +6,14 @@ })"> @if ($isUpgradeAvailable)
+ @if ($fullButton) + + Upgrade now + + + Updating… + + @else + @endif
+ @elseif ($fullButton) +

Coolify is up to date.

@endif diff --git a/routes/api.php b/routes/api.php index 0ce9a7f11f..2d19ae96dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\NotificationsController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; @@ -83,6 +84,8 @@ Route::group([ Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']); Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']); Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']); + Route::get('/settings/email', [InstanceEmailSettingsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/settings/email', [InstanceEmailSettingsController::class, 'update'])->middleware(['api.ability:write:sensitive']); Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 516a9d7ebc..5baac23acb 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -229,6 +229,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/tests/Feature/Api/InstanceEmailSettingsApiTest.php b/tests/Feature/Api/InstanceEmailSettingsApiTest.php new file mode 100644 index 0000000000..116481faa9 --- /dev/null +++ b/tests/Feature/Api/InstanceEmailSettingsApiTest.php @@ -0,0 +1,141 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->rootTeam = Team::factory()->create(['id' => 0]); +}); + +function instanceEmailToken(User $user, Team $team, string $role, array $abilities): string +{ + $team->members()->attach($user->id, ['role' => $role]); + session(['currentTeam' => $team]); + + return $user->createToken('instance-email-test', $abilities)->plainTextToken; +} + +function instanceEmailHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +test('root team owners can get instance email settings', function () { + InstanceSettings::findOrFail(0)->update([ + 'smtp_enabled' => true, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_enabled', true) + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com') + ->assertJsonMissingPath('smtp_password'); +}); + +test('root team admins can update instance email settings', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'alerts@example.com', + 'smtp_from_name' => 'Coolify', + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_encryption' => 'starttls', + 'smtp_username' => 'coolify', + 'smtp_password' => 'secret', + 'smtp_timeout' => 10, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); + + $settings = InstanceSettings::findOrFail(0); + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com'); +}); + +test('instance email settings reject non-root teams', function () { + $team = Team::factory()->create(); + $token = instanceEmailToken(User::factory()->create(), $team, 'owner', ['read', 'write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('instance email settings reject root team members', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'member', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('instance email settings validate the smtp ehlo domain', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_ehlo_domain' => 'not a hostname']) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); +}); + +test('updating instance email settings requires write sensitive', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('root admins cannot use a token issued for another team', function () { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $team = Team::factory()->create(); + $token = instanceEmailToken($user, $team, 'owner', ['read', 'write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('read sensitive exposes instance email secrets to root team admins', function () { + InstanceSettings::findOrFail(0)->update(['smtp_password' => 'secret']); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['read', 'read:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_password', 'secret'); +}); diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index 647c327dee..d6bb96d15a 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -59,6 +59,7 @@ describe('GET /api/v1/notifications/*', function () { $response->assertJsonStructure([ 'team_id', 'smtp_enabled', + 'smtp_ehlo_domain', 'deployment_failure_email_notifications', 'use_instance_email_settings', ]); @@ -171,6 +172,7 @@ describe('PATCH /api/v1/notifications/*', function () { 'smtp_enabled' => true, 'smtp_from_address' => 'alerts@example.com', 'smtp_host' => 'smtp.example.com', + 'smtp_ehlo_domain' => 'coolify.example.com', 'smtp_port' => 587, 'smtp_encryption' => 'starttls', 'deployment_failure_email_notifications' => false, @@ -178,16 +180,42 @@ describe('PATCH /api/v1/notifications/*', function () { $response->assertSuccessful(); $response->assertJsonPath('smtp_enabled', true); + $response->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); $response->assertJsonPath('deployment_failure_email_notifications', false); $settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_from_address)->toBe('alerts@example.com') ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com') ->and($settings->smtp_port)->toBe(587) ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('validates the smtp ehlo domain', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => 'not a hostname', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); + }); + + test('clears the smtp ehlo domain', function () { + $this->team->emailNotificationSettings->update([ + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => null, + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', null); + + expect($this->team->emailNotificationSettings->fresh()->smtp_ehlo_domain)->toBeNull(); + }); + test('updates discord notification settings', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/discord', [ diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php index beea1f126a..92c2076077 100644 --- a/tests/Feature/ApiTokenExpirationWarningTest.php +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -43,7 +43,7 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon describe('ApiTokenExpirationWarningJob', function () { test('notifies team when token expires within 24h', function () { - $token = createTokenExpiring($this->user, $this->team, now()->addHours(23)); + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); (new ApiTokenExpirationWarningJob)->handle(); @@ -52,7 +52,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('does not mark token as warned when notification fails', function () { - $token = createTokenExpiring($this->user, $this->team, now()->addHours(23)); + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); $dispatcher = Mockery::mock(Dispatcher::class); $dispatcher->shouldReceive('send') ->once() @@ -67,7 +67,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('database marker prevents duplicate warnings on repeat runs', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); (new ApiTokenExpirationWarningJob)->handle(); (new ApiTokenExpirationWarningJob)->handle(); @@ -76,7 +76,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('database marker prevents duplicate warnings after cache is flushed', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); (new ApiTokenExpirationWarningJob)->handle(); @@ -88,7 +88,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips tokens that already have an expiration warning marker', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour()); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12), Carbon::now()->subHour()); (new ApiTokenExpirationWarningJob)->handle(); @@ -96,8 +96,8 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('notifies once for each unmarked expiring token', function () { - createTokenExpiring($this->user, $this->team, now()->addHours(12)); - createTokenExpiring($this->user, $this->team, now()->addHours(23)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(23)); (new ApiTokenExpirationWarningJob)->handle(); @@ -105,7 +105,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips tokens expiring more than 24h out', function () { - createTokenExpiring($this->user, $this->team, now()->addDays(3)); + createTokenExpiring($this->user, $this->team, Carbon::now()->addDays(3)); (new ApiTokenExpirationWarningJob)->handle(); @@ -113,7 +113,7 @@ describe('ApiTokenExpirationWarningJob', function () { }); test('skips already-expired tokens', function () { - createTokenExpiring($this->user, $this->team, now()->subHour()); + createTokenExpiring($this->user, $this->team, Carbon::now()->subHour()); (new ApiTokenExpirationWarningJob)->handle(); @@ -127,4 +127,14 @@ describe('ApiTokenExpirationWarningJob', function () { Notification::assertNothingSent(); }); + + test('skips tokens whose owner is no longer a team member', function () { + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); + $this->team->members()->detach($this->user); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull(); + }); }); diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index 2a875ca26e..4497fe2b4a 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -76,6 +76,42 @@ test('member can still create read token', function () { ->and($token->abilities)->toBe(['read']); }); +test('api token list only contains tokens for the current team', function () { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'admin']); + $otherTeam->members()->attach($user->id, ['role' => 'admin']); + + session(['currentTeam' => $this->team]); + $currentTeamToken = $user->createToken('current-team-token', ['read'])->accessToken; + + session(['currentTeam' => $otherTeam]); + $user->createToken('other-team-token', ['read']); + + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->assertSet('tokens', fn ($tokens) => $tokens->pluck('id')->all() === [$currentTeamToken->id]); +}); + +test('user cannot revoke a token from another team through the current team', function () { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'admin']); + $otherTeam->members()->attach($user->id, ['role' => 'admin']); + + session(['currentTeam' => $otherTeam]); + $otherTeamToken = $user->createToken('other-team-token', ['read'])->accessToken; + + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class)->call('revoke', $otherTeamToken->id); + + expect($user->tokens()->whereKey($otherTeamToken->id)->exists())->toBeTrue(); +}); + test('owner can create root token', function () { $owner = User::factory()->create(); $this->team->members()->attach($owner->id, ['role' => 'owner']); diff --git a/tests/Feature/CaddyApplicationLabelGenerationTest.php b/tests/Feature/CaddyApplicationLabelGenerationTest.php new file mode 100644 index 0000000000..b31fa29a46 --- /dev/null +++ b/tests/Feature/CaddyApplicationLabelGenerationTest.php @@ -0,0 +1,38 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::CADDY->value], + ]); + $server->settings->update(['generate_exact_labels' => true]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + 'is_http_basic_auth_enabled' => false, + ]); + + $labels = generateLabelsApplication($application); + + expect($labels) + ->toContain('caddy_ingress_network=coolify') + ->not->toContain('traefik.enable=true'); +}); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php new file mode 100644 index 0000000000..18e5ff9952 --- /dev/null +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -0,0 +1,242 @@ + 'local']); + $this->seed([UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class]); +}); + +it('registers the interactive qemu command', function () { + expect(Artisan::all()) + ->toHaveKey('dev:qemu') + ->toHaveKey('dev:qemu:seed') + ->and(Artisan::all()['dev:qemu'])->toBeInstanceOf(ManageDevelopmentQemuVmCommand::class) + ->and(Artisan::all()['dev:qemu']->getDefinition()->getArgument('profiles')->isArray())->toBeTrue() + ->and(Artisan::all()['dev:qemu:seed'])->toBeInstanceOf(SeedDevelopmentQemuServerCommand::class); +}); + +it('prevents qemu commands from running outside development', function () { + config(['app.env' => 'production']); + Process::fake(); + + expect(Artisan::call('dev:qemu', ['profiles' => ['ubuntu-root']]))->toBe(Command::FAILURE) + ->and(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root']))->toBe(Command::FAILURE); + + Process::assertNothingRan(); +}); + +it('provides root and non-root profiles for every supported distribution', function () { + $profiles = collect(config('development-qemu.profiles')); + + expect($profiles->keys()->all())->toBe([ + 'ubuntu-root', + 'ubuntu-non-root', + 'debian-root', + 'debian-non-root', + 'centos-root', + 'centos-non-root', + 'alpine-root', + 'alpine-non-root', + ])->and($profiles->pluck('ip')->unique()->count())->toBe(8) + ->and($profiles->pluck('mac')->unique()->count())->toBe(8) + ->and($profiles->filter(fn (array $profile) => $profile['user'] === 'root')->count())->toBe(4) + ->and($profiles->filter(fn (array $profile) => $profile['user'] !== 'root')->count())->toBe(4); +}); + +it('stores vm disks in a libvirt-accessible directory', function () { + expect(config('development-qemu.storage_path'))->toStartWith('/var/lib/libvirt/images/'); +}); + +it('automatically configures the qemu host', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'command -v')) { + return Process::result(); + } + + if (str_contains($process->command, 'net-info')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'systemctl enable --now libvirtd')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-define')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-autostart')); + Process::assertRan(fn ($process) => str_contains($process->command, 'sysctl -w net.ipv4.ip_forward=1')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('does not restart an active libvirt network', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'net-info')) { + return Process::result(output: "Name: default\nActive: yes\n"); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI 1')); +}); + +it('seeds one predefined root qemu server', function () { + $server = SeedDevelopmentQemuServer::run('ubuntu-root'); + + expect($server->uuid)->toBe('development-qemu-ubuntu-root') + ->and($server->ip)->toBe('192.168.122.10') + ->and($server->user)->toBe('root') + ->and($server->team_id)->toBe(0) + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('replaces the seeded qemu server with the selected non-root equivalent', function () { + SeedDevelopmentQemuServer::run('ubuntu-root'); + $server = SeedDevelopmentQemuServer::run('ubuntu-non-root'); + + expect($server->ip)->toBe('192.168.122.11') + ->and($server->user)->toBe('coolify') + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('deletes managed vm data and freshly creates only the selected vm', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-reset-test-'.uniqid(); + config(['development-qemu.storage_path' => $storagePath]); + File::ensureDirectoryExists($storagePath); + File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'old data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data'); + + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* iptables -C *' => Process::result(exitCode: 1), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-non-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'net-update') && str_contains($process->command, 'ip-dhcp-host') && str_contains($process->command, '192.168.122.11')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); + expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBeFalse() + ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse(); +}); + +it('rejects qemu vm management outside development', function () { + config(['app.env' => 'production']); + + expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root')) + ->toThrow(RuntimeException::class, 'development environments'); +}); + +it('can create a vm without a host database connection', function () { + config([ + 'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-test-'.uniqid(), + ]); + DB::enableQueryLog(); + Process::fake(function ($process) { + if (str_contains($process->command, 'net-dumpxml')) { + return Process::result(output: ''); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'virsh dominfo')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + expect(DB::getQueryLog())->toBeEmpty(); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('seeds through the coolify container when the host database is unavailable', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fallback-test-'.uniqid()]); + SeedDevelopmentQemuServer::mock() + ->shouldReceive('handle') + ->once() + ->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable'))); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec coolify php artisan dev:qemu:seed') && str_contains($process->command, 'ubuntu-root')); +}); + +it('starts and seeds root and non-root profiles together', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-multi-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run(['ubuntu-root', 'ubuntu-non-root']); + + expect(Server::query()->whereIn('uuid', [ + 'development-qemu-ubuntu-root', + 'development-qemu-ubuntu-non-root', + ])->count())->toBe(2); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php new file mode 100644 index 0000000000..aed8eb85c8 --- /dev/null +++ b/tests/Feature/DnsValidationTest.php @@ -0,0 +1,57 @@ + InstanceSettings::query()->updateOrCreate( + ['id' => 0], + [ + 'is_dns_validation_enabled' => true, + 'custom_dns_servers' => '192.0.2.1,192.0.2.2', + ] + )); + + $queriedServers = new ArrayObject; + $targetIp = '203.0.113.10'; + + app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers, $resolvedIp) { + return new class($parameters['server'], $queriedServers, $resolvedIp) extends DNSQuery + { + public function __construct( + private readonly string $dnsServer, + private readonly ArrayObject $queriedServers, + private readonly string $resolvedIp, + ) { + parent::__construct($dnsServer); + } + + public function query(string $question, string $typeName = DNSTypes::NAME_A) + { + $this->queriedServers->append($this->dnsServer); + + return [new DNSResult($typeName, 1, 'IN', 60, $this->resolvedIp, $question, '', [])]; + } + + public function hasError(): bool + { + return false; + } + }; + }); + + $server = new Server(['ip' => $targetIp]); + $server->id = 1; + + expect(validateDNSEntry('https://example.com', $server))->toBeTrue() + ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']); +})->with([ + 'target server IP' => '203.0.113.10', + 'Cloudflare IP' => '104.16.0.1', +]); diff --git a/tests/Feature/GlobalIconTooltipTest.php b/tests/Feature/GlobalIconTooltipTest.php index 4ef1947f14..2cc9049299 100644 --- a/tests/Feature/GlobalIconTooltipTest.php +++ b/tests/Feature/GlobalIconTooltipTest.php @@ -36,12 +36,11 @@ it('keeps a tooltip hidden until its measured position is applied', function () ->toContain("positioned ? 'visible' : 'invisible'"); }); -it('anchors tooltips to the trigger and lets them grow toward the right', function () { +it('centers tooltips on the trigger and keeps them within the viewport', function () { $tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php')); expect($tooltip) - ->toContain('this.x = rect.left;') - ->toContain('Math.min(window.innerWidth - width - 8, this.x)') - ->not->toContain('rect.left + rect.width / 2') + ->toContain('this.x = rect.left + rect.width / 2;') + ->toContain('Math.min(window.innerWidth - width - 8, this.x - width / 2)') ->not->toContain('-translate-x-1/2'); }); diff --git a/tests/Feature/ServerSidebarIconsTest.php b/tests/Feature/ServerSidebarIconsTest.php index 205429d678..12d33b6e38 100644 --- a/tests/Feature/ServerSidebarIconsTest.php +++ b/tests/Feature/ServerSidebarIconsTest.php @@ -36,6 +36,13 @@ it('uses the shield-star reicon for sentinel in the server sidebar', function () ->toMatch("/'label' => 'Sentinel',\s*'route' => 'server\.sentinel',\s*'active' => request\(\)->routeIs\('server\.sentinel', 'server\.sentinel\.\*'\),\s*'icon' => 'shield-star'/s"); }); +it('shows a warning icon when sentinel is enabled but not working', function () { + $contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); + + expect($contents) + ->toContain("'warning' => \$server->isSentinelEnabled() && ! \$server->isSentinelLive()"); +}); + it('uses the network reicon for proxy in the server sidebar', function () { $contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); diff --git a/tests/Feature/ServerStatusIndicatorDesignTest.php b/tests/Feature/ServerStatusIndicatorDesignTest.php index 090d6d816a..5e2b8b5faa 100644 --- a/tests/Feature/ServerStatusIndicatorDesignTest.php +++ b/tests/Feature/ServerStatusIndicatorDesignTest.php @@ -13,7 +13,7 @@ test('server cards use warning icons instead of colored icon borders', function expect(substr_count($serverIndex, 'toBe(1) ->and($serverIndex) - ->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain("&& (\$server->proxy->status !== 'running' || \$server->hasCurrentTraefikOutdatedInfo())") ->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()') ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'") ->toContain("\$isReady => 'success'") @@ -29,11 +29,21 @@ test('dashboard server cards warn when proxy or sentinel needs attention', funct $dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php')); expect($dashboard) - ->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'") + ->toContain("\$proxyNeedsAttention = \$server->proxySet() && (\$server->proxy->status !== 'running' || \$server->hasCurrentTraefikOutdatedInfo())") ->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()') ->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']"); }); +test('server status summary uses warning indicators for proxy updates and sentinel outages', function () { + $summary = file_get_contents(resource_path('views/components/server/status-summary.blade.php')); + + expect($summary) + ->toContain('$server->hasCurrentTraefikOutdatedInfo()') + ->toContain("'bg-warning' => \$proxyNeedsAttention && (\$proxyUpdateAvailable") + ->toContain("'bg-warning' => \$sentinelNeedsAttention") + ->not->toContain("\$server->isSentinelLive() ? 'bg-success' : 'bg-error'"); +}); + test('server table keeps status text without a badge', function () { $serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php')); diff --git a/tests/Feature/ServerValidationDialogTest.php b/tests/Feature/ServerValidationDialogTest.php index a22056cac4..e1c75e14af 100644 --- a/tests/Feature/ServerValidationDialogTest.php +++ b/tests/Feature/ServerValidationDialogTest.php @@ -4,12 +4,29 @@ test('server revalidation opens in the centered process dialog', function () { $view = file_get_contents(resource_path('views/livewire/server/show.blade.php')); expect($view) - ->toContain('') + ->toContain('') ->toContain(':isHighlighted="! $server->isFunctional()"') ->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"') ->not->toContain('toContain('') + ->and($dialog) + ->toContain("'mobileFullscreen' => false") + ->toContain("'process-dialog-mobile-fullscreen' => \$mobileFullscreen") + ->and($styles) + ->toContain('@media (max-width: 639px)') + ->toContain('.process-dialog-mobile-fullscreen') + ->toContain('height: 100dvh !important') + ->toContain('box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important'); +}); + test('completed server validation shows a close action instead of empty logs', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); @@ -25,11 +42,17 @@ test('completed server validation shows a close action instead of empty logs', f test('installation logs are only shown after an installation starts', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); $component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + $styles = file_get_contents(resource_path('css/app.css')); - expect($view)->toContain('@elseif ($isInstalling)') + expect($view) + ->toContain('@elseif ($isInstalling)') + ->toContain('application-settings-section validation-installation-logs') ->and($component) ->toContain('public bool $isInstalling = false;') - ->toContain('$this->isInstalling = true;'); + ->toContain('$this->isInstalling = true;') + ->and($styles) + ->toContain('.validation-installation-logs') + ->toContain('border: 1px solid var(--coollabs-fill)'); }); test('server validation content scrolls within the dialog', function () { @@ -43,10 +66,22 @@ test('server validation content scrolls within the dialog', function () { test('validation checkpoints use the standard bordered list treatment', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $styles = file_get_contents(resource_path('css/app.css')); expect($view) ->toContain('data-validation-checkpoints') - ->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]'); + ->toContain('shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]') + ->toContain('checkpoint-scroll-fade') + ->toContain('snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth scrollbar') + ->toContain('data-checkpoint-status="{{ $checkpoint[\'status\'] }}"') + ->toContain('basis-[88%] shrink-0 snap-start sm:basis-72 lg:basis-80') + ->toContain("querySelector('[data-checkpoint-status=running]')") + ->toContain("scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })") + ->toContain('new MutationObserver') + ->toContain("attributeFilter: ['data-checkpoint-status']") + ->toContain('x-destroy="observer?.disconnect()"') + ->and($styles) + ->toContain('.checkpoint-scroll-fade::after'); }); test('all validation checkpoints remain visible while only the current phase runs', function () { diff --git a/tests/Feature/SettingsEmailSmtpSetupTest.php b/tests/Feature/SettingsEmailSmtpSetupTest.php index 654b454534..cbe401191d 100644 --- a/tests/Feature/SettingsEmailSmtpSetupTest.php +++ b/tests/Feature/SettingsEmailSmtpSetupTest.php @@ -1,5 +1,6 @@ 'smtp.example.com', 'smtpPort' => '587', 'smtpEncryption' => 'starttls', + 'smtpEhloDomain' => 'coolify.example.com', 'resendEnabled' => false, 'resendApiKey' => null, ]; @@ -56,9 +58,30 @@ test('saving smtp settings does not require a resend api key when resend is disa expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com') ->and($settings->resend_enabled)->toBeFalse(); }); +test('team smtp settings save their own ehlo domain', function () { + setupInstanceAdminForEmailSettings(); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(NotificationEmail::class) + ->fill(smtpSetupPayload()) + ->set('smtpEnabled', true) + ->call('submitSmtp') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + expect($team->emailNotificationSettings->fresh()->smtp_ehlo_domain) + ->toBe('coolify.example.com'); +}); + test('saving transactional email settings does not require a resend api key when resend is disabled', function () { $user = setupInstanceAdminForEmailSettings(); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index a52b382028..272156fbd2 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -87,25 +87,22 @@ it('reuses an existing healthy master without spawning a new one', function () { Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN')); }); -it('refreshes an expired master before reuse', function () { +it('reuses a healthy master regardless of its absolute age', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => false, - 'constants.ssh.mux_max_age' => 10, ]); $server = makeMuxServer(); - Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 30, 3600); + Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 7200, 10800); Process::fake([ '*-O check*' => Process::result(exitCode: 0), - '*-O exit*' => Process::result(exitCode: 0), - '*-fN *' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O exit')); - Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); }); it('does not spawn a master when the per-server lock is already held', function () { @@ -241,6 +238,101 @@ it('kills only old orphaned ssh masters whose control socket no longer exists', File::delete($liveSocket); }); +it('does not reap an ssh master that is intentionally retiring', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + $retiringSocket = $muxDir.'/mux_retiring_'.uniqid(); + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $retiringSocket); + + Process::fake([ + 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$retiringSocket} root@1.2.3.4\n"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); +}); + +it('does not treat a reused pid as retiring', function () { + $socket = storage_path('app/ssh/mux/mux_original'); + + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket, '1000'); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '1000'))->toBeTrue() + ->and(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '2000'))->toBeFalse(); +}); + +it('scopes retirement markers to the current host and pid namespace', function () { + $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processScope'); + $method->setAccessible(true); + + expect($method->invoke(null)) + ->toBeString() + ->toStartWith((gethostname() ?: 'unknown').'|'); +}); + +it('keeps a retirement marker for long-running ssh sessions', function () { + $socket = storage_path('app/ssh/mux/mux_retired'); + + SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket); + $this->travel((int) config('constants.ssh.mux_persist_time') * 2 + 1)->seconds(); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket))->toBeTrue(); +}); + +it('reads the process start time used to distinguish pid reuse', function () { + $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processStartTime'); + $method->setAccessible(true); + + expect($method->invoke(null, (string) getmypid()))->toMatch('/^\d+$/'); +}); + +it('marks a successfully stopped mux process as retiring', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=222)', exitCode: 0), + '*-O stop*' => Process::result(exitCode: 0), + ]); + + SshMultiplexingHelper::removeMuxFile($server); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('222', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); +}); + +it('marks a mux process as retiring before stopping it', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=555)', exitCode: 0), + '*-O stop*' => function () use ($server) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('555', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); + + return Process::result(exitCode: 0); + }, + ]); + + SshMultiplexingHelper::removeMuxFile($server); +}); + +it('does not mark a mux process as retiring when stop fails', function () { + $server = makeMuxServer(); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=444)', exitCode: 0), + '*-O stop*' => function () use ($server) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); + + return Process::result(exitCode: 1); + }, + ]); + + SshMultiplexingHelper::removeMuxFile($server); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeFalse(); +}); + it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); @@ -294,7 +386,10 @@ it('removes mux files for non-existent servers when reaping is enabled', functio Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); - Process::fake(); + Process::fake([ + '*-O check*' => Process::result(errorOutput: 'Master running (pid=333)', exitCode: 0), + '*-O stop*' => Process::result(exitCode: 0), + ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); @@ -302,6 +397,72 @@ it('removes mux files for non-existent servers when reaping is enabled', functio $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse(); + expect(SshMultiplexingHelper::isMuxProcessRetiring('333', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O exit')); +}); + +it('marks a stale mux process as retiring before stopping it', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=666)', exitCode: 0), + '*-O stop*' => function () use ($file) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('666', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); + + return Process::result(exitCode: 0); + }, + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); +}); + +it('removes a stale mux retirement marker when stopping fails', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$file}"; + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake([ + '*-O check*' => Process::result(output: 'Master running (pid=777)', exitCode: 0), + '*-O stop*' => function () use ($muxSocket) { + expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeTrue(); + + return Process::result(exitCode: 1); + }, + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeFalse(); +}); + +it('does not remove a healthy mux connection based on its absolute age', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $server = makeMuxServer(); + $file = "mux_{$server->uuid}"; + Storage::disk('ssh-mux')->put($file, str_repeat('x', 37).now()->subHours(2)->toIso8601String()); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupStaleConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -O stop')); }); it('keeps mux files for non-existent servers in dry-run mode', function () { diff --git a/tests/Feature/SwitchTeamTest.php b/tests/Feature/SwitchTeamTest.php new file mode 100644 index 0000000000..9c4724939a --- /dev/null +++ b/tests/Feature/SwitchTeamTest.php @@ -0,0 +1,32 @@ + 0]); + + $this->user = User::factory()->create(); + $this->currentTeam = $this->user->teams()->first(); + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->currentTeam]); +}); + +test('switching teams keeps the current page URL', function () { + $currentUrl = route('security.api-tokens', ['page' => 2]); + + Livewire::test(SwitchTeam::class) + ->call('switch_to', $this->otherTeam->id, $currentUrl) + ->assertRedirect($currentUrl); + + expect(session('currentTeam')->is($this->otherTeam))->toBeTrue(); +}); diff --git a/tests/Feature/TransactionalEmailFromNameTest.php b/tests/Feature/TransactionalEmailFromNameTest.php new file mode 100644 index 0000000000..2e5662c61f --- /dev/null +++ b/tests/Feature/TransactionalEmailFromNameTest.php @@ -0,0 +1,121 @@ +create(['id' => 0]); + InstanceSettings::forceCreate([ + 'id' => 0, + 'smtp_enabled' => true, + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'admin', + 'smtp_host' => 'coolify-mail', + 'smtp_port' => 1025, + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + return $user; +} + +test('saving transactional sender persists the configured from name', function () { + $user = setupTransactionalEmailFromNameAdmin(); + + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromName', 'Coolify') + ->set('smtpFromAddress', 'admin@example.com') + ->call('submit') + ->assertHasNoErrors(); + + Once::flush(); + + expect(instanceSettings()->smtp_from_name)->toBe('Coolify') + ->and(instanceSettings()->smtp_from_address)->toBe('admin@example.com'); +}); + +test('sending a test email persists the current from name before delivery', function () { + $user = setupTransactionalEmailFromNameAdmin(); + + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Notification::fake(); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromName', 'Coolify') + ->set('smtpFromAddress', 'admin@example.com') + ->set('testEmailAddress', $user->email) + ->call('sendTestEmail') + ->assertHasNoErrors(); + + Once::flush(); + + expect(instanceSettings()->smtp_from_name)->toBe('Coolify'); +}); + +test('transactional emails send with the configured from name instead of the address local part', function () { + setupTransactionalEmailFromNameAdmin(); + + InstanceSettings::findOrFail(0)->update([ + 'smtp_from_name' => 'Coolify', + 'smtp_from_address' => 'admin@example.com', + ]); + Once::flush(); + + config([ + 'mail.default' => 'array', + 'mail.from.address' => 'hello@example.com', + 'mail.from.name' => 'Example', + ]); + Mail::purge(); + Mail::mailer(); + + $this->mock(ConfigurationRepository::class, function ($mock) { + $mock->shouldReceive('updateMailConfig')->andReturnUsing(function ($settings) { + config([ + 'mail.from.address' => $settings->smtp_from_address, + 'mail.from.name' => $settings->smtp_from_name, + ]); + }); + }); + + $user = User::factory()->create(['email' => 'recipient@example.com']); + $notification = new EmailChangeVerification( + $user, + '123456', + 'new@example.com', + now()->addMinutes(10), + ); + + $channel = new TransactionalEmailChannel; + $channel->send($user, $notification); + + $messages = app('mail.manager')->mailer('array')->getSymfonyTransport()->messages(); + + expect($messages)->not->toBeEmpty(); + + $from = $messages->first()->getOriginalMessage()->getFrom()[0]; + + expect($from->getAddress())->toBe('admin@example.com') + ->and($from->getName())->toBe('Coolify') + ->and($from->getName())->not->toBe('admin'); +}); diff --git a/tests/Feature/UpgradeComponentTest.php b/tests/Feature/UpgradeComponentTest.php index dc14516a53..306a486de7 100644 --- a/tests/Feature/UpgradeComponentTest.php +++ b/tests/Feature/UpgradeComponentTest.php @@ -64,18 +64,34 @@ it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure' ->not->toContain('Lost contact with Coolify'); }); -it('uses sidebar state css instead of nested alpine state for upgrade labels', function () { +it('renders the upgrade control in the mobile top bar', function () { + $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); + + expect($layout) + ->toMatch('/MOBILE TOP BAR[\s\S]*?]*>[\s\S]*?Open sidebar/') + ->toContain('key="mobile-upgrade"'); +}); + +it('supports a full size update button for settings', function () { $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); - $utilitiesCss = file_get_contents(resource_path('css/utilities.css')); + $settingsView = file_get_contents(resource_path('views/livewire/settings/updates.blade.php')); expect($upgradeView) - ->toContain('class="text-left menu-item-label sidebar-collapsed-label"') - ->toContain('>In progress') - ->toContain('>Upgrade') - ->not->toContain(':class="collapsed && \'lg:hidden\'"') - ->and($utilitiesCss) - ->toContain('.sidebar-collapsed .sidebar-collapsed-label') - ->toContain('display: none;'); + ->toContain('$fullButton') + ->toContain('Upgrade now') + ->and($settingsView) + ->toContain('Update Coolify') + ->toContain(':full-button="true"') + ->toContain('key="settings-upgrade"'); +}); + +it('uses compact labels that do not depend on desktop sidebar state', function () { + $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); + + expect($upgradeView) + ->toContain('Updating') + ->toContain('Update available') + ->not->toContain(':class="collapsed'); }); it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () { diff --git a/tests/Unit/ConfigurationRepositoryMailConfigTest.php b/tests/Unit/ConfigurationRepositoryMailConfigTest.php new file mode 100644 index 0000000000..ebecda64ae --- /dev/null +++ b/tests/Unit/ConfigurationRepositoryMailConfigTest.php @@ -0,0 +1,55 @@ +updateMailConfig((object) [ + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_encryption' => $mode, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => $port, + 'smtp_username' => 'user', + 'smtp_password' => 'secret', + 'smtp_timeout' => null, + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($config->get('mail.mailers.smtp.scheme'))->toBe($scheme) + ->and($config->get('mail.mailers.smtp.encryption'))->toBe($encryption) + ->and($config->get('mail.mailers.smtp.auto_tls'))->toBe($autoTls); +})->with([ + 'none on port 465' => ['none', 465, 'smtp', null, '0'], + 'tls on a non-465 port' => ['tls', 587, 'smtps', 'tls', ''], +]); + +it('preserves the configured smtp ehlo domain', function () { + $config = new Repository([ + 'mail' => [ + 'mailers' => [ + 'smtp' => ['local_domain' => 'coolify.example.com'], + ], + ], + ]); + $repository = new ConfigurationRepository($config); + + $repository->updateMailConfig((object) [ + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_encryption' => 'starttls', + 'smtp_host' => 'smtp-relay.gmail.com', + 'smtp_port' => 587, + 'smtp_username' => null, + 'smtp_password' => null, + 'smtp_timeout' => null, + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($config->get('mail.mailers.smtp.local_domain'))->toBe('coolify.example.com'); +}); diff --git a/tests/Unit/DnsQueryTimeoutTest.php b/tests/Unit/DnsQueryTimeoutTest.php new file mode 100644 index 0000000000..ce0140a090 --- /dev/null +++ b/tests/Unit/DnsQueryTimeoutTest.php @@ -0,0 +1,10 @@ +getProperty('timeout')->getValue($query); + + expect($timeout)->toBe(5); +}); diff --git a/tests/Unit/MailFromIdentityTest.php b/tests/Unit/MailFromIdentityTest.php new file mode 100644 index 0000000000..d9587f9c7f --- /dev/null +++ b/tests/Unit/MailFromIdentityTest.php @@ -0,0 +1,87 @@ + 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($identity['address'])->toBe('admin@example.com') + ->and($identity['name'])->toBe('Coolify'); +}); + +it('does not fall back to the email local part when a from name is set', function () { + $address = mail_from_address((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($address)->toBeInstanceOf(Address::class) + ->and($address->getAddress())->toBe('admin@example.com') + ->and($address->getName())->toBe('Coolify') + ->and($address->getName())->not->toBe('admin'); +}); + +it('formats the transactional sender for resend', function () { + $formattedAddress = mail_from_formatted((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($formattedAddress)->toBe('"Coolify" '); +}); + +it('keeps the smtp from name and address on the same header line', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps the Laravel mail from name and address on the same header line', function () { + $message = mail_from_message(new Message(new Email), (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($message->getSymfonyMessage()->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps Symfony header folding for other smtp providers', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.example.com', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe("From: Advanced Digital Marketing LTDA\r\n "); +}); + +it('treats a blank from name as missing instead of sending an unnamed address', function () { + $identity = mail_from_identity((object) [ + 'smtp_from_address' => 'admin@example.com', + 'smtp_from_name' => ' ', + ]); + + expect($identity['name'])->toBe('Coolify') + ->and($identity['name'])->not->toBe('admin'); +}); + +it('rejects enabled email settings without a from address', function () { + mail_from_identity((object) [ + 'smtp_enabled' => true, + 'smtp_from_address' => null, + 'smtp_from_name' => 'Coolify', + ]); +})->throws(InvalidArgumentException::class, 'Transactional email sender address is not configured.'); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 98c60aae8c..055297b3cd 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -1,9 +1,28 @@ makePartial(); + $token->tokenable_id = $userId; + $token->tokenable_type = User::class; + $token->team_id = $teamId; + + return $token; +} + +function apiTokenTeam(int $teamId): Team +{ + $team = new Team; + $team->id = $teamId; + + return $team; +} + it('allows any user to view any api tokens', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -28,10 +47,9 @@ it('allows any user to manage api tokens', function () { it('allows owner to view their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->view($user, $token))->toBeTrue(); @@ -52,10 +70,9 @@ it('denies non-owner from viewing api token', function () { it('allows owner to update their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->update($user, $token))->toBeTrue(); @@ -76,10 +93,9 @@ it('denies non-owner from updating api token', function () { it('allows owner to delete their own api token', function () { $user = Mockery::mock(User::class)->makePartial(); $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); - $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); - $token->tokenable_id = 1; - $token->tokenable_type = User::class; + $token = apiTokenForUserAndTeam(1, 10); $policy = new ApiTokenPolicy; expect($policy->delete($user, $token))->toBeTrue(); @@ -97,6 +113,31 @@ it('denies non-owner from deleting api token', function () { expect($policy->delete($user, $token))->toBeFalse(); }); +it('denies access to an owned api token from another team', function (string $ability) { + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('currentTeam')->andReturn(apiTokenTeam(10)); + + $token = apiTokenForUserAndTeam(1, 20); + + $policy = new ApiTokenPolicy; + expect($policy->{$ability}($user, $token))->toBeFalse(); +})->with(['view', 'update', 'delete']); + +it('denies access to an owned api token without team identifiers', function (string $ability) { + $user = Mockery::mock(User::class)->makePartial(); + $user->id = 1; + $user->shouldReceive('currentTeam')->andReturnNull(); + + $token = Mockery::mock(PersonalAccessToken::class)->makePartial(); + $token->tokenable_id = 1; + $token->tokenable_type = User::class; + $token->team_id = null; + + $policy = new ApiTokenPolicy; + expect($policy->{$ability}($user, $token))->toBeFalse(); +})->with(['view', 'update', 'delete']); + it('allows admin to use root permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(true); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index c3b5e665f3..659d233122 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,9 +23,9 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.9'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.9') - ->and($versions['coolify']['nightly']['version'])->toBe('4.3.10') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.10'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.10') + ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); @@ -34,6 +34,12 @@ it('orders a maintenance development build before its stable release', function ->and(version_compare('4.3.2', '4.3.2-dev.d64cbda3e', '>'))->toBeTrue(); }); +it('orders rolling and exact release candidates before the stable release', function () { + expect(version_compare('4.4-rc.1.d64cbda', '4.4-rc.1', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.1', '4.4-rc.2', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.2', '4.4.0', '<'))->toBeTrue(); +}); + it('requires a reviewed draft release before building a stable version', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml'); @@ -104,12 +110,46 @@ it('generates the production changelog from main', function () { ->not->toContain('v4.x'); }); -it('excludes main from staging builds', function () { - $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml'); +it('publishes traceable rolling builds from next without creating an exact rc tag', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-next-build.yml'); expect($workflow) - ->toContain(' - main') - ->not->toContain(' - v4.x'); + ->toContain('name: Build Coolify Next') + ->toContain('branches: [next]') + ->toContain('group: coolify-next-build') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('VERSION="${RC_VERSION}.${SHORT_SHA}"') + ->toContain('COOLIFY_VERSION=${{ needs.prepare.outputs.version }}') + ->toContain('--tag "${IMAGE}:sha-${SHA}"') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->not->toContain('--tag "${IMAGE}:${RC_VERSION}"') + ->not->toContain('--tag "${IMAGE}:latest"'); +}); + +it('requires a reviewed draft prerelease before publishing an exact rc', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-rc-release.yml'); + + expect($workflow) + ->toContain('name: Release Coolify RC') + ->toContain('run-name: ${{ inputs.tag }}') + ->toContain("github.ref != 'refs/heads/next'") + ->toContain('group: coolify-rc-release') + ->toContain('^v[0-9]+\\.[0-9]+-rc\\.[0-9]+$') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('release.draft') + ->toContain('!release.prerelease') + ->toContain('release.body?.trim()') + ->toContain('revalidate:') + ->toMatch('/revalidate:.*?permissions:\s+contents: write/s') + ->toContain('needs: [validate, build, revalidate]') + ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->toContain('prerelease: true') + ->toContain('actions/github-script@v8') + ->not->toContain('--tag "${IMAGE}:latest"') + ->not->toContain('environment:'); }); it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () { diff --git a/tests/Unit/SmtpTransportFactoryTest.php b/tests/Unit/SmtpTransportFactoryTest.php new file mode 100644 index 0000000000..4ace54cfa4 --- /dev/null +++ b/tests/Unit/SmtpTransportFactoryTest.php @@ -0,0 +1,130 @@ + 'smtp.example.com', + 'smtp_port' => 25, + 'smtp_encryption' => 'none', + 'smtp_username' => 'user', + 'smtp_password' => 'secret', + 'smtp_timeout' => null, + ], $overrides); +} + +it('disables opportunistic STARTTLS when encryption is none', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream())->toBeInstanceOf(SocketStream::class) + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('does not issue STARTTLS for the issue 5877 anonymous port 25 relay', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + 'smtp_port' => 25, + 'smtp_username' => '', + 'smtp_password' => '', + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream()->isTLS())->toBeFalse() + ->and($transport->getStream()->getPort())->toBe(25) + ->and($transport->getUsername())->toBe('') + ->and($transport->getPassword())->toBe(''); +}); + +it('does not enable implicit TLS on port 465 when encryption is none', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'none', + 'smtp_port' => 465, + ])); + + expect($transport->isAutoTls())->toBeFalse() + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('keeps opportunistic STARTTLS when encryption is starttls', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'starttls', + ])); + + expect($transport->isAutoTls())->toBeTrue() + ->and($transport->getStream()->isTLS())->toBeFalse(); +}); + +it('uses implicit TLS when encryption is tls', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => 'tls', + 'smtp_port' => 465, + ])); + + expect($transport->isAutoTls())->toBeTrue() + ->and($transport->getStream()->isTLS())->toBeTrue(); +}); + +it('infers implicit TLS on port 465 when encryption is null', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_encryption' => null, + 'smtp_port' => 465, + ])); + + expect($transport->getStream()->isTLS())->toBeTrue(); +}); + +it('applies a configured SMTP timeout to the transport stream', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings([ + 'smtp_timeout' => 15, + ])); + + expect($transport->getStream()->getTimeout())->toBe(15.0); +}); + +it('applies the configured ehlo domain to the transport', function () { + $transport = SmtpTransportFactory::fromSettings( + smtpSettings(['smtp_ehlo_domain' => 'team.example.com']), + 'instance.example.com' + ); + + expect($transport->getLocalDomain())->toBe('team.example.com'); +}); + +it('falls back to the instance ehlo domain for legacy smtp settings', function () { + $transport = SmtpTransportFactory::fromSettings( + smtpSettings(), + 'instance.example.com' + ); + + expect($transport->getLocalDomain())->toBe('instance.example.com'); +}); + +it('maps none encryption to laravel mailer options that disable auto tls', function () { + expect(SmtpTransportFactory::mailerOptions(smtpSettings([ + 'smtp_encryption' => 'none', + ])))->toBe([ + 'scheme' => 'smtp', + 'encryption' => null, + 'auto_tls' => '0', + ]); +}); + +it('maps encryption to laravel mailer options', function (?string $mode, ?string $scheme, ?string $encryption) { + expect(SmtpTransportFactory::mailerOptions(smtpSettings([ + 'smtp_encryption' => $mode, + ])))->toBe([ + 'scheme' => $scheme, + 'encryption' => $encryption, + 'auto_tls' => $mode === 'none' ? '0' : '', + ]); +})->with([ + 'none' => ['none', 'smtp', null], + 'starttls' => ['starttls', 'smtp', null], + 'tls' => ['tls', 'smtps', 'tls'], + 'unset' => [null, null, null], +]); diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index a888bc3a41..677a8fec3e 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -62,6 +62,35 @@ it('persists the selected registry url during upgrades', function (string $path) 'nightly upgrade' => 'other/nightly/upgrade.sh', ]); +it('persists the target image and runtime version before recreating containers', function (string $path) { + $script = file_get_contents(getcwd().'/'.$path); + if ($script === false) { + throw new RuntimeException("Unable to read {$path}"); + } + + $position = static function (string $needle) use ($script): int { + $offset = strpos($script, $needle); + if ($offset === false) { + throw new RuntimeException("Missing marker: {$needle}"); + } + + return $offset; + }; + + $latestImagePosition = $position('set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"'); + $coolifyVersionPosition = $position('set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"'); + $imagesPulledPosition = $position('log "All images pulled successfully"'); + $composeUpPosition = $position('docker compose --env-file /data/coolify/source/.env'); + + expect($latestImagePosition)->toBeGreaterThan($imagesPulledPosition) + ->and($coolifyVersionPosition)->toBeGreaterThan($imagesPulledPosition) + ->and($latestImagePosition)->toBeLessThan($composeUpPosition) + ->and($coolifyVersionPosition)->toBeLessThan($composeUpPosition); +})->with([ + 'stable upgrade' => 'scripts/upgrade.sh', + 'nightly upgrade' => 'other/nightly/upgrade.sh', +]); + it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) { $script = file_get_contents(getcwd().'/'.$path); diff --git a/versions.json b/versions.json index 92a88ea023..440ad36160 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.3.9" + "version": "4.3.10" }, "nightly": { - "version": "4.3.10" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15"