Merge pull request #6933 from coollabsio/next

v4.0.0-beta.437
This commit is contained in:
Andras Bacsai 2025-10-21 09:08:55 +02:00 committed by GitHub
commit ad26fe9c3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1098 additions and 104 deletions

View file

@ -1,24 +1,25 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch: # Allow manual trigger
schedule:
- cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images)
workflow_dispatch: # Manual trigger only
env:
GITHUB_REGISTRY: ghcr.io
jobs:
cleanup-testing-host:
cleanup-all-packages:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
steps:
- name: Delete untagged coolify-testing-host images
- name: Delete untagged ${{ matrix.package }} images
uses: actions/delete-package-versions@v5
with:
package-name: 'coolify-testing-host'
package-name: ${{ matrix.package }}
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -4,11 +4,42 @@
use App\Models\InstanceSettings;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Spatie\Url\Url;
class TrustHosts extends Middleware
{
/**
* Handle the incoming request.
*
* Skip host validation for certain routes:
* - Terminal auth routes (called by realtime container)
* - API routes (use token-based authentication, not host validation)
* - Webhook endpoints (use cryptographic signature validation)
*/
public function handle(Request $request, $next)
{
// Skip host validation for these routes
if ($request->is(
'terminal/auth',
'terminal/auth/ips',
'api/*',
'webhooks/*'
)) {
return $next($request);
}
// Skip host validation if no FQDN is configured (initial setup)
$fqdnHost = Cache::get('instance_settings_fqdn_host');
if ($fqdnHost === '' || $fqdnHost === null) {
return $next($request);
}
// For all other routes, use parent's host validation
return parent::handle($request, $next);
}
/**
* Get the host patterns that should be trusted.
*
@ -44,6 +75,19 @@ public function hosts(): array
$trustedHosts[] = $fqdnHost;
}
// Trust the APP_URL host itself (not just subdomains)
$appUrl = config('app.url');
if ($appUrl) {
try {
$appUrlHost = parse_url($appUrl, PHP_URL_HOST);
if ($appUrlHost && ! in_array($appUrlHost, $trustedHosts, true)) {
$trustedHosts[] = $appUrlHost;
}
} catch (\Exception $e) {
// Ignore parse errors
}
}
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();

View file

@ -83,7 +83,7 @@ public function submit()
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncData(false);
$this->syncFromModel();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
@ -96,7 +96,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData(false);
$this->syncFromModel();
}
return handleError($e, $this);

View file

@ -12,7 +12,7 @@ class Index extends Component
public function render()
{
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->get();
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get();
return view('livewire.security.private-key.index', [
'privateKeys' => $privateKeys,

View file

@ -79,8 +79,14 @@ private function syncData(bool $toModel = false): void
public function mount()
{
try {
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail();
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail();
// Explicit authorization check - will throw 403 if not authorized
$this->authorize('view', $this->private_key);
$this->syncData(false);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
abort(403, 'You do not have permission to view this private key.');
} catch (\Throwable) {
abort(404);
}

View file

@ -82,9 +82,10 @@ public function getPublicKey()
public static function ownedByCurrentTeam(array $select = ['*'])
{
$teamId = currentTeam()->id;
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
return self::whereTeamId($teamId)->select($selectArray->all());
}
public static function validatePrivateKey($privateKey)

View file

@ -338,6 +338,39 @@ public function role()
return data_get($user, 'pivot.role');
}
/**
* Check if the user is an admin or owner of a specific team
*/
public function isAdminOfTeam(int $teamId): bool
{
$team = $this->teams->where('id', $teamId)->first();
if (! $team) {
return false;
}
$role = $team->pivot->role ?? null;
return $role === 'admin' || $role === 'owner';
}
/**
* Check if the user can access system resources (team_id=0)
* Must be admin/owner of root team
*/
public function canAccessSystemResources(): bool
{
// Check if user is member of root team
$rootTeam = $this->teams->where('id', 0)->first();
if (! $rootTeam) {
return false;
}
// Check if user is admin or owner of root team
return $this->isAdminOfTeam(0);
}
public function requestEmailChange(string $newEmail): void
{
// Generate 6-digit code

View file

@ -20,8 +20,18 @@ public function viewAny(User $user): bool
*/
public function view(User $user, PrivateKey $privateKey): bool
{
// return $user->teams->contains('id', $privateKey->team_id);
return true;
// Handle null team_id
if ($privateKey->team_id === null) {
return false;
}
// System resource (team_id=0): Only root team admins/owners can access
if ($privateKey->team_id === 0) {
return $user->canAccessSystemResources();
}
// Regular resource: Check team membership
return $user->teams->contains('id', $privateKey->team_id);
}
/**
@ -29,8 +39,9 @@ public function view(User $user, PrivateKey $privateKey): bool
*/
public function create(User $user): bool
{
// return $user->isAdmin();
return true;
// Only admins/owners can create private keys
// Members should not be able to create SSH keys that could be used for deployments
return $user->isAdmin();
}
/**
@ -38,8 +49,19 @@ public function create(User $user): bool
*/
public function update(User $user, PrivateKey $privateKey): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id);
return true;
// Handle null team_id
if ($privateKey->team_id === null) {
return false;
}
// System resource (team_id=0): Only root team admins/owners can update
if ($privateKey->team_id === 0) {
return $user->canAccessSystemResources();
}
// Regular resource: Must be admin/owner of the team
return $user->isAdminOfTeam($privateKey->team_id)
&& $user->teams->contains('id', $privateKey->team_id);
}
/**
@ -47,8 +69,19 @@ public function update(User $user, PrivateKey $privateKey): bool
*/
public function delete(User $user, PrivateKey $privateKey): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id);
return true;
// Handle null team_id
if ($privateKey->team_id === null) {
return false;
}
// System resource (team_id=0): Only root team admins/owners can delete
if ($privateKey->team_id === 0) {
return $user->canAccessSystemResources();
}
// Regular resource: Must be admin/owner of the team
return $user->isAdminOfTeam($privateKey->team_id)
&& $user->teams->contains('id', $privateKey->team_id);
}
/**

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.436',
'version' => '4.0.0-beta.437',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -3356,6 +3356,137 @@
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Databases"
],
"summary": "Create Backup",
"description": "Create a new scheduled backup configuration for a database",
"operationId": "create-database-backup",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"description": "Backup configuration data",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"frequency"
],
"properties": {
"frequency": {
"type": "string",
"description": "Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)"
},
"enabled": {
"type": "boolean",
"description": "Whether the backup is enabled",
"default": true
},
"save_s3": {
"type": "boolean",
"description": "Whether to save backups to S3",
"default": false
},
"s3_storage_uuid": {
"type": "string",
"description": "S3 storage UUID (required if save_s3 is true)"
},
"databases_to_backup": {
"type": "string",
"description": "Comma separated list of databases to backup"
},
"dump_all": {
"type": "boolean",
"description": "Whether to dump all databases",
"default": false
},
"backup_now": {
"type": "boolean",
"description": "Whether to trigger backup immediately after creation"
},
"database_backup_retention_amount_locally": {
"type": "integer",
"description": "Number of backups to retain locally"
},
"database_backup_retention_days_locally": {
"type": "integer",
"description": "Number of days to retain backups locally"
},
"database_backup_retention_max_storage_locally": {
"type": "integer",
"description": "Max storage (MB) for local backups"
},
"database_backup_retention_amount_s3": {
"type": "integer",
"description": "Number of backups to retain in S3"
},
"database_backup_retention_days_s3": {
"type": "integer",
"description": "Number of days to retain backups in S3"
},
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage (MB) for S3 backups"
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Backup configuration created successfully",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string",
"format": "uuid",
"example": "550e8400-e29b-41d4-a716-446655440000"
},
"message": {
"type": "string",
"example": "Backup configuration created successfully."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}": {
@ -5381,6 +5512,96 @@
]
}
},
"\/deployments\/{uuid}\/cancel": {
"post": {
"tags": [
"Deployments"
],
"summary": "Cancel",
"description": "Cancel a deployment by UUID.",
"operationId": "cancel-deployment-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Deployment UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Deployment cancelled successfully.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Deployment cancelled successfully."
},
"deployment_uuid": {
"type": "string",
"example": "cm37r6cqj000008jm0veg5tkm"
},
"status": {
"type": "string",
"example": "cancelled-by-user"
}
},
"type": "object"
}
}
}
},
"400": {
"description": "Deployment cannot be cancelled (already finished\/failed\/cancelled).",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Deployment cannot be cancelled. Current status: finished"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "User doesn't have permission to cancel this deployment.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "You do not have permission to cancel this deployment."
}
},
"type": "object"
}
}
}
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/deploy": {
"get": {
"tags": [
@ -5538,6 +5759,91 @@
}
},
"\/github-apps": {
"get": {
"tags": [
"GitHub Apps"
],
"summary": "List",
"description": "List all GitHub apps.",
"operationId": "list-github-apps",
"responses": {
"200": {
"description": "List of GitHub apps.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"uuid": {
"type": "string"
},
"name": {
"type": "string"
},
"organization": {
"type": "string",
"nullable": true
},
"api_url": {
"type": "string"
},
"html_url": {
"type": "string"
},
"custom_user": {
"type": "string"
},
"custom_port": {
"type": "integer"
},
"app_id": {
"type": "integer"
},
"installation_id": {
"type": "integer"
},
"client_id": {
"type": "string"
},
"private_key_id": {
"type": "integer"
},
"is_system_wide": {
"type": "boolean"
},
"is_public": {
"type": "boolean"
},
"team_id": {
"type": "integer"
},
"type": {
"type": "string"
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"GitHub Apps"

View file

@ -2130,6 +2130,94 @@ paths:
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Backup'
description: 'Create a new scheduled backup configuration for a database'
operationId: create-database-backup
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
format: uuid
requestBody:
description: 'Backup configuration data'
required: true
content:
application/json:
schema:
required:
- frequency
properties:
frequency:
type: string
description: 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'
enabled:
type: boolean
description: 'Whether the backup is enabled'
default: true
save_s3:
type: boolean
description: 'Whether to save backups to S3'
default: false
s3_storage_uuid:
type: string
description: 'S3 storage UUID (required if save_s3 is true)'
databases_to_backup:
type: string
description: 'Comma separated list of databases to backup'
dump_all:
type: boolean
description: 'Whether to dump all databases'
default: false
backup_now:
type: boolean
description: 'Whether to trigger backup immediately after creation'
database_backup_retention_amount_locally:
type: integer
description: 'Number of backups to retain locally'
database_backup_retention_days_locally:
type: integer
description: 'Number of days to retain backups locally'
database_backup_retention_max_storage_locally:
type: integer
description: 'Max storage (MB) for local backups'
database_backup_retention_amount_s3:
type: integer
description: 'Number of backups to retain in S3'
database_backup_retention_days_s3:
type: integer
description: 'Number of days to retain backups in S3'
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage (MB) for S3 backups'
type: object
responses:
'201':
description: 'Backup configuration created successfully'
content:
application/json:
schema:
properties:
uuid: { type: string, format: uuid, example: 550e8400-e29b-41d4-a716-446655440000 }
message: { type: string, example: 'Backup configuration created successfully.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}':
get:
tags:
@ -3532,6 +3620,55 @@ paths:
security:
-
bearerAuth: []
'/deployments/{uuid}/cancel':
post:
tags:
- Deployments
summary: Cancel
description: 'Cancel a deployment by UUID.'
operationId: cancel-deployment-by-uuid
parameters:
-
name: uuid
in: path
description: 'Deployment UUID'
required: true
schema:
type: string
responses:
'200':
description: 'Deployment cancelled successfully.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Deployment cancelled successfully.' }
deployment_uuid: { type: string, example: cm37r6cqj000008jm0veg5tkm }
status: { type: string, example: cancelled-by-user }
type: object
'400':
description: 'Deployment cannot be cancelled (already finished/failed/cancelled).'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Deployment cannot be cancelled. Current status: finished' }
type: object
'401':
$ref: '#/components/responses/401'
'403':
description: "User doesn't have permission to cancel this deployment."
content:
application/json:
schema:
properties:
message: { type: string, example: 'You do not have permission to cancel this deployment.' }
type: object
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/deploy:
get:
tags:
@ -3631,6 +3768,29 @@ paths:
-
bearerAuth: []
/github-apps:
get:
tags:
- 'GitHub Apps'
summary: List
description: 'List all GitHub apps.'
operationId: list-github-apps
responses:
'200':
description: 'List of GitHub apps.'
content:
application/json:
schema:
type: array
items:
properties: { id: { type: integer }, uuid: { type: string }, name: { type: string }, organization: { type: string, nullable: true }, api_url: { type: string }, html_url: { type: string }, custom_user: { type: string }, custom_port: { type: integer }, app_id: { type: integer }, installation_id: { type: integer }, client_id: { type: string }, private_key_id: { type: integer }, is_system_wide: { type: boolean }, is_public: { type: boolean }, team_id: { type: integer }, type: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
post:
tags:
- 'GitHub Apps'

22
package-lock.json generated
View file

@ -916,8 +916,7 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
@ -1372,7 +1371,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/asynckit": {
"version": "0.4.0",
@ -1535,7 +1535,6 @@
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@ -1550,7 +1549,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -1569,7 +1567,6 @@
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
@ -2331,6 +2328,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2407,6 +2405,7 @@
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@ -2491,7 +2490,6 @@
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
@ -2508,7 +2506,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -2527,7 +2524,6 @@
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@ -2542,7 +2538,6 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -2591,7 +2586,8 @@
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@ -2660,6 +2656,7 @@
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -2759,6 +2756,7 @@
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-sfc": "3.5.16",
@ -2781,7 +2779,6 @@
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -2803,7 +2800,6 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}

View file

@ -60,6 +60,15 @@ @utility select {
@apply w-full;
@apply input-select;
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1rem 1rem;
padding-right: 2.5rem;
&:where(.dark, .dark *) {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
}
}
@utility button {

View file

@ -45,7 +45,7 @@
</div>
</div>
<div class="sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden">
<div class="sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50">
<div class="flex items-center gap-3 flex-shrink-0">
<div class="text-xl font-bold tracking-wide dark:text-white">Coolify</div>
<livewire:switch-team />

View file

@ -14,22 +14,41 @@
</div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($privateKeys as $key)
<a class="box group"
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ data_get($key, 'name') }}
@can('view', $key)
{{-- Admin/Owner: Clickable link --}}
<a class="box group"
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ data_get($key, 'name') }}
</div>
<div class="box-description">
{{ $key->description }}
@if (!$key->isInUse())
<span
class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-400 text-black">Unused</span>
@endif
</div>
</div>
<div class="box-description">
{{ $key->description }}
@if (!$key->isInUse())
<span
class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-400 text-black">Unused</span>
@endif
</a>
@else
{{-- Member: Visible but not clickable --}}
<div class="box opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent" title="You don't have permission to view this private key">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ data_get($key, 'name') }}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-gray-400 dark:bg-gray-600 text-white">View Only</span>
</div>
<div class="box-description">
{{ $key->description }}
@if (!$key->isInUse())
<span
class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-400 text-black">Unused</span>
@endif
</div>
</div>
</div>
</a>
@endcan
@empty
<div>No private keys found.</div>
@endforelse

View file

@ -99,13 +99,11 @@
<div class="relative">
<button @click="dropdownOpen = !dropdownOpen"
class="relative p-2 dark:text-neutral-400 hover:dark:text-white transition-colors cursor-pointer"
title="Settings">
<!-- Gear Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="Settings">
title="Preferences">
<!-- Sliders Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="Preferences">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<!-- Unread Count Badge -->

View file

@ -1,43 +1,41 @@
<div>
<x-slot:title>
Terminal | Coolify
</x-slot>
<h1>Terminal</h1>
<div class="flex gap-2 items-end subtitle">
<div>Execute commands on your servers and containers without leaving the browser.</div>
<x-helper
helper="If you're having trouble connecting to your server, make sure that the port is open.<br><br><a class='underline' href='https://coolify.io/docs/knowledge-base/server/firewall/#terminal' target='_blank'>Documentation</a>"></x-helper>
</div>
<div x-init="$wire.loadContainers()">
@if ($isLoadingContainers)
<div class="pt-1">
<x-loading text="Loading servers and containers..." />
</div>
@else
@if ($servers->count() > 0)
<form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row"
wire:submit="$dispatchSelf('connectToContainer')">
<x-forms.datalist id="selected_uuid" required wire:model.live="selected_uuid" placeholder="Search servers or containers...">
@foreach ($servers as $server)
@if ($loop->first)
<option disabled value="default">Select a server or container</option>
@endif
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif
@endforeach
@endforeach
</x-forms.datalist>
<x-forms.button type="submit">Connect</x-forms.button>
</form>
</x-slot>
<h1>Terminal</h1>
<div class="flex gap-2 items-end subtitle">
<div>Execute commands on your servers and containers without leaving the browser.</div>
<x-helper
helper="If you're having trouble connecting to your server, make sure that the port is open.<br><br><a class='underline' href='https://coolify.io/docs/knowledge-base/server/firewall/#terminal' target='_blank'>Documentation</a>"></x-helper>
</div>
<div x-init="$wire.loadContainers()">
@if ($isLoadingContainers)
<div class="pt-1">
<x-loading text="Loading servers and containers..." />
</div>
@else
<div>No servers with terminal access found.</div>
@if ($servers->count() > 0)
<form class="flex flex-col gap-2 justify-center xl:items-end xl:flex-row"
wire:submit="$dispatchSelf('connectToContainer')">
<x-forms.select id="selected_uuid" required wire:model.live="selected_uuid">
<option value="default">Select a server or container</option>
@foreach ($servers as $server)
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
@foreach ($containers as $container)
@if ($container['server_uuid'] == $server->uuid)
<option value="{{ $container['uuid'] }}">
{{ $server->name }} -> {{ $container['name'] }}
</option>
@endif
@endforeach
@endforeach
</x-forms.select>
<x-forms.button type="submit">Connect</x-forms.button>
</form>
@else
<div>No servers with terminal access found.</div>
@endif
@endif
@endif
<livewire:project.shared.terminal />
</div>
</div>
<livewire:project.shared.terminal />
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -227,3 +227,84 @@
// Should only contain APP_URL pattern, not any FQDN
expect($hosts2)->not->toBeEmpty();
});
it('skips host validation for terminal auth routes', function () {
// These routes should be accessible with any Host header (for internal container communication)
$response = $this->postJson('/terminal/auth', [], [
'Host' => 'coolify:8080', // Internal Docker host
]);
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
expect($response->status())->not->toBe(400);
});
it('skips host validation for terminal auth ips route', function () {
// These routes should be accessible with any Host header (for internal container communication)
$response = $this->postJson('/terminal/auth/ips', [], [
'Host' => 'soketi:6002', // Another internal Docker host
]);
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
expect($response->status())->not->toBe(400);
});
it('still enforces host validation for non-terminal routes', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
// Regular routes should still validate Host header
$response = $this->get('/', [
'Host' => 'evil.com',
]);
// Should get 400 Bad Host for untrusted host
expect($response->status())->toBe(400);
});
it('skips host validation for API routes', function () {
// All API routes use token-based auth (Sanctum), not host validation
// They should be accessible from any host (mobile apps, CLI tools, scripts)
// Test health check endpoint
$response = $this->get('/api/health', [
'Host' => 'internal-lb.local',
]);
expect($response->status())->not->toBe(400);
// Test v1 health check
$response = $this->get('/api/v1/health', [
'Host' => '10.0.0.5',
]);
expect($response->status())->not->toBe(400);
// Test feedback endpoint
$response = $this->post('/api/feedback', [], [
'Host' => 'mobile-app.local',
]);
expect($response->status())->not->toBe(400);
});
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
// Test GitHub webhook
$response = $this->post('/webhooks/source/github/events', [], [
'Host' => 'github-webhook-proxy.local',
]);
expect($response->status())->not->toBe(400);
// Test GitLab webhook
$response = $this->post('/webhooks/source/gitlab/events/manual', [], [
'Host' => 'gitlab.example.com',
]);
expect($response->status())->not->toBe(400);
// Test Stripe webhook
$response = $this->post('/webhooks/payments/stripe/events', [], [
'Host' => 'stripe-webhook-forwarder.local',
]);
expect($response->status())->not->toBe(400);
});

View file

@ -0,0 +1,209 @@
<?php
use App\Models\PrivateKey;
use App\Models\User;
use App\Policies\PrivateKeyPolicy;
it('allows root team admin to view system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
});
it('allows root team owner to view system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'owner']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
});
it('denies regular member of root team to view system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
});
it('denies non-root team member to view system private key', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
});
it('allows team member to view their own team private key', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 1;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeTrue();
});
it('denies team member to view another team private key', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 2;
};
$policy = new PrivateKeyPolicy;
expect($policy->view($user, $privateKey))->toBeFalse();
});
it('allows root team admin to update system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeTrue();
});
it('denies root team member to update system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeFalse();
});
it('allows team admin to update their own team private key', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 1;
};
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeTrue();
});
it('denies team member to update their own team private key', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 1;
};
$policy = new PrivateKeyPolicy;
expect($policy->update($user, $privateKey))->toBeFalse();
});
it('allows root team admin to delete system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'admin']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->delete($user, $privateKey))->toBeTrue();
});
it('denies root team member to delete system private key', function () {
$teams = collect([
(object) ['id' => 0, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$privateKey = new class
{
public $team_id = 0;
};
$policy = new PrivateKeyPolicy;
expect($policy->delete($user, $privateKey))->toBeFalse();
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.436"
"version": "4.0.0-beta.437"
},
"nightly": {
"version": "4.0.0-beta.437"
"version": "4.0.0-beta.438"
},
"helper": {
"version": "1.0.11"