Merge remote-tracking branch 'origin/next' into feat/railpack
This commit is contained in:
commit
9717d9ff5a
47 changed files with 2015 additions and 83 deletions
|
|
@ -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<class-string<\Throwable>, \Psr\Log\LogLevel::*>
|
||||
* @var array<class-string<Throwable>, LogLevel::*>
|
||||
*/
|
||||
protected $levels = [
|
||||
//
|
||||
|
|
@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
|
|||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var array<int, class-string<\Throwable>>
|
||||
* @var array<int, class-string<Throwable>>
|
||||
*/
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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} "
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
81
bootstrap/helpers/audit.php
Normal file
81
bootstrap/helpers/audit.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
* Write a security-relevant audit entry to the dedicated `audit` log channel.
|
||||
*
|
||||
* Never include secrets (private keys, passwords, tokens, webhook secrets,
|
||||
* signature header values, env-var values) in $context.
|
||||
*
|
||||
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
|
||||
* @param array<string, mixed> $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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->integer('connection_timeout')->default(10)->after('deployment_queue_limit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('connection_timeout');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
@if ($fileStorage->is_too_large)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
|
||||
</div>
|
||||
@elseif ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
@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)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||
|
|
@ -76,8 +80,8 @@
|
|||
label="{{ $fileStorage->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 }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
|
||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-64">
|
||||
<x-forms.input canGate="update" :canResource="$server" type="number"
|
||||
id="connectionTimeout" label="SSH Connection Timeout (s)"
|
||||
helper="Seconds to wait for SSH connection before failing. Default: 10."
|
||||
min="1" max="300" required :disabled="$isValidating" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="serverTimezone">Server Timezone</label>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
445
tests/Feature/Security/AuditLogTest.php
Normal file
445
tests/Feature/Security/AuditLogTest.php
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditTeamUser(): array
|
||||
{
|
||||
$team = Team::factory()->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);
|
||||
});
|
||||
});
|
||||
74
tests/Feature/ServerConnectionTimeoutApiTest.php
Normal file
74
tests/Feature/ServerConnectionTimeoutApiTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 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']]);
|
||||
});
|
||||
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal file
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->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);
|
||||
});
|
||||
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
});
|
||||
61
tests/Unit/IsReachableChangedTest.php
Normal file
61
tests/Unit/IsReachableChangedTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
|
||||
{
|
||||
$settings = Mockery::mock();
|
||||
$settings->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();
|
||||
});
|
||||
66
tests/Unit/LocalFileVolumeContentSizeTest.php
Normal file
66
tests/Unit/LocalFileVolumeContentSizeTest.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for LocalFileVolume content size handling.
|
||||
*
|
||||
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
|
||||
* mount large host files. Coolify previously stored full file content in the
|
||||
* encrypted `content` mediumText column, then serialized it to the Livewire
|
||||
* payload, crashing the browser.
|
||||
*/
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('exposes a 5 MiB content size limit', function () {
|
||||
expect(LocalFileVolume::MAX_CONTENT_SIZE)->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();
|
||||
});
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
|
@ -126,16 +128,21 @@
|
|||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
$settings = Mockery::mock();
|
||||
$settings->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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue