feat(security): dashboard hardening, credential mgmt, system domain/TLS + channel, self-hardening
Security pivot — make server hardening, SSH access, firewall, dashboard domain/TLS and the release channel controllable from the dashboard (no SSH needed), with guards so a remote change can never lock the operator out. Foundation - ssh_credentials gains name + disabled_at + last_used_at; CredentialVault refuses a disabled credential. New key/value Setting model. FleetService::runPrivileged() / runPlain() — central sudo-aware exec (base64-wrapped sh -c), live-verified as root. A · control-plane self-hardening - SecurityHeaders middleware: env-aware CSP (allows the Vite dev origin in dev, strict same-origin in prod), X-Frame-Options DENY, nosniff, Referrer/Permissions-Policy, HSTS when secure. 2FA brute-force throttle (5/60s). install.sh sets SESSION_SAME_SITE=strict + EXPIRE_ON_CLOSE + SECURE_COOKIE (only behind TLS). B · SSH credential management - name/label on the access; a credential card on the server page with Bearbeiten / Sperren-Entsperren (kill-switch) / Löschen (R5), all audited. C · server hardening from the dashboard (guards + confirmation) - HardeningService (PermitRootLogin no, PasswordAuthentication no, fail2ban, unattended-upgrades) + FirewallService (UFW). HardeningAction modal previews the exact root commands before applying. GUARDS: refuse to disable password-login when Clusev itself logs in by password or no key exists; UFW opens the real sshd port + 80/443 before enabling. Live-verified non-destructively (previews, the password guard refusing, ufw status read). D+E · System page (/system) - Dashboard Domain + Let's-Encrypt email (Setting) -> DeploymentService renders the matching Caddy site block (honest: stages the file + shows the reload command, never fakes TLS). Release channel (stable|beta|dev) configurable; Versions reads it. Built largely by 4 parallel agents into disjoint files; shared files integrated + the security-critical bits hardened by hand (the password-auth lock-out guard + env-aware CSP). Verified (R12): /system + server detail + all 8 routes 200 / 0 console errors (CSP does not break Livewire/Alpine/Vite); credential card + 5 hardening "Anwenden" buttons render; the hardening modal opens with the command preview; System persists domain/channel + renders valid Caddy config; runPrivileged runs as root + the vault refuses disabled creds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
875678d0cd
commit
a64bd39c6c
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Adds hardening response headers to every web response.
|
||||
*
|
||||
* The Content-Security-Policy is deliberately PRAGMATIC: Livewire and Alpine
|
||||
* inject inline scripts/handlers and Alpine relies on `eval`, so 'unsafe-eval'
|
||||
* and 'unsafe-inline' are permitted for scripts/styles. connect-src allows
|
||||
* ws:/wss: so Reverb (realtime) and Vite HMR keep working in dev. This CSP can
|
||||
* be TIGHTENED LATER (e.g. nonce-based scripts, dropping 'unsafe-*') once the
|
||||
* app no longer depends on inline/eval — start strict on the structural
|
||||
* directives (base-uri, frame-ancestors) and relax only what the stack needs.
|
||||
*/
|
||||
class SecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$headers = $response->headers;
|
||||
|
||||
$headers->set('X-Frame-Options', 'DENY');
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$headers->set(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
|
||||
);
|
||||
|
||||
// Livewire/Alpine need inline + eval; Reverb rides ws/wss.
|
||||
$scriptSrc = ["'self'", "'unsafe-eval'", "'unsafe-inline'"];
|
||||
$styleSrc = ["'self'", "'unsafe-inline'"];
|
||||
$connectSrc = ["'self'", 'ws:', 'wss:'];
|
||||
$fontSrc = ["'self'"];
|
||||
|
||||
// In dev, Vite serves the bundle + HMR from a SEPARATE origin (e.g.
|
||||
// http://host:5173). A same-origin CSP would block it and break the app,
|
||||
// so allow the running dev-server origin (from public/hot) outside prod.
|
||||
// In production assets are same-origin (/build) and this branch is inert.
|
||||
if (! app()->isProduction() && is_file(public_path('hot'))) {
|
||||
$dev = trim((string) file_get_contents(public_path('hot')));
|
||||
if ($dev !== '') {
|
||||
$devWs = preg_replace('#^http#', 'ws', $dev);
|
||||
array_push($scriptSrc, $dev);
|
||||
array_push($styleSrc, $dev);
|
||||
array_push($fontSrc, $dev);
|
||||
array_push($connectSrc, $dev, $devWs);
|
||||
}
|
||||
}
|
||||
|
||||
$headers->set('Content-Security-Policy', implode('; ', [
|
||||
"default-src 'self'",
|
||||
'script-src '.implode(' ', $scriptSrc),
|
||||
'style-src '.implode(' ', $styleSrc),
|
||||
'connect-src '.implode(' ', $connectSrc),
|
||||
"img-src 'self' data:",
|
||||
'font-src '.implode(' ', $fontSrc),
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
]));
|
||||
|
||||
// Only assert HSTS over an already-secure connection so we never
|
||||
// pin clients to HTTPS on a plain-HTTP dev/LAN deployment.
|
||||
if ($request->isSecure()) {
|
||||
$headers->set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains'
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Auth;
|
|||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -29,6 +30,14 @@ class TwoFactorChallenge extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled()) {
|
||||
|
|
@ -39,9 +48,12 @@ class TwoFactorChallenge extends Component
|
|||
$valid = (new Google2FA())->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
|
||||
|
||||
if (! $valid) {
|
||||
RateLimiter::hit($key, 60);
|
||||
throw ValidationException::withMessages(['code' => 'Ungültiger Code.']);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class EditCredential extends ModalComponent
|
|||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $username = '';
|
||||
|
||||
public string $authType = 'password';
|
||||
|
|
@ -30,6 +32,7 @@ class EditCredential extends ModalComponent
|
|||
$this->serverId = $serverId;
|
||||
|
||||
if ($cred = Server::find($serverId)?->credential) {
|
||||
$this->name = $cred->name ?? '';
|
||||
$this->username = $cred->username;
|
||||
$this->authType = $cred->auth_type;
|
||||
// secret/passphrase are never pre-filled (encrypted at rest)
|
||||
|
|
@ -56,8 +59,14 @@ class EditCredential extends ModalComponent
|
|||
|
||||
return;
|
||||
}
|
||||
if (mb_strlen(trim($this->name)) > 120) {
|
||||
$this->error = 'Name darf höchstens 120 Zeichen lang sein.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->credential()->updateOrCreate([], [
|
||||
'name' => trim($this->name) !== '' ? trim($this->name) : null,
|
||||
'username' => trim($this->username),
|
||||
'auth_type' => $this->authType === 'key' ? 'key' : 'password',
|
||||
'secret' => $this->secret,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\FirewallService;
|
||||
use App\Services\HardeningService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Preview + confirm modal for a single server-hardening action (R5).
|
||||
*
|
||||
* mount() loads a human title + the EXACT shell commands (from the service's
|
||||
* preview) so the operator sees what will run BEFORE applying. `apply()` runs
|
||||
* the action over SSH, AUDITs (`harden.<key>`), notifies, asks the page to
|
||||
* reload the snapshot, and — on success — closes.
|
||||
*
|
||||
* Action keys: ssh_root, ssh_password, fail2ban, unattended, firewall.
|
||||
*/
|
||||
class HardeningAction extends ModalComponent
|
||||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $action;
|
||||
|
||||
/** Optional TCP port (reserved for future allow/deny firewall actions). */
|
||||
public ?int $port = null;
|
||||
|
||||
public string $heading = '';
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public string $preview = '';
|
||||
|
||||
/** Result of the apply run, set after the operator confirms. */
|
||||
public bool $done = false;
|
||||
|
||||
public bool $ok = false;
|
||||
|
||||
public string $output = '';
|
||||
|
||||
/** Set when the action key is unknown / server vanished — blocks apply. */
|
||||
public ?string $error = null;
|
||||
|
||||
public function mount(int $serverId, string $action, ?int $port = null): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
$this->action = $action;
|
||||
$this->port = $port;
|
||||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($action === 'firewall') {
|
||||
$this->heading = 'Firewall (UFW) aktivieren';
|
||||
$this->description = 'Öffnet zuerst den SSH-Port und 80/443, dann wird UFW aktiviert — die laufende SSH-Sitzung bleibt erreichbar.';
|
||||
$this->preview = app(FirewallService::class)->enablePreview($server);
|
||||
} else {
|
||||
$hardening = app(HardeningService::class);
|
||||
$this->heading = $hardening->title($action);
|
||||
$this->description = $hardening->description($action);
|
||||
$this->preview = $hardening->preview($server, $action);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function apply(): void
|
||||
{
|
||||
if ($this->error !== null || $this->done) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->action === 'firewall'
|
||||
? app(FirewallService::class)->enable($server)
|
||||
: app(HardeningService::class)->apply($server, $this->action);
|
||||
} catch (Throwable $e) {
|
||||
$this->done = true;
|
||||
$this->ok = false;
|
||||
$this->output = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
$this->output = $result['output'] !== '' ? $result['output'] : ($result['ok'] ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.');
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'harden.'.$this->action,
|
||||
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($this->ok) {
|
||||
$this->dispatch('hardeningApplied');
|
||||
$this->dispatch('notify', message: $this->heading.' angewendet.');
|
||||
} else {
|
||||
$this->dispatch('notify', message: $this->heading.' fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.hardening-action');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
namespace App\Livewire\Servers;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -166,6 +168,83 @@ class Show extends Component
|
|||
return $this->redirect(route('files.index'), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the credential between aktiv/gesperrt (sets/clears disabled_at).
|
||||
* The CredentialVault already refuses a disabled credential, so this is the
|
||||
* operator's kill-switch for a deposited login without deleting it.
|
||||
*/
|
||||
public function toggleCredential(): void
|
||||
{
|
||||
$cred = $this->server->credential;
|
||||
if (! $cred) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disable = ! $cred->disabled;
|
||||
$cred->forceFill(['disabled_at' => $disable ? now() : null])->save();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $this->server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $disable ? 'credential.disable' : 'credential.enable',
|
||||
'target' => $this->server->name.' · '.$cred->username,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->server->refresh();
|
||||
$this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the credential (R5): opens the confirm modal, which writes the
|
||||
* AuditEvent and re-dispatches `credentialDeleted`.
|
||||
*/
|
||||
public function confirmDeleteCredential(): void
|
||||
{
|
||||
$cred = $this->server->credential;
|
||||
if (! $cred) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => 'Zugang löschen',
|
||||
'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.",
|
||||
'confirmLabel' => 'Löschen',
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'credential.delete',
|
||||
'auditTarget' => $this->server->name.' · '.$cred->username,
|
||||
'serverId' => $this->server->id,
|
||||
'event' => 'credentialDeleted',
|
||||
'params' => [],
|
||||
'notify' => 'Zugang gelöscht.',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed credential deletion, then refreshes the row. */
|
||||
#[On('credentialDeleted')]
|
||||
public function deleteCredential(): void
|
||||
{
|
||||
$this->server->credential()->delete();
|
||||
$this->server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* A hardening/firewall action was applied -> re-pull the snapshot so the
|
||||
* checklist + firewall row reflect the new state.
|
||||
*/
|
||||
#[On('hardeningApplied')]
|
||||
public function reloadSnapshot(FleetService $fleet): void
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->ready = false;
|
||||
$this->load($fleet);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.servers.show');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\System;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* System settings — dashboard-configurable Domain + TLS and the release channel.
|
||||
*
|
||||
* Domain & TLS: persists the panel domain + Let's Encrypt admin email (Settings),
|
||||
* then renders the matching Caddy site block via DeploymentService for the operator
|
||||
* to review. The app NEVER reloads Caddy itself (separate container) — it only
|
||||
* stages the config and shows the reload command.
|
||||
*
|
||||
* Release-Kanal: a stable|beta|dev choice persisted as a Setting (default from
|
||||
* config('clusev.channel')), audited on change.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
#[Title('System — Clusev')]
|
||||
class Index extends Component
|
||||
{
|
||||
/** Valid release channels with their German descriptions. */
|
||||
public const CHANNELS = [
|
||||
'stable' => 'Nur getestete, freigegebene Releases. Empfohlen für den Produktivbetrieb.',
|
||||
'beta' => 'Vorabversionen zum Testen neuer Funktionen. Kann instabil sein.',
|
||||
'dev' => 'Entwicklungs-Builds direkt aus dem Haupt-Branch. Nur für Entwicklung.',
|
||||
];
|
||||
|
||||
public string $domain = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $channel = 'stable';
|
||||
|
||||
public function mount(DeploymentService $deployment): void
|
||||
{
|
||||
$this->domain = (string) ($deployment->domain() ?? '');
|
||||
$this->email = (string) ($deployment->email() ?? '');
|
||||
$this->channel = Setting::get('release_channel', config('clusev.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). */
|
||||
public function confirmChannel(string $channel): void
|
||||
{
|
||||
if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => 'Release-Kanal wechseln',
|
||||
'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.',
|
||||
'confirmLabel' => 'Wechseln',
|
||||
'danger' => false,
|
||||
'icon' => 'git-branch',
|
||||
'auditAction' => 'system.settings_updated',
|
||||
'auditTarget' => 'release_channel='.$channel,
|
||||
'event' => 'channelChanged',
|
||||
'params' => ['channel' => $channel],
|
||||
'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('channelChanged')]
|
||||
public function applyChannel(string $channel): void
|
||||
{
|
||||
if (! array_key_exists($channel, self::CHANNELS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Setting::put('release_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)
|
||||
{
|
||||
return view('livewire.system.index', [
|
||||
'hasTls' => $this->domain !== '',
|
||||
'caddyConfig' => $deployment->renderCaddyConfig(),
|
||||
'reloadHint' => $deployment->reloadHint(),
|
||||
'channels' => self::CHANNELS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Versions;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
|
@ -22,7 +23,13 @@ class Index extends Component
|
|||
public function checkUpdates(): void
|
||||
{
|
||||
$this->lastChecked = now()->format('H:i');
|
||||
$this->dispatch('notify', message: 'Entwicklungsversion — kein Release-Kanal konfiguriert.');
|
||||
|
||||
$channel = Setting::get('release_channel', config('clusev.channel'));
|
||||
$repository = config('clusev.repository');
|
||||
|
||||
$this->dispatch('notify', message: $repository
|
||||
? 'Kanal: '.$channel.' — Quelle: '.$repository
|
||||
: 'Entwicklungsversion — kein Release-Kanal konfiguriert.');
|
||||
}
|
||||
|
||||
/** Resolve the current commit from .git without needing the git binary. */
|
||||
|
|
@ -99,7 +106,7 @@ class Index extends Component
|
|||
{
|
||||
return view('livewire.versions.index', [
|
||||
'version' => config('clusev.version'),
|
||||
'channel' => config('clusev.channel'),
|
||||
'channel' => Setting::get('release_channel', config('clusev.channel')),
|
||||
'repository' => config('clusev.repository'),
|
||||
'license' => config('clusev.license'),
|
||||
'build' => $this->build(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Dashboard-configurable key/value settings (release channel, dashboard domain,
|
||||
* TLS email, …). Cached per key; cache is busted on write.
|
||||
*/
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$value = Cache::rememberForever("setting:{$key}", fn () => static::query()->find($key)?->value);
|
||||
|
||||
return $value ?? $default;
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
Cache::forget("setting:{$key}");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ class SshCredential extends Model
|
|||
protected $casts = [
|
||||
'secret' => 'encrypted',
|
||||
'passphrase' => 'encrypted',
|
||||
'disabled_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
|
@ -27,4 +29,15 @@ class SshCredential extends Model
|
|||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/** A "gesperrt" (disabled) credential is kept on record but refused by the vault. */
|
||||
public function getDisabledAttribute(): bool
|
||||
{
|
||||
return $this->disabled_at !== null;
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNull('disabled_at');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
|
||||
/**
|
||||
* Renders the Caddy reverse-proxy site config for the dashboard's own domain.
|
||||
*
|
||||
* Clusev ships behind a Caddy container (see docker-compose.prod.yml). In prod the
|
||||
* proxy address + ACME email are driven by .env (SITE_ADDRESS / ACME_EMAIL) and the
|
||||
* baked docker/caddy/Caddyfile is mounted read-only. This service lets the operator
|
||||
* configure the panel domain from the dashboard: it persists domain + email as
|
||||
* 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.
|
||||
*/
|
||||
class DeploymentService
|
||||
{
|
||||
/** Where writeCaddyConfig() drops the rendered block (app-writable, host-mountable). */
|
||||
public const CONFIG_FILENAME = 'clusev.caddy';
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
return Setting::get('dashboard_domain');
|
||||
}
|
||||
|
||||
public function email(): ?string
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->domain() !== null && $this->domain() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Caddy site block for the configured domain, mirroring the shape of
|
||||
* 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();
|
||||
$email = $this->email();
|
||||
|
||||
$siteAddress = $domain ? 'https://'.$domain : ':80';
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* UFW firewall control over SSH (as root).
|
||||
*
|
||||
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd
|
||||
* Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the
|
||||
* firewall on — so enabling UFW can never sever the operator's own SSH session.
|
||||
*
|
||||
* status()/preview() are read-only command builders; the mutating methods all
|
||||
* return array{ok: bool, output: string, preview: string}.
|
||||
*/
|
||||
class FirewallService
|
||||
{
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/**
|
||||
* Parse `ufw status verbose` into a structured snapshot.
|
||||
*
|
||||
* @return array{active: bool, raw: string, rules: array<int, string>}
|
||||
*/
|
||||
public function status(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw status verbose 2>&1');
|
||||
$raw = $res['output'];
|
||||
|
||||
$active = (bool) preg_match('/Status:\s*active/i', $raw);
|
||||
|
||||
$rules = [];
|
||||
foreach (preg_split('/\R/', $raw) as $line) {
|
||||
$line = trim($line);
|
||||
// rule lines carry an action token (ALLOW/DENY/REJECT/LIMIT)
|
||||
if ($line !== '' && preg_match('/\b(ALLOW|DENY|REJECT|LIMIT)\b/i', $line)) {
|
||||
$rules[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return ['active' => $active, 'raw' => $raw, 'rules' => $rules];
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact command(s) enable() will run, with the detected sshd port
|
||||
* already substituted — shown to the operator before applying.
|
||||
*/
|
||||
public function enablePreview(Server $server): string
|
||||
{
|
||||
return $this->enableScript($this->sshPort($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
|
||||
* enable a firewall that would drop the current SSH session.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
public function enable(Server $server): array
|
||||
{
|
||||
$port = $this->sshPort($server);
|
||||
$preview = $this->enableScript($port);
|
||||
$res = $this->fleet->runPrivileged($server, $this->enableScript($port));
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a TCP port through the firewall.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
public function allow(Server $server, int $port): array
|
||||
{
|
||||
$port = $this->clampPort($port);
|
||||
$preview = "ufw allow {$port}/tcp";
|
||||
$res = $this->fleet->runPrivileged($server, $preview);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny a TCP port through the firewall.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
public function deny(Server $server, int $port): array
|
||||
{
|
||||
$port = $this->clampPort($port);
|
||||
$preview = "ufw deny {$port}/tcp";
|
||||
$res = $this->fleet->runPrivileged($server, $preview);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/** Build the guarded enable script for a given sshd port. */
|
||||
private function enableScript(int $port): string
|
||||
{
|
||||
return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
||||
* back to 22 when nothing is set explicitly.
|
||||
*/
|
||||
private function sshPort(Server $server): int
|
||||
{
|
||||
$res = $this->fleet->runPrivileged(
|
||||
$server,
|
||||
'grep -rhiE "^[[:space:]]*Port[[:space:]]+[0-9]+" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null '
|
||||
.'| awk \'{print $2}\' | head -1'
|
||||
);
|
||||
|
||||
$port = (int) trim($res['output']);
|
||||
|
||||
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
|
||||
}
|
||||
|
||||
/** Keep a port in the valid TCP range. */
|
||||
private function clampPort(int $port): int
|
||||
{
|
||||
return max(1, min(65535, $port));
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,49 @@ class FleetService
|
|||
return 'priv() { if [ "$(id -u)" = 0 ]; then "$@"; else '.$branch.'; fi; }; ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an arbitrary command as root (sudo password / direct root), returning
|
||||
* [ok, output]. The command runs under `sh -c` so pipes/redirects work; it is
|
||||
* passed base64-encoded so quoting/escaping is never an issue.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function runPrivileged(Server $server, string $command): array
|
||||
{
|
||||
$b64 = base64_encode($command);
|
||||
$ssh = $this->client($server);
|
||||
|
||||
try {
|
||||
[$out, $code] = $ssh->run(
|
||||
'export LC_ALL=C; '.$this->sudoFn($server).
|
||||
'priv sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1'
|
||||
);
|
||||
|
||||
return ['ok' => $code === 0, 'output' => trim($out)];
|
||||
} finally {
|
||||
$ssh->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an unprivileged command as the SSH user, returning [ok, output].
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function runPlain(Server $server, string $command): array
|
||||
{
|
||||
$b64 = base64_encode($command);
|
||||
$ssh = $this->client($server);
|
||||
|
||||
try {
|
||||
[$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1');
|
||||
|
||||
return ['ok' => $code === 0, 'output' => trim($out)];
|
||||
} finally {
|
||||
$ssh->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a marker-delimited compound output into [section => body]. */
|
||||
private function sections(string $out): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Applies the dashboard server-hardening checklist items over SSH (as root).
|
||||
*
|
||||
* Safety model: "guards + confirmation". Every action exposes a `preview()`
|
||||
* of the exact shell commands the operator sees BEFORE applying, and
|
||||
* `ssh_password` is GUARDED so the operator can never lock themselves out by
|
||||
* disabling password auth without an authorized SSH key in place.
|
||||
*
|
||||
* Each apply returns array{ok: bool, output: string, preview: string}.
|
||||
*/
|
||||
class HardeningService
|
||||
{
|
||||
/** Drop-in that holds every Clusev-managed sshd override. */
|
||||
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf';
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/** Human title for an action key (shown in the modal heading). */
|
||||
public function title(string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => 'SSH-Root-Login deaktivieren',
|
||||
'ssh_password' => 'SSH-Passwort-Login deaktivieren',
|
||||
'fail2ban' => 'fail2ban installieren',
|
||||
'unattended' => 'Automatische Updates aktivieren',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Short description of what an action does (shown above the command preview). */
|
||||
public function description(string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => 'Schreibt PermitRootLogin no als Drop-in und lädt den SSH-Dienst neu. Direkter Root-Login über SSH wird unterbunden.',
|
||||
'ssh_password' => 'Schreibt PasswordAuthentication no als Drop-in und lädt den SSH-Dienst neu. Anmeldung ist danach nur noch per SSH-Key möglich.',
|
||||
'fail2ban' => 'Installiert fail2ban und startet den Dienst. Wiederholte fehlgeschlagene Logins werden automatisch gesperrt.',
|
||||
'unattended' => 'Installiert unattended-upgrades und aktiviert automatische Sicherheitsupdates.',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact shell command(s) an action will run — shown to the operator
|
||||
* before applying. Pure string-building, no remote calls.
|
||||
*/
|
||||
public function preview(Server $server, string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => $this->sshDropInPreview('PermitRootLogin no'),
|
||||
'ssh_password' => $this->sshDropInPreview('PasswordAuthentication no'),
|
||||
'fail2ban' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban'."\n"
|
||||
.'systemctl enable --now fail2ban',
|
||||
'unattended' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'."\n"
|
||||
.'systemctl enable --now unattended-upgrades'."\n"
|
||||
.'dpkg-reconfigure -f noninteractive unattended-upgrades',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an action over SSH (as root). Dispatcher over the action keys.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
public function apply(Server $server, string $action): array
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => $this->applySshRoot($server),
|
||||
'ssh_password' => $this->applySshPassword($server),
|
||||
'fail2ban' => $this->applyFail2ban($server),
|
||||
'unattended' => $this->applyUnattended($server),
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Disable direct SSH root login via a drop-in, then reload sshd. */
|
||||
private function applySshRoot(Server $server): array
|
||||
{
|
||||
$preview = $this->preview($server, 'ssh_root');
|
||||
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no'));
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/**
|
||||
* GUARD: refuse to disable password auth unless >=1 authorized SSH key is
|
||||
* present — otherwise the operator could be locked out (Aussperrgefahr).
|
||||
*/
|
||||
private function applySshPassword(Server $server): array
|
||||
{
|
||||
$preview = $this->preview($server, 'ssh_password');
|
||||
|
||||
// GUARD 1: if Clusev itself logs in with a PASSWORD, disabling password
|
||||
// auth would cut Clusev's own access (its credential is not a key) — refuse.
|
||||
if ($server->credential?->auth_type === 'password') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
|
||||
'preview' => $preview,
|
||||
];
|
||||
}
|
||||
|
||||
// GUARD 2: there must be at least one authorized key on the box.
|
||||
if (! $this->hasAuthorizedKey($server)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
|
||||
'preview' => $preview,
|
||||
];
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no'));
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/** Install + enable fail2ban. */
|
||||
private function applyFail2ban(Server $server): array
|
||||
{
|
||||
$preview = $this->preview($server, 'fail2ban');
|
||||
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban';
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/** Install + enable unattended-upgrades. */
|
||||
private function applyUnattended(Server $server): array
|
||||
{
|
||||
$preview = $this->preview($server, 'unattended');
|
||||
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'
|
||||
.' && systemctl enable --now unattended-upgrades'
|
||||
.' && dpkg-reconfigure -f noninteractive unattended-upgrades';
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the SSH user has at least one authorized key. Tries the structured
|
||||
* FleetService::sshKeys() first, then falls back to a raw grep so the guard
|
||||
* never wrongly reports "no key" on a parsing hiccup.
|
||||
*/
|
||||
private function hasAuthorizedKey(Server $server): bool
|
||||
{
|
||||
try {
|
||||
if (count($this->fleet->sshKeys($server)) > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fall through to the raw check
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPlain(
|
||||
$server,
|
||||
'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0'
|
||||
);
|
||||
|
||||
return $res['ok'] && (int) trim($res['output']) > 0;
|
||||
}
|
||||
|
||||
/** Human-readable preview of writing a directive into the Clusev drop-in. */
|
||||
private function sshDropInPreview(string $directive): string
|
||||
{
|
||||
return 'mkdir -p /etc/ssh/sshd_config.d'."\n"
|
||||
."echo '{$directive}' > ".self::SSHD_DROPIN."\n"
|
||||
.'systemctl reload ssh || systemctl reload sshd';
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell run to install one sshd directive: ensure the drop-in dir, write
|
||||
* the directive, then reload whichever ssh unit exists. Appends if the
|
||||
* drop-in already holds other Clusev directives (keeps both lines).
|
||||
*/
|
||||
private function sshDropInScript(string $directive): string
|
||||
{
|
||||
$key = strtok($directive, ' ');
|
||||
|
||||
return 'mkdir -p /etc/ssh/sshd_config.d'
|
||||
.' && touch '.self::SSHD_DROPIN
|
||||
// drop any prior copy of this directive, then append the new value
|
||||
.' && grep -viE "^[[:space:]]*'.$key.'[[:space:]]" '.self::SSHD_DROPIN.' > '.self::SSHD_DROPIN.'.tmp 2>/dev/null; '
|
||||
.'mv '.self::SSHD_DROPIN.'.tmp '.self::SSHD_DROPIN.' 2>/dev/null; '
|
||||
.'echo "'.$directive.'" >> '.self::SSHD_DROPIN
|
||||
.' && (systemctl reload ssh || systemctl reload sshd)';
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,10 @@ class CredentialVault
|
|||
throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt.");
|
||||
}
|
||||
|
||||
if ($cred->disabled_at !== null) {
|
||||
throw new RuntimeException("SSH-Zugang für {$server->name} ist gesperrt.");
|
||||
}
|
||||
|
||||
$secret = $cred->auth_type === 'key'
|
||||
? PublicKeyLoader::load($cred->secret, $cred->passphrase ?: false)
|
||||
: $cred->secret; // password auth
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\SecurityHeaders::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ssh_credentials', function (Blueprint $table) {
|
||||
// Human label so an operator can tell what an access is for.
|
||||
$table->string('name')->nullable()->after('server_id');
|
||||
// Soft "sperren": a disabled credential is kept but refused by the vault.
|
||||
$table->timestamp('disabled_at')->nullable()->after('passphrase');
|
||||
$table->timestamp('last_used_at')->nullable()->after('disabled_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ssh_credentials', function (Blueprint $table) {
|
||||
$table->dropColumn(['name', 'disabled_at', 'last_used_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Simple key/value store for dashboard-configurable settings
|
||||
// (release channel, dashboard domain + TLS email, …).
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -111,6 +111,12 @@ force_kv SITE_ADDRESS "$SITE_ADDRESS"
|
|||
force_kv REVERB_HOST "$REVERB_HOST"
|
||||
force_kv REVERB_PORT "$REVERB_PORT"
|
||||
force_kv REVERB_SCHEME "$REVERB_SCHEME"
|
||||
# Session hardening (control-plane self-security): strict same-site + end on
|
||||
# browser close everywhere; secure cookie ONLY behind TLS (else the cookie is
|
||||
# never returned over plain HTTP and login silently breaks).
|
||||
force_kv SESSION_SAME_SITE strict
|
||||
force_kv SESSION_EXPIRE_ON_CLOSE true
|
||||
if [ -n "$DOMAIN" ]; then force_kv SESSION_SECURE_COOKIE true; else force_kv SESSION_SECURE_COOKIE false; fi
|
||||
force_kv HOST_UID "$(id -u)"
|
||||
force_kv HOST_GID "$(id -g)"
|
||||
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
|
||||
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">Konto</p>
|
||||
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">Einstellungen</x-nav-item>
|
||||
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">System</x-nav-item>
|
||||
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">Version</x-nav-item>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name (optional)</label>
|
||||
<input wire:model="name" type="text" placeholder="z. B. Prod-Server X · Backup-User" maxlength="120"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
|
||||
<input wire:model="username" type="text" placeholder="root"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="shield" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ $heading ?: 'Härtung anwenden' }}</h2>
|
||||
@if ($description)
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Befehle (als root)</p>
|
||||
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3.5 py-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $preview }}</pre>
|
||||
</div>
|
||||
|
||||
@if ($done)
|
||||
<div @class([
|
||||
'mt-3 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<p @class([
|
||||
'flex items-center gap-1.5 font-mono text-[11px]',
|
||||
'text-online' => $ok,
|
||||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'Angewendet' : 'Fehlgeschlagen' }}
|
||||
</p>
|
||||
@if ($output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
@if ($done)
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
@unless ($error)
|
||||
<x-btn variant="accent" wire:click="apply" wire:target="apply" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="apply" />
|
||||
<svg wire:loading wire:target="apply" 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>
|
||||
Anwenden
|
||||
</x-btn>
|
||||
@endunless
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,9 +64,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" /> Zugang
|
||||
</x-btn>
|
||||
<x-btn variant="secondary" wire:click="openFiles">
|
||||
<x-icon name="folder" class="h-3.5 w-3.5" /> Dateien
|
||||
</x-btn>
|
||||
|
|
@ -80,6 +77,63 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- SSH-Zugang (Credential) --}}
|
||||
@php
|
||||
$cred = $server->credential;
|
||||
$credStatus = $cred ? ($cred->disabled ? 'offline' : 'online') : null;
|
||||
$credStatusLabel = $cred ? ($cred->disabled ? 'Gesperrt' : 'Aktiv') : null;
|
||||
$credAuthLabel = $cred ? ($cred->auth_type === 'key' ? 'SSH-Key' : 'Passwort') : null;
|
||||
@endphp
|
||||
<x-panel title="SSH-Zugang" subtitle="Hinterlegte Anmeldung für die Live-Steuerung" :padded="false">
|
||||
@if ($cred)
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
|
||||
<span @class([
|
||||
'grid h-10 w-10 shrink-0 place-items-center rounded-md border',
|
||||
'border-online/30 bg-online/10 text-online' => ! $cred->disabled,
|
||||
'border-offline/30 bg-offline/10 text-offline' => $cred->disabled,
|
||||
])>
|
||||
<x-icon name="shield" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: '—' }}</p>
|
||||
<x-status-pill :status="$credStatus" class="shrink-0">{{ $credStatusLabel }}</x-status-pill>
|
||||
<x-badge tone="neutral" class="shrink-0">{{ $credAuthLabel }}</x-badge>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate font-mono text-[11px] text-ink-3">
|
||||
<span class="text-ink-2">{{ $cred->username }}</span>@<span>{{ $server->ip }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" /> Bearbeiten
|
||||
</x-btn>
|
||||
<x-btn variant="secondary" wire:click="toggleCredential" wire:target="toggleCredential" wire:loading.attr="disabled">
|
||||
<x-icon name="power" class="h-3.5 w-3.5" wire:loading.remove wire:target="toggleCredential" />
|
||||
<svg wire:loading wire:target="toggleCredential" 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>
|
||||
{{ $cred->disabled ? 'Entsperren' : 'Sperren' }}
|
||||
</x-btn>
|
||||
<x-btn variant="ghost-danger" wire:click="confirmDeleteCredential">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" /> Löschen
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
|
||||
<x-icon name="shield" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-display text-sm font-semibold text-ink">Kein Zugang hinterlegt</p>
|
||||
<p class="mt-1.5 font-mono text-[11px] text-ink-3">Ohne SSH-Zugang ist keine Live-Steuerung möglich.</p>
|
||||
</div>
|
||||
<x-btn variant="accent" class="shrink-0" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" /> Zugang hinterlegen
|
||||
</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
||||
{{-- Vitals: live gauges, full width, dense --}}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
@foreach ($vitals as $v)
|
||||
|
|
@ -111,7 +165,21 @@
|
|||
|
||||
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
|
||||
<div class="divide-y divide-line">
|
||||
@php
|
||||
// Map each checklist label to its hardening-action key.
|
||||
$hardenKey = function (string $label): ?string {
|
||||
return match (true) {
|
||||
str_contains($label, 'Root-Login') => 'ssh_root',
|
||||
str_contains($label, 'Passwort-Login') => 'ssh_password',
|
||||
str_contains($label, 'fail2ban') => 'fail2ban',
|
||||
str_contains($label, 'Firewall') => 'firewall',
|
||||
str_contains($label, 'Updates') => 'unattended',
|
||||
default => null,
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
@foreach ($hardening as $check)
|
||||
@php $action = $hardenKey($check['label']); @endphp
|
||||
<div @class([
|
||||
'flex items-center gap-3 border-l-2 px-4 py-3 sm:px-5',
|
||||
'border-online' => $check['status'] === 'online',
|
||||
|
|
@ -122,9 +190,16 @@
|
|||
<p class="truncate text-sm text-ink">{{ $check['label'] }}</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
|
||||
</div>
|
||||
<x-status-pill :status="$check['status']" class="shrink-0">
|
||||
{{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }}
|
||||
</x-status-pill>
|
||||
@if ($check['status'] !== 'online' && $action)
|
||||
<x-btn variant="accent" size="sm" class="shrink-0"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $action }}' } })">
|
||||
Anwenden
|
||||
</x-btn>
|
||||
@else
|
||||
<x-status-pill :status="$check['status']" class="shrink-0">
|
||||
{{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }}
|
||||
</x-status-pill>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
@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">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
|
||||
<span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 text-accent">
|
||||
<x-icon name="settings" class="h-6 w-6" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">System</p>
|
||||
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">Domain, TLS & Release-Kanal</h2>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">Zugriffsadresse des Panels und Update-Quelle</p>
|
||||
</div>
|
||||
<span @class([
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]',
|
||||
'border-online/30 text-online' => $hasTls,
|
||||
'border-warning/30 text-warning' => ! $hasTls,
|
||||
])>
|
||||
<x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? 'HTTPS aktiv' : 'Klartext-HTTP' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Domain & TLS --}}
|
||||
<x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat">
|
||||
{{-- Current TLS state --}}
|
||||
@if ($hasTls)
|
||||
<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" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-ink">HTTPS via Let's Encrypt (Caddy)</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>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/25 bg-warning/5 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">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>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="saveDomain" class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="{{ $label }}">Domain</label>
|
||||
<input wire:model="domain" type="text" placeholder="panel.example.com" autocomplete="off" class="{{ $field }}" />
|
||||
@error('domain') <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">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 class="flex justify-end">
|
||||
<x-btn variant="primary" type="submit" wire:target="saveDomain" wire:loading.attr="disabled">
|
||||
<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>
|
||||
Speichern
|
||||
</x-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Generated Caddy site config --}}
|
||||
<div class="mt-5 border-t border-line pt-5">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5 text-ink-3" />
|
||||
<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>
|
||||
|
||||
{{-- Release-Kanal --}}
|
||||
<x-panel title="Release-Kanal" subtitle="Quelle für Updates">
|
||||
<div class="space-y-4">
|
||||
{{-- Segmented control --}}
|
||||
<div role="radiogroup" aria-label="Release-Kanal" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
|
||||
@foreach ($channels as $key => $desc)
|
||||
<button type="button"
|
||||
role="radio"
|
||||
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
|
||||
wire:click="confirmChannel('{{ $key }}')"
|
||||
@class([
|
||||
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
|
||||
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $channel === $key,
|
||||
'text-ink-3 hover:bg-raised hover:text-ink-2' => $channel !== $key,
|
||||
])>
|
||||
@if ($channel === $key)<x-icon name="git-branch" class="h-3.5 w-3.5" />@endif{{ $key }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Per-channel description --}}
|
||||
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
|
||||
<x-icon name="git-branch" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-ink">Kanal: <span class="font-mono text-accent-text">{{ $channel }}</span></p>
|
||||
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $channels[$channel] ?? '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
@ -8,6 +8,7 @@ use App\Livewire\Files;
|
|||
use App\Livewire\Servers;
|
||||
use App\Livewire\Services;
|
||||
use App\Livewire\Settings;
|
||||
use App\Livewire\System;
|
||||
use App\Livewire\Versions;
|
||||
use Illuminate\Support\Facades\Auth as AuthFacade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -43,5 +44,6 @@ Route::middleware('auth')->group(function () {
|
|||
|
||||
Route::get('/settings', Settings\Index::class)->name('settings');
|
||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||
Route::get('/system', System\Index::class)->name('system');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue