clusev/app/Services/DeploymentService.php

214 lines
7.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\DB;
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';
/**
* 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 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',
];
}
}