Files
coolify/app/Models/ServerSetting.php

624 lines
25 KiB
PHP

<?php
namespace App\Models;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use App\Helpers\SshMultiplexingHelper;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
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'],
'backup_compression_cpu_percentage' => ['type' => 'integer'],
'dynamic_timeout' => ['type' => 'integer'],
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'readOnly' => true, 'description' => 'Deprecated: use server_role instead. true when server_role is build.'],
'is_cloudflare_tunnel' => ['type' => 'boolean'],
'is_jump_server' => ['type' => 'boolean'],
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'],
'is_logdrain_custom_enabled' => ['type' => 'boolean'],
'is_logdrain_newrelic_enabled' => ['type' => 'boolean'],
'is_metrics_enabled' => ['type' => 'boolean'],
'is_traffic_analytics_enabled' => ['type' => 'boolean'],
'traffic_topn' => ['type' => 'integer'],
'traffic_sample_threshold' => ['type' => 'integer'],
'traffic_retention_1h_days' => ['type' => 'integer'],
'traffic_retention_1d_days' => ['type' => 'integer'],
'is_geoip_enabled' => ['type' => 'boolean'],
'geoip_refresh_days' => ['type' => 'integer'],
'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']],
'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'],
'is_reachable' => ['type' => 'boolean'],
'ssh_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'Pinned SSH host key(s) of the server, one "type base64" line per key.'],
'ssh_host_key_source' => ['type' => 'string', 'nullable' => true, 'enum' => ['tofu', 'upgrade', 'transfer', 'manual']],
'ssh_host_key_trusted_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'ssh_host_key_pending' => ['type' => 'string', 'nullable' => true, 'description' => 'Host key that the server sent when it did not match the pinned key.'],
'is_ssh_host_key_check_disabled' => ['type' => 'boolean'],
'ssh_host_key_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the pinned host keys, as `ssh-keygen -lf` shows them.'],
'ssh_host_key_pending_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string'], 'readOnly' => true, 'description' => 'SHA256 fingerprints of the host key that did not match.'],
'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_axiom_domain' => ['type' => 'string', 'description' => 'Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co.'],
'logdrain_cloudwatch_region' => ['type' => 'string'],
'logdrain_cloudwatch_group' => ['type' => 'string'],
'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'],
'logdrain_custom_config' => ['type' => 'string'],
'logdrain_custom_config_parser' => ['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.'],
'docker_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Engine version on the server.'],
'docker_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Engine version was last detected.'],
'compose_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Compose plugin version on the server.'],
'compose_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Compose version was last detected.'],
]
)]
class ServerSetting extends Model
{
/**
* Allowed Traefik certificate resolver names; also keeps the value safe inside generated labels.
*/
public const string TRAEFIK_CERTIFICATE_RESOLVER_PATTERN = '/^[A-Za-z0-9_-]+$/';
/**
* Resolver value that turns the resolver off (no certresolver label) on a resource or server.
*/
public const string TRAEFIK_CERTIFICATE_RESOLVER_NONE = 'none';
public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10;
public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7;
public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60;
/**
* Axiom edge deployment for US East 1. See https://axiom.co/docs/reference/edge-deployments.
*/
public const string DEFAULT_AXIOM_DOMAIN = 'us-east-1.aws.edge.axiom.co';
protected $fillable = [
'server_id',
'is_image_scan_enabled',
'image_candidate_scans_today',
'image_candidate_scans_date',
'is_swarm_manager',
'is_jump_server',
'is_build_server',
'server_role',
'is_reachable',
'is_usable',
'wildcard_domain',
'is_cloudflare_tunnel',
'cloudflare_tunnel_id',
'cloudflare_tunnel_account_id',
'cloudflare_tunnel_integration_token_id',
'is_logdrain_newrelic_enabled',
'logdrain_newrelic_license_key',
'logdrain_newrelic_base_uri',
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'logdrain_axiom_domain',
'is_swarm_worker',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
'logdrain_custom_config_parser',
'is_logdrain_cloudwatch_enabled',
'logdrain_cloudwatch_region',
'logdrain_cloudwatch_group',
'logdrain_cloudwatch_stream_prefix',
'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',
'server_disk_usage_notification_interval_hours',
'is_terminal_enabled',
'deployment_queue_limit',
'backup_compression_cpu_percentage',
'disable_application_image_retention',
'connection_timeout',
'is_traffic_analytics_enabled',
'traffic_topn',
'traffic_sample_threshold',
'traffic_retention_1h_days',
'traffic_retention_1d_days',
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
'traefik_certificate_resolver',
'docker_version',
'docker_version_checked_at',
'compose_version',
'compose_version_checked_at',
'patch_check_frequency',
'patch_check_result',
'patch_check_error',
'patch_checked_at',
'is_auto_security_updates_enabled',
'auto_security_updates_time',
];
/**
* Model-level defaults that mirror database column defaults, so a freshly
* created instance exposes them without a refresh.
*
* @var array<string, mixed>
*/
protected $attributes = [
'is_traffic_analytics_enabled' => false,
'is_image_scan_enabled' => false,
'image_candidate_scans_today' => 0,
];
protected $casts = [
'force_disabled' => 'boolean',
'force_docker_cleanup' => 'boolean',
'docker_cleanup_threshold' => 'integer',
'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_build_server' => 'boolean',
'server_role' => ServerRole::class,
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
'is_traffic_analytics_enabled' => 'boolean',
'traffic_topn' => 'integer',
'traffic_sample_threshold' => 'integer',
'traffic_retention_1h_days' => 'integer',
'traffic_retention_1d_days' => 'integer',
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer',
'geoip_maxmind_license_key' => 'encrypted',
'logdrain_custom_config' => 'encrypted',
'traffic_ip_mode' => TrafficIpMode::class,
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
'backup_compression_cpu_percentage' => 'integer',
'server_disk_usage_notification_interval_hours' => 'integer',
'patch_check_result' => 'array',
'patch_checked_at' => 'datetime',
'is_auto_security_updates_enabled' => 'boolean',
'is_logdrain_cloudwatch_enabled' => 'boolean',
'is_image_scan_enabled' => 'boolean',
'image_candidate_scans_today' => 'integer',
'image_candidate_scans_date' => 'date',
'ssh_host_key_trusted_at' => 'datetime',
'is_ssh_host_key_check_disabled' => 'boolean',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'is_build_server',
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
'geoip_maxmind_license_key',
];
/** @var list<string>|null */
private static ?array $columnsWithoutPatchCheckResult = null;
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) {
// A save that sets the key too (a transfer import) already has the key of the new address.
if ($settings->wasChanged('is_cloudflare_tunnel') && ! $settings->wasChanged('ssh_host_key')) {
$settings->forgetSshHostKey('cloudflare_tunnel_changed');
}
if ($settings->wasChanged('ssh_host_key') && $settings->server) {
SshMultiplexingHelper::deleteStaleKnownHostsFiles($settings->server->uuid, $settings->ssh_host_key);
}
if (
$settings->wasChanged('sentinel_token') ||
$settings->wasChanged('sentinel_custom_url') ||
$settings->wasChanged('is_metrics_enabled') ||
$settings->wasChanged('is_sentinel_debug_enabled') ||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->wasChanged('sentinel_metrics_history_days') ||
$settings->wasChanged('sentinel_push_interval_seconds') ||
$settings->wasChanged('traffic_topn') ||
$settings->wasChanged('traffic_sample_threshold') ||
$settings->wasChanged('traffic_retention_1h_days') ||
$settings->wasChanged('traffic_retention_1d_days') ||
$settings->wasChanged('is_geoip_enabled') ||
$settings->wasChanged('geoip_refresh_days') ||
$settings->wasChanged('geoip_maxmind_license_key') ||
$settings->wasChanged('traffic_ip_mode')
) {
// Only recreate Sentinel when it is already enabled. Otherwise a change to a
// traffic/geoip tuning knob would turn Sentinel on as a side effect, because
// StartSentinel unconditionally sets is_sentinel_enabled = true.
if ($settings->is_sentinel_enabled) {
$settings->server->restartSentinel();
}
}
});
}
public function effectiveServerRole(): ServerRole
{
return $this->server_role ?? ($this->is_build_server ? ServerRole::BUILD : ServerRole::BOTH);
}
/**
* 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
{
$data = [
'server_uuid' => $this->server->uuid,
];
$token = encrypt(json_encode($data));
$this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
} else {
$this->save();
}
}
return $token;
}
public function ensureSentinelUrl(): string
{
$url = $this->sentinel_custom_url;
// The local Sentinel runs on the coolify network. A host gateway URL leaves that network
// through the host INPUT chain, where host firewalls such as UFW drop it.
if ($this->server->isLocalhost() && strtolower((string) parse_url((string) $url, PHP_URL_HOST)) === 'host.docker.internal') {
$url = null;
}
if (blank($url)) {
$url = $this->generateSentinelUrl(ignoreEvent: true);
}
if (blank($url)) {
throw new \RuntimeException('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.');
}
return $url;
}
/**
* The Sentinel push URL that the instance URL or public IP gives to a remote server.
*/
public static function instanceSentinelUrl(?string $fqdn, ?string $publicIpv4, ?string $publicIpv6): ?string
{
return match (true) {
filled($fqdn) => $fqdn,
filled($publicIpv4) => 'http://'.$publicIpv4.':8000',
filled($publicIpv6) => 'http://'.formatHostForUrl($publicIpv6).':8000',
default => null,
};
}
/**
* Move remote servers that use the generated Sentinel URL to the new instance URL. Saving the
* new URL restarts their Sentinel. Servers with a custom URL keep it.
*/
public static function followInstanceUrlChange(?string $oldUrl, ?string $newUrl): void
{
if (blank($oldUrl) || blank($newUrl) || $oldUrl === $newUrl) {
return;
}
self::query()
->where('sentinel_custom_url', $oldUrl)
->where('server_id', '!=', 0)
->each(function (ServerSetting $setting) use ($newUrl) {
$setting->sentinel_custom_url = $newUrl;
$setting->save();
});
}
public function restoreDefaultSentinelConfiguration(): void
{
$this->generateSentinelUrl(save: false, ignoreEvent: true);
$this->sentinel_metrics_refresh_rate_seconds = self::DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS;
$this->sentinel_metrics_history_days = self::DEFAULT_SENTINEL_METRICS_HISTORY_DAYS;
$this->sentinel_push_interval_seconds = self::DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS;
$this->is_sentinel_debug_enabled = false;
$this->saveQuietly();
}
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string
{
$settings = InstanceSettings::get();
if ($this->server->isLocalhost()) {
$domain = 'http://coolify:8080';
} else {
$domain = self::instanceSentinelUrl($settings->fqdn, $settings->public_ipv4, $settings->public_ipv6)
?? $this->sentinelUrlFromCurrentRequest();
}
$this->sentinel_custom_url = $domain;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
} else {
$this->save();
}
}
return $domain;
}
private function sentinelUrlFromCurrentRequest(): ?string
{
if (! app()->bound('request')) {
return null;
}
$request = request();
$host = normalizeIpAddress(strtolower($request->getHost()));
if (
$host === 'localhost' ||
str_ends_with($host, '.localhost') ||
$host === '::1' ||
$host === '::' ||
$host === '0.0.0.0' ||
str_starts_with($host, '127.')
) {
return null;
}
return $request->getSchemeAndHttpHost();
}
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);
}
);
}
/**
* Selects every column except patch_check_result, which holds the full update list of the last patch
* check (often tens of kilobytes). Use it for instance-wide server loops that do not read the result.
*/
public function scopeWithoutPatchCheckResult(Builder $query): Builder
{
self::$columnsWithoutPatchCheckResult ??= array_values(array_diff(Schema::getColumnListing($this->getTable()), ['patch_check_result']));
return $query->select(self::$columnsWithoutPatchCheckResult);
}
/**
* Summary of the saved patch check, or null when the server was never checked.
* Results saved before security and reboot detection count as 0 and false.
*
* @return array{updates: int, security: int, reboot_required: bool, error: ?string, checked_at: Carbon}|null
*/
public function patchStatus(): ?array
{
if ($this->patch_checked_at === null) {
return null;
}
$result = $this->patch_check_result ?? [];
return [
'updates' => (int) ($result['total_updates'] ?? 0),
'security' => (int) ($result['security_updates'] ?? 0),
'reboot_required' => ($result['reboot_required'] ?? null) === true,
'error' => filled($this->patch_check_error) ? $this->patch_check_error : null,
'checked_at' => $this->patch_checked_at,
];
}
/**
* Remove the pinned and the pending host key after the address of the server changed, so that the next
* connection trusts the key of the new address. Only an admin can change the address.
*/
public function forgetSshHostKey(string $reason): void
{
if (blank($this->ssh_host_key) && blank($this->ssh_host_key_pending)) {
return;
}
$previousFingerprints = self::sshHostKeyFingerprints($this->ssh_host_key);
$cleared = [
'ssh_host_key' => null,
'ssh_host_key_source' => null,
'ssh_host_key_trusted_at' => null,
'ssh_host_key_pending' => null,
];
// A query update, so that other unsaved changes stay unsaved and the model events do not run again.
self::query()->whereKey($this->getKey())->update($cleared);
$this->forceFill($cleared)->syncOriginalAttributes(array_keys($cleared));
if ($this->server) {
SshMultiplexingHelper::deleteStaleKnownHostsFiles($this->server->uuid, null);
}
auditLog('server.ssh_host_key.forgotten', [
'team_id' => $this->server?->team_id,
'server_uuid' => $this->server?->uuid,
'reason' => $reason,
'previous_fingerprints' => $previousFingerprints,
]);
}
/**
* Read SSH host keys from public key lines ("type base64 [comment]") or known_hosts lines
* ("host type base64"). Returns the keys as "type base64" lines, or null when there is no valid key.
*/
public static function normalizeSshHostKeys(?string $input): ?string
{
$keys = [];
foreach (preg_split('/\R/', (string) $input) as $line) {
$fields = preg_split('/\s+/', trim($line), -1, PREG_SPLIT_NO_EMPTY);
if ($fields === [] || str_starts_with($fields[0], '#') || str_starts_with($fields[0], '@')) {
continue;
}
if (! self::isSshHostKeyType($fields[0])) {
array_shift($fields);
}
[$type, $blob] = [$fields[0] ?? '', $fields[1] ?? ''];
if (self::isSshHostKeyType($type) && self::sshKeyBlobType($blob) === $type) {
$keys[] = "{$type} {$blob}";
}
}
return $keys === [] ? null : implode("\n", array_values(array_unique($keys)));
}
/**
* The SHA256 fingerprints of host key lines, in the format of `ssh-keygen -lf`.
*
* @return list<string>
*/
public static function sshHostKeyFingerprints(?string $keys): array
{
return collect(explode("\n", (string) $keys))
->filter()
->map(function (string $line) {
[$type, $blob] = explode(' ', $line, 2);
return 'SHA256:'.rtrim(base64_encode(hash('sha256', base64_decode($blob), true)), '=')." ({$type})";
})
->values()
->all();
}
private static function isSshHostKeyType(string $type): bool
{
return preg_match('/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)$/', $type) === 1;
}
/**
* The key type that is encoded in the first field of an SSH public key blob.
*/
private static function sshKeyBlobType(string $blob): ?string
{
$decoded = base64_decode($blob, true);
if ($decoded === false || strlen($decoded) < 4) {
return null;
}
$length = unpack('N', substr($decoded, 0, 4))[1];
return strlen($decoded) >= 4 + $length ? substr($decoded, 4, $length) : null;
}
}