Merge remote-tracking branch 'origin/next' into suppress-horizon-job-failures

This commit is contained in:
Andras Bacsai 2026-04-30 11:42:52 +02:00
commit f9b3f89757
6 changed files with 128 additions and 17 deletions

View file

@ -4,7 +4,6 @@
use App\Events\SentinelRestarted; use App\Events\SentinelRestarted;
use App\Models\Server; use App\Models\Server;
use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel 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'); $metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds'); $refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds'); $pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token'); $token = $server->settings->ensureValidSentinelToken();
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$endpoint = data_get($server, 'settings.sentinel_custom_url'); $endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';

View file

@ -29,6 +29,7 @@ class UploadController extends BaseController
'archive.gz', 'archive.gz',
'bz2', 'bz2',
'xz', 'xz',
'dmp',
]; ];
public function upload(Request $request) public function upload(Request $request)

View file

@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null; public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])] #[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
public ?string $smtpEncryption = null; public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null; public ?string $smtpUsername = null;

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -144,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters. * Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands. * 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); 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 = [ $data = [
'server_uuid' => $this->server->uuid, 'server_uuid' => $this->server->uuid,
]; ];
$token = json_encode($data); $token = encrypt(json_encode($data));
$encrypted = encrypt($token); $this->sentinel_token = $token;
$this->sentinel_token = $encrypted;
if ($save) { if ($save) {
if ($ignoreEvent) { if ($ignoreEvent) {
$this->saveQuietly(); $this->saveQuietly();

View file

@ -2,7 +2,9 @@
namespace App\Traits; namespace App\Traits;
use App\Models\ServerSetting; use App\Models\Server;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Log;
trait HasMetrics trait HasMetrics
{ {
@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString(); $from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from); $endpoint = $this->getMetricsEndpoint($type, $from);
$token = $server->settings->sentinel_token; $previousToken = null;
if (! ServerSetting::isValidSentinelToken($token)) { try {
throw new \Exception('Invalid sentinel token format. Please regenerate the token.'); $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( $response = instant_remote_process(
@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool 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; return $this->isServerMetrics() ? $this : $this->destination->server;
} }

View file

@ -4,6 +4,7 @@
use App\Models\ServerSetting; use App\Models\ServerSetting;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -78,11 +79,73 @@
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse(); expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
}); });
it('returns false for null sentinel token', function () {
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
});
it('rejects the reported PoC payload', function () { it('rejects the reported PoC payload', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse(); 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 () { describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () { it('generates tokens that pass format validation', function () {
$settings = $this->server->settings; $settings = $this->server->settings;
@ -92,4 +155,11 @@
expect($token)->not->toBeEmpty(); expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); 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);
});
}); });