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/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8d85c8932..270d847f2 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -2,9 +2,11 @@ namespace App\Models; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use OpenApi\Attributes as OA; #[OA\Schema( @@ -144,19 +146,51 @@ 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 { - $data = [ - 'server_uuid' => $this->server->uuid, - ]; - $token = json_encode($data); - $encrypted = encrypt($token); - $this->sentinel_token = $encrypted; + 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 + { + $token = Str::random(64); + $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/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php index 43048fcaa..1074ec4fe 100644 --- a/tests/Feature/SentinelTokenValidationTest.php +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -3,7 +3,10 @@ use App\Models\Server; use App\Models\ServerSetting; use App\Models\User; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); @@ -78,11 +81,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 +157,24 @@ expect($token)->not->toBeEmpty(); expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); }); + + it('does not double-encrypt newly generated tokens', function () { + $settings = $this->server->settings; + $token = $settings->generateSentinelToken(save: true, ignoreEvent: true); + + $raw = DB::table('server_settings')->where('id', $settings->id)->value('sentinel_token'); + + $once = Crypt::decryptString($raw); + expect($once)->toBe($token); + + expect(fn () => Crypt::decryptString($once)) + ->toThrow(DecryptException::class); + }); + + 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); + }); });