311 lines
12 KiB
PHP
311 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\System;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Setting;
|
|
use App\Services\DeploymentService;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* System settings — dashboard-configurable Domain + TLS and the release channel.
|
|
*
|
|
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
|
|
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
|
|
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
|
|
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
|
|
* mid-session — that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
|
|
*
|
|
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
|
|
* config('clusev.channel')), audited on change.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
/** Valid user-facing release channels (descriptions are localized at render time). */
|
|
public const CHANNELS = ['stable', 'beta'];
|
|
|
|
/** Valid TLS termination modes. */
|
|
public const TLS_MODES = ['caddy', 'external'];
|
|
|
|
/** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */
|
|
public string $domain = '';
|
|
|
|
/** The domain form field (FQDN, or empty to clear the override). */
|
|
public string $domainInput = '';
|
|
|
|
/** True after a save — shows the "restart the stack to apply" notice. */
|
|
public bool $restartPending = false;
|
|
|
|
/** True once the operator clicked "restart now" — the host watcher is applying it. */
|
|
public bool $restartRequested = false;
|
|
|
|
public string $channel = 'stable';
|
|
|
|
public string $tlsMode = 'caddy';
|
|
|
|
/** Channel key => localized description, built per-request for the view. */
|
|
private function channelDescriptions(): array
|
|
{
|
|
return [
|
|
'stable' => __('system.channel_stable'),
|
|
'beta' => __('system.channel_beta'),
|
|
];
|
|
}
|
|
|
|
public function mount(DeploymentService $deployment): void
|
|
{
|
|
// Active = what the stack currently serves (snapshot); the form edits the PENDING
|
|
// (configured) domain. They differ when a saved change awaits a restart.
|
|
$this->domain = (string) ($deployment->domain() ?? '');
|
|
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
|
|
$this->restartPending = $deployment->restartPending();
|
|
$this->restartRequested = $deployment->restartRequested();
|
|
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
|
|
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
|
|
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
|
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
|
|
}
|
|
|
|
/** @return array<string, array<int, string>> */
|
|
private function domainRules(): array
|
|
{
|
|
// Empty clears the override (revert to install-time APP_DOMAIN / bare IP).
|
|
return ['domainInput' => ['nullable', 'string', 'max:253', 'regex:'.DeploymentService::DOMAIN_REGEX]];
|
|
}
|
|
|
|
/** Validate, then confirm the domain change via the shared modal (audited there). */
|
|
public function confirmDomain(DeploymentService $deployment): void
|
|
{
|
|
$this->validate($this->domainRules(), [
|
|
'domainInput.regex' => __('system.domain_invalid'),
|
|
'domainInput.max' => __('system.domain_invalid'),
|
|
]);
|
|
|
|
$new = strtolower(trim($this->domainInput));
|
|
|
|
if ($new === (string) ($deployment->configuredDomain() ?? '')) {
|
|
return; // no change vs the already-configured (pending) domain
|
|
}
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('system.change_domain_heading'),
|
|
'body' => $new !== ''
|
|
? __('system.change_domain_body', ['domain' => $new])
|
|
: __('system.clear_domain_body'),
|
|
'confirmLabel' => __('system.change_domain_confirm'),
|
|
'danger' => true,
|
|
'icon' => 'globe',
|
|
'notify' => '', // defer: we surface the restart notice instead of a toast
|
|
'token' => ConfirmToken::issue(
|
|
'domainChanged',
|
|
['domain' => $new],
|
|
'system.settings_updated',
|
|
'panel_domain='.($new !== '' ? $new : '(cleared)'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('domainChanged')]
|
|
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
|
|
{
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
|
|
} catch (InvalidConfirmToken) {
|
|
return; // forged / replayed / direct-bypass attempt — no-op
|
|
}
|
|
$domain = strtolower(trim($payload['params']['domain']));
|
|
|
|
// Defensive re-validation (the value round-trips through the modal event).
|
|
if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) {
|
|
return;
|
|
}
|
|
|
|
$pending = $deployment->setDomain($domain);
|
|
// The active (served) domain stays frozen until restart; only the pending changes.
|
|
$this->domainInput = (string) ($pending ?? '');
|
|
$this->restartPending = $deployment->restartPending();
|
|
$this->dispatch('notify', message: __('system.domain_saved_notify'));
|
|
}
|
|
|
|
/**
|
|
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
|
|
* The container never touches the Docker socket — it only requests via a marker file
|
|
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
|
|
* (a button, not auto-fired on save) so the operator controls the timing.
|
|
*/
|
|
public function restartNow(DeploymentService $deployment): void
|
|
{
|
|
$deployment->requestRestart();
|
|
$this->restartRequested = true;
|
|
$this->dispatch('notify', message: __('system.restart_running'));
|
|
}
|
|
|
|
/**
|
|
* Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS
|
|
* pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user
|
|
* throttled (5 / 10 min, auto-expiring — never a control-plane lockout) so it can't hammer
|
|
* ACME, and audited. Only meaningful in caddy mode with an active domain.
|
|
*/
|
|
public function requestCertificate(DeploymentService $deployment): void
|
|
{
|
|
$domain = $deployment->domain();
|
|
|
|
if ($domain === null || $deployment->externalTls()) {
|
|
$this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error');
|
|
|
|
return;
|
|
}
|
|
|
|
$key = 'cert-request:'.Auth::id();
|
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
|
|
|
return;
|
|
}
|
|
RateLimiter::hit($key, 600);
|
|
|
|
$result = $deployment->requestCertificate($domain);
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => 'tls.cert_request',
|
|
'target' => $domain.' → '.$result['status'],
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
[$message, $level] = match ($result['status']) {
|
|
'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'],
|
|
'dns_mismatch' => [__('system.cert_dns_mismatch', [
|
|
'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—',
|
|
'server' => $result['serverIp'] ?? '?',
|
|
]), 'error'],
|
|
default => [__('system.cert_failed'), 'error'],
|
|
};
|
|
|
|
$this->dispatch('notify', message: $message, level: $level);
|
|
}
|
|
|
|
/** Release channel changes are audited (confirmation via the shared modal). */
|
|
public function confirmChannel(string $channel): void
|
|
{
|
|
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
|
|
return;
|
|
}
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('system.change_channel_heading'),
|
|
'body' => __('system.change_channel_body', ['channel' => $channel]),
|
|
'confirmLabel' => __('system.change_channel_confirm'),
|
|
'danger' => false,
|
|
'icon' => 'git-branch',
|
|
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
|
|
'token' => ConfirmToken::issue(
|
|
'channelChanged',
|
|
['channel' => $channel],
|
|
'system.settings_updated',
|
|
'release_channel='.$channel,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('channelChanged')]
|
|
public function applyChannel(string $confirmToken): void
|
|
{
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
|
|
} catch (InvalidConfirmToken) {
|
|
return; // forged / replayed / direct-bypass attempt — no-op
|
|
}
|
|
$channel = $payload['params']['channel'];
|
|
|
|
if (! in_array($channel, self::CHANNELS, true)) {
|
|
return;
|
|
}
|
|
|
|
Setting::put('release_channel', $channel);
|
|
$this->channel = $channel;
|
|
}
|
|
|
|
/** TLS-mode changes are audited (confirmation via the shared modal). */
|
|
public function confirmTlsMode(string $mode): void
|
|
{
|
|
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
|
|
return;
|
|
}
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('system.change_tls_heading'),
|
|
'body' => $mode === 'external'
|
|
? __('system.change_tls_body_external')
|
|
: __('system.change_tls_body_caddy'),
|
|
'confirmLabel' => __('system.change_tls_confirm'),
|
|
'danger' => true,
|
|
'icon' => 'shield',
|
|
'notify' => '',
|
|
'token' => ConfirmToken::issue(
|
|
'tlsModeChanged',
|
|
['mode' => $mode],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('tlsModeChanged')]
|
|
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
|
|
{
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
|
|
} catch (InvalidConfirmToken) {
|
|
return; // forged / replayed / direct-bypass attempt — no-op
|
|
}
|
|
$mode = $payload['params']['mode'];
|
|
|
|
if (! in_array($mode, self::TLS_MODES, true)) {
|
|
return;
|
|
}
|
|
|
|
$deployment->setTlsMode($mode);
|
|
$this->tlsMode = $mode;
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => 'system.settings_updated',
|
|
'target' => 'tls_mode='.$mode,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
$this->restartPending = true;
|
|
$this->dispatch('notify', message: __('system.tls_saved_notify'));
|
|
}
|
|
|
|
public function render(DeploymentService $deployment)
|
|
{
|
|
return view('livewire.system.index', [
|
|
'hasTls' => $this->domain !== '',
|
|
'panelUrl' => $deployment->panelUrl(),
|
|
'isOverridden' => $deployment->domainIsOverridden(),
|
|
'channels' => $this->channelDescriptions(),
|
|
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
|
|
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
|
|
'certStatus' => $deployment->certStatus(),
|
|
])->title(__('system.title'));
|
|
}
|
|
}
|