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
parent
4a4d107bfc
commit
c34ced0c0e
|
|
@ -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=
|
||||||
|
|
|
||||||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 --}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue