feat(deploy): install.sh + Caddy auto-TLS + prod hardening

One-command, idempotent production install per docs/install-update-design.md
(the "JETZT" phase). Onboarding (forced password change + 2FA) already ships
via EnsureSecurityOnboarded; the in-dashboard self-updater stays deferred to v1.x.

- install.sh: preflight -> idempotent secrets (set_kv never regenerates) ->
  derive SITE_ADDRESS/APP_URL/REVERB_* from the single APP_DOMAIN knob -> build ->
  up -> wait DB -> migrate + cache -> clusev:install. One-time admin password is
  printed only on the terminal, never stored. Bare-IP and private-IP warnings.
- app/Console/Commands/Install.php: idempotent first admin (Str::password(20),
  must_change_password); hard no-op once a user exists (INSTALL_LOCK idiom).
- docker/caddy/Caddyfile: one template, both modes; Reverb wss over /app/* and
  /apps/* on the same address; admin-API-free, security headers. Validated for
  bare-IP (:80) and domain (https) modes.
- docker-compose.prod.yml: Caddy is the only host-published service (80/443 +
  HTTP/3); app/reverb/queue/mariadb lose host ports (internal net only);
  CLUSEV_IMAGE indirection (locally-built tag, GHCR digest later); Redis requires
  a password; caddy-data/caddy-config volumes persist ACME certs.
- .env.example: APP_DOMAIN/APP_SCHEME/ACME_EMAIL/SITE_ADDRESS/CLUSEV_IMAGE/
  UPDATE_HMAC_KEY documented.
- README: real Clusev install/deploy guide (replaces Laravel boilerplate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 15:17:32 +02:00
parent 218806727c
commit 16655eb537
6 changed files with 383 additions and 49 deletions

View File

@ -72,3 +72,16 @@ HOST_GID=1002
# GHCR namespace for prod images (ghcr.io/<owner>/clusev):
GHCR_OWNER=OWNER
IMAGE_TAG=latest
# ── Reverse proxy / TLS (prod — written by install.sh) ───────────────
# 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=
APP_SCHEME=http
ACME_EMAIL=
# Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"):
SITE_ADDRESS=:80
# Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io/<owner>/clusev@sha256:..).
CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_KEY=

103
README.md
View File

@ -1,58 +1,83 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Clusev
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
Self-hosted control panel to administer a **fleet of Linux servers from one dashboard**,
agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers
with **phpseclib** (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted
SSH-credential vault, and a full audit log. Multi-server is free and never paywalled.
## About Laravel
> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details,
> auth + 2FA. UI copy is German; meta-docs are English.
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
**Stack:** Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3.
Everything runs in Docker. See `CLAUDE.md` for architecture and `rules.md` for the hard conventions.
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
---
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Development
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
The host needs only Docker (no PHP/Composer/Node). The dev `app` container runs php-fpm + nginx +
Vite via supervisor; `reverb`, `queue`, `mariadb`, `redis` are their own services.
```bash
composer require laravel/boost --dev
php artisan boost:install
cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build # app on :80, Vite HMR on :5173
docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate
docker compose exec app php artisan migrate --seed
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
Run any tooling inside the container, e.g.
`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt).
## Contributing
---
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Production install
## Code of Conduct
One host, only Docker + a sudo user. **Caddy** is the single host-exposed service: auto-TLS when a
domain is set, plain HTTP on the bare IP otherwise. `install.sh` is idempotent — it generates
secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin
with a **one-time random password printed only on the terminal**.
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
```bash
git clone https://git.bave.dev/boban/clusev.git && cd clusev
./install.sh
```
## Security Vulnerabilities
Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment):
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
```
Domain (empty = access by IP over HTTP): clusev.example.com
Admin e-mail (login + Let's Encrypt): admin@example.com
```
The closing banner shows the URL + the one-time admin password. On first login Clusev **forces** a
password change and 2FA enrolment before the panel unlocks.
### One knob: `APP_DOMAIN`
| `APP_DOMAIN` | proxy | URL | Reverb |
|---|---|---|---|
| *(empty)* | Caddy serves plain HTTP on `APP_PORT` | `http://<ip>` | `ws://<ip>/app/*` |
| `clusev.example.com` | Caddy gets a Let's Encrypt cert, forces HTTPS | `https://…` | `wss://…/app/*` |
`SITE_ADDRESS`, `APP_URL`, `REVERB_*` are **derived** from it by the installer — nothing hardcoded.
Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs
publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead.
### Manual deploy (for operators who don't `curl | bash`)
```bash
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com
```
> In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified,
> 2FA-gated, with backup + rollback) are designed in `docs/install-update-design.md` and land in
> v1.x — they are intentionally **not** shipped yet.
---
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Idempotent first-run bootstrap (called by install.sh, phase 7).
*
* Creates the initial admin with a one-time random password that is printed
* exactly once on stdout never written to .env, a file, or the database in
* plaintext. Re-running once an admin exists is a hard no-op (INSTALL_LOCK
* idiom): no default credential, no "first-visitor-wins" claim window.
*/
class Install extends Command
{
protected $signature = "clusev:install
{--email= : Admin-Login + Let's-Encrypt-E-Mail}
{--name=Administrator : Anzeigename des Admins}";
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Einmal-Zufallspasswort an';
public function handle(): int
{
if (User::query()->exists()) {
$this->info('Clusev ist bereits installiert — kein Admin angelegt.');
return self::SUCCESS;
}
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost';
$email = (string) ($this->option('email') ?: "admin@{$host}");
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Ungueltige Admin-E-Mail: {$email}");
return self::FAILURE;
}
$password = Str::password(20);
$user = User::create([
'name' => (string) ($this->option('name') ?: 'Administrator'),
'email' => $email,
'password' => $password, // 'hashed' cast hashes on save
'must_change_password' => true, // forces rotate + 2FA on first login
]);
// One-time password marker — the ONLY place the plaintext appears.
// install.sh greps these two lines for the closing banner; never stored.
$this->newLine();
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
$this->line('CLUSEV_ADMIN_PASSWORD='.$password);
$this->newLine();
$this->info('Admin angelegt. Passwort erscheint nur jetzt und wird nicht gespeichert.');
return self::SUCCESS;
}
}

View File

@ -1,14 +1,18 @@
# Clusev — PROD stack. Plain Docker Compose, no Portainer.
# Image from GHCR (set GHCR_OWNER + IMAGE_TAG in .env), or build on the VM:
#
# Caddy is the ONLY host-exposed service (auto-TLS when APP_DOMAIN is set; plain
# HTTP on the bare IP otherwise). app/reverb are internal-only on the clusev net.
#
# Bootstrap + operate with ./install.sh. Manual deploy:
# docker compose -f docker-compose.prod.yml build
# Deploy:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
# docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
# docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
name: clusev
# Locally-built tag by default; override CLUSEV_IMAGE with a GHCR digest
# (ghcr.io/<owner>/clusev@sha256:...) once CI signs + publishes images.
x-image: &image
image: "ghcr.io/${GHCR_OWNER:-OWNER}/clusev:${IMAGE_TAG:-latest}"
image: "${CLUSEV_IMAGE:-clusev-app:prod}"
restart: unless-stopped
env_file:
- .env
@ -22,8 +26,8 @@ services:
context: .
dockerfile: Dockerfile
target: prod
ports:
- "${APP_PORT:-80}:80"
expose:
- "80"
depends_on:
mariadb:
condition: service_healthy
@ -33,8 +37,8 @@ services:
reverb:
<<: *image
command: php artisan reverb:start --host=0.0.0.0 --port=8080
ports:
- "${REVERB_HOST_PORT:-8080}:8080"
expose:
- "8080"
depends_on:
redis:
condition: service_started
@ -48,6 +52,27 @@ services:
redis:
condition: service_started
caddy:
image: caddy:2-alpine # pin by @sha256 digest before the first prod push
restart: unless-stopped
ports:
- "${APP_PORT:-80}:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
SITE_ADDRESS: "${SITE_ADDRESS:-:80}"
# never empty: bare-IP mode has no cert, so this address is unused
ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}"
volumes:
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # ACME account + certs — MUST persist across recreate
- caddy-config:/config
depends_on:
- app
- reverb
networks:
- clusev
mariadb:
image: mariadb:11
restart: unless-stopped
@ -65,11 +90,12 @@ services:
retries: 30
networks:
- clusev
# no host ports in prod — DB is reachable only on the internal clusev network
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}"
volumes:
- redis-data:/data
networks:
@ -78,6 +104,8 @@ services:
volumes:
mariadb-data:
redis-data:
caddy-data:
caddy-config:
networks:
clusev:

30
docker/caddy/Caddyfile Normal file
View File

@ -0,0 +1,30 @@
# Clusev reverse proxy one template, both modes (driven by $SITE_ADDRESS).
#
# SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert
# 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/*
# (event API), proxied to reverb:8080 no extra exposed port, no mixed content.
{
# ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost
# in bare-IP mode, where no cert is issued so the address is simply unused.
email {$ACME_EMAIL}
}
{$SITE_ADDRESS} {
@reverb path /app/* /apps/*
reverse_proxy @reverb reverb:8080
reverse_proxy app:80
encode zstd gzip
header {
# conservative hardening; HSTS only meaningful under HTTPS
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
}

177
install.sh Executable file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# Clusev installer — idempotent. Generates secrets, brings up the prod stack,
# migrates the schema, and creates the first admin with a one-time random
# password. Safe to re-run: existing secrets are preserved, never regenerated.
#
# ./install.sh # interactive (prompts for domain + admin email)
# CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh
set -euo pipefail
cd "$(dirname "$0")"
COMPOSE="docker compose -f docker-compose.prod.yml"
ENV_FILE=".env"
# ── output helpers (German, no emoji) ────────────────────────────────
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[33m ! %s\033[0m\n' "$*"; }
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
phase() { printf '\n\033[1m[%s] %s\033[0m\n' "$1" "$2"; }
# ── .env mutators ────────────────────────────────────────────────────
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
force_kv() {
local key="$1" val="$2"
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
set_kv() {
local key="$1" val="$2" cur=""
if grep -qE "^${key}=" "$ENV_FILE"; then
cur="$(grep -E "^${key}=" "$ENV_FILE" | head -n1 | cut -d= -f2-)"
case "$cur" in
""|null|changeme|change-me-strong|change-me-strong-root) force_kv "$key" "$val" ;;
*) : ;; # keep an existing real value — secrets are never regenerated
esac
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
rand_hex() { openssl rand -hex "${1:-32}"; }
# ── [1/7] preflight ──────────────────────────────────────────────────
phase 1/7 "Voraussetzungen pruefen"
command -v docker >/dev/null 2>&1 || die "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || die "docker compose (v2) nicht gefunden."
command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden."
info "docker + compose + openssl vorhanden"
# ── inputs (interactive at a TTY, else env / bare-IP fallback) ───────
DOMAIN="${CLUSEV_DOMAIN:-}"
ADMIN_EMAIL="${CLUSEV_ADMIN_EMAIL:-}"
HTTP_PORT="${CLUSEV_HTTP_PORT:-80}"
if [ -t 0 ]; then
echo; bold "Clusev Installer"; echo "================"
printf '%s' "Domain (leer = Zugriff per IP ueber HTTP): "; read -r in_domain || true
[ -n "${in_domain:-}" ] && DOMAIN="$in_domain"
if [ -z "$DOMAIN" ]; then
printf '%s' "HTTP-Port (Standard 80): "; read -r in_port || true
[ -n "${in_port:-}" ] && HTTP_PORT="$in_port"
fi
printf '%s' "Admin E-Mail (Login + Let's Encrypt): "; read -r in_email || true
[ -n "${in_email:-}" ] && ADMIN_EMAIL="$in_email"
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HOST_IP="127.0.0.1"
# derive every proxy/url var from the single APP_DOMAIN knob
if [ -n "$DOMAIN" ]; then
[ -n "$ADMIN_EMAIL" ] || die "Bei gesetzter Domain ist eine Admin E-Mail fuer Let's Encrypt Pflicht."
APP_SCHEME="https"; APP_PORT="$HTTP_PORT"
SITE_ADDRESS="https://${DOMAIN}"
APP_URL="https://${DOMAIN}"
REVERB_HOST="$DOMAIN"; REVERB_PORT="443"; REVERB_SCHEME="https"
case "$HOST_IP" in
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|127.*)
warn "Host-IP ${HOST_IP} ist privat (RFC1918). Let's Encrypt braucht oeffentlich erreichbare 80/443 — sonst schlaegt das Zertifikat fehl (dann DNS-01-Build noetig)." ;;
esac
else
APP_SCHEME="http"; APP_PORT="$HTTP_PORT"
SITE_ADDRESS=":${HTTP_PORT}"
if [ "$HTTP_PORT" = "80" ]; then APP_URL="http://${HOST_IP}"; else APP_URL="http://${HOST_IP}:${HTTP_PORT}"; fi
REVERB_HOST="$HOST_IP"; REVERB_PORT="$HTTP_PORT"; REVERB_SCHEME="http"
warn "Bare-IP-Modus: Panel inkl. 2FA/Audit laeuft ueber KLARTEXT-HTTP. Fuer Produktion Domain + TLS setzen."
fi
# ── [2/7] .env + secrets (idempotent) ────────────────────────────────
phase 2/7 "Secrets generieren"
if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt";
else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi
set_kv DB_PASSWORD "$(rand_hex 24)"
set_kv DB_ROOT_PASSWORD "$(rand_hex 24)"
set_kv REDIS_PASSWORD "$(rand_hex 24)"
set_kv REVERB_APP_ID "$(rand_hex 8)"
set_kv REVERB_APP_KEY "$(rand_hex 16)"
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
set_kv UPDATE_HMAC_KEY "$(rand_hex 32)"
force_kv APP_ENV production
force_kv APP_DEBUG false
force_kv APP_URL "$APP_URL"
force_kv APP_DOMAIN "$DOMAIN"
force_kv APP_SCHEME "$APP_SCHEME"
force_kv APP_PORT "$APP_PORT"
force_kv ACME_EMAIL "$ADMIN_EMAIL"
force_kv SITE_ADDRESS "$SITE_ADDRESS"
force_kv REVERB_HOST "$REVERB_HOST"
force_kv REVERB_PORT "$REVERB_PORT"
force_kv REVERB_SCHEME "$REVERB_SCHEME"
force_kv HOST_UID "$(id -u)"
force_kv HOST_GID "$(id -g)"
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet"
# ── [3/7] image ──────────────────────────────────────────────────────
phase 3/7 "Image bauen"
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi
info "Image bereit"
cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)"
case "$cur_key" in
""|base64:)
NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)"
[ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden."
force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;;
*) info "APP_KEY vorhanden" ;;
esac
# ── [4/7] stack ──────────────────────────────────────────────────────
phase 4/7 "Stack starten"
$COMPOSE up -d
info "Container gestartet"
# ── [5/7] wait for the database ──────────────────────────────────────
phase 5/7 "Datenbank bereit"
db_ready=0
for _ in $(seq 1 60); do
if $COMPOSE exec -T mariadb healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then
db_ready=1; info "MariaDB healthy"; break
fi
sleep 2
done
[ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit."
# ── [6/7] migrate + caches ───────────────────────────────────────────
phase 6/7 "Migrationen"
$COMPOSE exec -T -u app app php artisan migrate --force
$COMPOSE exec -T -u app app php artisan config:cache >/dev/null
$COMPOSE exec -T -u app app php artisan route:cache >/dev/null
info "Schema migriert; Caches gebaut"
# ── [7/7] first admin ────────────────────────────────────────────────
phase 7/7 "Admin anlegen"
email_args=()
[ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL")
INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)"
ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)"
ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p' | head -n1)"
[ -n "$ADMIN_MAIL" ] || ADMIN_MAIL="$ADMIN_EMAIL"
# ── closing banner (password shown ONLY here) ────────────────────────
echo
echo "============================================================"
bold " Clusev ist bereit."
info "URL: ${APP_URL}"
info "Admin: ${ADMIN_MAIL:-<bestehend>}"
if [ -n "$ADMIN_PW" ]; then
info "Passwort: ${ADMIN_PW} (nur jetzt sichtbar, nicht gespeichert)"
else
info "Passwort: <bereits installiert — kein neuer Admin angelegt>"
fi
info "Erster Login: Passwort aendern + 2FA einrichten (Pflicht)."
[ "$APP_SCHEME" = "http" ] && warn "HTTP ohne TLS — fuer Produktion eine Domain setzen und neu ausfuehren."
echo "============================================================"