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 d8834f995..07a3d9f64 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 a6056b9f3..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.', ]); @@ -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.', @@ -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/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ffa71b55a..eda2014a6 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -58,6 +58,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 +76,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 +93,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', @@ -118,6 +136,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', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 62adf5410..c6297905e 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -68,6 +68,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 +84,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', @@ -117,6 +129,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.', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 4158016d0..7489b433f 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -82,6 +82,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 +98,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', @@ -131,6 +143,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', @@ -224,6 +245,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.'); } } @@ -311,6 +339,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'], diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 4453a0e7a..99ee4e477 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -32,6 +32,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.', @@ -101,6 +104,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 +119,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', @@ -150,6 +165,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.', 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/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/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..8d85c8932 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -49,6 +49,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 +98,7 @@ class ServerSetting extends Model 'is_terminal_enabled', 'deployment_queue_limit', 'disable_application_image_retention', + 'connection_timeout', ]; protected $casts = [ @@ -108,6 +110,7 @@ class ServerSetting extends Model 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', + 'connection_timeout' => 'integer', ]; protected static function booted() 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 f2f6946fb..8ebf27eed 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0', + 'version' => '4.0.1', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), 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/docker-compose.dev.yml b/docker-compose.dev.yml index f608fe3cb..82b7edb44 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ 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" diff --git a/openapi.json b/openapi.json index 453289970..2c85b6efe 100644 --- a/openapi.json +++ b/openapi.json @@ -10550,6 +10550,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" @@ -13355,6 +13359,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 a3844bb18..a92a3bdda 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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': @@ -8539,6 +8542,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/versions.json b/other/nightly/versions.json index 3307b7f2e..fe7f6418d 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "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/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 @@
- @if ($isReadOnly) + @if ($fileStorage->is_too_large) +
+ File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server. +
+ @elseif ($isReadOnly)
@if ($fileStorage->is_directory) This directory is mounted as read-only and cannot be modified from the UI. @@ -44,7 +48,7 @@ confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" shortConfirmationLabel="Filepath" /> @else - @if (!$fileStorage->is_binary) + @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version." rows="20" id="content" - readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"> - @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) + readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"> + @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large) Save @endif @else diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d694174d5..cfbeccd0c 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1 label="Port" required :disabled="$isValidating" />
+
+ +
diff --git a/routes/api.php b/routes/api.php index 7394d4e16..c944a6ebc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -215,6 +215,8 @@ Route::post('/sentinel/push', function () { $token = request()->header('Authorization'); if (! $token) { + auditLogWebhookFailure('sentinel', 'token_missing'); + return response()->json(['message' => 'Unauthorized'], 401); } $naked_token = str_replace('Bearer ', '', $token); @@ -222,26 +224,49 @@ $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); } catch (Exception $e) { + auditLogWebhookFailure('sentinel', 'decrypt_failed'); + return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); if (! $server_uuid) { + auditLogWebhookFailure('sentinel', 'invalid_token_payload'); + return response()->json(['message' => 'Invalid token'], 401); } $server = Server::where('uuid', $server_uuid)->first(); if (! $server) { + auditLogWebhookFailure('sentinel', 'server_not_found', [ + 'server_uuid' => $server_uuid, + ]); + return response()->json(['message' => 'Server not found'], 404); } if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + auditLogWebhookFailure('sentinel', 'subscription_unpaid', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } if ($server->isFunctional() === false) { + auditLogWebhookFailure('sentinel', 'server_not_functional', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Server is not functional'], 401); } if ($server->settings->sentinel_token !== $naked_token) { + auditLogWebhookFailure('sentinel', 'token_mismatch', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'Unauthorized'], 401); } $data = request()->all(); @@ -249,6 +274,11 @@ // \App\Jobs\ServerCheckNewJob::dispatch($server, $data); PushServerUpdateJob::dispatch($server, $data); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + return response()->json(['message' => 'ok'], 200); }); }); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php new file mode 100644 index 000000000..34e9168ec --- /dev/null +++ b/tests/Feature/Security/AuditLogTest.php @@ -0,0 +1,445 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$team, $user]; +} + +function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string +{ + $token = $user->createToken('audit-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeAuditApplication(string $repo = 'test-org/test-repo'): Application +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'audit-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +describe('audit channel helper', function () { + test('auditLog writes structured payload to audit channel', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('test.event', Mockery::on(function ($context) { + return $context['event'] === 'test.event' + && $context['custom_field'] === 'value' + && array_key_exists('ip', $context) + && array_key_exists('user_id', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.event', ['custom_field' => 'value']); + }); + + test('auditLog warning level routes correctly', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.failed', [], 'warning'); + }); + + test('auditLogWebhookFailure logs warning with provider tag', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->once() + ->with('webhook.github.signature_failed', Mockery::on(function ($context) { + return $context['reason'] === 'invalid_signature' + && $context['event'] === 'webhook.github.signature_failed' + && array_key_exists('ip', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']); + }); + + test('auditLog never includes raw secret keys in context', function () { + $captured = null; + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) { + $captured = $context; + + return true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + auditLog('test.private_key.created', [ + 'team_id' => '1', + 'private_key_uuid' => 'abc', + 'fingerprint' => 'SHA256:xyz', + ]); + + expect($captured)->toBeArray(); + // Helper itself never injects secret-bearing keys. + $disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret']; + foreach (array_keys($captured) as $key) { + expect(in_array(strtolower($key), $disallowed, true))->toBeFalse(); + } + }); +}); + +describe('webhook signature failure logging', function () { + test('GitHub manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.github.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('GitLab manual webhook with bad token logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitlab.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Bitbucket manual webhook with malformed signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.bitbucket.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1=anyvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('Gitea manual webhook with bad signature logs to audit channel', function () { + $app = makeAuditApplication(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.gitea.signature_failed', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); +}); + +describe('API mutation audit logging', function () { + test('private key creation emits api.private_key.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.private_key.created', Mockery::on(function ($context) { + return $context['event'] === 'api.private_key.created' + && ! array_key_exists('private_key', $context); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + // Generate a valid OpenSSH-format private key for the test. + $opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n". + base64_encode(str_repeat('a', 256)). + "\n-----END OPENSSH PRIVATE KEY-----"; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/security/keys', [ + 'name' => 'test-key', + 'description' => 'audit test', + 'private_key' => $opensshKey, + ]); + + // Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201. + expect($response->status())->toBeIn([201, 422]); + }); + + test('enable_api denial for non-root team emits warning audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.instance.enable_denied', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/enable'); + + $response->assertStatus(403); + }); + + test('project creation emits api.project.created audit event', function () { + [$team, $user] = makeAuditTeamUser(); + $token = makeAuditApiToken($user, $team); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->atLeast() + ->once() + ->with('api.project.created', Mockery::on(function ($context) { + return $context['event'] === 'api.project.created' + && ! empty($context['project_uuid']) + && $context['project_name'] === 'audit-project'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'audit-project', + 'description' => 'audit', + ]); + + $response->assertStatus(201); + }); +}); + +describe('threat-detection audit logging (Phase 2)', function () { + test('missing bearer token logs api.auth.unauthenticated', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('expired bearer token logs api.auth.unauthenticated', function () { + [$team, $user] = makeAuditTeamUser(); + $token = $user->createToken('expired-audit', ['read'], now()->subDay()); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.unauthenticated', Mockery::any()); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('read-only token hitting write endpoint logs api.auth.ability_denied', function () { + [$team, $user] = makeAuditTeamUser(); + $readToken = makeAuditApiToken($user, $team, ['read']); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('api.auth.ability_denied', Mockery::on(function ($ctx) { + return in_array('write', $ctx['required_abilities'] ?? [], true); + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'should-fail', + ]); + + $response->assertStatus(403); + }); + + test('sentinel push without Authorization logs token_missing', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'token_missing'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); + + test('sentinel push with un-decryptable bearer logs decrypt_failed', function () { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('warning') + ->atLeast() + ->once() + ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) { + return $ctx['reason'] === 'decrypt_failed'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + Log::shouldReceive('info')->andReturnNull(); + Log::shouldReceive('error')->andReturnNull(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer not-a-valid-encrypted-payload', + ])->postJson('/api/v1/sentinel/push', []); + + $response->assertStatus(401); + }); +}); diff --git a/tests/Feature/ServerConnectionTimeoutApiTest.php b/tests/Feature/ServerConnectionTimeoutApiTest.php new file mode 100644 index 000000000..287122523 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutApiTest.php @@ -0,0 +1,74 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $newToken = $this->user->createToken('write-token', ['write']); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $newToken->plainTextToken; +}); + +it('PATCH updates connection_timeout via API', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 45, + ]); + + $response->assertStatus(201); + expect($this->server->settings->fresh()->connection_timeout)->toBe(45); +}); + +it('PATCH rejects connection_timeout out of range', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 0, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects connection_timeout above max', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 999, + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); + +it('PATCH rejects non-integer connection_timeout', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/servers/'.$this->server->uuid, [ + 'connection_timeout' => 'fast', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['errors' => ['connection_timeout']]); +}); diff --git a/tests/Feature/ServerConnectionTimeoutTest.php b/tests/Feature/ServerConnectionTimeoutTest.php new file mode 100644 index 000000000..b457f3f01 --- /dev/null +++ b/tests/Feature/ServerConnectionTimeoutTest.php @@ -0,0 +1,43 @@ +create(); + $this->team = $user->teams()->first(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('defaults connection_timeout to 10 seconds for new servers', function () { + expect($this->server->settings->connection_timeout)->toBe(10); +}); + +it('persists a custom connection_timeout value', function () { + $this->server->settings->connection_timeout = 30; + $this->server->settings->save(); + + expect($this->server->settings->fresh()->connection_timeout)->toBe(30); +}); + +it('returns the per-server connection_timeout from getConnectionTimeout', function () { + $this->server->settings->connection_timeout = 45; + $this->server->settings->save(); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45); +}); + +it('falls back to config default when connection_timeout is invalid', function () { + $this->server->settings->connection_timeout = 0; + $this->server->settings->saveQuietly(); + + $expected = (int) config('constants.ssh.connection_timeout'); + + expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected); +}); diff --git a/tests/Feature/ServerReachabilityNotificationTest.php b/tests/Feature/ServerReachabilityNotificationTest.php new file mode 100644 index 000000000..e996ba028 --- /dev/null +++ b/tests/Feature/ServerReachabilityNotificationTest.php @@ -0,0 +1,105 @@ +team = Team::factory()->create(); + $this->team->emailNotificationSettings()->update([ + 'use_instance_email_settings' => true, + 'server_unreachable_email_notifications' => true, + 'server_reachable_email_notifications' => true, + ]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + Notification::fake(); +}); + +it('sends Unreachable notification when threshold reached and not yet notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue(); +}); + +it('does not send Unreachable on first transient failure (count=1)', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 1, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('does not send Unreachable when already notified', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 5, + 'unreachable_notification_sent' => true, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('sends Reachable notification on recovery when previously notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => true, + ])->save(); + + $fresh = $this->server->fresh(); + expect($fresh->unreachable_notification_sent)->toBeTrue(); + expect((bool) $fresh->settings->is_reachable)->toBeTrue(); + + ServerReachabilityChanged::dispatch($fresh); + + Notification::assertSentTo($this->team, Reachable::class); + expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse(); +}); + +it('does not send Reachable when never notified', function () { + $this->server->settings()->update(['is_reachable' => true]); + $this->server->forceFill([ + 'unreachable_count' => 0, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertNothingSent(); +}); + +it('routes Unreachable notification through EmailChannel when email toggle is on', function () { + $this->server->settings()->update(['is_reachable' => false]); + $this->server->forceFill([ + 'unreachable_count' => 2, + 'unreachable_notification_sent' => false, + ])->save(); + + ServerReachabilityChanged::dispatch($this->server->fresh()); + + Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) { + return in_array(EmailChannel::class, $channels); + }); +}); diff --git a/tests/Unit/IsReachableChangedTest.php b/tests/Unit/IsReachableChangedTest.php new file mode 100644 index 000000000..76f9863bf --- /dev/null +++ b/tests/Unit/IsReachableChangedTest.php @@ -0,0 +1,61 @@ +is_reachable = $isReachable; + + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('refresh')->andReturnSelf(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + + return $server; +} + +it('sends Reachable notification when reachable and notification was previously sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0); + $server->shouldReceive('sendReachableNotification')->once(); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send any notification when reachable and notification was never sent', function () { + $server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0); + $server->shouldNotReceive('sendReachableNotification'); + $server->shouldNotReceive('sendUnreachableNotification'); + + $server->isReachableChanged(); +}); + +it('sends Unreachable notification when count >= 2 and not yet notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2); + $server->shouldReceive('sendUnreachableNotification')->once(); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not send Unreachable notification on first transient failure (count=1)', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); + +it('does not double-send Unreachable when already notified', function () { + $server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5); + $server->shouldNotReceive('sendUnreachableNotification'); + $server->shouldNotReceive('sendReachableNotification'); + + $server->isReachableChanged(); +}); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php new file mode 100644 index 000000000..1fd315884 --- /dev/null +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -0,0 +1,66 @@ +toBe(5_242_880); +}); + +it('exposes binary and too-large placeholder constants', function () { + expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]'); + expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]'); +}); + +it('flags is_too_large when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + expect($volume->is_too_large)->toBeTrue(); + expect($volume->is_binary)->toBeFalse(); +}); + +it('flags is_binary when content matches the placeholder', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::BINARY_PLACEHOLDER; + + expect($volume->is_binary)->toBeTrue(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag normal content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = "hello\nworld\n"; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('does not flag empty content as binary or too large', function () { + $volume = new LocalFileVolume; + $volume->content = null; + + expect($volume->is_binary)->toBeFalse(); + expect($volume->is_too_large)->toBeFalse(); +}); + +it('exposes the too-large flag via toArray for Livewire serialization', function () { + $volume = new LocalFileVolume; + $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER; + + $array = $volume->toArray(); + + expect($array)->toHaveKey('is_too_large'); + expect($array['is_too_large'])->toBeTrue(); +}); diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index bdcefb74f..9f1f747d4 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -1,11 +1,13 @@ is_reachable = true; $settings->shouldReceive('update') ->with(['is_reachable' => false, 'is_usable' => false]) ->once(); $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false); $server->shouldReceive('increment')->with('unreachable_count')->once(); $server->id = 1; $server->name = 'test-server'; + $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is $job = new ServerConnectionCheckJob($server); $job->failed(new TimeoutExceededException); @@ -152,6 +159,50 @@ }); }); +describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () { + // ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that + // call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy + // through the event dispatcher (which trips Eloquent static method lookups on the proxy class). + $invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) { + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount); + $server->shouldReceive('getAttribute')->with('id')->andReturn(1); + if ($expectDispatch) { + $server->shouldReceive('isReachableChanged')->once()->andReturnNull(); + } else { + $server->shouldNotReceive('isReachableChanged'); + } + + $job = new ServerConnectionCheckJob($server); + $method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded'); + $method->invoke($job, $wasReachable, $wasNotified, $isReachable); + }; + + it('dispatches event when count crosses unreachable threshold', function () use ($invoke) { + $invoke(true, false, false, 2, true); + }); + + it('does not dispatch on first transient failure (count=1)', function () use ($invoke) { + $invoke(true, false, false, 1, false); + }); + + it('does not dispatch when already notified and still unreachable', function () use ($invoke) { + $invoke(false, true, false, 5, false); + }); + + it('dispatches recovery event when previously unreachable', function () use ($invoke) { + $invoke(false, false, true, 0, true); + }); + + it('dispatches recovery event when previously notified', function () use ($invoke) { + $invoke(true, true, true, 0, true); + }); + + it('does not dispatch when consistently reachable and never notified', function () use ($invoke) { + $invoke(true, false, true, 0, false); + }); +}); + describe('ServerCheckJob unreachable_count', function () { it('increments unreachable_count on timeout', function () { $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); diff --git a/versions.json b/versions.json index 3307b7f2e..fe7f6418d 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0" + "version": "4.0.1" }, "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/vite.config.js b/vite.config.js index fc739c95d..4b967c40e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const viteHost = env.VITE_HOST || null; + const vitePort = Number(env.VITE_PORT || 5173); return { server: { @@ -14,9 +16,11 @@ export default defineConfig(({ mode }) => { ], }, host: "0.0.0.0", - hmr: { - host: env.VITE_HOST || '0.0.0.0' - }, + allowedHosts: true, + origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined, + hmr: viteHost + ? { host: viteHost, clientPort: vitePort } + : true, }, plugins: [ laravel({