feat(domain): change the panel domain from the dashboard (v0.4.0)
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.
How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
DIRECTLY (cache-independent) and retries until the settings table is readable; if it
never is, it freezes the env fallback so the active domain is always a FIXED value
(never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
(/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
command; DE/EN strings (R16).
Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
always a recovery path. trustProxies('*') only in production (dev can't be tricked into
faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
and IP-recovery redirects stay on the IP.
Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).
Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
v0.4.0
parent
ec1516184e
commit
a44e29426a
|
|
@ -6,6 +6,7 @@
|
|||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/framework/active-domain
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
|
|
|
|||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -13,6 +13,38 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
|||
|
||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||
|
||||
## [0.4.0] - 2026-06-14
|
||||
|
||||
Die Panel-Domain ist wieder aus dem Dashboard änderbar — diesmal sicher: gespeichert wird in der Datenbank, angewendet beim Neustart, das Zertifikat holt Caddy automatisch.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Domain aus dem Dashboard ändern.** Auf der System-Seite lässt sich die Panel-Domain
|
||||
eintragen oder leeren. Gespeichert wird als Einstellung (`panel_domain`, übersteuert die
|
||||
Installations-Domain `APP_DOMAIN`); nach einem Stack-Neustart leiten sich URL, der
|
||||
WebSocket-Endpunkt (Reverb), das TLS-Zertifikat und die Cookie-Sicherheit automatisch davon
|
||||
ab. Kein Terminal-Editieren mehr — nur „speichern und neu starten".
|
||||
- **Automatisches TLS per On-Demand (Caddy).** Caddy stellt das Zertifikat beim ersten
|
||||
HTTPS-Zugriff aus und fragt vorher die App (`/_caddy/ask`), ob für genau diese Domain
|
||||
ausgestellt werden darf — so kann keine fremde, auf die IP gezeigte Domain Zertifikate
|
||||
erzwingen. Voraussetzung bleibt: die Domain zeigt per DNS auf den Server.
|
||||
|
||||
### Geändert
|
||||
- Der Reverb-Client (Browser) bezieht seinen Endpunkt jetzt zur Laufzeit aus der aktuellen
|
||||
Anfrage und läuft über dieselbe Adresse wie das Panel (Proxy/Reverse-Tunnel `/app/*`,
|
||||
`/apps/*` — Caddy in Produktion, nginx in Dev). So folgt Realtime jeder Domain-Änderung
|
||||
ohne Neu-Bauen der Assets und ohne separat geöffneten Port.
|
||||
- Server-seitiges Event-Publishing läuft intern direkt zum `reverb`-Dienst, ist damit
|
||||
unabhängig von der öffentlichen Domain/dem Zertifikat.
|
||||
|
||||
### Sicherheit
|
||||
- **Kein Aussperren.** Sicherer-Cookie-Schalter und HTTPS-Redirect richten sich nach der
|
||||
tatsächlichen Anfrage (hinter Caddy via `X-Forwarded-*`, das nur in Produktion vertraut
|
||||
wird). Der Zugriff per **IP über HTTP bleibt immer als Rückfallweg erreichbar**, falls
|
||||
eine neue Domain (DNS/Zertifikat) noch nicht bereit ist.
|
||||
- **Nur die aktive Domain liefert das Panel aus.** Bei gesetzter Domain wird das Panel per
|
||||
Hostname ausschließlich über genau diese Domain (HTTPS) bedient; eine alte/fremde, auf den
|
||||
Server gezeigte Domain wird abgewiesen (404) — auch wenn Caddy ihr Zertifikat noch hält.
|
||||
|
||||
## [0.3.0] - 2026-06-13
|
||||
|
||||
Zweisprachige Oberfläche: die gesamte UI gibt es jetzt auf Deutsch und Englisch, frei umschaltbar pro Konto.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Freeze the currently-configured panel domain as the ACTIVE one for this container's
|
||||
* lifetime. Run by the entrypoint at every container start, so a dashboard domain change
|
||||
* takes effect on restart (and never mid-session).
|
||||
*
|
||||
* The snapshot itself is the readiness probe: it returns false until the setting can be
|
||||
* read authoritatively (database reachable AND the settings table migrated — neither is
|
||||
* guaranteed at startup: depends_on health isn't honored on host reboot, and on a fresh
|
||||
* install the app starts before install.sh migrates). We retry until it succeeds, and only
|
||||
* if it never does do we freeze the install-time env fallback — so a snapshot file always
|
||||
* exists and the active domain is never a live value that could shift after startup.
|
||||
*/
|
||||
class SnapshotDomain extends Command
|
||||
{
|
||||
protected $signature = 'clusev:snapshot-domain {--tries=30}';
|
||||
|
||||
protected $description = 'Snapshot the configured panel domain as the active serving domain (run at container start)';
|
||||
|
||||
public function handle(DeploymentService $deployment): int
|
||||
{
|
||||
$tries = max(1, (int) $this->option('tries'));
|
||||
|
||||
for ($i = 1; $i <= $tries; $i++) {
|
||||
if ($deployment->snapshotActiveDomain()) {
|
||||
$this->info('Active domain: '.($deployment->domain() ?? '(bare IP / HTTP)'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($i < $tries) {
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Settings never became readable in time. Keep any existing snapshot (last-known-good,
|
||||
// frozen); only if none exists, freeze the install-time env fallback — so the active
|
||||
// domain is always a FIXED snapshot and never falls back to a live value that could
|
||||
// change once the DB/migrations catch up (which would break the restart boundary).
|
||||
if ($deployment->hasSnapshot()) {
|
||||
$this->warn('Settings not reachable; kept the existing active-domain snapshot.');
|
||||
} else {
|
||||
$deployment->snapshotFallback();
|
||||
$this->warn('Settings not reachable; froze the install-time domain (dashboard override applies on the next ready restart).');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\DeploymentService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Ties cookie security, HTTPS enforcement and host enforcement to the ACTUAL request so
|
||||
* a panel-domain change can never lock the operator out nor expose the panel on the wrong
|
||||
* host. Runs behind TrustProxies (prod), so isSecure()/getHost() reflect Caddy's
|
||||
* X-Forwarded-* headers.
|
||||
*
|
||||
* - The session cookie's `Secure` flag follows the real request scheme, so the bare-IP /
|
||||
* plain-HTTP recovery path always receives its cookie and can log in.
|
||||
* - When a domain is active, ONLY that domain (over HTTPS) and the literal server IP
|
||||
* (over HTTP, the recovery path) may serve the panel. Any other hostname — a stale/old
|
||||
* domain whose certificate Caddy may still hold, or an attacker's domain pointed at this
|
||||
* server — is refused, so the authenticated panel is never served in plaintext or on an
|
||||
* unintended host. Caddy's TLS-ask + the health check are exempt.
|
||||
* - In bare-IP mode app.url is kept in step with the request, so generated absolute URLs
|
||||
* never point at a removed/old domain left in .env.
|
||||
*/
|
||||
class PanelScheme
|
||||
{
|
||||
public function __construct(private DeploymentService $deployment) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Cookie security follows the real request scheme (never a static .env flag).
|
||||
config(['session.secure' => $request->isSecure()]);
|
||||
|
||||
$domain = $this->deployment->domain();
|
||||
|
||||
// Bare-IP mode: no TLS, no host restriction. Keep app.url on the current request
|
||||
// so URLs don't point at a domain that was removed from service.
|
||||
if ($domain === null) {
|
||||
config(['app.url' => $request->getSchemeAndHttpHost()]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
|
||||
// a plain-HTTP call with an internal Host; the health check likewise).
|
||||
if ($request->is('_caddy/*', 'up')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$host = strtolower($request->getHost());
|
||||
|
||||
// The literal server IP is the plaintext recovery path — always served over HTTP.
|
||||
// Pin app.url to the IP request too, so login/onboarding redirects (absolute
|
||||
// route() URLs) stay on the reachable IP instead of bouncing to the (possibly
|
||||
// unreachable) domain that AppServiceProvider set as app.url.
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
config(['app.url' => $request->getSchemeAndHttpHost()]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Only the active domain may serve the panel by hostname.
|
||||
if ($host !== $domain) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Force HTTPS for the active domain — on the default TLS port (443). Build the URL
|
||||
// explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL
|
||||
// (Caddy serves TLS only on 443; https://domain:8080 would be unreachable).
|
||||
if (! $request->isSecure()) {
|
||||
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,11 @@ use Livewire\Component;
|
|||
/**
|
||||
* System settings — dashboard-configurable Domain + TLS and the release channel.
|
||||
*
|
||||
* Domain & TLS: READ-ONLY status. The panel domain is an install-time decision
|
||||
* (APP_DOMAIN) so it stays consistent with URL/WebSocket/cookie security; Caddy then
|
||||
* issues, renews and HTTP→HTTPS-redirects fully automatically. Nothing to apply or
|
||||
* reload here — to change the domain, re-run install.sh.
|
||||
* 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.
|
||||
|
|
@ -25,6 +26,17 @@ class Index extends Component
|
|||
/** Valid user-facing release channels (descriptions are localized at render time). */
|
||||
public const CHANNELS = ['stable', 'beta'];
|
||||
|
||||
/** 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;
|
||||
|
||||
public string $channel = 'stable';
|
||||
|
||||
/** Channel key => localized description, built per-request for the view. */
|
||||
private function channelDescriptions(): array
|
||||
{
|
||||
|
|
@ -34,19 +46,75 @@ class Index extends Component
|
|||
];
|
||||
}
|
||||
|
||||
/** Install-time panel domain (read-only display). */
|
||||
public string $domain = '';
|
||||
|
||||
public string $channel = 'stable';
|
||||
|
||||
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();
|
||||
$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';
|
||||
}
|
||||
|
||||
/** @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',
|
||||
'auditAction' => 'system.settings_updated',
|
||||
'auditTarget' => 'panel_domain='.($new !== '' ? $new : '(cleared)'),
|
||||
'event' => 'domainChanged',
|
||||
'params' => ['domain' => $new],
|
||||
'notify' => '', // defer: we surface the restart notice instead of a toast
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('domainChanged')]
|
||||
public function applyDomain(string $domain, DeploymentService $deployment): void
|
||||
{
|
||||
$domain = strtolower(trim($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'));
|
||||
}
|
||||
|
||||
/** Release channel changes are audited (confirmation via the shared modal). */
|
||||
public function confirmChannel(string $channel): void
|
||||
{
|
||||
|
|
@ -87,6 +155,7 @@ class Index extends Component
|
|||
return view('livewire.system.index', [
|
||||
'hasTls' => $this->domain !== '',
|
||||
'panelUrl' => $deployment->panelUrl(),
|
||||
'isOverridden' => $deployment->domainIsOverridden(),
|
||||
'channels' => $this->channelDescriptions(),
|
||||
])->title(__('system.title'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,41 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
* Apply the effective panel domain (dashboard override in DB, else install-time
|
||||
* APP_DOMAIN) to runtime config at boot. boot() runs every request, so a domain
|
||||
* change takes effect on the next request after the operator restarts the stack —
|
||||
* nothing is switched mid-session, which is what makes it safe.
|
||||
*
|
||||
* Note: the panel domain / app URL is an INSTALL-TIME value (APP_URL in .env,
|
||||
* derived from APP_DOMAIN by install.sh), kept consistent with the Reverb endpoint,
|
||||
* cookie security and Caddy's auto-TLS. It is deliberately NOT overridden at runtime
|
||||
* from the database, so URL generation always matches how the panel is actually served.
|
||||
* - Server -> Reverb publishing is pinned to the INTERNAL reverb service (both dev
|
||||
* and prod compose name it `reverb`), so realtime keeps working right after a
|
||||
* domain change and never depends on the new public cert. The browser endpoint is
|
||||
* injected by the layout from DeploymentService::reverbClient().
|
||||
* - app.url -> https://<domain> so absolute URL generation matches how the panel is
|
||||
* served. With no domain (bare IP / dev) the env-derived app.url is left as is.
|
||||
*/
|
||||
public function boot(): void
|
||||
public function boot(DeploymentService $deployment): void
|
||||
{
|
||||
//
|
||||
config([
|
||||
'broadcasting.connections.reverb.options.host' => 'reverb',
|
||||
'broadcasting.connections.reverb.options.port' => 8080,
|
||||
'broadcasting.connections.reverb.options.scheme' => 'http',
|
||||
'broadcasting.connections.reverb.options.useTLS' => false,
|
||||
]);
|
||||
|
||||
$domain = $deployment->domain();
|
||||
if ($domain !== null) {
|
||||
config(['app.url' => 'https://'.$domain]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,34 +2,212 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Read-only view of the panel's domain + TLS state. The panel domain is an
|
||||
* INSTALL-TIME decision (APP_DOMAIN → config('clusev.domain')) because it must stay
|
||||
* consistent with APP_URL, the Reverb (wss) endpoint, secure-cookie + HSTS settings,
|
||||
* and Caddy's auto-TLS — none of which can be safely re-derived at runtime. TLS itself
|
||||
* is fully automatic (Caddy issues + renews + redirects HTTP→HTTPS); the dashboard only
|
||||
* SHOWS the status. To change the domain, re-run install.sh with the new domain.
|
||||
* 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
|
||||
{
|
||||
public function domain(): ?string
|
||||
{
|
||||
$domain = config('clusev.domain');
|
||||
/** 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';
|
||||
|
||||
return ($domain !== null && $domain !== '') ? strtolower(trim((string) $domain)) : null;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** True once a domain is configured — i.e. automatic TLS is in effect. */
|
||||
/**
|
||||
* 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 once a domain is configured. */
|
||||
/** 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\PanelScheme;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
|
@ -14,10 +15,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(append: [
|
||||
SetLocale::class,
|
||||
SecurityHeaders::class,
|
||||
]);
|
||||
// Trust Caddy's X-Forwarded-* ONLY in production, where the app has no host port
|
||||
// (docker-compose.prod.yml exposes app:80 on the internal net only) so Caddy is
|
||||
// the sole peer. In dev the app port is published on the host, so trusting forged
|
||||
// proxy headers would let a LAN peer fake HTTPS (forcing a Secure cookie over
|
||||
// plain HTTP and breaking the bare-IP recovery login).
|
||||
if (($_SERVER['APP_ENV'] ?? getenv('APP_ENV') ?: 'local') === 'production') {
|
||||
$middleware->trustProxies(at: '*');
|
||||
}
|
||||
|
||||
// PanelScheme first (scheme/host enforcement before auth), so an HTTP request to
|
||||
// the active domain is sent straight to HTTPS and a wrong host is refused before
|
||||
// any auth redirect.
|
||||
$middleware->web(
|
||||
prepend: [PanelScheme::class],
|
||||
append: [SetLocale::class, SecurityHeaders::class],
|
||||
);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
return [
|
||||
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
|
||||
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
|
||||
'version' => '0.3.0',
|
||||
'version' => '0.4.0',
|
||||
|
||||
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
||||
'channel' => 'stable',
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ services:
|
|||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3
|
||||
environment:
|
||||
SITE_ADDRESS: "${SITE_ADDRESS:-:80}"
|
||||
# never empty: bare-IP mode has no cert, so this address is unused
|
||||
# Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so
|
||||
# no SITE_ADDRESS is needed. Only the ACME contact comes from .env.
|
||||
ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}"
|
||||
volumes:
|
||||
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
|
|
|
|||
|
|
@ -1,21 +1,33 @@
|
|||
# Clusev reverse proxy — one template, both modes (driven by $SITE_ADDRESS).
|
||||
# Clusev reverse proxy — the panel domain is changeable FROM THE DASHBOARD.
|
||||
#
|
||||
# SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert
|
||||
# SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect
|
||||
# TLS is issued ON DEMAND: on the first HTTPS handshake for a host, Caddy asks the app
|
||||
# (/_caddy/ask) whether to issue a certificate, and the app approves ONLY the domain
|
||||
# currently configured in the dashboard (DB) / .env. So the operator changes the domain
|
||||
# in the UI, points DNS at this host, restarts the stack, and Caddy gets the cert for the
|
||||
# new domain automatically — no Caddyfile edit, no SITE_ADDRESS.
|
||||
#
|
||||
# Fully automatic: Caddy issues + renews the certificate and redirects HTTP->HTTPS with
|
||||
# no script, cron or reload. The domain is the single install knob (APP_DOMAIN); to
|
||||
# change it later, re-run install.sh with the new domain. Reverb (Pusher protocol) rides
|
||||
# the same address over /app/* (WS) and /apps/* (event API), proxied to reverb:8080 —
|
||||
# no extra exposed port, no mixed content.
|
||||
|
||||
# HTTP is ALWAYS served (bare-IP access + the operator's recovery path); the app issues
|
||||
# the HTTPS redirect for the configured domain (PanelScheme), and never for the raw IP.
|
||||
# Reverb (Pusher protocol) rides the same address over /app/* (WS) and /apps/* (event
|
||||
# API), proxied to reverb:8080 — no extra exposed port, no mixed content.
|
||||
{
|
||||
# ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost
|
||||
# in bare-IP mode, where no cert is issued so the address is simply unused.
|
||||
# ACME contact for Let's Encrypt (set by install.sh; defaults to admin@localhost).
|
||||
email {$ACME_EMAIL}
|
||||
|
||||
# Only mint a certificate for the host the app approves — prevents cert-issuance
|
||||
# abuse from arbitrary domains pointed at this IP.
|
||||
on_demand_tls {
|
||||
ask http://app:80/_caddy/ask
|
||||
}
|
||||
|
||||
# Keep serving plain HTTP (still issue certs on demand): the APP decides the
|
||||
# HTTP->HTTPS redirect — only for the configured domain, never for the raw IP —
|
||||
# so bare-IP HTTP always stays reachable as the operator's recovery path.
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
{$SITE_ADDRESS} {
|
||||
# Snippet: the shared proxy + hardening, reused by both the HTTP and HTTPS sites.
|
||||
(clusev_proxy) {
|
||||
@reverb path /app/* /apps/*
|
||||
reverse_proxy @reverb reverb:8080
|
||||
|
||||
|
|
@ -24,10 +36,40 @@
|
|||
encode zstd gzip
|
||||
|
||||
header {
|
||||
# conservative hardening; HSTS only meaningful under HTTPS
|
||||
# conservative hardening; HSTS is asserted by the app only over HTTPS
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS for any host the app approves (the configured panel domain); cert obtained
|
||||
# on demand on the first handshake.
|
||||
https:// {
|
||||
tls {
|
||||
on_demand
|
||||
}
|
||||
import clusev_proxy
|
||||
}
|
||||
|
||||
# Plain HTTP: always served — bare-IP access and the recovery path. Also carries the
|
||||
# ACME HTTP-01 challenge. The app redirects regular requests to HTTPS for the configured
|
||||
# domain (PanelScheme); the raw IP stays on HTTP.
|
||||
http:// {
|
||||
# Reverb (/app WS, /apps events) bypasses the app, so enforce transport here: on a
|
||||
# HOSTNAME force the WS/event paths onto HTTPS, so broadcasts (incl. the public metrics
|
||||
# channel) are never carried in cleartext and a stale/wrong host is refused by on-demand
|
||||
# TLS. On a bare IP there is no TLS, so plaintext there is expected and left alone.
|
||||
@reverb_cleartext {
|
||||
path /app/* /apps/*
|
||||
not header_regexp Host ^(\d{1,3}\.){3}\d{1,3}(:\d+)?$
|
||||
}
|
||||
handle @reverb_cleartext {
|
||||
redir https://{host}{uri}
|
||||
}
|
||||
|
||||
handle {
|
||||
import clusev_proxy
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,13 @@ if [ "$(id -u)" = "0" ]; then
|
|||
chown -R app:app storage bootstrap/cache 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Freeze the configured panel domain as the active one — ONLY in the web/app container
|
||||
# (its CMD is supervisord). The queue/reverb workers share this entrypoint and, in dev,
|
||||
# the same bind-mounted storage; snapshotting from a worker restart would apply a pending
|
||||
# domain without an app restart, breaking the "applies on restart" boundary. Best-effort:
|
||||
# a failure here must never block startup.
|
||||
if [ "$1" = "supervisord" ]; then
|
||||
php artisan clusev:snapshot-domain >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,22 @@ http {
|
|||
index index.php;
|
||||
charset utf-8;
|
||||
|
||||
# Reverb (Pusher protocol) over the SAME origin — mirrors Caddy in prod so the
|
||||
# browser reaches Reverb through this front door (/app/* = WS, /apps/* = events).
|
||||
# A resolver + variable defers DNS to request time, so nginx still starts if the
|
||||
# reverb container is momentarily unresolvable.
|
||||
location ~ ^/(app|apps)/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $reverb_upstream reverb:8080;
|
||||
proxy_pass http://$reverb_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,25 @@ return [
|
|||
'tls_bare_title' => 'Bare-IP-Modus: Klartext-HTTP',
|
||||
'tls_bare_body' => 'Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.',
|
||||
|
||||
// Read-only domain facts
|
||||
// Domain form + status
|
||||
'domain' => 'Domain',
|
||||
'domain_via_ip' => '— (Zugriff per IP)',
|
||||
'domain_placeholder' => 'panel.example.com (leer = Zugriff per IP)',
|
||||
'domain_help' => 'Die Domain muss per DNS auf diesen Server zeigen. Nach dem Speichern den Stack neu starten — TLS-Zertifikat, URL, WebSocket und Cookie-Sicherheit richten sich automatisch ein. Leer lassen für Zugriff per IP über HTTP.',
|
||||
'domain_invalid' => 'Bitte eine gültige Domain eingeben (z. B. panel.example.com) — ohne http://, Port oder Pfad.',
|
||||
'access_address' => 'Zugriffsadresse',
|
||||
'access_bare_ip' => 'HTTP (Bare-IP)',
|
||||
'domain_note_pre' => 'Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und Cookie-Sicherheit konsistent. Zum Ändern',
|
||||
'domain_note_post' => 'mit der neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein.',
|
||||
|
||||
// Change-domain confirmation modal
|
||||
'change_domain_heading' => 'Domain ändern',
|
||||
'change_domain_body' => 'Die Panel-Domain wird auf „:domain“ gesetzt. Nach einem Neustart des Stacks ist das Panel unter https://:domain erreichbar; das Zertifikat wird automatisch ausgestellt. Voraussetzung: DNS zeigt bereits auf diesen Server.',
|
||||
'clear_domain_body' => 'Die Domain wird entfernt. Nach einem Neustart ist das Panel per IP über unverschlüsseltes HTTP erreichbar (kein TLS). Eine Domain kann jederzeit wieder eingetragen werden.',
|
||||
'change_domain_confirm' => 'Ändern',
|
||||
'domain_saved_notify' => 'Domain gespeichert — Stack neu starten, um sie anzuwenden.',
|
||||
|
||||
// Restart-required notice
|
||||
'restart_title' => 'Neustart erforderlich',
|
||||
'restart_body' => 'Die Domain ist gespeichert. Starte den Stack auf dem Host neu, damit sie übernommen wird:',
|
||||
'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http://<Server-IP> als Rückfallweg erreichbar.',
|
||||
|
||||
// Release channel panel
|
||||
'channel_title' => 'Release-Kanal',
|
||||
|
|
|
|||
|
|
@ -18,13 +18,25 @@ return [
|
|||
'tls_bare_title' => 'Bare-IP mode: plaintext HTTP',
|
||||
'tls_bare_body' => 'Without a domain the panel — including 2FA and audit — runs over unencrypted HTTP. Enter a domain and TLS is then set up automatically, with no further steps.',
|
||||
|
||||
// Read-only domain facts
|
||||
// Domain form + status
|
||||
'domain' => 'Domain',
|
||||
'domain_via_ip' => '— (access via IP)',
|
||||
'domain_placeholder' => 'panel.example.com (empty = access via IP)',
|
||||
'domain_help' => 'The domain must point to this server via DNS. After saving, restart the stack — the TLS certificate, URL, WebSocket and cookie security set themselves up automatically. Leave empty for access via IP over HTTP.',
|
||||
'domain_invalid' => 'Please enter a valid domain (e.g. panel.example.com) — without http://, port or path.',
|
||||
'access_address' => 'Access address',
|
||||
'access_bare_ip' => 'HTTP (bare IP)',
|
||||
'domain_note_pre' => 'The panel domain is set at installation and stays consistent with URL, WebSocket and cookie security. To change it, re-run',
|
||||
'domain_note_post' => 'with the new domain — TLS then sets itself up automatically.',
|
||||
|
||||
// Change-domain confirmation modal
|
||||
'change_domain_heading' => 'Change domain',
|
||||
'change_domain_body' => 'The panel domain will be set to “:domain”. After a stack restart the panel is reachable at https://:domain; the certificate is issued automatically. Requirement: DNS already points to this server.',
|
||||
'clear_domain_body' => 'The domain will be removed. After a restart the panel is reachable via IP over unencrypted HTTP (no TLS). You can set a domain again at any time.',
|
||||
'change_domain_confirm' => 'Change',
|
||||
'domain_saved_notify' => 'Domain saved — restart the stack to apply it.',
|
||||
|
||||
// Restart-required notice
|
||||
'restart_title' => 'Restart required',
|
||||
'restart_body' => 'The domain is saved. Restart the stack on the host to apply it:',
|
||||
'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http://<server-IP> as a fallback.',
|
||||
|
||||
// Release channel panel
|
||||
'channel_title' => 'Release channel',
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import Pusher from 'pusher-js';
|
|||
|
||||
window.Pusher = Pusher;
|
||||
|
||||
// Reverb (Pusher protocol). Host/port/scheme are env-driven (derived from APP_DOMAIN later).
|
||||
// Reverb (Pusher protocol). The endpoint is injected at runtime by the layout
|
||||
// (window.__clusev.reverb), derived from the effective panel domain — so changing the
|
||||
// domain from the dashboard needs no JS rebuild. Falls back to Vite env for HMR/dev.
|
||||
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
|
||||
const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
|
||||
const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80));
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: Number(import.meta.env.VITE_REVERB_PORT ?? 80),
|
||||
wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443),
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: rc.host ?? import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: reverbPort,
|
||||
wssPort: reverbPort,
|
||||
forceTLS: reverbScheme === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
@inject('deployment', 'App\Services\DeploymentService')
|
||||
@php
|
||||
// Reverb (Echo) endpoint derived at RUNTIME from the effective domain, so changing
|
||||
// the panel domain from the dashboard needs no JS rebuild. Read by resources/js/app.js.
|
||||
$reverb = $deployment->reverbClient();
|
||||
@endphp
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Clusev' }}</title>
|
||||
<script>
|
||||
window.__clusev = { reverb: {
|
||||
key: @js($reverb['key']),
|
||||
host: @js($reverb['host']),
|
||||
port: {{ (int) $reverb['port'] }},
|
||||
scheme: @js($reverb['scheme']),
|
||||
} };
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -41,27 +41,53 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Read-only: the panel domain is an install-time decision (kept consistent
|
||||
with URL/WebSocket/cookie security); TLS provisions itself automatically. --}}
|
||||
<dl class="divide-y divide-line overflow-hidden rounded-md border border-line">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<dt class="text-sm text-ink-2">{{ __('system.domain') }}</dt>
|
||||
<dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : __('system.domain_via_ip') }}</dd>
|
||||
{{-- Restart-required notice (shown after a successful save) --}}
|
||||
@if ($restartPending)
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/30 bg-warning/10 p-3">
|
||||
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-ink">{{ __('system.restart_title') }}</p>
|
||||
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.restart_body') }}</p>
|
||||
<code class="mt-2 block overflow-x-auto rounded border border-line bg-void px-2.5 py-1.5 font-mono text-[11px] text-accent-text">docker compose -f docker-compose.prod.yml restart</code>
|
||||
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_lockout_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<dt class="text-sm text-ink-2">{{ __('system.access_address') }}</dt>
|
||||
<dd class="truncate font-mono text-sm text-ink">
|
||||
@if ($panelUrl)
|
||||
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a>
|
||||
@else
|
||||
{{ __('system.access_bare_ip') }}
|
||||
@endif
|
||||
</dd>
|
||||
@endif
|
||||
|
||||
{{-- Editable panel domain. Saving persists it and asks for a restart; on restart
|
||||
app URL, the Reverb endpoint, Caddy's cert and the secure cookie re-derive from it. --}}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="domainInput" class="mb-1 block text-sm text-ink-2">{{ __('system.domain') }}</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input wire:model="domainInput" id="domainInput" type="text" autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false"
|
||||
placeholder="{{ __('system.domain_placeholder') }}"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
<x-btn variant="secondary" wire:click="confirmDomain" wire:loading.attr="disabled" class="shrink-0">
|
||||
<x-icon name="check" class="h-3.5 w-3.5" /> {{ __('common.save') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@error('domainInput')
|
||||
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
|
||||
@enderror
|
||||
<p class="mt-1.5 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.domain_help') }}</p>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="mt-2.5 font-mono text-[11px] leading-relaxed text-ink-4">
|
||||
{{ __('system.domain_note_pre') }} <span class="text-ink-3">install.sh</span> {{ __('system.domain_note_post') }}
|
||||
</p>
|
||||
|
||||
{{-- Current access address (read-only status) --}}
|
||||
<dl class="divide-y divide-line overflow-hidden rounded-md border border-line">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<dt class="text-sm text-ink-2">{{ __('system.access_address') }}</dt>
|
||||
<dd class="truncate font-mono text-sm text-ink">
|
||||
@if ($panelUrl)
|
||||
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a>
|
||||
@else
|
||||
{{ __('system.access_bare_ip') }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</x-panel>
|
||||
|
||||
{{-- Release-Kanal --}}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Livewire\Services;
|
|||
use App\Livewire\Settings;
|
||||
use App\Livewire\System;
|
||||
use App\Livewire\Versions;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth as AuthFacade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -28,6 +29,18 @@ Route::get('/locale/{locale}', function (Request $request, string $locale) {
|
|||
return redirect()->back();
|
||||
})->name('locale.set');
|
||||
|
||||
// Caddy on-demand TLS gate: Caddy calls this before issuing a certificate for a host.
|
||||
// Approve ONLY the configured panel domain, so Caddy can never be tricked into
|
||||
// requesting certs for arbitrary domains pointed at this IP (rate-limit abuse).
|
||||
Route::get('/_caddy/ask', function (Request $request, DeploymentService $deployment) {
|
||||
$asked = strtolower(trim((string) $request->query('domain')));
|
||||
$domain = $deployment->domain();
|
||||
|
||||
abort_unless($domain !== null && $asked !== '' && $asked === $domain, 403);
|
||||
|
||||
return response('ok', 200);
|
||||
})->name('caddy.ask');
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', Auth\Login::class)->name('login');
|
||||
Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
|
||||
|
|
|
|||
Loading…
Reference in New Issue