diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 071f3ec46..289ab9ebe 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -4,7 +4,6 @@ use App\Events\SentinelRestarted; use App\Models\Server; -use App\Models\ServerSetting; use Lorisleiva\Actions\Concerns\AsAction; class StartSentinel @@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer $metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days'); $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); - $token = data_get($server, 'settings.sentinel_token'); - if (! ServerSetting::isValidSentinelToken($token)) { - throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.'); - } + $token = $server->settings->ensureValidSentinelToken(); $endpoint = data_get($server, 'settings.sentinel_custom_url'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $mountDir = '/data/coolify/sentinel'; diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 96fbd7193..6c3dda402 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -29,6 +29,7 @@ class UploadController extends BaseController 'archive.gz', 'bz2', 'xz', + 'dmp', ]; public function upload(Request $request) diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 364163ff8..724dd0bac 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -45,7 +45,7 @@ class Email extends Component public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] - public ?string $smtpEncryption = null; + public ?string $smtpEncryption = 'starttls'; #[Validate(['nullable', 'string'])] public ?string $smtpUsername = null; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8d85c8932..79f62f4b7 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; @@ -144,19 +145,54 @@ protected static function booted() * Validate that a sentinel token contains only safe characters. * Prevents OS command injection when the token is interpolated into shell commands. */ - public static function isValidSentinelToken(string $token): bool + public static function isValidSentinelToken(?string $token): bool { + if ($token === null) { + return false; + } + return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token); } - public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false) + /** + * Returns a valid sentinel token, regenerating it if the stored value is + * empty, undecryptable, or otherwise invalid. Throws only when regeneration + * still fails to produce a valid token. + */ + public function ensureValidSentinelToken(): string + { + try { + $token = $this->sentinel_token; + } catch (DecryptException) { + $token = null; + } + + if (! self::isValidSentinelToken($token)) { + // Clear undecryptable raw value so Eloquent's dirty-check won't try to + // decrypt the bad original during save(). + $attrs = $this->getAttributes(); + $attrs['sentinel_token'] = null; + $this->setRawAttributes($attrs, true); + + $this->generateSentinelToken(save: true, ignoreEvent: true); + $this->refresh(); + $token = $this->sentinel_token; + } + + if (! self::isValidSentinelToken($token)) { + throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.'); + } + + return $token; + } + + public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string { $data = [ 'server_uuid' => $this->server->uuid, ]; - $token = json_encode($data); - $encrypted = encrypt($token); - $this->sentinel_token = $encrypted; + $token = encrypt(json_encode($data)); + $this->sentinel_token = $token; if ($save) { if ($ignoreEvent) { $this->saveQuietly(); diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 7ed82cc91..20b3752f5 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -2,7 +2,9 @@ namespace App\Traits; -use App\Models\ServerSetting; +use App\Models\Server; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Support\Facades\Log; trait HasMetrics { @@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array $from = now()->subMinutes($mins)->toIso8601ZuluString(); $endpoint = $this->getMetricsEndpoint($type, $from); - $token = $server->settings->sentinel_token; - if (! ServerSetting::isValidSentinelToken($token)) { - throw new \Exception('Invalid sentinel token format. Please regenerate the token.'); + $previousToken = null; + try { + $previousToken = $server->settings->sentinel_token; + } catch (DecryptException) { + // fall through to ensureValidSentinelToken which will regenerate + } + $token = $server->settings->ensureValidSentinelToken(); + if ($token !== $previousToken) { + Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]); } $response = instant_remote_process( @@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array private function isServerMetrics(): bool { - return $this instanceof \App\Models\Server; + return $this instanceof Server; } - private function getMetricsServer(): \App\Models\Server + private function getMetricsServer(): Server { return $this->isServerMetrics() ? $this : $this->destination->server; } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 48e0a8c78..4707b0a07 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -12,8 +12,9 @@ use Spatie\Url\Url; use Visus\Cuid2\Cuid2; -function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) +function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { + $commit = $commit ?: ($application->git_commit_sha ?: 'HEAD'); $application_id = $application->id; $deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}"); $deployment_url = $deployment_link->getPath(); diff --git a/tests/Feature/QueueApplicationDeploymentCommitTest.php b/tests/Feature/QueueApplicationDeploymentCommitTest.php new file mode 100644 index 000000000..ac6be5c9e --- /dev/null +++ b/tests/Feature/QueueApplicationDeploymentCommitTest.php @@ -0,0 +1,107 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->unique()->word(), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application +{ + $attributes = [ + 'environment_id' => $environmentId, + 'destination_id' => $destinationId, + 'destination_type' => StandaloneDocker::class, + ]; + + if ($gitCommitSha !== null) { + $attributes['git_commit_sha'] = $gitCommitSha; + } + + return Application::factory()->create($attributes); +} + +describe('queue_application_deployment commit resolution', function () { + test('uses application git_commit_sha when commit parameter omitted', function () { + $pinnedSha = 'abc123def456abc123def456abc123def456abc1'; + $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha); + + $result = queue_application_deployment( + application: $application, + deployment_uuid: 'test-deploy-uuid-1', + ); + + expect($result['status'])->toBe('queued'); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first(); + expect($deployment)->not->toBeNull(); + expect($deployment->commit)->toBe($pinnedSha); + }); + + test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () { + $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD'); + + $result = queue_application_deployment( + application: $application, + deployment_uuid: 'test-deploy-uuid-2', + ); + + expect($result['status'])->toBe('queued'); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first(); + expect($deployment->commit)->toBe('HEAD'); + }); + + test('explicit commit parameter overrides application git_commit_sha', function () { + $pinnedSha = 'abc123def456abc123def456abc123def456abc1'; + $webhookSha = '111222333444555666777888999000aaabbbccc1'; + $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha); + + $result = queue_application_deployment( + application: $application, + deployment_uuid: 'test-deploy-uuid-3', + commit: $webhookSha, + ); + + expect($result['status'])->toBe('queued'); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first(); + expect($deployment->commit)->toBe($webhookSha); + }); + + test('treats empty string commit parameter as unset and uses git_commit_sha', function () { + $pinnedSha = 'abc123def456abc123def456abc123def456abc1'; + $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha); + + $result = queue_application_deployment( + application: $application, + deployment_uuid: 'test-deploy-uuid-4', + commit: '', + ); + + expect($result['status'])->toBe('queued'); + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first(); + expect($deployment->commit)->toBe($pinnedSha); + }); +}); diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php index 43048fcaa..14f24d03a 100644 --- a/tests/Feature/SentinelTokenValidationTest.php +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -4,6 +4,7 @@ use App\Models\ServerSetting; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); @@ -78,11 +79,73 @@ expect(ServerSetting::isValidSentinelToken(''))->toBeFalse(); }); + it('returns false for null sentinel token', function () { + expect(ServerSetting::isValidSentinelToken(null))->toBeFalse(); + }); + it('rejects the reported PoC payload', function () { expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse(); }); }); +describe('ServerSetting::ensureValidSentinelToken', function () { + it('regenerates empty sentinel token via ensureValidSentinelToken', function () { + $settings = $this->server->settings; + DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']); + + $settings->refresh(); + $token = $settings->ensureValidSentinelToken(); + + expect($token)->not->toBeEmpty(); + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + expect($settings->fresh()->sentinel_token)->toBe($token); + }); + + it('regenerates token when stored value cannot be decrypted', function () { + $settings = $this->server->settings; + DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']); + + $settings->refresh(); + $token = $settings->ensureValidSentinelToken(); + + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + expect($settings->fresh()->sentinel_token)->toBe($token); + }); + + it('returns existing valid token without regenerating', function () { + $settings = $this->server->settings; + $original = $settings->sentinel_token; + + $token = $settings->ensureValidSentinelToken(); + + expect($token)->toBe($original); + }); + + it('throws RuntimeException only when regeneration also fails', function () { + $settings = $this->server->settings; + DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']); + + $stub = new class extends ServerSetting + { + protected $table = 'server_settings'; + + public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string + { + DB::table('server_settings')->where('id', $this->id)->update([ + 'sentinel_token' => encrypt('invalid token with spaces!'), + ]); + + return ''; + } + }; + $stub->setRawAttributes($settings->fresh()->getAttributes(), true); + $stub->exists = true; + + expect(fn () => $stub->ensureValidSentinelToken()) + ->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration'); + }); +}); + describe('generated sentinel tokens are valid', function () { it('generates tokens that pass format validation', function () { $settings = $this->server->settings; @@ -92,4 +155,11 @@ expect($token)->not->toBeEmpty(); expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); }); + + it('returns the same value the cast reads back', function () { + $settings = $this->server->settings; + $returned = $settings->generateSentinelToken(save: true, ignoreEvent: true); + + expect($settings->fresh()->sentinel_token)->toBe($returned); + }); });