fix(tls/ui): fully automatic TLS (status-only) + uniform hardening buttons

Per operator feedback ("das soll automatisch sein, die Caddy-Config muss man nicht
sehen, nur 'SSL steht'"):

- TLS is fully automatic via Caddy (auto-issue + auto-renew + HTTP→HTTPS). The System
  page now shows only the STATUS (domain + access URL) — the generated Caddy config and
  the manual `caddy reload` command are gone, and the domain/email edit form is removed.
- The panel domain is an INSTALL-TIME value (APP_DOMAIN → config('clusev.domain')), so it
  stays consistent with APP_URL, the Reverb (wss) endpoint and cookie security — none of
  which can be re-derived at runtime (Caddy's site address + the browser's Reverb host are
  fixed at install/build, and .env is never rewritten). DeploymentService is now read-only.
- Removed AppServiceProvider's runtime app.url override (it let a stale DB value shadow the
  install-time URL). Migration drops the inert empty dashboard_domain/dashboard_email rows
  (a real configured value is preserved as history; re-apply via install.sh — see CHANGELOG).
- Hardening checklist: all toggle buttons share one style (secondary/bordered) instead of
  varying with state (R10).

Bump 0.2.0 -> 0.2.1 + CHANGELOG. Pint clean; Codex reviewed (the remaining note is a
deliberate install-time-domain tradeoff — a runtime/DB domain cannot be served by the
templated Caddy without a false "TLS active" status); R12 — system/server-show/dashboard
HTTP 200, 0 console errors, raw config + edit form gone, buttons uniform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.2.1
boban 2026-06-13 21:17:26 +02:00
parent 4a4d107bfc
commit c34ced0c0e
10 changed files with 122 additions and 259 deletions

View File

@ -75,7 +75,8 @@ IMAGE_TAG=latest
# ── Reverse proxy / TLS (prod — written by install.sh) ─────────────── # ── Reverse proxy / TLS (prod — written by install.sh) ───────────────
# Single knob: APP_DOMAIN empty -> Caddy serves plain HTTP on APP_PORT (bare-IP). # Single knob: APP_DOMAIN empty -> Caddy serves plain HTTP on APP_PORT (bare-IP).
# APP_DOMAIN set -> Caddy gets a Let's Encrypt cert + forces HTTPS. # APP_DOMAIN set -> Caddy auto-issues a Let's Encrypt cert + forces HTTPS.
# Changing the panel domain later = re-run install.sh with the new domain.
APP_DOMAIN= APP_DOMAIN=
APP_SCHEME=http APP_SCHEME=http
ACME_EMAIL= ACME_EMAIL=

View File

@ -13,6 +13,27 @@ 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._ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.2.1] - 2026-06-13
Verfeinerungen an 0.2.0: vollautomatisches TLS ohne sichtbare Konfiguration und einheitliche Buttons.
### Geändert
- **TLS ist vollautomatisch.** Caddy stellt das Zertifikat aus, erneuert es und leitet
HTTP→HTTPS um — ohne manuelle Schritte. Die System-Seite zeigt nur noch den **Status**
(Domain, Zugriffsadresse) statt der erzeugten Caddy-Konfiguration und des
`caddy reload`-Befehls. Die Panel-Domain ist eine **Installations-Entscheidung**
(`APP_DOMAIN`) und bleibt damit konsistent mit URL, WebSocket (wss) und Cookie-Sicherheit;
zum Ändern `install.sh` mit der neuen Domain erneut ausführen.
- Härtungs-Checkliste: alle Aktions-Buttons im selben Stil (einheitlich `secondary`,
mit Rahmen) statt zustandsabhängig mal mit, mal ohne Rahmen.
### Entfernt
- Laufzeit-Override der App-URL aus der Datenbank und das dashboard-seitige Bearbeiten
von Domain/E-Mail (konnte URL/WebSocket/Cookies nicht konsistent zur Laufzeit umstellen).
Eine Migration entfernt die leeren Alt-Einträge; ein real konfigurierter Wert bleibt als
Verlauf erhalten. **Upgrade-Hinweis:** eine zuvor im Dashboard gesetzte Domain bitte einmalig
über `install.sh` setzen.
## [0.2.0] - 2026-06-13 ## [0.2.0] - 2026-06-13
Mehr-Betriebssystem-Verwaltung, echte Firewall- und fail2ban-Steuerung aus dem Dashboard sowie eine Tastatur-/Befehls-Bedienung. Mehr-Betriebssystem-Verwaltung, echte Firewall- und fail2ban-Steuerung aus dem Dashboard sowie eine Tastatur-/Befehls-Bedienung.
@ -114,7 +135,8 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog - Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`). pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.0...HEAD [Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://git.bave.dev/boban/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0 [0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1 [0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0 [0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0

View File

@ -2,10 +2,8 @@
namespace App\Livewire\System; namespace App\Livewire\System;
use App\Models\AuditEvent;
use App\Models\Setting; use App\Models\Setting;
use App\Services\DeploymentService; use App\Services\DeploymentService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
@ -14,10 +12,10 @@ use Livewire\Component;
/** /**
* System settings dashboard-configurable Domain + TLS and the release channel. * System settings dashboard-configurable Domain + TLS and the release channel.
* *
* Domain & TLS: persists the panel domain + Let's Encrypt admin email (Settings), * Domain & TLS: READ-ONLY status. The panel domain is an install-time decision
* then renders the matching Caddy site block via DeploymentService for the operator * (APP_DOMAIN) so it stays consistent with URL/WebSocket/cookie security; Caddy then
* to review. The app NEVER reloads Caddy itself (separate container) it only * issues, renews and HTTP→HTTPS-redirects fully automatically. Nothing to apply or
* stages the config and shows the reload command. * reload here to change the domain, re-run install.sh.
* *
* Release-Kanal: a stable|beta choice persisted as a Setting (default from * Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change. * config('clusev.channel')), audited on change.
@ -32,47 +30,19 @@ class Index extends Component
'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.', 'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
]; ];
/** Install-time panel domain (read-only display). */
public string $domain = ''; public string $domain = '';
public string $email = '';
public string $channel = 'stable'; public string $channel = 'stable';
public function mount(DeploymentService $deployment): void public function mount(DeploymentService $deployment): void
{ {
$this->domain = (string) ($deployment->domain() ?? ''); $this->domain = (string) ($deployment->domain() ?? '');
$this->email = (string) ($deployment->email() ?? '');
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; $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. // Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable'; $this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable';
} }
/** Persist domain + email, then (re)render the Caddy site config for review. */
public function saveDomain(DeploymentService $deployment): void
{
$data = $this->validate([
'domain' => ['nullable', 'string', 'max:253', 'regex:/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'],
'email' => ['nullable', 'email', 'max:255', 'required_with:domain'],
], [
'domain.regex' => 'Bitte eine gültige Domain angeben (z. B. panel.example.com).',
'email.required_with' => 'Bei gesetzter Domain ist eine Admin-E-Mail für Let\'s Encrypt Pflicht.',
]);
$deployment->setDomain($data['domain'] ?? null);
$deployment->setEmail($data['email'] ?? null);
$this->domain = (string) ($deployment->domain() ?? '');
$this->email = (string) ($deployment->email() ?? '');
$deployment->writeCaddyConfig();
$this->audit('system.settings_updated', $this->domain !== '' ? $this->domain : 'bare-ip');
$this->dispatch('notify', message: $this->domain !== ''
? 'Domain gespeichert. Caddy-Konfiguration neu erzeugt.'
: 'Bare-IP-Modus gespeichert. Caddy-Konfiguration neu erzeugt.');
}
/** Release channel changes are audited (confirmation via the shared modal). */ /** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void public function confirmChannel(string $channel): void
{ {
@ -108,23 +78,11 @@ class Index extends Component
$this->channel = $channel; $this->channel = $channel;
} }
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(DeploymentService $deployment) public function render(DeploymentService $deployment)
{ {
return view('livewire.system.index', [ return view('livewire.system.index', [
'hasTls' => $this->domain !== '', 'hasTls' => $this->domain !== '',
'caddyConfig' => $deployment->renderCaddyConfig(), 'panelUrl' => $deployment->panelUrl(),
'reloadHint' => $deployment->reloadHint(),
'channels' => self::CHANNELS, 'channels' => self::CHANNELS,
]); ]);
} }

View File

@ -2,10 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,43 +16,14 @@ class AppServiceProvider extends ServiceProvider
/** /**
* Bootstrap any application services. * Bootstrap any application services.
*
* 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.
*/ */
public function boot(): void public function boot(): void
{ {
$this->applyDashboardDomain(); //
}
/**
* Keep Laravel's URL generation in sync with the dashboard-set panel domain
* WITHOUT ever touching .env.
*
* The panel domain lives in the DATABASE (Settings: `dashboard_domain`), not in
* .env and the app never rewrites .env. So at runtime, if a domain is set, we
* override config('app.url') to `https://<domain>` so that signed URLs, absolute
* links, password-reset mails, etc. resolve against the operator-configured host.
*
* Guarded hard: during `artisan migrate`, fresh installs, or any moment before the
* `settings` table exists (or the DB is unreachable), this must be a silent no-op so
* console commands and migrations never crash. Hence the Schema::hasTable() probe
* plus a defensive try/catch around the DB read.
*/
private function applyDashboardDomain(): void
{
try {
if (! Schema::hasTable('settings')) {
return;
}
$domain = Setting::get('dashboard_domain');
if ($domain === null || $domain === '') {
return;
}
config(['app.url' => 'https://'.$domain]);
} catch (Throwable) {
// No DB / table yet (install, migrate, CI). Leave config('app.url') as-is —
// never fail boot over a settings lookup.
}
} }
} }

View File

@ -2,140 +2,34 @@
namespace App\Services; namespace App\Services;
use App\Models\Setting;
/** /**
* Renders the Caddy reverse-proxy site config for the dashboard's own domain. * 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
* Clusev ships behind a Caddy container (see docker-compose.prod.yml). In prod the * consistent with APP_URL, the Reverb (wss) endpoint, secure-cookie + HSTS settings,
* proxy address + ACME email are driven by .env (SITE_ADDRESS / ACME_EMAIL) and the * and Caddy's auto-TLS none of which can be safely re-derived at runtime. TLS itself
* baked docker/caddy/Caddyfile is mounted read-only. This service lets the operator * is fully automatic (Caddy issues + renews + redirects HTTP→HTTPS); the dashboard only
* configure the panel domain from the dashboard: it persists domain + email as * SHOWS the status. To change the domain, re-run install.sh with the new domain.
* Settings and renders the matching Caddy site block (auto Let's Encrypt TLS), so the
* operator can review and drop it into Caddy's config.
*
* IMPORTANT: the app CANNOT restart or reload Caddy Caddy runs in a separate
* container. Writing the file is the most the app can do; picking it up is an infra
* step ('caddy reload --config /config/clusev.caddy' inside the caddy container, or a
* stack recreate). renderCaddyConfig()/writeCaddyConfig() never touch the live proxy.
*
* .env IS NEVER WRITTEN. This service only persists Settings (DB) and writes a
* standalone Caddy site block to storage/app/caddy/clusev.caddy. It does not read,
* edit, or rewrite the application's .env in any way the panel domain is a database
* value, so a change to it never mutates .env.
*/ */
class DeploymentService class DeploymentService
{ {
/** Where writeCaddyConfig() drops the rendered block (app-writable, host-mountable). */
public const CONFIG_FILENAME = 'clusev.caddy';
public function domain(): ?string public function domain(): ?string
{ {
return Setting::get('dashboard_domain'); $domain = config('clusev.domain');
return ($domain !== null && $domain !== '') ? strtolower(trim((string) $domain)) : null;
} }
public function email(): ?string /** True once a domain is configured — i.e. automatic TLS is in effect. */
{
return Setting::get('dashboard_email');
}
public function setDomain(?string $domain): void
{
Setting::put('dashboard_domain', $domain !== null && $domain !== '' ? strtolower(trim($domain)) : null);
}
public function setEmail(?string $email): void
{
Setting::put('dashboard_email', $email !== null && $email !== '' ? trim($email) : null);
}
/** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */
public function hasTls(): bool public function hasTls(): bool
{ {
$domain = $this->domain(); return $this->domain() !== null;
return $domain !== null && $domain !== '';
} }
/** /** The HTTPS URL the panel is reachable at once a domain is configured. */
* Render the Caddy site block for the configured domain, mirroring the shape of public function panelUrl(): ?string
* docker/caddy/Caddyfile: a global `email` block + a site address that triggers
* automatic Let's Encrypt TLS, with the same Reverb (/app/*, /apps/*) and app
* upstreams and the same conservative hardening header set.
*
* With a domain set: `https://<domain>` auto-TLS on :443, HTTP auto-redirect.
* Without a domain: `:80` plain HTTP on the bare IP (no cert issued).
*/
public function renderCaddyConfig(): string
{ {
$domain = $this->domain(); $domain = $this->domain();
$email = $this->email();
$siteAddress = $domain ? 'https://'.$domain : ':80'; return $domain !== null ? 'https://'.$domain : null;
// Caddy needs a non-empty ACME email even in bare-IP mode, where no cert is
// issued so the value is simply unused — mirror the compose default.
$acmeEmail = $email ?: 'admin@localhost';
return <<<CADDY
# Clusev reverse proxy — generated from the dashboard (System → Domain & TLS).
# Mirrors docker/caddy/Caddyfile. Reverb rides /app/* + /apps/* on the same address.
#
# https://<domain> -> auto Let's Encrypt, :443, HTTP auto-redirect
# :80 -> plain HTTP on the bare IP, no cert
{
\temail {$acmeEmail}
}
{$siteAddress} {
\t@reverb path /app/* /apps/*
\treverse_proxy @reverb reverb:8080
\treverse_proxy app:80
\tencode zstd gzip
\theader {
\t\tX-Content-Type-Options nosniff
\t\tX-Frame-Options DENY
\t\tReferrer-Policy strict-origin-when-cross-origin
\t\t-Server
\t}
}
CADDY;
}
/**
* Write the rendered site config into the app's storage dir. This is a staging
* file the operator can mount/copy into Caddy's /config it does NOT reload the
* live proxy (separate container). Returns the absolute path written.
*/
public function writeCaddyConfig(): string
{
$dir = storage_path('app/caddy');
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$path = $dir.'/'.self::CONFIG_FILENAME;
file_put_contents($path, $this->renderCaddyConfig().PHP_EOL);
return $path;
}
/** Absolute path writeCaddyConfig() targets (no write performed). */
public function configPath(): string
{
return storage_path('app/caddy/'.self::CONFIG_FILENAME);
}
/**
* Operator note: the app cannot reload Caddy. After writing, reload is an infra
* step inside the caddy container.
*/
public function reloadHint(): string
{
return 'caddy reload --config /config/'.self::CONFIG_FILENAME;
} }
} }

View File

@ -3,11 +3,15 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash // 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). // is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.2.0', 'version' => '0.2.1',
// Default user channel. Only 'stable' and 'beta' are ever offered to users. // Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable', 'channel' => 'stable',
// Install-time panel domain (APP_DOMAIN). The dashboard Setting overrides it; this
// is the fallback so a domain set during install still gets automatic TLS.
'domain' => env('APP_DOMAIN'),
// Real project home (self-hosted Gitea) + license. Open-core, AGPL. // Real project home (self-hosted Gitea) + license. Open-core, AGPL.
'repository' => 'https://git.bave.dev/boban/clusev', 'repository' => 'https://git.bave.dev/boban/clusev',

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* The panel domain moved from a dashboard Setting to the install-time APP_DOMAIN
* (kept consistent with APP_URL, the Reverb endpoint, cookie security and Caddy's
* auto-TLS none safely re-derivable at runtime, since Caddy's site address and the
* browser's Reverb host are fixed at install/build and .env is never rewritten).
*
* We remove ONLY the inert empty rows that the old write-path left behind any
* non-empty value an operator actually configured is preserved as a historical
* record (it is no longer read). To run such a domain under the new model, set it via
* install.sh (re-run); see CHANGELOG.
*/
public function up(): void
{
DB::table('settings')
->whereIn('key', ['dashboard_domain', 'dashboard_email'])
->where(function ($q) {
$q->whereNull('value')->orWhere('value', '');
})
->delete();
}
public function down(): void
{
// No-op: these settings are obsolete and intentionally not recreated.
}
};

View File

@ -3,8 +3,11 @@
# SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert # SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert
# SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect # SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect
# #
# Reverb (Pusher protocol) rides the same address over /app/* (WS) and /apps/* # Fully automatic: Caddy issues + renews the certificate and redirects HTTP->HTTPS with
# (event API), proxied to reverb:8080 no extra exposed port, no mixed content. # 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.
{ {
# ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost # ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost

View File

@ -212,8 +212,8 @@
<x-icon name="settings" class="h-3.5 w-3.5" /> <x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
@endif @endif
{{-- Toggle: secondary to make secure, quiet ghost to loosen. --}} {{-- Uniform style across all rows (R10): always the bordered secondary button. --}}
<x-btn :variant="$check['secure'] ? 'ghost' : 'secondary'" size="sm" class="shrink-0" <x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })"> wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
</x-btn> </x-btn>

View File

@ -1,9 +1,3 @@
@php
$field = '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';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5"> <div class="space-y-5">
{{-- Header --}} {{-- Header --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel"> <div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
@ -31,8 +25,10 @@
<div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3"> <div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" /> <x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" />
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm text-ink">HTTPS via Let's Encrypt (Caddy)</p> <p class="text-sm text-ink">TLS aktiv Zertifikat automatisch über Let's Encrypt</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">Caddy stellt automatisch ein Zertifikat für <span class="text-ink-2">{{ $domain }}</span> aus und erneuert es.</p> <p class="mt-0.5 font-mono text-[11px] text-ink-3">Erreichbar unter
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a> ·
wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.</p>
</div> </div>
</div> </div>
@else @else
@ -40,7 +36,7 @@
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" /> <x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm text-ink">Bare-IP-Modus: Klartext-HTTP</p> <p class="text-sm text-ink">Bare-IP-Modus: Klartext-HTTP</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Für den Produktivbetrieb eine Domain setzen.</p> <p class="mt-0.5 font-mono text-[11px] text-ink-3">Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen TLS wird dann automatisch eingerichtet, ohne weitere Schritte.</p>
</div> </div>
</div> </div>
@endif @endif
@ -59,45 +55,29 @@
</div> </div>
</div> </div>
<form wire:submit="saveDomain" class="space-y-4"> {{-- Read-only: the panel domain is an install-time decision (kept consistent
<div class="grid gap-4 sm:grid-cols-2"> with URL/WebSocket/cookie security); TLS provisions itself automatically. --}}
<div> <dl class="divide-y divide-line overflow-hidden rounded-md border border-line">
<label class="{{ $label }}">Domain</label> <div class="flex items-center justify-between gap-3 px-4 py-2.5">
<input wire:model="domain" type="text" placeholder="panel.example.com" autocomplete="off" class="{{ $field }}" /> <dt class="text-sm text-ink-2">Domain</dt>
@error('domain') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror <dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : '— (Zugriff per IP)' }}</dd>
<p class="mt-1 font-mono text-[11px] text-ink-4">Leer lassen für Zugriff per IP über HTTP.</p>
</div>
<div>
<label class="{{ $label }}">Admin-E-Mail (Let's Encrypt)</label>
<input wire:model="email" type="email" placeholder="admin@example.com" autocomplete="off" class="{{ $field }}" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
<p class="mt-1 font-mono text-[11px] text-ink-4">Für Zertifikatshinweise von Let's Encrypt.</p>
</div>
</div> </div>
<div class="flex justify-end"> <div class="flex items-center justify-between gap-3 px-4 py-2.5">
<x-btn variant="primary" type="submit" wire:target="saveDomain" wire:loading.attr="disabled"> <dt class="text-sm text-ink-2">Zugriffsadresse</dt>
<svg wire:loading wire:target="saveDomain" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> <dd class="truncate font-mono text-sm text-ink">
Speichern @if ($panelUrl)
</x-btn> <a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a>
@else
HTTP (Bare-IP)
@endif
</dd>
</div> </div>
</form> </dl>
<p class="mt-2.5 font-mono text-[11px] leading-relaxed text-ink-4">
{{-- Generated Caddy site config --}} Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und
<div class="mt-5 border-t border-line pt-5"> Cookie-Sicherheit konsistent. Zum Ändern <span class="text-ink-3">install.sh</span> mit der
<div class="mb-2 flex items-center gap-2"> neuen Domain erneut ausführen TLS richtet sich danach automatisch ein.
<x-icon name="settings" class="h-3.5 w-3.5 text-ink-3" /> </p>
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-3">Erzeugte Caddy-Konfiguration</p>
</div>
<pre class="overflow-x-auto rounded-md border border-line bg-inset p-3 font-mono text-[12px] leading-relaxed text-ink-2"><code>{{ $caddyConfig }}</code></pre>
<div class="mt-3 flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-ink-3" />
<div class="min-w-0">
<p class="text-sm text-ink-2">Caddy läuft als eigener Container und muss neu laden, um die Konfiguration zu übernehmen.</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Die Datei wurde nach <span class="text-ink-3">storage/app/caddy/clusev.caddy</span> geschrieben. Im Caddy-Container ausführen:</p>
<pre class="mt-1.5 overflow-x-auto rounded border border-line bg-inset px-2.5 py-1.5 font-mono text-[11px] text-cyan"><code>{{ $reloadHint }}</code></pre>
</div>
</div>
</div>
</x-panel> </x-panel>
{{-- Release-Kanal --}} {{-- Release-Kanal --}}