clusev/app/Services/DeploymentService.php

437 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Throwable;
/**
* The panel's domain + TLS state, with a clear split between the PENDING domain
* (changeable from the dashboard) and the ACTIVE domain (what the running stack
* actually serves, frozen until a restart).
*
* - PENDING (configuredDomain): a dashboard override stored as Setting `panel_domain`
* wins over the install-time APP_DOMAIN. A panel_domain ROW — even empty — is an
* explicit choice (empty = bare IP); only the ABSENCE of the row falls back to
* APP_DOMAIN.
* - ACTIVE (domain): read from a snapshot file written ONCE at container start
* (entrypoint -> `clusev:snapshot-domain`). So saving a new domain does NOT change
* how the panel is served until the operator restarts the stack — nothing switches
* mid-session, which is what makes it safe. Falls back to the pending value when no
* snapshot exists (dev / first run / tests).
*
* Everything serving-related (app.url, Caddy's /_caddy/ask, the Reverb client endpoint,
* the HTTPS redirect/secure-cookie) keys off the ACTIVE domain; bare-IP/HTTP access
* always stays a recovery path.
*/
class DeploymentService
{
/** FQDN matcher (labels 1-63 chars, no scheme/port/path, TLD >= 2 alpha). */
public const DOMAIN_REGEX = '/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i';
/** Snapshot of the active domain, written at container start (relative to storage/). */
private const ACTIVE_FILE = 'framework/active-domain';
/**
* Restart sentinel — a marker file the dashboard writes to REQUEST a stack restart
* (relative to storage/). It lives in its own bind-mounted directory (storage/app/
* restart-signal, mapped to ./run on the host in docker-compose.prod.yml) so a
* host-side watcher (a scoped systemd .path unit — see docker/restart-sentinel/)
* sees it appear and runs `docker compose restart`, then deletes it. The container
* NEVER gets the Docker socket; it only writes a non-sensitive marker file.
*/
private const RESTART_FILE = 'app/restart-signal/restart.request';
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
* what will become active after the next restart. null = bare IP / plain HTTP.
*/
public function configuredDomain(): ?string
{
try {
$db = Setting::get('panel_domain'); // null = no row; '' = explicit bare IP; 'x' = domain
} catch (Throwable) {
// Settings table not migrated yet (install/migrate) -> env fallback.
$db = null;
}
$source = $db !== null ? $db : config('clusev.domain');
return ($source !== null && trim((string) $source) !== '')
? strtolower(trim((string) $source))
: null;
}
/**
* The ACTIVE domain the running stack serves — frozen at container start. Falls back
* to the pending value when no snapshot exists (dev / first run / tests).
*/
public function domain(): ?string
{
$path = storage_path(self::ACTIVE_FILE);
if (is_file($path)) {
$value = trim((string) @file_get_contents($path));
return $value !== '' ? strtolower($value) : null;
}
return $this->configuredDomain();
}
/** True once an active-domain snapshot file exists. */
public function hasSnapshot(): bool
{
return is_file(storage_path(self::ACTIVE_FILE));
}
/**
* Snapshot the configured domain as the active one. Called (with retries) by the
* entrypoint at container start, so a domain change applies on restart and never
* mid-session.
*
* Reads the override DIRECTLY from the database (not the Redis-cached Setting::get), so
* it never freezes a stale env fallback when only the cache is down. Returns FALSE when
* the setting can't be read authoritatively yet — DB unreachable OR the settings table
* not migrated (fresh install) — so the caller retries and only then freezes the env
* fallback. This guarantees the active domain is always a FIXED snapshot, never a live
* value that could change when the DB/migrations catch up. Never throws.
*/
public function snapshotActiveDomain(): bool
{
try {
// null = no override row; '' = explicit bare IP; 'x' = domain.
$db = DB::table('settings')->where('key', 'panel_domain')->value('value');
} catch (Throwable) {
return false; // DB down OR settings table not migrated yet — not authoritative
}
$source = $db !== null ? $db : config('clusev.domain');
return $this->writeSnapshot($this->normalizeDomain($source));
}
/**
* Freeze the install-time domain (env) as the active one when the settings are
* unreachable at boot, so serving stays FIXED (the restart boundary holds) instead of
* falling back to a live value. A dashboard override (DB-only) then applies on the next
* restart where the database/migrations are ready.
*/
public function snapshotFallback(): bool
{
return $this->writeSnapshot($this->normalizeDomain(config('clusev.domain')));
}
/** Lowercased/trimmed domain, or null when empty (bare IP). */
private function normalizeDomain(mixed $value): ?string
{
return ($value !== null && trim((string) $value) !== '')
? strtolower(trim((string) $value))
: null;
}
private function writeSnapshot(?string $domain): bool
{
try {
$dir = storage_path('framework');
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return @file_put_contents(storage_path(self::ACTIVE_FILE), (string) $domain) !== false;
} catch (Throwable) {
return false;
}
}
/** A restart is needed when the pending domain differs from the active one. */
public function restartPending(): bool
{
return $this->configuredDomain() !== $this->domain();
}
/** Absolute path of the restart sentinel on the shared (bind-mounted) volume. */
public function restartSignalPath(): string
{
return storage_path(self::RESTART_FILE);
}
/**
* Request a stack restart by writing the sentinel file. A host-side watcher (a scoped
* systemd .path unit, see docker/restart-sentinel/) reacts to its appearance and runs
* `docker compose restart`, then removes it. The content is a non-sensitive marker
* (an ISO timestamp) — the trigger is the file existing, not what it holds. Never throws.
*/
public function requestRestart(): void
{
$path = $this->restartSignalPath();
$dir = dirname($path);
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@file_put_contents($path, now()->toIso8601String().PHP_EOL);
}
/** True while a restart request is pending (the host watcher clears it once handled). */
public function restartRequested(): bool
{
return is_file($this->restartSignalPath());
}
/** Remove the sentinel (used by tests / a manual reset; the host watcher deletes it normally). */
public function clearRestartRequest(): void
{
$path = $this->restartSignalPath();
if (is_file($path)) {
@unlink($path);
}
}
/** True when the panel domain is a dashboard override (a panel_domain row exists). */
public function domainIsOverridden(): bool
{
try {
return Setting::get('panel_domain') !== null;
} catch (Throwable) {
return false;
}
}
/**
* Persist a new panel domain. Pass null/'' to choose bare IP (an explicit override
* that also disables the install-time APP_DOMAIN). Returns the pending domain.
*/
public function setDomain(?string $domain): ?string
{
Setting::put('panel_domain', $domain !== null ? strtolower(trim($domain)) : '');
return $this->configuredDomain();
}
/** True when an upstream reverse proxy terminates TLS (the panel serves HTTP; Caddy issues no certs). */
public function externalTls(): bool
{
try {
return Setting::get('tls_mode', 'caddy') === 'external';
} catch (Throwable) {
return false;
}
}
/** Persist the TLS mode (only the two valid values; anything else falls back to caddy). */
public function setTlsMode(string $mode): void
{
Setting::put('tls_mode', $mode === 'external' ? 'external' : 'caddy');
}
/** True once an ACTIVE domain is configured — i.e. automatic TLS is in effect. */
public function hasTls(): bool
{
return $this->domain() !== null;
}
/** The HTTPS URL the panel is reachable at for the ACTIVE domain. */
public function panelUrl(): ?string
{
$domain = $this->domain();
return $domain !== null ? 'https://'.$domain : null;
}
/**
* Browser-facing Reverb (Echo) endpoint. Derived from the CURRENT request, because the
* browser always reaches Reverb through the SAME front door it loaded the panel from:
* both Caddy (prod) and nginx (dev) tunnel /app/* + /apps/* to the internal reverb:8080.
* So the endpoint is always the live request's host+port+scheme — never a stale .env
* REVERB_* value (which would break realtime after a domain change/clear) and needing no
* JS rebuild. Falls back to config/reverb.php only when there is no request context.
*
* @return array{key: ?string, host: ?string, port: int, scheme: string}
*/
public function reverbClient(): array
{
$key = config('broadcasting.connections.reverb.key');
$request = request();
if ($request !== null) {
return [
'key' => $key,
'host' => $request->getHost(),
'port' => $request->getPort(),
'scheme' => $request->isSecure() ? 'https' : 'http',
];
}
$opt = config('reverb.apps.apps.0.options', []);
return [
'key' => $key,
'host' => $opt['host'] ?? '127.0.0.1',
'port' => (int) ($opt['port'] ?? 80),
'scheme' => $opt['scheme'] ?? 'http',
];
}
/**
* The server's own public IP, used only to pre-check that a domain's DNS points here
* before triggering ACME. Best-effort (cached ~5 min on success); null when it can't be
* determined — the caller then treats the DNS comparison as "unknown" and proceeds, since
* the actual issuance handshake is the real source of truth.
*/
public function serverPublicIp(): ?string
{
$cached = Cache::get('clusev:public-ip');
if (is_string($cached) && $cached !== '') {
return $cached;
}
try {
$res = Process::timeout(5)->run(['curl', '-fsS', '--max-time', '3', 'https://api.ipify.org']);
$ip = trim($res->output());
} catch (Throwable) {
$ip = '';
}
if (filter_var($ip, FILTER_VALIDATE_IP)) {
Cache::put('clusev:public-ip', $ip, now()->addMinutes(5));
return $ip;
}
return null;
}
/**
* Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
*
* @return array{ok: ?bool, resolved: string[], serverIp: ?string}
* ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable)
*/
public function domainResolvesHere(string $domain): array
{
$domain = strtolower(trim($domain));
$serverIp = $this->serverPublicIp();
$resolved = [];
try {
foreach ([DNS_A, DNS_AAAA] as $type) {
foreach (@dns_get_record($domain, $type) ?: [] as $rec) {
$ip = $rec['ip'] ?? $rec['ipv6'] ?? null;
if (is_string($ip) && $ip !== '') {
$resolved[] = $ip;
}
}
}
} catch (Throwable) {
// resolution failed — treat as no records
}
$resolved = array_values(array_unique($resolved));
$ok = $serverIp === null ? null : in_array($serverIp, $resolved, true);
return ['ok' => $ok, 'resolved' => $resolved, 'serverIp' => $serverIp];
}
/**
* Trigger Caddy's on-demand TLS for the domain by doing an internal HTTPS handshake to the
* `caddy` service with SNI = the domain (`--connect-to` pins the dial to caddy regardless of
* the domain, so there is no SSRF — the connection target is fixed, the domain only sets
* SNI/Host). Returns true only when curl completes WITH certificate verification (exit 0),
* i.e. Caddy is now serving a trusted (Let's Encrypt) cert for the domain. Never throws.
*/
public function probeCertificate(string $domain): bool
{
$domain = strtolower(trim($domain));
if ($domain === '') {
return false;
}
try {
$res = Process::timeout(25)->run([
'curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}',
'--connect-to', "{$domain}:443:caddy:443",
'--max-time', '20',
"https://{$domain}/",
]);
// Exit 0 means the TLS handshake AND certificate verification succeeded (curl exits
// 60 on an untrusted/self-signed cert) — so a trusted cert is being served.
return $res->successful();
} catch (Throwable) {
return false;
}
}
/**
* Operator-triggered certificate request: DNS pre-check, then (only if DNS is not a clear
* mismatch) trigger on-demand issuance and verify. Persists the outcome to a Setting so the
* dashboard can show a status without a live (ACME-triggering) probe on every page load.
*
* @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string}
* status: issued | dns_mismatch | failed | not_applicable
*/
public function requestCertificate(string $domain): array
{
$domain = strtolower(trim($domain));
if ($domain === '' || $this->externalTls()) {
return $this->persistCertStatus('not_applicable');
}
$dns = $this->domainResolvesHere($domain);
if ($dns['ok'] === false) {
// Don't trigger ACME against a wrong host — protects the Let's Encrypt failed-
// validation rate limit.
return $this->persistCertStatus('dns_mismatch', [
'resolved' => $dns['resolved'],
'serverIp' => $dns['serverIp'],
]);
}
$issued = $this->probeCertificate($domain);
return $this->persistCertStatus($issued ? 'issued' : 'failed');
}
/** Last persisted certificate-request outcome, or null when never checked. */
public function certStatus(): ?array
{
try {
$raw = Setting::get('tls_cert_status');
} catch (Throwable) {
return null;
}
if (! is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/** @return array{status: string, checkedAt: string} */
private function persistCertStatus(string $status, array $extra = []): array
{
$payload = array_merge(['status' => $status, 'checkedAt' => now()->toIso8601String()], $extra);
try {
Setting::put('tls_cert_status', json_encode($payload));
} catch (Throwable $e) {
// Status display is best-effort (the notify still reports the live result), but
// surface the failure to logs so a broken settings store doesn't fail silently.
report($e);
}
return $payload;
}
}