` → optional `({health})` in same color.
+
+#### Deprecated Badge
+
+`resources/views/components/deprecated-badge.blade.php`:
+```
+px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30
+```
+
+#### Tag
+
+Utility `.tag`:
+```
+px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200
+```
+
+### Overlays
+
+#### Callout
+
+Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`.
+
+| Type | Background | Border | Title text | Body text |
+|---|---|---|---|---|
+| warning | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` |
+| danger | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` |
+| info | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` |
+| success | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` |
+
+Icon colors (600 light / 400 dark) match type.
+
+#### Modal (input variant)
+
+`resources/views/components/modal.blade.php`:
+```
+relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col
+```
+
+Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon.
+
+#### Modal Confirmation
+
+`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password):
+```
+relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col
+```
+
+Uses `` for warning. Password step hidden for OAuth users.
+
+#### Confirm Modal
+
+`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog.
+
+#### Popup / Popup-Small
+
+Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`.
+
+#### Slide-Over
+
+`resources/views/components/slide-over.blade.php`:
+
+Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10`
+
+Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200`
+
+#### Toast
+
+`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system.
+
+- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`).
+- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`.
+- Stacks up to 4 (oldest gets scale 82% then burns).
+- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack.
+- HTML payload sanitized via `window.sanitizeHTML` (XSS guard).
+- Per-toast copy-to-clipboard + close buttons.
+
+Icon colors:
+
+| Type | Class |
+|---|---|
+| success | `text-green-500` |
+| info | `text-blue-500` |
+| warning | `text-orange-400` |
+| danger | `text-red-500` |
+| default | `text-gray-800` |
+
+#### Helper / Tooltip
+
+`resources/views/components/helper.blade.php`. Icon utility `.info-helper`:
+```
+cursor-pointer text-coollabs dark:text-warning
+```
+
+Popup utility `.info-helper-popup`:
+```
+hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words
+```
+
+Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`).
+
+### Navigation
+
+#### Sidebar / Navbar
+
+Component: `resources/views/components/navbar.blade.php`.
+
+Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base`
+
+Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`.
+
+Utility `.menu-item`:
+```
+flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0
+```
+
+Utility `.menu-item-active`:
+```
+text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden
+```
+
+Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons.
+
+#### Breadcrumbs
+
+`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: ``. Each link `text-xs lg:text-sm hover:text-warning`. Chevron buttons `text-warning`. Dropdowns `absolute ... bg-white dark:bg-coolgray-100 rounded-md shadow-lg border`. Active item `dark:text-warning font-semibold`.
+
+#### External-Link
+
+Mini icon — `inline-flex w-3 h-3 dark:text-neutral-400 text-black` with arrow-out-of-box SVG. Appended to external anchors.
+
+#### Internal-Link
+
+Arrow SVG — `inline-flex w-4 h-4 text-black dark:text-white`. Used in CTA links ("go to deployment" etc).
+
+#### Banner
+
+`resources/views/components/banner.blade.php` — dismissible top bar:
+```
+relative z-999 w-full py-2 mx-auto duration-100 ease-out shadow-xs bg-coolgray-100 sm:py-0 sm:h-14
+```
+
+Close button: `w-6 h-6 rounded-full hover:bg-coolgray-500 text-neutral-200`. Reveals via Alpine `x-transition` after 100ms delay.
+
+### Feedback
+
+#### Loading Spinner
+
+`resources/views/components/loading.blade.php` — inline flex with optional text + spinning SVG:
+```
+w-4 h-4 mx-1 ml-3 text-coollabs dark:text-warning animate-spin
+```
+
+SVG has two paths at `opacity-25` (track) + `opacity-75` (arc).
+
+Utility `.loading`: `w-4 dark:text-warning text-coollabs`.
+
+#### Loading-On-Button
+
+`resources/views/components/loading-on-button.blade.php` — same SVG but **no light-mode color** (`w-4 h-4 mx-1 ml-3 dark:text-warning animate-spin`), meant to inherit button text color.
+
+#### Page-Loading
+
+Full-page loader overlay (variant of `loading` component, fills viewport).
+
+### Text
+
+#### Highlighted text
+
+`resources/views/components/highlighted.blade.php` / utility `.text-helper`:
+```
+inline-block font-bold text-coollabs dark:text-warning
+```
+
+Also used for required-field asterisks via ``.
+
+#### Kbd
+
+Utility `.kbd-custom`:
+```
+px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning
+```
+
+### Chrome
+
+#### Scrollbar
+
+Utility `.scrollbar` (uses `tailwind-scrollbar` plugin):
+```
+scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin
+```
+
+Applied globally to `` in `app.css`.
+
+#### Table
+
+Styled via base element rules in `app.css` (not a reusable component):
+
+```css
+table { @apply min-w-full divide-y dark:divide-coolgray-200 divide-neutral-300; }
+thead { @apply uppercase; }
+tbody { @apply divide-y dark:divide-coolgray-200 divide-neutral-300; }
+tr { @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100; }
+tr th { @apply px-3 py-3.5 text-left text-black dark:text-white; }
+tr th:first-child { @apply py-3.5 pr-3 pl-4 sm:pl-6; }
+tr td { @apply px-3 py-4 whitespace-nowrap; }
+tr td:first-child { @apply pr-3 pl-4 font-bold sm:pl-6; }
+```
+
+#### Dropdown
+
+`resources/views/components/dropdown.blade.php`. Container:
+```
+border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200
+```
+
+Utility `.dropdown-item`:
+```
+flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs
+```
+
+Touch variant adds `min-h-10 px-3 py-2 text-sm`.
+
+## Do's and Don'ts
+
+- **Do** force `dark:text-white` on h1–h4 and card titles. Default body text `#a3a3a3` is unreadable on `coolgray-100`.
+- **Do** swap the accent: `coollabs` in light, `warning` in dark. For focus rings, active nav, helpers, spinners, highlighted text, scrollbar thumb, helper links.
+- **Do** use the inset box-shadow system on inputs, selects, and textareas — not a border. It enables the 4px left dirty-bar.
+- **Do** wire the dirty indicator via `wire:dirty.class` so Livewire flips the bar color on modified state.
+- **Do** flip `.box-title` and `.box-description` to the contrast color on hover. On dark hover the card goes purple `#7317ff`; `text-neutral-500` description becomes invisible.
+- **Do** maintain WCAG AA contrast (4.5:1 for normal text).
+- **Do** sanitize HTML passed into toasts via `window.sanitizeHTML`.
+- **Do** use `` for in-button spinners and as `wire:loading.delay.longer` indicators in status components.
+- **Don't** use purple `coollabs` as the dark-mode accent. Always use yellow `warning` in dark.
+- **Don't** mix corner radii — 2px everywhere except callouts (8px) and pills (full).
+- **Don't** use shadows for elevation in dark mode. Use tonal layers from the coolgray ladder.
+- **Don't** set `border` utilities without expecting `coolgray-200` in dark (default override in base layer).
+- **Don't** add gradients. The one exception is the `.bg-coollabs-gradient` upsell strip.
+- **Don't** use more than two font weights on a single screen (typically 400 body + 700 bold).
+
+---
+
+Source files:
+- Theme tokens: `resources/css/app.css` (`@theme` block)
+- Fonts: `resources/css/fonts.css`
+- Component utilities: `resources/css/utilities.css`
+- Blade components: `resources/views/components/**/*.blade.php`
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 071f3ec46..289ab9ebe 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -4,7 +4,6 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
- $token = data_get($server, 'settings.sentinel_token');
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
- }
+ $token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 71de48bcd..58f21c793 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -4,8 +4,10 @@
use App\Models\InstanceSettings;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
- * @var array, \Psr\Log\LogLevel::*>
+ * @var array, LogLevel::*>
*/
protected $levels = [
//
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
- * @var array>
+ * @var array>
*/
protected $dontReport = [
ProcessException::class,
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $exception->getMessage(),
+ 'guards' => $exception->guards(),
+ ], 'warning');
+ }
+
return response()->json(['message' => $exception->getMessage()], 401);
}
@@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.policy_denied', [
+ 'reason' => $e->getMessage(),
+ 'route' => $request->route()?->getName() ?? $request->path(),
+ ], 'warning');
+ }
+
// Get the custom message from the policy if available
$message = $e->getMessage();
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index aa9d06996..4629df571 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
- $connectionTimeout = config('constants.ssh.connection_timeout');
+ $connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
@@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
+ $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
@@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
- $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
+ $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
@@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
}
}
+ public static function getConnectionTimeout(Server $server): int
+ {
+ $timeout = data_get($server, 'settings.connection_timeout');
+
+ return is_numeric($timeout) && (int) $timeout > 0
+ ? (int) $timeout
+ : (int) config('constants.ssh.connection_timeout');
+ }
+
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index eb2e7fc53..bb72ebabe 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type)
StartService::dispatch($service);
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => data_get($service, 'uuid'),
+ 'service_name' => data_get($service, 'name'),
+ 'application_type' => $type,
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($service, 'uuid'),
'domains' => data_get($service, 'domains'),
@@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.application.deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
return response()->json([
'message' => 'Application deletion request queued.',
]);
@@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request)
}
$application->save();
+ auditLog('api.application.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.application.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_count' => $returnedEnvs->count(),
+ ]);
+
return response()->json($returnedEnvs)->setStatusCode(201);
}
@@ -3446,6 +3534,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3471,6 +3567,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.',
], 404);
}
+ $envKey = $found_env->key;
+ $envUuid = $found_env->uuid;
$found_env->forceDelete();
+ auditLog('api.application.env_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json([
'message' => 'Environment variable deleted.',
]);
@@ -3675,6 +3788,15 @@ public function action_deploy(Request $request)
);
}
+ auditLog('api.application.deployed', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'force_rebuild' => $force,
+ 'instant_deploy' => $instant_deploy,
+ ]);
+
return response()->json(
[
'message' => 'Deployment request queued.',
@@ -3763,6 +3885,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
+ auditLog('api.application.stopped', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Application stopping request queued.',
@@ -3853,6 +3982,13 @@ public function action_restart(Request $request)
], 200);
}
+ auditLog('api.application.restarted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ ]);
+
return response()->json(
[
'message' => 'Restart request queued.',
@@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.application.storage_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.application.storage_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.application.storage_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
@@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+ auditLog('api.application.preview_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'pull_request_id' => $pullRequestId,
+ ]);
+
return response()->json(['message' => 'Preview deletion request queued.']);
}
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..d652f2ba1 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -244,7 +245,7 @@ public function store(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'],
]);
+ auditLog('api.cloud_token.created', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudProviderToken->uuid,
+ 'cloud_token_name' => $cloudProviderToken->name,
+ 'provider' => $cloudProviderToken->provider,
+ ]);
+
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@@ -355,7 +363,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields)));
+ auditLog('api.cloud_token.updated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
+ ]);
+
return response()->json([
'uuid' => $token->uuid,
]);
@@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
+ auditLog('api.cloud_token.deleted', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
return response()->json(['message' => 'Cloud provider token deleted.']);
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index c05af152f..dc9b6f5b5 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database);
}
+ auditLog('api.database.updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'message' => 'Database updated.',
]);
@@ -639,10 +647,10 @@ public function update_by_uuid(Request $request)
'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_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) 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'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +711,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -826,6 +834,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'frequency' => $backupConfig->frequency,
+ 'save_s3' => (bool) $backupConfig->save_s3,
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -878,10 +895,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +950,10 @@ public function update_backup(Request $request)
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_updated', [
+ 'team_id' => $teamId,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'database_id' => $backupConfig->database_id,
+ 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
@@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
@@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
@@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
@@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
@@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
@@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.database.deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete();
DB::commit();
+ auditLog('api.database.backup_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'delete_s3' => $deleteS3,
+ 'executions_deleted' => $executions->count(),
+ ]);
+
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete();
+ auditLog('api.database.backup_execution_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'execution_uuid' => $request->execution_uuid,
+ 'delete_s3' => $deleteS3,
+ ]);
+
return response()->json([
'message' => 'Backup execution deleted.',
]);
@@ -2633,6 +2751,13 @@ public function action_deploy(Request $request)
}
StartDatabase::dispatch($database);
+ auditLog('api.database.started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2724,6 +2849,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
+ auditLog('api.database.stopped', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2801,6 +2934,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database);
+ auditLog('api.database.restarted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.database.env_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
+ auditLog('api.database.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3266,6 +3419,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.database.env_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.database.env_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.database.storage_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.database.storage_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.database.storage_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 6ff06c10a..c93731d68 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
+ auditLog('api.deployment.cancelled', [
+ 'team_id' => $teamId,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'application_id' => $application?->id,
+ 'application_uuid' => $application?->uuid,
+ 'server_id' => $deployment->server_id,
+ ]);
+
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
+ auditLog('api.deployment.triggered', [
+ 'resource_type' => 'application',
+ 'application_uuid' => $resource->uuid,
+ 'application_name' => $resource->name,
+ 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'force_rebuild' => $force,
+ 'pull_request_id' => $pr,
+ ]);
}
break;
case Service::class:
@@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient.";
+ auditLog('api.service.deployed', [
+ 'service_uuid' => $resource->uuid,
+ 'service_name' => $resource->name,
+ ]);
break;
default:
// Database resource - check authorization
@@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 9a2cf2b9f..651969b97 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload);
+ auditLog('api.github_app.created', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ ]);
+
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app
$githubApp->update($payload);
+ auditLog('api.github_app.updated', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
+ ]);
+
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409);
}
+ $deletedUuid = $githubApp->uuid;
+ $deletedName = $githubApp->name;
$githubApp->delete();
+ auditLog('api.github_app.deleted', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $deletedUuid,
+ 'github_app_name' => $deletedName,
+ ]);
+
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index 092c48594..2f35ba576 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -550,7 +552,7 @@ public function createServer(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested
if ($request->instant_validate) {
- \App\Actions\Server\ValidateServer::dispatch($server);
+ ValidateServer::dispatch($server);
}
+ auditLog('api.hetzner_server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 49468b597..5ac274f93 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
+ auditLog('api.instance.enabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API enabled.'], 200);
}
@@ -137,11 +141,15 @@ public function disable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
+ auditLog('api.instance.disabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API disabled.'], 200);
}
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index ec2e300ff..0e5f6e93b 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId,
]);
+ auditLog('api.project.created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields));
+ auditLog('api.project.updated', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
+ $projectUuid = $project->uuid;
+ $projectName = $project->name;
$project->delete();
+ auditLog('api.project.deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $projectUuid,
+ 'project_name' => $projectName,
+ ]);
+
return response()->json(['message' => 'Project deleted.']);
}
@@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name,
]);
+ auditLog('api.project.environment_created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'environment_name' => $environment->name,
+ ]);
+
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
+ $envUuid = $environment->uuid;
+ $envName = $environment->name;
$environment->delete();
+ auditLog('api.project.environment_deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $envUuid,
+ 'environment_name' => $envName,
+ ]);
+
return response()->json(['message' => 'Environment deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
index 6245dc2ec..d7b109918 100644
--- a/app/Http/Controllers/Api/ScheduledTasksController.php
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -6,6 +6,7 @@
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks);
}
- private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save();
+ auditLog('api.scheduled_task.created', [
+ 'team_id' => $teamId,
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json($this->removeSensitiveData($task), 201);
}
- private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields));
+ auditLog('api.scheduled_task.updated', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json($this->removeSensitiveData($task), 200);
}
- private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
- $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
- if (! $deleted) {
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
+ $taskUuid = $task->uuid;
+ $taskName = $task->name;
+ $task->delete();
+
+ auditLog('api.scheduled_task.deleted', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $taskUuid,
+ 'task_name' => $taskName,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json(['message' => 'Scheduled task deleted.']);
}
- private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
- public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
- public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
- public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
- public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index 2c62928c2..e59c40866 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key,
]);
+ auditLog('api.private_key.created', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $key->uuid,
+ 'private_key_name' => $key->name,
+ 'fingerprint' => $fingerPrint,
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@@ -333,6 +340,13 @@ public function update_key(Request $request)
}
$foundKey->update($request->only($allowedFields));
+ auditLog('api.private_key.updated', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $foundKey->uuid,
+ 'private_key_name' => $foundKey->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
]))->setStatusCode(201);
@@ -415,8 +429,16 @@ public function delete_key(Request $request)
], 422);
}
+ $keyUuid = $key->uuid;
+ $keyName = $key->name;
$key->forceDelete();
+ auditLog('api.private_key.deleted', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $keyUuid,
+ 'private_key_name' => $keyName,
+ ]);
+
return response()->json([
'message' => 'Private Key deleted.',
]);
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index c13c6665c..6c3b2da00 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@@ -477,7 +478,7 @@ public function create_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'ip' => $server->ip,
+ 'is_build_server' => (bool) $request->is_build_server,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@@ -639,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -647,7 +657,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
+ 'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
- $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
+ $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
@@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.updated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -807,6 +825,9 @@ public function delete_server(Request $request)
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id
);
+ auditLog('api.server.deleted', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $deletedUuid,
+ 'server_name' => $deletedName,
+ 'ip' => $deletedIp,
+ 'force' => $force,
+ ]);
+
return response()->json(['message' => 'Server deleted.']);
}
@@ -881,6 +910,12 @@ public function validate_server(Request $request)
}
ValidateServer::dispatch($server);
+ auditLog('api.server.validated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
+
return response()->json(['message' => 'Validation started.'], 201);
}
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 20560635e..11a23d46c 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => $oneClickServiceName ?? null,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => 'docker_compose',
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.service.deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json([
'message' => 'Service deletion request queued.',
]);
@@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.service.env_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.service.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.service.env_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.service.env_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
}
StartService::dispatch($service);
+ auditLog('api.service.deployed', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json(
[
'message' => 'Service starting request queued.',
@@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
+ auditLog('api.service.stopped', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Service stopping request queued.',
@@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
+ auditLog('api.service.restarted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'pull_latest' => $pullLatest,
+ ]);
+
return response()->json(
[
'message' => 'Service restarting request queued.',
@@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.service.storage_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.service.storage_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.service.storage_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 3a3f18c9c..4038fe63e 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -19,7 +19,12 @@ public function callback(string $provider)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 96fbd7193..6c3dda402 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -29,6 +29,7 @@ class UploadController extends BaseController
'archive.gz',
'bz2',
'xz',
+ 'dmp',
];
public function upload(Request $request)
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index ffa71b55a..ee7f25431 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +13,8 @@
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -31,6 +34,16 @@ public function manual(Request $request)
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
+ // Bitbucket webhooks ship up to 5 commits per change. Larger pushes
+ // are evaluated only on the visible 5.
+ $skip_deploy_commits = self::shouldSkipDeploy(
+ collect(data_get($payload, 'push.changes', []))
+ ->flatMap(fn ($change) => data_get($change, 'commits', []))
+ ->pluck('message')
+ ->filter()
+ ->values()
+ ->all()
+ );
if (! $branch) {
return response([
@@ -45,6 +58,8 @@ public function manual(Request $request)
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
+ $pull_request_title = data_get($payload, 'pullrequest.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
@@ -58,6 +73,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -70,6 +91,12 @@ public function manual(Request $request)
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
+ auditLogWebhookFailure('bitbucket', 'malformed_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -81,6 +108,12 @@ public function manual(Request $request)
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
+ auditLogWebhookFailure('bitbucket', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -101,6 +134,17 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -118,6 +162,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'bitbucket',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => $commit,
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -134,6 +187,15 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
new file mode 100644
index 000000000..69695e99b
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
@@ -0,0 +1,55 @@
+ $messages
+ */
+ public static function shouldSkipDeploy(array $messages): bool
+ {
+ $messages = array_values(array_filter($messages, fn ($m) => filled($m)));
+
+ if (empty($messages)) {
+ return false;
+ }
+
+ foreach ($messages as $message) {
+ $lower = strtolower((string) $message);
+ if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if at least one non-empty message contains [skip cd] or
+ * [skip ci]. Used for PR/MR title + latest-commit signals where any one
+ * marker should trigger the skip.
+ *
+ * @param array $messages
+ */
+ public static function shouldSkipDeployAny(array $messages): bool
+ {
+ foreach ($messages as $message) {
+ if (! filled($message)) {
+ continue;
+ }
+ $lower = strtolower((string) $message);
+ if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index 62adf5410..64807d694 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -40,12 +43,15 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
@@ -68,6 +74,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -78,6 +90,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('gitea', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -100,6 +118,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -117,6 +146,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitea',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -149,6 +187,15 @@ public function manual(Request $request)
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 4158016d0..b0e11f60c 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@@ -16,6 +17,8 @@
class Github extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -43,12 +46,14 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@@ -82,6 +87,12 @@ public function manual(Request $request)
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('github', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -92,6 +103,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -114,6 +131,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -131,6 +159,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -180,6 +217,7 @@ public function manual(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
@@ -224,6 +262,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'mode' => 'app',
+ 'github_app_id' => $github_app->id,
+ 'github_app_name' => $github_app->name,
+ 'installation_target_id' => $x_github_hook_installation_target_id,
+ ]);
+
return response('Invalid signature.');
}
}
@@ -246,12 +291,14 @@ public function normal(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@@ -300,6 +347,17 @@ public function normal(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -311,6 +369,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
+ if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'app',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'github_app_id' => $github_app->id,
+ ]);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -360,6 +429,7 @@ public function normal(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 4453a0e7a..205bede8f 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -32,6 +35,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +64,7 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@@ -66,6 +73,9 @@ public function manual(Request $request)
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
+ $pull_request_title = data_get($payload, 'object_attributes.title');
+ $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@@ -101,6 +111,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -110,6 +126,12 @@ public function manual(Request $request)
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
+ auditLogWebhookFailure('gitlab', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -132,6 +154,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -150,6 +183,15 @@ public function manual(Request $request)
'application_name' => $application->name,
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitlab',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -182,6 +224,15 @@ public function manual(Request $request)
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index d59adf0ca..41e70b2ce 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ public function events(Request $request)
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
- $event = \Stripe\Webhook::constructEvent(
+ $event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
+ } catch (SignatureVerificationException $e) {
+ auditLogWebhookFailure('stripe', 'invalid_signature', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index 324eeebaa..f81c7d184 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
+use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities)
}
return parent::handle($request, $next, ...$abilities);
- } catch (\Illuminate\Auth\AuthenticationException $e) {
+ } catch (AuthenticationException $e) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $e->getMessage(),
+ 'required_abilities' => $abilities,
+ ], 'warning');
+
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
+ auditLog('api.auth.ability_denied', [
+ 'required_abilities' => $abilities,
+ 'token_id' => $request->user()?->currentAccessToken()?->id,
+ 'reason' => $e->getMessage(),
+ ], 'warning');
+
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 7e5025c8a..84bb4a09d 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -3075,29 +3075,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 041cd812c..54e386676 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@@ -17,6 +18,7 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,6 +33,7 @@ public function __construct(
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
+ public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
@@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return;
}
+ if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
+ return;
+ }
+
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 7ce316dcd..98ad60fff 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
@@ -43,6 +44,9 @@ private function disableSshMux(): void
public function handle()
{
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
}
@@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
+ /**
+ * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
+ * or when a previously-notified server recovers. Skips noise from single transient flaps.
+ */
+ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
+ {
+ if ($isReachable) {
+ if (! $wasReachable || $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+
+ return;
+ }
+
+ if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+ }
+
private function checkHetznerStatus(): void
{
$status = null;
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 364163ff8..724dd0bac 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
- public ?string $smtpEncryption = null;
+ public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null;
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 844e37854..2f1a229b4 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -63,13 +63,16 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
+ if ($this->fileStorage->is_too_large) {
+ return;
+ }
$this->validate();
// Sync to model
@@ -172,6 +175,12 @@ public function submit()
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
+
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 6f43662d5..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
- $this->fileStorage = $this->resource->fileStorages()->get();
+ $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
+ if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
+ $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+ }
+ });
$this->resource->load('persistentStorages.resource');
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 84cb65ee6..3e05d9306 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -32,6 +32,8 @@ class Show extends Component
public string $port;
+ public int $connectionTimeout;
+
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@@ -110,6 +112,7 @@ protected function rules(): array
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
+ 'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@@ -138,6 +141,10 @@ protected function messages(): array
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
+ 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
+ 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+ 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
+ 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@@ -210,6 +217,7 @@ public function syncData(bool $toModel = false)
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
+ $this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
+ $this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false)
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@@ -471,7 +480,7 @@ public function startHetznerServer()
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 4b5c602c2..627750232 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel
{
+ public const MAX_CONTENT_SIZE = 5_242_880;
+
+ public const BINARY_PLACEHOLDER = '[binary file]';
+
+ public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
+
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
- public $appends = ['is_binary'];
+ public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
- get: function () {
- return $this->content === '[binary file]';
- }
+ get: fn () => $this->content === self::BINARY_PLACEHOLDER
+ );
+ }
+
+ protected function isTooLarge(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ $this->is_directory = false;
+ $this->save();
+
+ return;
+ }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// 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)) {
- $content = '[binary file]';
+ $content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
+ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
+ {
+ $sizeOutput = instant_remote_process(
+ ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
+ $server,
+ false,
+ );
+ $size = (int) trim((string) $sizeOutput);
+
+ return $size > self::MAX_CONTENT_SIZE;
+ }
+
public function deleteStorageOnServer()
{
$this->load(['service']);
@@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
- $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ } else {
+ $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ }
$this->is_directory = false;
- $this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 06426f211..74e8ba5b0 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
- if ($isReachable === true) {
- $this->unreachable_count = 0;
- $this->save();
+ if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
- $this->increment('unreachable_count');
-
- if ($this->unreachable_count === 1) {
- $this->settings->is_reachable = true;
- $this->settings->save();
-
- return;
- }
-
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
- $failedChecks = 0;
- for ($i = 0; $i < 3; $i++) {
- $status = $this->serverStatus();
- sleep(5);
- if (! $status) {
- $failedChecks++;
- }
- }
-
- if ($failedChecks === 3 && ! $unreachableNotificationSent) {
- $this->sendUnreachableNotification();
- }
+ $this->sendUnreachableNotification();
}
}
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 30fc1e165..79f62f4b7 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -49,6 +50,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@@ -97,6 +99,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
+ 'connection_timeout',
];
protected $casts = [
@@ -108,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
+ 'connection_timeout' => 'integer',
];
protected static function booted()
@@ -141,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
- public static function isValidSentinelToken(string $token): bool
+ public static function isValidSentinelToken(?string $token): bool
{
+ if ($token === null) {
+ return false;
+ }
+
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
- public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
+ /**
+ * Returns a valid sentinel token, regenerating it if the stored value is
+ * empty, undecryptable, or otherwise invalid. Throws only when regeneration
+ * still fails to produce a valid token.
+ */
+ public function ensureValidSentinelToken(): string
+ {
+ try {
+ $token = $this->sentinel_token;
+ } catch (DecryptException) {
+ $token = null;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ // Clear undecryptable raw value so Eloquent's dirty-check won't try to
+ // decrypt the bad original during save().
+ $attrs = $this->getAttributes();
+ $attrs['sentinel_token'] = null;
+ $this->setRawAttributes($attrs, true);
+
+ $this->generateSentinelToken(save: true, ignoreEvent: true);
+ $this->refresh();
+ $token = $this->sentinel_token;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
+ }
+
+ return $token;
+ }
+
+ public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
- $token = json_encode($data);
- $encrypted = encrypt($token);
- $this->sentinel_token = $encrypted;
+ $token = encrypt(json_encode($data));
+ $this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
index 0caa3a3a9..c29f7fc41 100644
--- a/app/Providers/HorizonServiceProvider.php
+++ b/app/Providers/HorizonServiceProvider.php
@@ -3,9 +3,12 @@
namespace App\Providers;
use App\Contracts\CustomJobRepositoryInterface;
+use App\Exceptions\DeploymentException;
use App\Models\ApplicationDeploymentQueue;
use App\Models\User;
use App\Repositories\CustomJobRepository;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Contracts\JobRepository;
@@ -48,6 +51,26 @@ public function boot(): void
]);
}
});
+
+ Event::listen(function (JobFailed $event) {
+ if (! isCloud()) {
+ return;
+ }
+
+ $exception = $event->exception;
+ if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) {
+ return;
+ }
+
+ try {
+ $uuid = $event->job->uuid();
+ if ($uuid) {
+ app(JobRepository::class)->deleteFailed($uuid);
+ }
+ } catch (\Throwable $e) {
+ // Best-effort scrub; never mask the original failure.
+ }
+ });
}
protected function gate(): void
diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php
index 7ed82cc91..20b3752f5 100644
--- a/app/Traits/HasMetrics.php
+++ b/app/Traits/HasMetrics.php
@@ -2,7 +2,9 @@
namespace App\Traits;
-use App\Models\ServerSetting;
+use App\Models\Server;
+use Illuminate\Contracts\Encryption\DecryptException;
+use Illuminate\Support\Facades\Log;
trait HasMetrics
{
@@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
- $token = $server->settings->sentinel_token;
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
+ $previousToken = null;
+ try {
+ $previousToken = $server->settings->sentinel_token;
+ } catch (DecryptException) {
+ // fall through to ensureValidSentinelToken which will regenerate
+ }
+ $token = $server->settings->ensureValidSentinelToken();
+ if ($token !== $previousToken) {
+ Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
}
$response = instant_remote_process(
@@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool
{
- return $this instanceof \App\Models\Server;
+ return $this instanceof Server;
}
- private function getMetricsServer(): \App\Models\Server
+ private function getMetricsServer(): Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 48e0a8c78..4707b0a07 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -12,8 +12,9 @@
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
-function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
+function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
+ $commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
$deployment_url = $deployment_link->getPath();
diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php
new file mode 100644
index 000000000..8477450c4
--- /dev/null
+++ b/bootstrap/helpers/audit.php
@@ -0,0 +1,81 @@
+ $context Identifiers + outcome details.
+ * @param string $level Log level: info | warning | error.
+ */
+ function auditLog(string $event, array $context = [], string $level = 'info'): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+ $user = auth()->check() ? auth()->user() : null;
+ $token = $user?->currentAccessToken();
+
+ $base = [
+ 'event' => $event,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'user_id' => $user?->id,
+ 'user_email' => $user?->email,
+ 'team_id' => $token ? data_get($token, 'team_id') : null,
+ 'token_id' => $token?->id ?? null,
+ 'token_name' => $token?->name ?? null,
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ ];
+
+ $payload = array_merge($base, $context);
+
+ Log::channel('audit')->{$level}($event, $payload);
+ } catch (Throwable $e) {
+ // Audit logging must never break the request path.
+ try {
+ Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
+
+if (! function_exists('auditLogWebhookFailure')) {
+ /**
+ * Record a webhook signature/auth verification failure to the `audit` channel.
+ */
+ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+
+ $event = "webhook.{$provider}.signature_failed";
+
+ $base = [
+ 'event' => $event,
+ 'reason' => $reason,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ 'event_header' => $request?->header('X-GitHub-Event')
+ ?? $request?->header('X-Gitlab-Event')
+ ?? $request?->header('X-Gitea-Event')
+ ?? $request?->header('X-Event-Key'),
+ ];
+
+ Log::channel('audit')->warning($event, array_merge($base, $context));
+ } catch (Throwable $e) {
+ try {
+ Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
diff --git a/config/constants.php b/config/constants.php
index 7504b6ba8..867cc22d9 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,9 +2,9 @@
return [
'coolify' => [
- 'version' => '4.0.0',
+ 'version' => '4.1.0',
'helper_version' => '1.0.13',
- 'realtime_version' => '1.0.13',
+ 'realtime_version' => '1.0.14',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
diff --git a/config/logging.php b/config/logging.php
index 1dbb1135f..05cf8e13d 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
+
+ 'audit' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/audit.log'),
+ 'level' => env('LOG_AUDIT_LEVEL', 'info'),
+ 'days' => env('LOG_AUDIT_DAYS', 90),
+ 'replace_placeholders' => true,
+ ],
],
];
diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
new file mode 100644
index 000000000..1700feebc
--- /dev/null
+++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
@@ -0,0 +1,22 @@
+integer('connection_timeout')->default(10)->after('deployment_queue_limit');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('connection_timeout');
+ });
+ }
+};
diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php
index baa7abffc..930a7db8e 100644
--- a/database/seeders/InstanceSettingsSeeder.php
+++ b/database/seeders/InstanceSettingsSeeder.php
@@ -23,23 +23,25 @@ public function run(): void
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
]);
- try {
- $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
- $ipv4 = trim($ipv4);
- $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv4) && $ipv4) {
- $settings->update(['public_ipv4' => $ipv4]);
+ if (! isDev()) {
+ try {
+ $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
+ $ipv4 = trim($ipv4);
+ $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv4) && $ipv4) {
+ $settings->update(['public_ipv4' => $ipv4]);
+ }
+ $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
+ $ipv6 = trim($ipv6);
+ $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv6) && $ipv6) {
+ $settings->update(['public_ipv6' => $ipv6]);
+ }
+ } catch (\Throwable $e) {
+ echo "Error: {$e->getMessage()}\n";
}
- $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
- $ipv6 = trim($ipv6);
- $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv6) && $ipv6) {
- $settings->update(['public_ipv6' => $ipv6]);
- }
- } catch (\Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
}
}
}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f608fe3cb..50edc140f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -129,10 +129,9 @@ services:
networks:
- coolify
minio:
- image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
+ image: ghcr.io/coollabsio/maxio:latest
pull_policy: always
container_name: coolify-minio
- command: server /data --console-address ":9001"
ports:
- "${FORWARD_MINIO_PORT:-9000}:9000"
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 174077562..eae81be6a 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -165,9 +165,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 3ae77857f..f5760279f 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
+const HEARTBEAT_INTERVAL_MS = 30000;
+const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+
wss.on('connection', async (ws, req) => {
+ ws.isAlive = true;
+ ws.on('pong', () => { ws.isAlive = true; });
+
const userId = generateUserId();
- const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
+ ws.userId = userId;
+ const userSession = {
+ ws,
+ userId,
+ ptyProcess: null,
+ isActive: false,
+ authorizedIPs: [],
+ lastActivityAt: Date.now(),
+ authReady: false,
+ pendingMessages: [],
+ };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => {
hasLaravelSession: Boolean(laravelSession),
};
+ // Register socket handlers up front so messages sent immediately by the client
+ // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
+ // below is still pending.
+ ws.on('message', (message) => {
+ if (userSession.authReady) {
+ handleMessage(userSession, message);
+ } else {
+ userSession.pendingMessages.push(message);
+ }
+ });
+ ws.on('error', (err) => handleError(err, userId));
+ ws.on('close', (code, reason) => {
+ logTerminal('log', 'Terminal websocket connection closed.', {
+ userId,
+ code,
+ reason: reason?.toString(),
+ });
+ handleClose(userId);
+ });
+
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
@@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => {
}
userSessions.set(userId, userSession);
+ userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
+ bufferedMessages: userSession.pendingMessages.length,
});
- ws.on('message', (message) => {
- handleMessage(userSession, message);
- });
- ws.on('error', (err) => handleError(err, userId));
- ws.on('close', (code, reason) => {
- logTerminal('log', 'Terminal websocket connection closed.', {
- userId,
- code,
- reason: reason?.toString(),
- });
- handleClose(userId);
- });
+ // Drain any messages that arrived while we were waiting on the IP auth call.
+ while (userSession.pendingMessages.length > 0) {
+ handleMessage(userSession, userSession.pendingMessages.shift());
+ }
});
+const heartbeat = setInterval(() => {
+ wss.clients.forEach((ws) => {
+ if (ws.isAlive === false) {
+ logTerminal('warn', 'Terminating WS due to missed protocol pong.');
+ return ws.terminate();
+ }
+ ws.isAlive = false;
+ try {
+ ws.ping();
+ } catch (_) {
+ // ignore — close handler will follow
+ }
+
+ const session = ws.userId ? userSessions.get(ws.userId) : null;
+ if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
+ const idleMs = Date.now() - session.lastActivityAt;
+ logTerminal('warn', 'Closing terminal session due to idle timeout.', {
+ userId: ws.userId,
+ idleMs,
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
+ });
+ try {
+ ws.send('idle-timeout');
+ } catch (_) {
+ // ignore — close still attempted below
+ }
+ killPtyProcess(ws.userId);
+ setTimeout(() => {
+ try {
+ ws.close(1000, 'Idle timeout');
+ } catch (_) {
+ // ignore — already closed
+ }
+ }, 100);
+ }
+ });
+}, HEARTBEAT_INTERVAL_MS);
+
+wss.on('close', () => clearInterval(heartbeat));
+
const messageHandlers = {
- message: (session, data) => session.ptyProcess.write(data),
+ message: (session, data) => {
+ session.lastActivityAt = Date.now();
+ session.ptyProcess.write(data);
+ },
resize: (session, { cols, rows }) => {
+ session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@@ -197,12 +271,6 @@ function handleMessage(userSession, message) {
return;
}
- logTerminal('log', 'Received websocket message.', {
- userId: userSession.userId,
- keys: Object.keys(parsed),
- isActive: userSession.isActive,
- });
-
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
@@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
+ userSession.lastActivityAt = Date.now();
ws.send('pty-ready');
diff --git a/openapi.json b/openapi.json
index d83b30d80..059b3d911 100644
--- a/openapi.json
+++ b/openapi.json
@@ -4381,8 +4381,8 @@
"description": "Number of days to retain backups locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
- "description": "Max storage (MB) for local backups"
+ "type": "number",
+ "description": "Max storage (GB) for local backups"
},
"database_backup_retention_amount_s3": {
"type": "integer",
@@ -4393,8 +4393,8 @@
"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": "number",
+ "description": "Max storage (GB) for S3 backups"
},
"timeout": {
"type": "integer",
@@ -4951,7 +4951,7 @@
"description": "Retention days of the backup locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup locally"
},
"database_backup_retention_amount_s3": {
@@ -4963,7 +4963,7 @@
"description": "Retention days of the backup in s3"
},
"database_backup_retention_max_storage_s3": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup in S3"
},
"timeout": {
@@ -10545,6 +10545,10 @@
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
+ },
+ "connection_timeout": {
+ "type": "integer",
+ "description": "SSH connection timeout in seconds (1-300). Default: 10."
}
},
"type": "object"
@@ -13349,6 +13353,10 @@
"delete_unused_networks": {
"type": "boolean",
"description": "The flag to indicate if the unused networks should be deleted."
+ },
+ "connection_timeout": {
+ "type": "integer",
+ "description": "SSH connection timeout in seconds."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index aab408098..83aa30744 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2765,8 +2765,8 @@ paths:
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'
+ type: number
+ description: 'Max storage (GB) for local backups'
database_backup_retention_amount_s3:
type: integer
description: 'Number of backups to retain in S3'
@@ -2774,8 +2774,8 @@ paths:
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: number
+ description: 'Max storage (GB) for S3 backups'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
@@ -3160,7 +3160,7 @@ paths:
type: integer
description: 'Retention days of the backup locally'
database_backup_retention_max_storage_locally:
- type: integer
+ type: number
description: 'Max storage of the backup locally'
database_backup_retention_amount_s3:
type: integer
@@ -3169,7 +3169,7 @@ paths:
type: integer
description: 'Retention days of the backup in s3'
database_backup_retention_max_storage_s3:
- type: integer
+ type: number
description: 'Max storage of the backup in S3'
timeout:
type: integer
@@ -6734,6 +6734,9 @@ paths:
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
+ connection_timeout:
+ type: integer
+ description: 'SSH connection timeout in seconds (1-300). Default: 10.'
type: object
responses:
'201':
@@ -8538,6 +8541,9 @@ components:
delete_unused_networks:
type: boolean
description: 'The flag to indicate if the unused networks should be deleted.'
+ connection_timeout:
+ type: integer
+ description: 'SSH connection timeout in seconds.'
type: object
Service:
description: 'Service model'
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 3307b7f2e..f8b4ea890 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0"
+ "version": "4.1.0"
},
"nightly": {
"version": "4.0.0"
@@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
- "version": "1.0.13"
+ "version": "1.0.14"
},
"sentinel": {
"version": "0.0.21"
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index aa5f37353..7a7fc8536 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
+ // Last successfully sent SSH command — replayed after a transient reconnect
+ // so the PTY respawns automatically. Cleared on intentional terminations
+ // (pty-exited, idle-timeout, unprocessable).
+ lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
- this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
-
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
- [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
- .forEach(timer => timer && clearInterval(timer));
+ if (this.keepAliveInterval) {
+ clearInterval(this.keepAliveInterval);
+ }
+ [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
+ .forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
resetTerminal() {
if (this.term) {
- this.$wire.dispatch('error', 'Terminal websocket connection lost.');
- this.term.reset();
- this.term.clear();
+ this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
+ // Preserve scrollback so the user keeps the context of their previous
+ // session. Print a visible marker so they know where the disconnect
+ // happened. Old PTY shell state cannot be restored — this is purely
+ // a visual carry-over.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — terminal not ready to receive writes
+ }
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
@@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
- // Flush any buffered command from before WebSocket was ready
+ // Flush any buffered command from before WebSocket was ready, otherwise
+ // replay the last command so a transient reconnect respawns the PTY
+ // automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
+ } else if (this.lastSentCommand) {
+ logTerminal('log', '[Terminal] Replaying last command after reconnect.');
+ this.sendMessage(this.lastSentCommand);
+ }
+
+ // (Re)start application-level keepalive on every successful connect.
+ // Server-side WebSocket protocol pings are the primary heartbeat; this
+ // adds a JSON-level ping in case the server-side is older or restarting.
+ if (!this.keepAliveInterval) {
+ this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
@@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
+ if (message && message.command) {
+ this.lastSentCommand = message;
+ }
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
},
handleSocketMessage(event) {
- logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
-
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
@@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
- this.term.reset();
+ // Already initialized — this is a reconnect or a follow-up command.
+ // Preserve scrollback so the user keeps context. Write a visible
+ // separator so the new shell prompt is easy to spot.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — fall through; xterm will render the new prompt anyway
+ }
}
this.terminalActive = true;
this.term.focus();
@@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
+ this.lastSentCommand = null;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
@@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
+ this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
+ } else if (event.data === 'idle-timeout') {
+ this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
+ this.terminalActive = false;
+ if (this.term) {
+ this.term.reset();
+ }
+ this.commandBuffer = '';
+ this.lastSentCommand = null;
+ this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
- // Skip keepalive when document is hidden to prevent unnecessary disconnects
- if (!this.isDocumentVisible) {
- return;
- }
-
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
- // Send immediate ping to verify connection is still alive
+ // Connection may be half-open after Cloudflare/proxy idle drop while hidden.
+ // Probe with a short timeout (5s) instead of the default 35s — force a
+ // reconnect quickly if no pong arrives so the user is not stuck typing
+ // into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
- this.resetPingTimeout();
+ if (this.pingTimeoutId) {
+ clearTimeout(this.pingTimeoutId);
+ }
+ this.pingTimeoutId = setTimeout(() => {
+ logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
+ try {
+ this.socket.close(4000, 'Visibility-resume timeout');
+ } catch (_) {
+ // ignore — close handler will run on its own
+ }
+ }, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php
index d6294f3c8..8618872f5 100644
--- a/resources/views/livewire/project/application/deployment/show.blade.php
+++ b/resources/views/livewire/project/application/deployment/show.blade.php
@@ -9,6 +9,9 @@
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
rafId: null,
+ scrollDebounce: null,
+ isScrolling: false,
+ lastTouchY: 0,
showTimestamps: true,
searchQuery: '',
matchCount: 0,
@@ -19,9 +22,54 @@
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
+ this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
+ setTimeout(() => { this.isScrolling = false; }, 50);
}
},
+ disableFollow() {
+ if (!this.alwaysScroll) return;
+ this.alwaysScroll = false;
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ },
+ handleWheel(event) {
+ if (this.alwaysScroll && event.deltaY < 0) {
+ this.disableFollow();
+ }
+ },
+ handleTouchStart(event) {
+ this.lastTouchY = event.touches[0].clientY;
+ },
+ handleTouchMove(event) {
+ if (!this.alwaysScroll) return;
+ const currentY = event.touches[0].clientY;
+ if (currentY > this.lastTouchY) {
+ this.disableFollow();
+ }
+ this.lastTouchY = currentY;
+ },
+ handleKeyScroll(event) {
+ if (!this.alwaysScroll) return;
+ const upKeys = ['ArrowUp', 'PageUp', 'Home'];
+ if (upKeys.includes(event.key)) {
+ this.disableFollow();
+ }
+ },
+ handleScroll(event) {
+ if (this.isScrolling) return;
+ clearTimeout(this.scrollDebounce);
+ this.scrollDebounce = setTimeout(() => {
+ const el = event.target;
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (!this.alwaysScroll && distanceFromBottom <= 10) {
+ this.alwaysScroll = true;
+ this.scheduleScroll();
+ }
+ }, 150);
+ },
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
@@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-
@@ -122,7 +122,7 @@
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php
index 48eb935ab..b7f68c6ec 100644
--- a/resources/views/livewire/project/service/file-storage.blade.php
+++ b/resources/views/livewire/project/service/file-storage.blade.php
@@ -1,6 +1,10 @@