`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'; /** * 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(); } /** 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', ]; } }