Merge remote-tracking branch 'origin/main' into feat/services

This commit is contained in:
peaklabs-dev committed 2026-08-24 19:48:56 +02:00
commit 145c0f6672
249 files changed
+8928 -1027

No files matched your search

+152
View File
@@ -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"
+304
View File
@@ -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,
});
-134
View File
@@ -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 }}
+62
View File
@@ -0,0 +1,62 @@
name: Sync main to next
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-next
cancel-in-progress: false
jobs:
sync:
name: Merge main into next
runs-on: ubuntu-latest
steps:
- name: Checkout next
uses: actions/checkout@v5
with:
ref: next
fetch-depth: 0
- name: Merge main into next
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch origin main next
if git merge --no-edit origin/main; then
git push origin HEAD:next
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
git merge --abort
if [ -z "$conflicts" ]; then
echo 'The merge failed without conflicts, so no pull request was created.'
exit 1
fi
sync_branch='automation/sync-main-to-next'
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
if [ -n "$existing_pr" ]; then
echo "A main to next pull request already exists: $existing_pr"
else
git push --force origin origin/main:"refs/heads/$sync_branch"
gh pr create \
--base next \
--head "$sync_branch" \
--title 'chore: merge main into next' \
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
fi
echo 'main could not be merged into next without conflicts.'
exit 1
+1
View File
@@ -39,6 +39,7 @@ docker/coolify-realtime/node_modules
CHANGELOG.md
/.workspaces
/.superpowers/
/docs/superpowers/plans/
tests/Browser/Screenshots
tests/v4/Browser/Screenshots
ref
+22 -1
View File
@@ -99,9 +99,30 @@ function loginAsRoot(): mixed
```
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`).
- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
### How Browser Tests Actually Run (no Docker, no display needed)
`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin:
1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`).
2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself.
Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres.
`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements).
### Browser Test Gotchas
- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`.
- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache.
- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`.
- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up.
- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds.
- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions.
- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible.
- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner.
## Architecture
### Backend Structure (app/)
+29
View File
@@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor
## Local Development
To build and run Coolify locally, see: [Development](./DEVELOPMENT.md)
### Testing the Coolify Helper Locally
Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications.
Run the complete workflow:
```bash
./scripts/dev-helper test my-helper-test
```
This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications.
You can also run each step separately:
```bash
./scripts/dev-helper build my-helper-test
./scripts/dev-helper use my-helper-test
./scripts/dev-helper verify my-helper-test
./scripts/dev-helper deploy my-helper-test
```
Clear the helper override when finished:
```bash
./scripts/dev-helper reset
```
The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name.
### macOS Development with Lima
Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS.
+6 -5
View File
@@ -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-<commit>`, `4.4-rc.1.<short-sha>`, 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-<short-sha>`, `4.4-rc.1.<short-sha>`, 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
@@ -29,7 +29,7 @@ class StopApplicationOneServer
instant_remote_process(
[
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
dockerRemoveCommand($containerName),
],
$server
);
+2 -2
View File
@@ -24,10 +24,10 @@ class StopDatabaseProxy
{
$server = data_get($database, 'destination.server');
$uuid = $database->uuid;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($database->getMorphClass() === ServiceDatabase::class) {
$server = data_get($database, 'service.server');
}
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server);
$database->save();
@@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork
$safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server);
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class ConfigureDevelopmentQemuHost
{
use AsAction;
public function handle(): void
{
$this->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 <<<XML
<network>
<name>{$network}</name>
<forward mode="nat"/>
<bridge name="virbr0" stp="on" delay="0"/>
<ip address="192.168.122.1" netmask="255.255.255.0">
<dhcp>
<range start="192.168.122.2" end="192.168.122.254"/>
</dhcp>
</ip>
</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.');
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Development;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class ManageDevelopmentQemuVm
{
use AsAction;
/** @param string|array<int, string> $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;
}
}
}
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Development;
use App\Models\PrivateKey;
use App\Models\Server;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class SeedDevelopmentQemuServer
{
use AsAction;
public function handle(string $profileName, bool $removeOtherServers = true): Server
{
$this->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.');
}
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class StartDevelopmentQemuVm
{
use AsAction;
public function handle(string $profileName, bool $resetManagedVms = true): void
{
$this->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 <<<YAML
#cloud-config
disable_root: false
users:
- name: {$profile['user']}
{$sudo} shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- {$publicKey}
package_update: true
packages:
{$packages}
runcmd:
- {$startDocker}{$addUserToDockerGroup}
YAML;
}
/** @param array{mac: string} $profile */
private function networkConfig(array $profile): string
{
return <<<YAML
version: 2
ethernets:
default:
match:
macaddress: "{$profile['mac']}"
dhcp4: true
YAML;
}
/** @param array{domain: string, ip: string, mac: string} $profile */
private function configureDhcpReservation(array $profile): void
{
$network = escapeshellarg(config('development-qemu.libvirt_network'));
$networkXml = Process::run("virsh net-dumpxml {$network}");
if ($networkXml->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("<host mac='%s' name='%s' ip='%s'/>", $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.');
}
}
}
+9 -1
View File
@@ -13,6 +13,8 @@ class GetProxyConfiguration
{
use AsAction;
public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public function handle(Server $server, bool $forceRegenerate = false): string
{
$proxyType = $server->proxyType();
@@ -98,11 +100,17 @@ class GetProxyConfiguration
private function backfillFromDisk(Server $server): ?string
{
$proxy_path = $server->proxyPath();
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
"if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
], $server, false);
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}
if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();
+35 -60
View File
@@ -2,77 +2,52 @@
namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
use App\Models\Service;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteService
{
use AsAction;
public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void
{
try {
$server = data_get($service, 'server');
if ($deleteVolumes && $server->isFunctional()) {
$storagesToDelete = collect([]);
$service->environment_variables()->delete();
$commands = [];
foreach ($service->applications()->get() as $application) {
$storages = $application->persistentStorages()->get();
foreach ($storages as $storage) {
$storagesToDelete->push($storage);
}
}
foreach ($service->databases()->get() as $database) {
$storages = $database->persistentStorages()->get();
foreach ($storages as $storage) {
$storagesToDelete->push($storage);
}
}
foreach ($storagesToDelete as $storage) {
$server = data_get($service, 'server');
if ($deleteVolumes && $server->isFunctional()) {
$commands = [];
foreach ($service->applications()->get() as $application) {
foreach ($application->persistentStorages()->get() as $storage) {
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
if (! empty($commands)) {
foreach ($commands as $command) {
$result = instant_remote_process([$command], $server, false);
if ($result !== null && $result !== 0) {
Log::error('Error deleting volumes: '.$result);
}
}
}
}
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
} finally {
if ($deleteConfigurations) {
$service->deleteConfigurations();
}
foreach ($service->applications()->get() as $application) {
$application->forceDelete();
}
foreach ($service->databases()->get() as $database) {
$database->forceDelete();
foreach ($database->persistentStorages()->get() as $storage) {
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
}
foreach ($service->scheduled_tasks as $task) {
$task->delete();
}
$service->tags()->detach();
$service->forceDelete();
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
foreach ($commands as $command) {
instant_remote_process([$command], $server, false);
}
}
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
if ($deleteConfigurations) {
$service->deleteConfigurations();
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
}
public function deleteLocal(Service $service): void
{
foreach ($service->applications()->get() as $application) {
$application->forceDelete();
}
foreach ($service->databases()->get() as $database) {
$database->forceDelete();
}
foreach ($service->scheduled_tasks as $task) {
$task->delete();
}
$service->environment_variables()->delete();
$service->tags()->detach();
$service->forceDelete();
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Actions\Shared;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
class CheckDomainDns
{
use AsAction;
/**
* @param array<string, string> $entries
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
*/
public function handle(
array $entries,
?Server $server,
?string $expectedIp,
bool $skipForMultipleServers = false,
int $timeoutSeconds = 5,
): array {
if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) {
return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp);
}
if (! $server) {
return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null);
}
if ($skipForMultipleServers) {
return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp);
}
$deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000);
$dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers'))
->explode(',')
->map(fn ($dnsServer) => trim((string) $dnsServer))
->filter()
->values();
$results = [];
foreach ($entries as $key => $url) {
$results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline);
}
return $results;
}
/**
* @param array<int, string> $dnsServers
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array
{
try {
$host = Url::fromString($url)->getHost();
} catch (\Throwable) {
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
}
if (str($host)->contains('sslip.io')) {
return $this->result('ok', 'DNS looks correct.', $expectedIp);
}
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
foreach ($dnsServers as $dnsServer) {
$remainingNanoseconds = $deadline - hrtime(true);
if ($remainingNanoseconds < 1_000_000_000) {
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
}
try {
$query = app()->make(DNSQuery::class, [
'server' => $dnsServer,
'port' => 53,
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
]);
$records = $query->query($host, $type);
if ($records === false || $query->hasError()) {
continue;
}
foreach ($records as $record) {
if ($record->getType() !== $type) {
continue;
}
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
}
}
} catch (\Throwable) {
continue;
}
}
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
}
private function successMessage(Server $server, ?string $expectedIp): string
{
if (
filled($expectedIp)
&& filled($server->ip)
&& $server->ip !== $expectedIp
&& filter_var($server->ip, FILTER_VALIDATE_IP) === false
) {
return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare).";
}
return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.';
}
/**
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function result(string $status, string $message, ?string $expectedIp): array
{
return [
'status' => $status,
'message' => $message,
'expected_ip' => $expectedIp,
'checked_at' => now()->toIso8601String(),
];
}
/**
* @param array<string, string> $entries
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
*/
private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array
{
$result = $this->result($status, $message, $expectedIp);
return array_fill_keys(array_keys($entries), $result);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Actions\Team;
use App\Models\Application;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DeleteTeam
{
public function handle(Team $team, User $user): ?Team
{
$newTeam = DB::transaction(function () use ($team, $user): ?Team {
$team = Team::query()->lockForUpdate()->findOrFail($team->id);
$role = DB::table('team_user')
->where('team_id', $team->id)
->where('user_id', $user->id)
->lockForUpdate()
->value('role');
if ($role !== 'owner') {
throw new AuthorizationException('Only team owners can delete a team.');
}
$hasRunningApplications = Application::query()
->whereHas('environment.project', fn ($query) => $query->where('team_id', $team->id))
->lockForUpdate()
->get(['id', 'status'])
->contains(fn (Application $application): bool => $application->isRunning());
if ($hasRunningApplications) {
throw new RuntimeException('Stop all running applications before deleting this team.');
}
if ($team->servers()->lockForUpdate()->get(['servers.id'])->isNotEmpty()) {
throw new RuntimeException('Delete all team servers before deleting this team.');
}
if (! $team->isEmpty()) {
throw new RuntimeException('Delete all team resources before deleting this team.');
}
$team->members()
->where('users.id', '!=', $user->id)
->get()
->each(function (User $member) use ($team): void {
$member->teams()->detach($team);
DB::table('sessions')->where('user_id', $member->id)->delete();
});
$team->delete();
return $user->teams()->first();
});
Cache::forget("user:{$user->id}:team:{$team->id}");
return $newTeam;
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\ManageDevelopmentQemuVm;
use Illuminate\Console\Command;
use function Laravel\Prompts\multiselect;
class ManageDevelopmentQemuVmCommand extends Command
{
protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php}';
protected $description = 'Recreate selected development QEMU VMs and seed their Coolify servers';
public function handle(): int
{
if (! isDev()) {
$this->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\SeedDevelopmentQemuServer;
use Illuminate\Console\Command;
class SeedDevelopmentQemuServerCommand extends Command
{
protected $signature = 'dev:qemu:seed {profile : Profile key from config/development-qemu.php} {--keep-others}';
protected $description = 'Seed one development QEMU server in the Coolify database';
public function handle(): int
{
if (! isDev()) {
$this->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;
}
}
+59 -41
View File
@@ -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
@@ -242,25 +275,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);
@@ -273,6 +287,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;
@@ -284,14 +320,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;
}
@@ -382,14 +410,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}");
}
}
@@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
$githubApp = GithubApp::where('uuid', $githubAppUuid)
->where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
})
->first();
if (! $githubApp) {
return response()->json(['message' => 'Github App not found.'], 404);
}
@@ -15,9 +15,9 @@ use OpenApi\Attributes as OA;
class GithubController extends Controller
{
private function removeSensitiveData($githubApp)
private function removeSensitiveData(GithubApp $githubApp, int $teamId)
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) {
$githubApp->makeVisible([
'client_secret',
'webhook_secret',
@@ -97,8 +97,8 @@ class GithubController extends Controller
->orWhere('is_system_wide', true);
})->get();
$githubApps = $githubApps->map(function ($app) {
return $this->removeSensitiveData($app);
$githubApps = $githubApps->map(function ($app) use ($teamId) {
return $this->removeSensitiveData($app, $teamId);
});
return response()->json($githubApps);
@@ -642,7 +642,7 @@ class GithubController extends Controller
$rules['webhook_secret'] = 'string';
}
if (isset($payload['private_key_uuid'])) {
$rules['private_key_uuid'] = 'string|uuid';
$rules['private_key_uuid'] = 'string';
}
if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean';
@@ -13,9 +13,9 @@ use OpenApi\Attributes as OA;
class GitlabController extends Controller
{
private function removeSensitiveData(GitlabApp $gitlabApp)
private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId)
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) {
$gitlabApp->makeVisible([
'client_secret',
'webhook_token',
@@ -108,8 +108,8 @@ class GitlabController extends Controller
->orWhere('is_system_wide', true);
})->get();
$gitlabApps = $gitlabApps->map(function ($app) {
return $this->removeSensitiveData($app);
$gitlabApps = $gitlabApps->map(function ($app) use ($teamId) {
return $this->removeSensitiveData($app, $teamId);
});
return response()->json($gitlabApps);
@@ -280,7 +280,7 @@ class GitlabController extends Controller
'gitlab_app_name' => $gitlabApp->name,
]);
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201);
return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201);
} catch (\Throwable $e) {
return handleError($e);
}
@@ -441,7 +441,7 @@ class GitlabController extends Controller
return response()->json([
'message' => 'GitLab app updated successfully',
'data' => $this->removeSensitiveData($gitlabApp->fresh()),
'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId),
]);
} catch (ModelNotFoundException $e) {
return response()->json([
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\InstanceSettings;
use App\Rules\ValidHostname;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use OpenApi\Attributes as OA;
class InstanceEmailSettingsController extends Controller
{
private const FIELDS = [
'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host',
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
];
#[OA\Get(
summary: 'Get instance email settings',
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.',
path: '/settings/email', operationId: 'get-instance-email-settings',
security: [['bearerAuth' => []]], 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);
}
}
@@ -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' => []]],
@@ -158,6 +158,8 @@ class ProjectController extends Controller
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('view', $project);
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
if (! $environment) {
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
@@ -550,11 +550,7 @@ class ServersController extends Controller
}
$foundServer = ModelsServer::whereIp($request->ip)->first();
if ($foundServer) {
if ($foundServer->team_id === $teamId) {
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
}
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
+9 -7
View File
@@ -56,7 +56,7 @@ class TeamController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$teams = auth()->user()->teams->sortBy('id');
$teams = auth()->user()->teams->where('id', $teamId)->values();
$teams = $teams->map(function ($team) {
return $this->removeSensitiveData($team);
});
@@ -100,13 +100,14 @@ class TeamController extends Controller
)]
public function team_by_id(Request $request)
{
$id = $request->id;
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$teams = auth()->user()->teams;
$team = $teams->where('id', $id)->first();
if ((int) $request->id !== (int) $teamId) {
return response()->json(['message' => 'Team not found.'], 404);
}
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
@@ -159,13 +160,14 @@ class TeamController extends Controller
)]
public function members_by_id(Request $request)
{
$id = $request->id;
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$teams = auth()->user()->teams;
$team = $teams->where('id', $id)->first();
if ((int) $request->id !== (int) $teamId) {
return response()->json(['message' => 'Team not found.'], 404);
}
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
@@ -34,7 +34,7 @@ use RuntimeException;
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000),
],
type: 'object',
additionalProperties: false,
@@ -261,7 +261,7 @@ class VolumeBackupsController extends Controller
string $resourceType,
Model $resource,
): JsonResponse {
$backup = $storage->scheduledBackups()->updateOrCreate([], [
$attributes = [
'team_id' => $teamId,
'frequency' => $request->string('frequency')->toString(),
'enabled' => $request->boolean('enabled', true),
@@ -275,8 +275,12 @@ class VolumeBackupsController extends Controller
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
'retention_days_s3' => $request->integer('retention_days_s3'),
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
'timeout' => $request->integer('timeout', 3600),
]);
];
if ($request->has('timeout')) {
$attributes['timeout'] = $request->integer('timeout');
}
$backup = $storage->scheduledBackups()->updateOrCreate([], $attributes);
$created = $backup->wasRecentlyCreated;
auditLog('api.volume_backup.schedule_set', [
+99 -51
View File
@@ -8,12 +8,15 @@ use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
@@ -95,61 +98,105 @@ class Controller extends BaseController
return response()->json(['message' => 'Transactional emails are not active'], 400);
}
public function link()
public function link(): View|RedirectResponse
{
$token = request()->get('token');
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->first();
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) {
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$invitation->delete();
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
$credentials = is_string($token) ? $this->magicLinkCredentials($token) : null;
if (! $credentials) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
return redirect()->route('login')->with('error', 'Invalid credentials.');
[$user, $invitation] = $credentials;
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(),
'formAction' => route('auth.link.accept'),
'token' => $token,
]);
}
public function acceptLink(Request $request): RedirectResponse
{
$token = $request->input('token');
if (! is_string($token)) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
$acceptedInvitation = DB::transaction(function () use ($token) {
$credentials = $this->magicLinkCredentials($token, lockForUpdate: true);
if (! $credentials) {
return null;
}
[$user, $invitation] = $credentials;
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
$invitation->delete();
return [$user, $team];
});
if (! $acceptedInvitation) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
[$user, $team] = $acceptedInvitation;
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
/**
* @return array{0: User, 1: TeamInvitation}|null
*/
private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array
{
if ($token === '') {
return null;
}
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return null;
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} elseif (count($payload) === 2) {
[$email, $password] = $payload;
$invitationUuid = null;
} else {
return null;
}
$email = Str::lower($email);
$user = User::query()->where('email', $email)->first();
$invitationQuery = TeamInvitation::query()
->where('email', $email)
->when($lockForUpdate, fn ($query) => $query->lockForUpdate());
$invitation = $invitationUuid
? $invitationQuery->where('uuid', $invitationUuid)->first()
: $invitationQuery->get()->first(
fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token)
);
if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) {
return null;
}
return Hash::check($password, $user->password) ? [$user, $invitation] : null;
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
@@ -185,6 +232,7 @@ class Controller extends BaseController
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $alreadyMember,
'formAction' => route('team.invitation.accept', $invitation->uuid),
]);
}
+1
View File
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
@@ -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));
+39 -5
View File
@@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
@@ -612,6 +614,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $this->dockerImagePreviewTag;
}
if ($this->rollback && str($this->commit)->isNotEmpty()) {
return $this->commit;
}
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
return $this->application->docker_registry_image_tag;
}
@@ -3977,15 +3983,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
);
} else {
$this->execute_remote_command(
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
);
$this->removeContainerWithTimeout($containerName);
}
} catch (Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
}
}
private function removeContainerWithTimeout(string $containerName): void
{
$outputKey = 'container_remove_'.md5($containerName);
$this->execute_remote_command([
dockerRemoveCommandWithTimeout($containerName),
'hidden' => true,
'ignore_errors' => true,
'save' => $outputKey,
'append' => false,
]);
if (! isset($this->saved_outputs)) {
return;
}
$output = (string) $this->saved_outputs->get($outputKey, '');
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
return;
}
$this->application_deployment_queue->addLogEntry(
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
'stderr'
);
RemoveContainerJob::dispatch($this->server->id, $containerName)
->delay(now()->addMinutes(5));
}
private function stop_running_container(bool $force = false)
{
try {
@@ -5016,9 +5052,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// do not remove already running container for PR deployments
} else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command(
["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true]
);
$this->removeContainerWithTimeout($this->container_name);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Jobs;
use App\Actions\Shared\CheckDomainDns;
use App\Models\Application;
use App\Models\Server;
use App\Models\ServiceApplication;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 30;
public function __construct(
public Application|ServiceApplication $resource,
public string $statusKey,
public string $url,
public ?Server $server,
public ?string $expectedIp,
public string $checkId,
public bool $skipForMultipleServers = false,
) {}
public function handle(): void
{
$this->persistResults(CheckDomainDns::run(
[$this->statusKey => $this->url],
$this->server,
$this->expectedIp,
$this->skipForMultipleServers,
));
}
public function failed(?\Throwable $exception): void
{
$this->persistResults([
$this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'),
]);
}
/**
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function status(string $status, string $message): array
{
return [
'status' => $status,
'message' => $message,
'expected_ip' => $this->expectedIp,
'checked_at' => now()->toIso8601String(),
];
}
/**
* @param array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}> $results
*/
private function persistResults(array $results): void
{
DB::transaction(function () use ($results): void {
$resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey());
if (! $resource) {
return;
}
$statuses = $resource->domain_dns_statuses ?? [];
foreach ($results as $key => $result) {
if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) {
continue;
}
$statuses[$key] = $result;
}
$resource->domain_dns_statuses = $statuses === [] ? null : $statuses;
$resource->save();
});
}
}
+7 -1
View File
@@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
$this->clearOutdatedInfo();
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
// Update detected version in database
$this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) {
@@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
ProxyStatusChangedUI::dispatch($this->server->team_id);
}
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
}
/**
* Get information about newer branches if available.
*/
+18 -12
View File
@@ -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', [
+1 -1
View File
@@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
$command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}");
instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
}
}
+67 -42
View File
@@ -4,7 +4,6 @@ namespace App\Jobs;
use App\Actions\Application\StopApplication;
use App\Actions\Database\StopDatabase;
use App\Actions\Server\CleanupDocker;
use App\Actions\Service\DeleteService;
use App\Actions\Service\StopService;
use App\Actions\Shared\DeleteScheduledVolumeBackup;
@@ -28,6 +27,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -43,20 +44,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->onQueue('high');
}
public function handle()
public function handle(): void
{
if (! $this->resource instanceof ApplicationPreview) {
$this->deleteScheduledVolumeBackups();
if ($this->resource instanceof ApplicationPreview) {
DB::transaction(function (): void {
$this->deleteApplicationPreview();
});
return;
}
try {
// Handle ApplicationPreview instances separately
if ($this->resource instanceof ApplicationPreview) {
$this->deleteApplicationPreview();
return;
}
switch ($this->resource->type()) {
case 'application':
StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
@@ -73,21 +71,71 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
break;
case 'service':
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
return;
app(DeleteService::class)->cleanupRemote(
$this->resource,
$this->deleteVolumes,
$this->deleteConnectedNetworks,
$this->deleteConfigurations,
);
break;
}
if ($this->deleteConfigurations) {
$this->resource->deleteConfigurations();
if (! $this->resource instanceof Service) {
if ($this->deleteConfigurations) {
$this->resource->deleteConfigurations();
}
if ($this->deleteVolumes) {
$this->resource->deleteVolumes();
}
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
$this->resource->deleteConnectedNetworks();
}
}
} catch (\Throwable $e) {
Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->type(),
'error' => $e->getMessage(),
]);
}
DB::transaction(function (): void {
try {
$this->deleteScheduledVolumeBackups();
} catch (\Throwable $e) {
Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->type(),
'error' => $e->getMessage(),
]);
}
if ($this->resource instanceof Service) {
app(DeleteService::class)->deleteLocal($this->resource);
return;
}
if ($this->deleteVolumes) {
$this->resource->deleteVolumes();
$this->resource->persistentStorages()->delete();
}
$this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag
$this->resource->fileStorages()->delete();
$isDatabase = $this->resource instanceof StandalonePostgresql
if ($this->isDatabase()) {
$this->resource->sslCertificates()->delete();
$this->resource->scheduledBackups()->delete();
$this->resource->tags()->detach();
}
$this->resource->environment_variables()->delete();
$this->resource->forceDelete();
});
Artisan::queue('cleanup:stucked-resources');
}
private function isDatabase(): bool
{
return $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis
|| $this->resource instanceof StandaloneMongodb
|| $this->resource instanceof StandaloneMysql
@@ -95,29 +143,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|| $this->resource instanceof StandaloneKeydb
|| $this->resource instanceof StandaloneDragonfly
|| $this->resource instanceof StandaloneClickhouse;
if ($isDatabase) {
$this->resource->sslCertificates()->delete();
$this->resource->scheduledBackups()->delete();
$this->resource->tags()->detach();
}
$this->resource->environment_variables()->delete();
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
$this->resource->deleteConnectedNetworks();
}
} catch (\Throwable $e) {
throw $e;
} finally {
$this->resource->forceDelete();
if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if ($server) {
CleanupDocker::dispatch($server, false, false);
}
}
Artisan::queue('cleanup:stucked-resources');
}
}
private function deleteScheduledVolumeBackups(): void
+34
View File
@@ -155,4 +155,38 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
}
}
}
public function failed(?\Throwable $exception): void
{
$execution = DockerCleanupExecution::query()
->where('server_id', $this->server->id)
->where('status', 'running')
->whereNull('finished_at')
->latest('id')
->first();
if (! $execution) {
return;
}
$message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.';
$updated = DockerCleanupExecution::query()
->whereKey($execution->id)
->where('status', 'running')
->whereNull('finished_at')
->update([
'status' => 'failed',
'message' => $message,
'finished_at' => Carbon::now()->toImmutable(),
]);
if ($updated === 0) {
return;
}
$execution->refresh();
event(new DockerCleanupDone($execution));
$this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$message));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RemoveContainerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 90;
public function __construct(public int $serverId, public string $containerName) {}
public function handle(): void
{
$server = Server::findOrFail($this->serverId);
instant_remote_process(
[dockerRemoveCommandWithTimeout($this->containerName)],
$server,
timeout: 75,
disableMultiplexing: true,
);
}
public function backoff(): array
{
return [300, 900];
}
public function failed(?\Throwable $exception): void
{
Log::warning('Deferred container removal failed', [
'server_id' => $this->serverId,
'container' => $this->containerName,
'error' => $exception?->getMessage(),
]);
}
}
+14 -2
View File
@@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;
/**
* The number of times the job may be attempted.
*/
@@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
@@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function boundedTaskCommand(string $command): string
{
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;
$readLimit = $maxOutputBytes + 1;
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
+2 -2
View File
@@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
public int $maxExceptions = 1;
public int $timeout = 3600;
public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
private ?ScheduledVolumeBackupExecution $execution = null;
public function __construct(public ScheduledVolumeBackup $backup)
{
$this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600;
$this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT;
}
public function middleware(): array
@@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',
dockerRemoveCommand('coolify-proxy'),
], $server);
}
}
+14 -1
View File
@@ -29,7 +29,10 @@ class ActivityMonitor extends Component
public static $eventDispatched = false;
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
protected $listeners = [
'activityMonitor' => 'newMonitorActivity',
'processDialogClosed' => 'clearActivity',
];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{
@@ -50,6 +53,16 @@ class ActivityMonitor extends Component
$this->isPollingActive = true;
}
public function clearActivity(): void
{
$this->activityId = null;
$this->activity = null;
$this->isPollingActive = false;
$this->eventToDispatch = 'activityFinished';
$this->eventData = null;
self::$eventDispatched = false;
}
public function hydrateActivity()
{
if ($this->activityId === null) {
+1 -1
View File
@@ -81,7 +81,7 @@ class Show extends Component
}
$safeNetwork = escapeshellarg($this->destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server);
instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server);
}
$this->destination->delete();
@@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Dev;
use Illuminate\Http\Exceptions\HttpResponseException;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
class LivewireRequestFailurePreview extends Component
{
/**
* @var list<int>
*/
public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530];
public function fail(int $status): never
{
abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND);
throw new HttpResponseException(response(
'<!doctype html><html><body><h1>Gateway time-out</h1><p>cloudflare proxy error '.$status.'</p></body></html>',
$status,
['Content-Type' => 'text/html']
));
}
public function render(): mixed
{
return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple');
}
}
+2 -19
View File
@@ -2,10 +2,8 @@
namespace App\Livewire;
use App\Actions\Team\DeleteTeam;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@@ -28,22 +26,7 @@ class NavbarDeleteTeam extends Component
$currentTeam = currentTeam();
$this->authorize('delete', $currentTeam);
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
$newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
refreshSession($newTeam);
return redirect()->route('team.index');
+10
View File
@@ -6,6 +6,7 @@ use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
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;
@@ -57,6 +58,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;
@@ -129,6 +133,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;
@@ -140,6 +145,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;
@@ -175,6 +181,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)
@@ -261,6 +268,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.',
@@ -284,6 +292,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.');
@@ -375,6 +384,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;
+212 -54
View File
@@ -2,6 +2,8 @@
namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
@@ -10,6 +12,7 @@ use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Domains extends Component
@@ -141,6 +144,39 @@ class Domains extends Component
$this->loadDomainState();
}
public function pollDnsChecks(): void
{
$this->authorize('view', $this->application);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
protected function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
{
$this->authorize('update', $this->application);
@@ -464,6 +500,7 @@ class Domains extends Component
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
@@ -478,6 +515,7 @@ class Domains extends Component
'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp,
'checked_at' => null,
'check_id' => null,
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
@@ -533,6 +571,8 @@ class Domains extends Component
|| ! $server
|| $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$reason = ! $this->dnsValidationEnabled
@@ -548,7 +588,11 @@ class Domains extends Component
continue;
}
$this->applyDnsStatus($index, $row['url'], $server);
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistDomainDnsStatuses();
@@ -575,45 +619,50 @@ class Domains extends Component
return;
}
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
$this->applyDnsStatus($index, $server);
$this->persistDomainDnsStatuses();
}
protected function applyDnsStatus(int $index, string $url, Server $server): void
protected function applyDnsStatus(int $index, Server $server): void
{
$target = $this->dnsTargetLabel();
$this->applyDnsStatuses([$index], $server);
}
try {
$isValid = validateDNSEntry($url, $server);
if ($isValid) {
$this->domainRows[$index]['dns_status'] = 'ok';
$this->domainRows[$index]['dns_message'] = $target
? "DNS points to {$target} (or Cloudflare)."
: 'DNS looks correct.';
} else {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
/**
* @param array<int, int> $indexes
*/
protected function applyDnsStatuses(array $indexes, Server $server): void
{
$entries = [];
foreach ($indexes as $index) {
$entries[(string) $index] = $this->domainRows[$index]['url'];
}
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
foreach ($results as $index => $result) {
$index = (int) $index;
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
// Keep suggested-row copy short after DNS checks (no role badge).
if ($this->domainRows[$index]['is_suggested'] ?? false) {
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$serviceName = $this->domainRows[$index]['service'] ?? null;
$meta = $this->suggestedDomainMeta(
$isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
);
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
}
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
}
// Keep suggested-row copy short after DNS checks (no role badge).
if ($this->domainRows[$index]['is_suggested'] ?? false) {
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$serviceName = $this->domainRows[$index]['service'] ?? null;
$meta = $this->suggestedDomainMeta(
$isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
);
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
$this->domainRows[$index]['check_id'] = null;
}
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
/**
@@ -647,11 +696,34 @@ class Domains extends Component
'message' => (string) ($row['dns_message'] ?? ''),
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
'check_id' => $row['check_id'] ?? null,
];
}
DB::transaction(function () use (&$statuses): void {
$application = Application::query()->lockForUpdate()->findOrFail($this->application->id);
$storedStatuses = $application->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
$statuses[$key] = $storedStatuses[$key];
continue;
}
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
$statuses[$key] = $storedStatuses[$key];
}
}
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$application->save();
});
$this->application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$this->application->save();
}
protected function pruneDomainDnsStatusesToCurrentDomains(): void
@@ -804,16 +876,6 @@ class Domains extends Component
}
}
if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) {
$dnsFailure = $this->findDnsFailureMessage($newUrls);
if ($dnsFailure !== null) {
$this->addDomainDnsFailed = true;
$this->addDomainDnsMessage = $dnsFailure;
return;
}
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
if (! $this->saveDomainList($merged, $this->newDomainService)) {
@@ -825,14 +887,110 @@ class Domains extends Component
$serviceForCheck = $this->newDomainService;
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
->map(fn (string $url, string $statusKey) => [
'status_key' => $statusKey,
'url' => $url,
'check_id' => new_public_id(),
]);
foreach ($dnsChecks as $dnsCheck) {
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
}
$this->persistDomainDnsStatuses();
$failedDnsChecks = 0;
foreach ($dnsChecks as $dnsCheck) {
try {
CheckDomainDnsJob::dispatch(
$this->application,
$dnsCheck['status_key'],
$dnsCheck['url'],
$this->application->destination?->server,
$this->serverIp,
$dnsCheck['check_id'],
$this->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$failedDnsChecks++;
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
}
}
if ($failedDnsChecks > 0) {
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
}
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
? 'Domain added.'
: 'Domain added. DNS check started.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void
{
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ($service !== null && ($row['service'] ?? null) !== $service) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void
{
$this->markUrlsAsChecking($urls, $service, $checkId);
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ($service !== null && ($row['service'] ?? null) !== $service) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
/**
* @param array<int, string> $urls
* @return array<string, string>
*/
protected function dnsEntriesForUrls(array $urls, ?string $service = null): array
{
$entries = [];
foreach ($urls as $url) {
$entries[$this->domainDnsStatusKey($url, $service)] = $url;
}
return $entries;
}
/**
* Run a first-time DNS check for newly added/updated domain URLs and persist results.
*
@@ -875,7 +1033,11 @@ class Domains extends Component
continue;
}
$this->applyDnsStatus($index, $url, $server);
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistDomainDnsStatuses();
@@ -909,15 +1071,11 @@ class Domains extends Component
return null;
}
$target = $this->dnsTargetLabel() ?? $server->ip;
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
foreach ($urls as $url) {
try {
if (! validateDNSEntry($url, $server)) {
return dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
return 'Could not validate DNS for this domain.';
foreach ($results as $result) {
if ($result['status'] === 'failed') {
return $result['message'];
}
}
+26 -2
View File
@@ -796,6 +796,8 @@ EOD;
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - converts custom-format (PGDMP) archives to SQL with pg_restore
* before scanning, and rejects archives that cannot be inspected,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
@@ -817,8 +819,30 @@ EOD;
$escapedSqlPattern = escapeshellarg($sqlPattern);
$escapedPsqlPattern = escapeshellarg($psqlPattern);
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
$scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
$customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
$sqlScan = $scan($contents);
$blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
$blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
return <<<SH
header=\$({$contents} | head -c 5)
if [ "\$header" = 'PGDMP' ]; then
inspect=\$(mktemp)
trap 'rm -f "\$inspect"' EXIT
if ! {$contents} > "\$inspect"; then
{$blockedInspect}
fi
if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
{$blockedInspect}
fi
if {$customScan}; then
{$blockedProgram}
fi
elif {$sqlScan}; then
{$blockedProgram}
fi
SH;
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
@@ -883,7 +907,7 @@ EOD;
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
$restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
+11 -4
View File
@@ -26,10 +26,17 @@ class Configuration extends Component
public array $parameters;
protected $listeners = [
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
return [
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
'configurationChanged' => 'refreshServices',
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices',
];
}
public function render()
{
+182 -43
View File
@@ -2,6 +2,8 @@
namespace App\Livewire\Project\Service;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
@@ -131,6 +133,39 @@ class Domains extends Component
$this->loadDomainState();
}
public function pollDnsChecks(): void
{
$this->authorize('view', $this->service);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']);
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
protected function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
{
$application = $this->service->applications()->findOrFail($serviceApplicationId);
@@ -282,6 +317,7 @@ class Domains extends Component
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
@@ -298,6 +334,7 @@ class Domains extends Component
'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp,
'checked_at' => null,
'check_id' => null,
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
@@ -404,6 +441,8 @@ class Domains extends Component
$server = $this->service->server;
$skipDns = ! $this->dnsValidationEnabled || ! $server;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$this->domainRows[$index]['dns_status'] = 'skipped';
@@ -415,7 +454,11 @@ class Domains extends Component
continue;
}
$this->applyDnsStatus($index, $row['url'], $server);
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistAllDomainDnsStatuses();
@@ -443,33 +486,37 @@ class Domains extends Component
return;
}
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
$this->applyDnsStatus($index, $server);
$this->persistAllDomainDnsStatuses();
}
protected function applyDnsStatus(int $index, string $url, Server $server): void
protected function applyDnsStatus(int $index, Server $server): void
{
$target = $this->dnsTargetLabel();
$this->applyDnsStatuses([$index], $server);
}
try {
$isValid = validateDNSEntry($url, $server);
if ($isValid) {
$this->domainRows[$index]['dns_status'] = 'ok';
$this->domainRows[$index]['dns_message'] = $target
? "DNS points to {$target} (or Cloudflare)."
: 'DNS looks correct.';
} else {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
/**
* @param array<int, int> $indexes
*/
protected function applyDnsStatuses(array $indexes, Server $server): void
{
$entries = [];
foreach ($indexes as $index) {
$entries[(string) $index] = $this->domainRows[$index]['url'];
}
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
foreach ($results as $index => $result) {
$index = (int) $index;
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
$this->domainRows[$index]['check_id'] = null;
$this->decorateSuggestedDomainAfterDnsCheck($index);
}
}
/**
@@ -516,6 +563,7 @@ class Domains extends Component
'message' => (string) ($row['dns_message'] ?? ''),
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
'check_id' => $row['check_id'] ?? null,
];
}
@@ -528,8 +576,30 @@ class Domains extends Component
->all();
$statuses = array_intersect_key($statuses, array_flip($currentUrls));
DB::transaction(function () use ($app, &$statuses): void {
$application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id);
$storedStatuses = $application->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
$statuses[$key] = $storedStatuses[$key];
continue;
}
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
$statuses[$key] = $storedStatuses[$key];
}
}
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$application->save();
});
$app->domain_dns_statuses = $statuses === [] ? null : $statuses;
$app->save();
}
$this->service->load('applications');
@@ -928,16 +998,6 @@ class Domains extends Component
}
}
if (! $this->forceSaveDns && $this->shouldValidateDns()) {
$dnsFailure = $this->findDnsFailureMessage($newUrls);
if ($dnsFailure !== null) {
$this->addDomainDnsFailed = true;
$this->addDomainDnsMessage = $dnsFailure;
return;
}
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
@@ -955,14 +1015,93 @@ class Domains extends Component
$this->forceRemovePort = false;
$this->pendingAction = null;
$this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$serviceApplicationId = (int) $app->id;
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
'url' => $url,
'check_id' => new_public_id(),
]);
foreach ($dnsChecks as $dnsCheck) {
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
}
$this->persistAllDomainDnsStatuses();
$failedDnsChecks = 0;
foreach ($dnsChecks as $dnsCheck) {
try {
CheckDomainDnsJob::dispatch(
$app,
$dnsCheck['url'],
$dnsCheck['url'],
$this->service->server,
$this->serverIp,
$dnsCheck['check_id'],
);
} catch (\Throwable) {
$failedDnsChecks++;
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
}
}
if ($failedDnsChecks > 0) {
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
}
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
? 'Domain added.'
: 'Domain added. DNS check started.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void
{
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void
{
$this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId);
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
public function startEdit(int $index): void
{
if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) {
@@ -1309,7 +1448,11 @@ class Domains extends Component
continue;
}
$this->applyDnsStatus($index, $url, $server);
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistAllDomainDnsStatuses();
@@ -1330,15 +1473,11 @@ class Domains extends Component
return null;
}
$target = $this->dnsTargetLabel() ?? $server->ip;
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
foreach ($urls as $url) {
try {
if (! validateDNSEntry($url, $server)) {
return dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
return 'Could not validate DNS for this domain.';
foreach ($results as $result) {
if ($result['status'] === 'failed') {
return $result['message'];
}
}
@@ -99,8 +99,8 @@ class Create extends Component
$label = str($resource->name)->headline();
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id,
'type' => 'Volume · '.$label,
'name' => $volume->name,
'type' => $label,
'name' => str($volume->name)->after($this->service->uuid.'_')->value(),
]));
$targets->push(...$resource->fileStorages()
->where('is_directory', true)
@@ -109,8 +109,8 @@ class Create extends Component
->get()
->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id,
'type' => 'Directory · '.$label,
'name' => $directory->fs_path,
'type' => $label,
'name' => $directory->fs_path.' (directory)',
]));
}
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Events\ApplicationConfigurationChanged;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
@@ -298,6 +299,10 @@ class Show extends Component
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
if ($this->is_required && $this->resource instanceof Service) {
event(new ApplicationConfigurationChanged($this->resource->team()->id));
}
} catch (\Exception $e) {
return handleError($e);
}
+34 -8
View File
@@ -25,6 +25,8 @@ class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
@@ -154,14 +156,12 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
} else {
if ($this->server->isSwarm()) {
@@ -170,22 +170,39 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
}
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;
return;
}
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
$newOutputs = implode('', $logChunks);
@@ -198,6 +215,10 @@ class GetLogs extends Component
})->join("\n");
}
if ($truncated) {
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
}
// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
}
@@ -239,6 +260,7 @@ class GetLogs extends Component
$command = $command[0];
}
$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
@@ -252,20 +274,19 @@ class GetLogs extends Component
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
$logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
}
$truncated = true;
return;
}
$logChunks[] = $output;
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
@@ -287,6 +308,11 @@ class GetLogs extends Component
return sanitizeLogsForExport($allLogs);
}
private function boundedLogCommand(string $command, int $maxBytes): string
{
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
}
public function render()
{
return view('livewire.project.shared.get-logs');
@@ -2,7 +2,10 @@
namespace App\Livewire\Project\Shared\ScheduledTask;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use App\Models\StandalonePostgresql;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
@@ -59,13 +62,13 @@ class Add extends Component
// Get the resource based on type and id
switch ($this->type) {
case 'application':
$this->resource = \App\Models\Application::findOrFail($this->id);
$this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id);
break;
case 'service':
$this->resource = \App\Models\Service::findOrFail($this->id);
$this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id);
break;
case 'standalone-postgresql':
$this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id);
$this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id);
break;
default:
throw new \Exception('Invalid resource type');
@@ -10,6 +10,7 @@ use Livewire\Component;
class Executions extends Component
{
#[Locked]
public ScheduledTask $task;
#[Locked]
@@ -28,6 +29,7 @@ class Executions extends Component
public $logsPerPage = 100;
#[Locked]
public $selectedExecution = null;
public $isPollingActive = false;
@@ -45,7 +47,7 @@ class Executions extends Component
{
try {
$this->taskId = $taskId;
$this->task = ScheduledTask::findOrFail($taskId);
$this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
$this->executions = $this->task->executions()->take(20)->get();
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
if (! $this->serverTimezone) {
@@ -15,8 +15,10 @@ class Show extends Component
{
use AuthorizesRequests;
#[Locked]
public Application|Service $resource;
#[Locked]
public ScheduledTask $task;
#[Locked]
@@ -115,6 +117,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->isEnabled = ! $this->isEnabled;
$this->task->enabled = $this->isEnabled;
$this->task->save();
@@ -128,6 +131,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.');
$this->refreshTasks();
@@ -140,6 +144,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.');
} catch (\Exception $e) {
@@ -160,6 +165,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->resource);
$this->authorize('delete', $this->task);
$this->task->delete();
if ($this->type === 'application') {
@@ -176,6 +182,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task);
$this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) {
@@ -56,7 +56,7 @@ class VolumeBackups extends Component
public string $timezone = '';
public int $timeout = 3600;
public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
public int $perPage = 10;
+8 -2
View File
@@ -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();
@@ -2,7 +2,6 @@
namespace App\Livewire\Server;
use App\Models\DockerCleanupExecution;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component
->get();
if ($this->selectedKey) {
$this->selectedExecution = DockerCleanupExecution::find($this->selectedKey);
$this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey);
if ($this->selectedExecution && $this->selectedExecution->status !== 'running') {
$this->isPollingActive = false;
}
@@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component
return;
}
$this->selectedKey = $key;
$this->selectedExecution = DockerCleanupExecution::find($key);
$this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key);
$this->currentPage = 1;
if ($this->selectedExecution && $this->selectedExecution->status === 'running') {
+16 -3
View File
@@ -163,6 +163,7 @@ class Navbar extends Component
$previousStatus = $this->proxyStatus;
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->dispatchProxyConfigurationState();
// If event contains activityId, open activity monitor
if ($event && isset($event['activityId'])) {
@@ -227,6 +228,16 @@ class Navbar extends Component
{
$this->server->refresh();
$this->server->load('settings');
$this->dispatchProxyConfigurationState();
}
private function dispatchProxyConfigurationState(): void
{
$this->dispatch(
'proxy-configuration-state-changed',
pending: $this->server->hasPendingProxyConfiguration(),
traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(),
);
}
public function refreshSentinelStatus($event = null): void
@@ -248,10 +259,12 @@ class Navbar extends Component
return false;
}
// Check if server has outdated info stored
$outdatedInfo = $this->server->traefik_outdated_info;
return $this->server->hasCurrentTraefikOutdatedInfo();
}
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
public function getHasPendingProxyConfigurationProperty(): bool
{
return $this->server->hasPendingProxyConfiguration();
}
public function render()
+5 -1
View File
@@ -161,6 +161,7 @@ class Proxy extends Component
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -175,6 +176,7 @@ class Proxy extends Component
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -276,7 +278,9 @@ class Proxy extends Component
// Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info;
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
$storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) {
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
@@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
{
use AuthorizesRequests;
public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;
public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_CONFIGURATION_FILES = 100;
public ?Server $server = null;
public $parameters = [];
@@ -44,15 +50,36 @@ class DynamicConfigurations extends Component
return handleError($e, $this);
}
$proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server);
$fileLimit = self::MAX_CONFIGURATION_FILES + 1;
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
$files = $files->map(fn ($file) => trim($file));
$files = $files->sort();
$contents = collect([]);
foreach ($files as $file) {
$skippedFiles = collect([]);
$totalBytes = 0;
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
$skippedFiles->push('additional files');
}
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
$without_extension = str_replace('.', '|', $file);
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server);
$contents[$without_extension] = $content ?? '';
$filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
$readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
$content = $content ?? '';
$contentBytes = strlen($content);
if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
$skippedFiles->push($file);
continue;
}
$contents[$without_extension] = $content;
$totalBytes += $contentBytes;
}
if ($skippedFiles->isNotEmpty()) {
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
}
$this->contents = $contents;
$this->dispatch('$refresh');
+1 -1
View File
@@ -146,7 +146,7 @@ class Sentinel extends Component
{
try {
$this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated.');
$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
+6 -1
View File
@@ -20,6 +20,9 @@ class Index extends Component
#[Validate('nullable|string|max:255|url')]
public ?string $fqdn = null;
#[Validate('boolean')]
public bool $is_dashboard_force_https_enabled = true;
#[Validate('required|integer|min:1025|max:65535')]
public int $public_port_min;
@@ -68,6 +71,7 @@ class Index extends Component
$this->server = Server::findOrFail(0);
}
$this->fqdn = $this->settings->fqdn;
$this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
$this->instance_name = $this->settings->instance_name;
@@ -91,6 +95,7 @@ class Index extends Component
$this->authorize('update', $this->settings);
$this->validate();
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled;
$this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name;
@@ -207,7 +212,7 @@ class Index extends Component
return;
}
$imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");
$buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
+18
View File
@@ -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;
@@ -167,6 +174,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.',
@@ -184,6 +192,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;
@@ -234,11 +243,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,
+8 -2
View File
@@ -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);
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ class Create extends Component
'personal_team' => false,
'is_mcp_server_enabled' => true,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
auth()->user()->teams()->attach($team, ['role' => 'owner']);
refreshSession($team);
return redirectRoute($this, 'team.index');
+8 -19
View File
@@ -2,11 +2,9 @@
namespace App\Livewire\Team;
use App\Actions\Team\DeleteTeam;
use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class DangerZone extends Component
@@ -25,22 +23,7 @@ class DangerZone extends Component
try {
$currentTeam = currentTeam();
$this->authorize('delete', $currentTeam);
$currentTeam->members->each(function ($user) use ($currentTeam): void {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
$newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
refreshSession($newTeam);
return redirect()->route('team.index');
@@ -49,6 +32,12 @@ class DangerZone extends Component
}
}
public function refreshResources(): void
{
$this->team = Team::query()->findOrFail($this->team->id);
refreshSession($this->team);
}
public function render(): mixed
{
return view('livewire.team.danger-zone');
+8 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Invitations extends Component
@@ -21,12 +22,14 @@ class Invitations extends Component
$this->authorize('manageInvitations', currentTeam());
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
DB::transaction(function () use ($invitation): void {
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
$invitation->delete();
$invitation->delete();
});
$this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) {
+17 -8
View File
@@ -7,6 +7,7 @@ use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Member extends Component
@@ -25,8 +26,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -43,8 +46,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -61,8 +66,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -79,8 +86,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam());
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
DB::transaction(function () use ($teamId): void {
$this->member->teams()->detach($teamId);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}");
+1 -1
View File
@@ -47,7 +47,7 @@ class Index extends Component
return [
'name' => data_get($container, 'Names'),
'connection_name' => data_get($container, 'Names'),
'uuid' => data_get($container, 'Names'),
'uuid' => $server->uuid.':'.data_get($container, 'Names'),
'status' => data_get_str($container, 'State')->lower(),
'server' => $server,
'server_uuid' => $server->uuid,
+2
View File
@@ -20,6 +20,8 @@ class Upgrade extends Component
public bool $devMode = false;
public bool $fullButton = false;
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()
+18 -3
View File
@@ -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,12 @@ 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<ApplicationFactory> */
use HasFactory;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
private static $parserVersion = '5';
@@ -2109,6 +2115,9 @@ class Application extends BaseModel
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
@@ -2139,7 +2148,7 @@ class Application extends BaseModel
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
} else {
$commands = collect([
@@ -2151,11 +2160,14 @@ class Application extends BaseModel
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
}
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
}
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
@@ -2171,6 +2183,9 @@ class Application extends BaseModel
}
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
+16 -6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use RuntimeException;
use Spatie\Url\Url;
class ApplicationPreview extends BaseModel
@@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel
'pull_request_id' => 'integer',
];
protected static function booted()
protected static function booted(): void
{
static::forceDeleting(function ($preview) {
static::forceDeleting(function (ApplicationPreview $preview): void {
$server = $preview->application->destination->server;
$application = $preview->application;
@@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel
});
} else {
// Regular application volume cleanup
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() > 0) {
foreach ($persistentStorages as $storage) {
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
$persistentStorages = $application->persistentStorages()
->get()
->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path)
&& $storage->is_preview_suffix_enabled);
foreach ($persistentStorages as $storage) {
$volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id);
try {
instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server);
} catch (RuntimeException $exception) {
if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) {
throw $exception;
}
}
}
}
+1
View File
@@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model
'smtp_username',
'smtp_password',
'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled',
'resend_api_key',
+6
View File
@@ -3,9 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB;
class GithubApp extends BaseModel
{
public function delete(): ?bool
{
return DB::transaction(fn () => parent::delete());
}
protected $fillable = [
'team_id',
'private_key_id',
+1
View File
@@ -100,6 +100,7 @@ class GitlabApp extends BaseModel
if ($gitlabApp->applications()->count() > 0) {
throw new \RuntimeException('This source is being used by an application. Please delete all applications first.');
}
});
}
+7
View File
@@ -9,6 +9,10 @@ use Spatie\Url\Url;
class InstanceSettings extends Model
{
protected $attributes = [
'is_dashboard_force_https_enabled' => true,
];
protected $fillable = [
'public_ipv4',
'public_ipv6',
@@ -29,6 +33,7 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled',
'resend_api_key',
'is_dns_validation_enabled',
@@ -51,6 +56,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost',
'avatar_storage_type',
'avatar_s3_storage_id',
'is_dashboard_force_https_enabled',
];
protected $hidden = [
@@ -89,6 +95,7 @@ class InstanceSettings extends Model
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
];
protected static function booted(): void
+24 -3
View File
@@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$content = $this->readRemoteFileContent($escapedPath, $server);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
@@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel
return $size > self::MAX_CONTENT_SIZE;
}
/**
* Cap the remote read itself so a file that grows after the size check
* cannot be fully slurped into PHP memory.
*/
protected function readRemoteFileContent(string $escapedPath, $server): string
{
$readLimit = self::MAX_CONTENT_SIZE + 1;
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
return self::contentFromBoundedRead($content);
}
public static function contentFromBoundedRead(?string $content): string
{
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
return self::TOO_LARGE_PLACEHOLDER;
}
return (string) $content;
}
public function deleteStorageOnServer()
{
if ($this->is_host_file) {
@@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->content = $this->readRemoteFileContent($escapedPath, $server);
}
$this->is_directory = false;
$this->save();
+2
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledVolumeBackup extends BaseModel
{
public const int DEFAULT_TIMEOUT = 36000;
protected $fillable = [
'uuid',
'backupable_type',
+48 -4
View File
@@ -731,9 +731,7 @@ class Server extends BaseModel
];
if ($schema === 'https') {
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [
0 => 'redirect-to-https',
];
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [
'middlewares' => [
@@ -792,8 +790,9 @@ class Server extends BaseModel
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
$schema = $url->getScheme();
$siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host);
$caddy_file = "
$schema://$host {
$siteAddress {
encode zstd gzip
handle /app/* {
reverse_proxy coolify-realtime:6001
@@ -819,6 +818,24 @@ $schema://$host {
], $this);
}
public function dashboardHttpMiddlewares(InstanceSettings $settings): array
{
if ($settings->is_dashboard_force_https_enabled) {
return ['redirect-to-https'];
}
return ['gzip'];
}
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
{
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
return "http://{$host}, https://{$host}";
}
return "{$schema}://{$host}";
}
public function proxyPath()
{
$base_path = config('constants.coolify.base_config_path');
@@ -841,6 +858,33 @@ $schema://$host {
return data_get($this->proxy, 'type');
}
public function hasPendingProxyConfiguration(): bool
{
if ($this->proxy->get('status') !== 'running') {
return false;
}
$savedSettings = $this->proxy->get('last_saved_settings');
$appliedSettings = $this->proxy->get('last_applied_settings');
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
}
public function hasCurrentTraefikOutdatedInfo(): bool
{
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$detectedVersion = ltrim((string) $this->detected_traefik_version, 'v');
$storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v');
$type = data_get($this->traefik_outdated_info, 'type');
return filled($detectedVersion)
&& $storedVersion === $detectedVersion
&& in_array($type, ['patch_update', 'minor_upgrade'], true);
}
public function scopeWithProxy(): Builder
{
return $this->proxy->modelScope();
+7 -4
View File
@@ -33,11 +33,9 @@ class TeamInvitation extends Model
return TeamInvitation::whereTeamId(currentTeam()->id);
}
public function isValid()
public function isValid(): bool
{
$createdAt = $this->created_at;
$diff = $createdAt->diffInDays(now());
if ($diff <= config('constants.invitation.link.expiration_days')) {
if (! $this->hasExpired()) {
return true;
} else {
$this->delete();
@@ -49,4 +47,9 @@ class TeamInvitation extends Model
return false;
}
}
public function hasExpired(): bool
{
return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days');
}
}
+16 -27
View File
@@ -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')) {
@@ -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())
@@ -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')]);
+18 -3
View File
@@ -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;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Policies;
use App\Models\ScheduledTask;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ScheduledTaskPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, ScheduledTask $scheduledTask): bool
{
return $user->teams->contains('id', $scheduledTask->team_id);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, ScheduledTask $scheduledTask): Response
{
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
}
return Response::allow();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, ScheduledTask $scheduledTask): bool
{
return $user->isAdminOfTeam($scheduledTask->team_id);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ class TeamPolicy
return false;
}
return $user->isAdminOfTeam($team->id);
return $user->roleInTeam($team->id) === 'owner';
}
/**
+5
View File
@@ -19,6 +19,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -56,6 +57,7 @@ use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy;
use App\Policies\S3StoragePolicy;
use App\Policies\ScheduledTaskPolicy;
use App\Policies\ServerPolicy;
use App\Policies\ServiceApplicationPolicy;
use App\Policies\ServiceDatabasePolicy;
@@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider
// S3 storage policy
S3Storage::class => S3StoragePolicy::class,
// Scheduled task policy
ScheduledTask::class => ScheduledTaskPolicy::class,
// Team policy
Team::class => TeamPolicy::class,
+7
View File
@@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($email.'|'.$realIp);
});
RateLimiter::for('magic-link', function (Request $request) {
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
$token = (string) $request->input('token');
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
+9 -5
View File
@@ -75,12 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
$root_user = User::find(0);
Gate::define('viewHorizon', function (User $user) {
if ($user->id === 0) {
return true;
}
return in_array($user->email, [
$root_user->email,
]);
return str(config()->string('horizon.allowed_emails'))
->lower()
->explode(',')
->map(fn (string $email) => trim($email))
->contains($user->email);
});
}
}
+26 -13
View File
@@ -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);
@@ -17,28 +17,8 @@ class ConfigurationDiffer
*/
private const IGNORED_KEYS = ['build.docker_compose'];
/**
* Defaults for fields introduced after configuration snapshots were first
* stored. Older snapshots omitted these keys, which should not make an
* unchanged default look like a pending configuration change.
*
* @var array<string, mixed>
*/
private const INTRODUCED_DEFAULTS = [
'build.is_static' => false,
'build.is_spa' => false,
'build.is_git_submodules_enabled' => true,
'build.is_git_lfs_enabled' => true,
'build.is_git_shallow_clone_enabled' => true,
'build.is_env_sorting_enabled' => [false, true],
'runtime.is_consistent_container_name_enabled' => false,
'runtime.is_container_label_escape_enabled' => true,
'runtime.is_container_label_readonly_enabled' => true,
'runtime.is_log_drain_enabled' => false,
'runtime.is_swarm_only_worker_nodes' => true,
'runtime.is_preserve_repository_enabled' => false,
'domains.noindex_domains' => [],
];
/** @var array<int, string> */
private const DYNAMIC_SECTIONS = ['environment', 'storage'];
/**
* @param array<string, mixed> $previousSnapshot
@@ -59,11 +39,7 @@ class ConfigurationDiffer
$previous = $previousItems[$key] ?? null;
$current = $currentItems[$key] ?? null;
if (
$previous === null
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
&& $this->matchesIntroducedDefault($key, data_get($current, 'compare_value'))
) {
if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) {
continue;
}
@@ -127,17 +103,6 @@ class ConfigurationDiffer
return ConfigurationDiff::fromChanges($changes);
}
private function matchesIntroducedDefault(string $key, mixed $value): bool
{
$default = self::INTRODUCED_DEFAULTS[$key];
if (is_array($default) && $default !== [] && array_is_list($default)) {
return in_array($value, $default, true);
}
return $value === $default;
}
/**
* Reduce two multi-line values to only the lines that differ, so the modal
* shows just the changed container labels instead of the whole block.
+7 -2
View File
@@ -90,8 +90,11 @@ class DatabaseBackupFileValidator
public static function containsPostgresqlProgramExecution(string $sql): bool
{
$requireStatementBoundary = true;
if (str_starts_with($sql, 'PGDMP')) {
return false;
$sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql;
$requireStatementBoundary = false;
}
$withoutComments = self::stripSqlComments($sql);
@@ -100,7 +103,9 @@ class DatabaseBackupFileValidator
return true;
}
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
$copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b';
return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
+2 -2
View File
@@ -4,11 +4,11 @@ namespace App\Support;
class DomainUrlParts
{
public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string
public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string
{
$scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https';
$host = trim($host);
$port = trim($port);
$port = trim((string) $port);
$path = trim($path);
if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) {
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Support;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
class SmtpTransportFactory
{
public static function fromSettings(object $settings, ?string $localDomain = null): EsmtpTransport
{
$mode = self::encryptionMode($settings);
$transport = new EsmtpTransport(
$settings->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);
}
}
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more