Replace hard validation error with self-healing token logic. Tokens that are null, empty, or fail decryption are now regenerated automatically rather than crashing sentinel startup or metrics reads. Token format changed from encrypted JSON payload to a plain 64-char random string (Str::random), eliminating double-encryption issues and simplifying the validation regex to cover the new character set. New `ensureValidSentinelToken()` method on ServerSetting centralises the get-or-regenerate contract; both StartSentinel and HasMetrics now delegate to it. HasMetrics logs a warning when regeneration occurs so operators know a sentinel container restart is required. `isValidSentinelToken()` now accepts `?string` (null → false). Adds feature tests covering: null/empty/undecryptable stored values, idempotent return of valid tokens, RuntimeException only when regeneration itself produces an invalid token, no double-encryption of newly generated tokens, and cast round-trip consistency.
246 lines
8.9 KiB
PHP
246 lines
8.9 KiB
PHP
<?php
|
|
|
|
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(
|
|
description: 'Server Settings model',
|
|
type: 'object',
|
|
properties: [
|
|
'id' => ['type' => 'integer'],
|
|
'concurrent_builds' => ['type' => 'integer'],
|
|
'deployment_queue_limit' => ['type' => 'integer'],
|
|
'dynamic_timeout' => ['type' => 'integer'],
|
|
'force_disabled' => ['type' => 'boolean'],
|
|
'force_server_cleanup' => ['type' => 'boolean'],
|
|
'is_build_server' => ['type' => 'boolean'],
|
|
'is_cloudflare_tunnel' => ['type' => 'boolean'],
|
|
'is_jump_server' => ['type' => 'boolean'],
|
|
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
|
|
'is_logdrain_custom_enabled' => ['type' => 'boolean'],
|
|
'is_logdrain_highlight_enabled' => ['type' => 'boolean'],
|
|
'is_logdrain_newrelic_enabled' => ['type' => 'boolean'],
|
|
'is_metrics_enabled' => ['type' => 'boolean'],
|
|
'is_reachable' => ['type' => 'boolean'],
|
|
'is_sentinel_enabled' => ['type' => 'boolean'],
|
|
'is_swarm_manager' => ['type' => 'boolean'],
|
|
'is_swarm_worker' => ['type' => 'boolean'],
|
|
'is_terminal_enabled' => ['type' => 'boolean'],
|
|
'is_usable' => ['type' => 'boolean'],
|
|
'logdrain_axiom_api_key' => ['type' => 'string'],
|
|
'logdrain_axiom_dataset_name' => ['type' => 'string'],
|
|
'logdrain_custom_config' => ['type' => 'string'],
|
|
'logdrain_custom_config_parser' => ['type' => 'string'],
|
|
'logdrain_highlight_project_id' => ['type' => 'string'],
|
|
'logdrain_newrelic_base_uri' => ['type' => 'string'],
|
|
'logdrain_newrelic_license_key' => ['type' => 'string'],
|
|
'sentinel_metrics_history_days' => ['type' => 'integer'],
|
|
'sentinel_metrics_refresh_rate_seconds' => ['type' => 'integer'],
|
|
'sentinel_token' => ['type' => 'string'],
|
|
'docker_cleanup_frequency' => ['type' => 'string'],
|
|
'docker_cleanup_threshold' => ['type' => 'integer'],
|
|
'server_id' => ['type' => 'integer'],
|
|
'wildcard_domain' => ['type' => 'string'],
|
|
'created_at' => ['type' => 'string'],
|
|
'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
|
|
{
|
|
protected $fillable = [
|
|
'server_id',
|
|
'is_swarm_manager',
|
|
'is_jump_server',
|
|
'is_build_server',
|
|
'is_reachable',
|
|
'is_usable',
|
|
'wildcard_domain',
|
|
'is_cloudflare_tunnel',
|
|
'is_logdrain_newrelic_enabled',
|
|
'logdrain_newrelic_license_key',
|
|
'logdrain_newrelic_base_uri',
|
|
'is_logdrain_highlight_enabled',
|
|
'logdrain_highlight_project_id',
|
|
'is_logdrain_axiom_enabled',
|
|
'logdrain_axiom_dataset_name',
|
|
'logdrain_axiom_api_key',
|
|
'is_swarm_worker',
|
|
'is_logdrain_custom_enabled',
|
|
'logdrain_custom_config',
|
|
'logdrain_custom_config_parser',
|
|
'concurrent_builds',
|
|
'dynamic_timeout',
|
|
'force_disabled',
|
|
'is_metrics_enabled',
|
|
'generate_exact_labels',
|
|
'force_docker_cleanup',
|
|
'docker_cleanup_frequency',
|
|
'docker_cleanup_threshold',
|
|
'server_timezone',
|
|
'delete_unused_volumes',
|
|
'delete_unused_networks',
|
|
'is_sentinel_enabled',
|
|
'sentinel_token',
|
|
'sentinel_metrics_refresh_rate_seconds',
|
|
'sentinel_metrics_history_days',
|
|
'sentinel_push_interval_seconds',
|
|
'sentinel_custom_url',
|
|
'server_disk_usage_notification_threshold',
|
|
'is_sentinel_debug_enabled',
|
|
'server_disk_usage_check_frequency',
|
|
'is_terminal_enabled',
|
|
'deployment_queue_limit',
|
|
'disable_application_image_retention',
|
|
'connection_timeout',
|
|
];
|
|
|
|
protected $casts = [
|
|
'force_disabled' => 'boolean',
|
|
'force_docker_cleanup' => 'boolean',
|
|
'docker_cleanup_threshold' => 'integer',
|
|
'sentinel_token' => 'encrypted',
|
|
'is_reachable' => 'boolean',
|
|
'is_usable' => 'boolean',
|
|
'is_terminal_enabled' => 'boolean',
|
|
'disable_application_image_retention' => 'boolean',
|
|
'connection_timeout' => 'integer',
|
|
];
|
|
|
|
protected static function booted()
|
|
{
|
|
static::creating(function ($setting) {
|
|
try {
|
|
if (str($setting->sentinel_token)->isEmpty()) {
|
|
$setting->generateSentinelToken(save: false, ignoreEvent: true);
|
|
}
|
|
if (str($setting->sentinel_custom_url)->isEmpty()) {
|
|
$setting->generateSentinelUrl(save: false, ignoreEvent: true);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('Error creating server setting: '.$e->getMessage());
|
|
}
|
|
});
|
|
static::updated(function ($settings) {
|
|
if (
|
|
$settings->wasChanged('sentinel_token') ||
|
|
$settings->wasChanged('sentinel_custom_url') ||
|
|
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
|
|
$settings->wasChanged('sentinel_metrics_history_days') ||
|
|
$settings->wasChanged('sentinel_push_interval_seconds')
|
|
) {
|
|
$settings->server->restartSentinel();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
if ($token === null) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$token = Str::random(64);
|
|
$this->sentinel_token = $token;
|
|
if ($save) {
|
|
if ($ignoreEvent) {
|
|
$this->saveQuietly();
|
|
} else {
|
|
$this->save();
|
|
}
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false)
|
|
{
|
|
$domain = null;
|
|
$settings = InstanceSettings::get();
|
|
if ($this->server->isLocalhost()) {
|
|
$domain = 'http://host.docker.internal:8000';
|
|
} elseif ($settings->fqdn) {
|
|
$domain = $settings->fqdn;
|
|
} elseif ($settings->public_ipv4) {
|
|
$domain = 'http://'.$settings->public_ipv4.':8000';
|
|
} elseif ($settings->public_ipv6) {
|
|
$domain = 'http://'.$settings->public_ipv6.':8000';
|
|
}
|
|
$this->sentinel_custom_url = $domain;
|
|
if ($save) {
|
|
if ($ignoreEvent) {
|
|
$this->saveQuietly();
|
|
} else {
|
|
$this->save();
|
|
}
|
|
}
|
|
|
|
return $domain;
|
|
}
|
|
|
|
public function server()
|
|
{
|
|
return $this->belongsTo(Server::class);
|
|
}
|
|
|
|
public function dockerCleanupFrequency(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
set: function ($value) {
|
|
return translate_cron_expression($value);
|
|
},
|
|
get: function ($value) {
|
|
return translate_cron_expression($value);
|
|
}
|
|
);
|
|
}
|
|
}
|