Ctrl K
ComunidadGuías…

app/Models/ServerSetting.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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'],
        'is_ssh_host_key_check_enforced' => ['type' => 'boolean', 'description' => 'Refuse a changed host key on this server, also when the instance only warns.'],
        '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';

    /** Memory limit of each scanner container (Syft, Grype, regctl), in MB. */
    public const int DEFAULT_IMAGE_SCAN_MEMORY_MB = 1024;

    /** @var list<int> */
    public const array IMAGE_SCAN_MEMORY_OPTIONS_MB = [1024, 2048, 4096, 8192];

    /** Time limit of one Syft run and of one Grype run, in seconds. */
    public const int DEFAULT_IMAGE_SCAN_TIMEOUT_SECONDS = 600;

    /** @var list<int> */
    public const array IMAGE_SCAN_TIMEOUT_OPTIONS_SECONDS = [300, 600, 1200, 1800, 3600];

    /** CPUs of each scanner container. */
    public const float DEFAULT_IMAGE_SCAN_CPUS = 1.0;

    /** @var list<float> */
    public const array IMAGE_SCAN_CPU_OPTIONS = [0.5, 1.0, 2.0, 4.0];

    protected $fillable = [
        'server_id',
        'is_image_scan_enabled',
        'image_scan_memory_mb',
        'image_candidate_scans_today',
        'image_candidate_scans_date',
        'image_scan_syft_timeout',
        'image_scan_grype_timeout',
        'image_scan_cpus',
        '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_scan_memory_mb' => self::DEFAULT_IMAGE_SCAN_MEMORY_MB,
        'image_candidate_scans_today' => 0,
        'image_scan_syft_timeout' => self::DEFAULT_IMAGE_SCAN_TIMEOUT_SECONDS,
        'image_scan_grype_timeout' => self::DEFAULT_IMAGE_SCAN_TIMEOUT_SECONDS,
        'image_scan_cpus' => self::DEFAULT_IMAGE_SCAN_CPUS,
    ];

    protected $casts = [
        'force_disabled' => 'boolean',
        'image_scan_syft_timeout' => 'integer',
        'image_scan_grype_timeout' => 'integer',
        'image_scan_cpus' => 'float',
        '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_scan_memory_mb' => 'integer',
        'image_candidate_scans_today' => 'integer',
        'image_candidate_scans_date' => 'date',
        'ssh_host_key_trusted_at' => 'datetime',
        'is_ssh_host_key_check_disabled' => 'boolean',
        'is_ssh_host_key_check_enforced' => '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;
    }
}