mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 16:46:37 +00:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
624 lines
25 KiB
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;
|
|
}
|
|
}
|