230 lines
8.4 KiB
PHP
230 lines
8.4 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 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',
|
|
];
|
|
}
|
|
}
|