`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'; /** * Update sentinel — same mechanism and bind-mounted directory as RESTART_FILE, but the * host watcher reacts by running update.sh (git pull + idempotent re-install) instead of a * plain restart. Separate filename so the two requests trigger distinct host units. */ private const UPDATE_FILE = 'app/restart-signal/update.request'; /** * Update start marker — written (best-effort) when an update is requested, on the same * bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase * checklist are anchored to the SERVER-side start, not to page-load time — a browser refresh * mid-update no longer resets the timer to zero nor drops the checklist back to phase one. */ private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json'; /** * Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses * (fetch → build → restart → migrate → done). Same bind-mounted directory. Read-only token. */ private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json'; /** * 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(): bool { $path = $this->restartSignalPath(); $dir = dirname($path); if (! is_dir($dir)) { @mkdir($dir, 0775, true); } // Returns false when the (bind-mounted) sentinel dir is not writable by the app user, so // the caller can surface that instead of a "restarting" state that never resolves. return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; } /** 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); } } /** Absolute path of the update sentinel on the shared (bind-mounted) volume. */ public function updateSignalPath(): string { return storage_path(self::UPDATE_FILE); } /** Absolute path of the update-start marker on the shared (bind-mounted) volume. */ public function updateStartedPath(): string { return storage_path(self::UPDATE_STARTED_FILE); } /** * Epoch seconds when the current update was requested, or null if there is no readable marker. * The update-progress page anchors its elapsed timer and live phase feed to this so both * survive a browser refresh instead of resetting on every page load. Never throws. */ public function updateStartedAt(): ?int { $path = $this->updateStartedPath(); if (! is_file($path)) { return null; } $data = json_decode((string) @file_get_contents($path), true); $at = is_array($data) ? ($data['at'] ?? null) : null; return is_int($at) && $at > 0 ? $at : null; } /** * The current update macro-stage written by the host updater, or null when absent/unrecognised. * Only ever returns a whitelisted stage token (never arbitrary file content). Shape: * ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => ]. Never throws. * * @return array{stage: string, at: int}|null */ public function updatePhase(): ?array { $path = storage_path(self::UPDATE_PHASE_FILE); if (! is_file($path)) { return null; } $data = json_decode((string) @file_get_contents($path), true); if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done', 'error'], true)) { return null; } // Require a real timestamp: the progress page's freshness logic ties a stage to THIS update // via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0` // fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a // premature completion redirect. Treat it as "no stage" and let the page fall back. $at = $data['at'] ?? null; if (! is_int($at) || $at <= 0) { return null; } return ['stage' => (string) $data['stage'], 'at' => $at]; } /** * Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped * clusev-update.path unit) reacts by running update.sh — git pull + idempotent re-install * (rebuild, migrate, restart) — then removes the sentinel. The container never runs git or * Docker itself; it only writes this non-sensitive marker. Never throws. */ public function requestUpdate(): bool { $path = $this->updateSignalPath(); $dir = dirname($path); if (! is_dir($dir)) { @mkdir($dir, 0775, true); } // Returns false when the (bind-mounted) sentinel dir is not writable by the app user — // the caller surfaces that instead of showing a "running" state that never resolves. $written = @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false; if ($written) { // Best-effort start marker (a failure here must never fail the update): lets the // progress page render a refresh-proof elapsed time and accept the live phase feed // regardless of how long the current stage has been running. @file_put_contents($this->updateStartedPath(), json_encode([ 'at' => now()->timestamp, 'from' => (string) config('clusev.version'), ]).PHP_EOL); } return $written; } /** True while an update request is pending (the host watcher clears it once handled). */ public function updateRequested(): bool { return is_file($this->updateSignalPath()); } /** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes * the sentinel normally). */ public function clearUpdateRequest(): void { foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) { 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(); $domain = $this->domain(); if ($request !== null) { // Served over the active domain → the browser reached the panel through the HTTPS // front door (Caddy or an external TLS proxy on 443), which tunnels /app/* + /apps/* // to reverb. Connect Reverb the SAME way — wss on 443 — derived from the active // domain, NOT the request scheme/port: behind an external TLS proxy those look like // plain HTTP:80 here, which would (wrongly) produce wss://host:80 and fail. if ($domain !== null && strtolower($request->getHost()) === $domain) { return ['key' => $key, 'host' => $domain, 'port' => 443, 'scheme' => 'https']; } // Bare IP / recovery path → exactly what the request used (plain ws over the IP). 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; } }