Compare commits

...

5 Commits

Author SHA1 Message Date
boban 3d86c8db8d chore(release): v0.9.2
One-command installer (Docker install, clusev user, DNS check) + themed MOTD,
professional README, and constant-time password-reset (account-enumeration
timing fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:22:03 +02:00
boban 217189598b fix(security): constant-time password-reset (flatten account-enumeration timing)
Queues the reset-link notification so sendResetLink returns in constant time regardless
of account existence; resetPassword does equivalent dummy verify work for unknown/no-2FA
users. Closes the timing side-channels flagged on the forgot-password review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:16:30 +02:00
boban 9035078ac0 docs(readme): professional product README — install (sudo ./install.sh), domain/TLS, recovery, optional 2FA
Replaces the dev-oriented readme: no CLAUDE.md/rules.md references, 2FA is optional
(not forced), documents the overhauled root installer + the clusev user + recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:15:14 +02:00
boban 4a3c572a89 feat(installer): root one-command bootstrap — apt Docker install, clusev user, DNS check, themed MOTD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:11:51 +02:00
boban c54932b532 docs(spec): installer overhaul (Docker install, clusev user, DNS check) + themed MOTD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:07:52 +02:00
12 changed files with 571 additions and 130 deletions

View File

@ -13,6 +13,30 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.2] - 2026-06-15
### Hinzugefügt
- **Ein-Befehl-Installer (`sudo ./install.sh`).** Bootstrappt einen frischen Debian/Ubuntu-Host
vollständig: installiert Docker aus dem offiziellen apt-Repo, legt einen dedizierten
`clusev`-Login-Benutzer (Zufallspasswort, `docker`-Gruppe) an, prüft per DNS-Abgleich, ob die
angegebene Domain auf diesen Host zeigt (sonst Warnung + Wahl „Domain trotzdem nehmen — HTTP bis
DNS stimmt" oder „auf der IP bleiben"), baut/startet den Stack, migriert und legt den ersten Admin
an. Das Abschluss-Banner zeigt ausschließlich Dashboard-URL, Admin-Login und — bei Erstanlage — den
`clusev`-Host-Benutzer; Passwörter erscheinen nur dieses eine Mal. Idempotent (Re-Run baut/migriert
neu, Secrets bleiben erhalten).
- **Themed Host-MOTD.** Bei jeder Host-Anmeldung erscheint ein Login-Banner in der
Produkt-Signalfarbe mit der Dashboard-Adresse.
### Geändert
- **Professionelles README** — Produktbeschreibung, Voraussetzungen, Installation (`git clone` +
`sudo ./install.sh`), Domain/TLS (inkl. externem Reverse-Proxy), Updates, Konto-Wiederherstellung
und AGPL-Lizenz; ohne interne Entwicklungs-Hinweise.
### Sicherheit
- **Passwort-Reset in konstanter Zeit.** Unbekannte E-Mail-Adressen durchlaufen jetzt denselben
Verifikationspfad (Dummy-Hash- bzw. Code-Prüfung) wie existierende Konten — die Antwortzeit verrät
nicht mehr, ob ein Konto existiert (Account-Enumeration über ein Timing-Orakel ausgeschlossen).
## [0.9.1] - 2026-06-15
### Geändert

151
README.md
View File

@ -1,101 +1,106 @@
# Clusev
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.
**Self-hosted control panel for a fleet of Linux servers — one dashboard, agentless over SSH.**
> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details,
> auth + 2FA. UI copy is German; meta-docs are English.
Clusev is the control plane: a modern, security-first web UI that administers your servers by talking
to them directly over SSH (exec + SFTP via phpseclib). It installs nothing on the target machines and
never reimplements their daemons. Multi-server management is free and never paywalled.
**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.
## Features
---
- **Dashboard & live metrics** — CPU, memory, load and disk per server, streamed in real time.
- **systemd services** — list, start/stop/restart, live journal tail.
- **SFTP file manager** — browse, edit text files, preview images.
- **Server details & hardening** — resource gauges, volumes, interfaces, SSH keys; UFW/firewalld
rules and fail2ban status with one-click controls; and a one-click "generate an SSH key and disable
password login, safely" flow.
- **Security** — optional, pluggable 2FA (TOTP and/or hardware security keys, or off), one-time
backup codes, an encrypted SSH-credential vault, and a complete, tamper-evident audit log.
- **Multiple administrators** — add further admin accounts; every action is attributed in the audit
log; view and revoke active sessions (per device, per user, or globally).
- **Domain, TLS & e-mail** — run on a bare IP over HTTP, set a domain for automatic HTTPS, or put your
own reverse proxy in front. Configure SMTP in-panel for password-reset e-mails.
## Development
The panel is built on Laravel 13, Livewire 3, Tailwind v4, Laravel Reverb (realtime), Redis, MariaDB
and phpseclib — all running in Docker. The interface is in German (English available).
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.
## Requirements
- A **Debian or Ubuntu** server (a small VM is enough) with **root** access.
- A public IP. Optionally a domain whose DNS points at the server (for automatic HTTPS).
- Nothing else — the installer sets up Docker for you.
## Install
```bash
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
git clone https://git.bave.dev/boban/clusev.git
cd clusev
sudo ./install.sh
```
Run any tooling inside the container, e.g.
`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt).
The installer is idempotent (safe to re-run) and, in one pass:
---
1. Installs **Docker** (Debian/Ubuntu, from Docker's official repository) if it isn't already present.
2. Creates a dedicated **`clusev`** system user — in the `docker` group, owning and running the stack —
with a random password.
3. Asks for a **domain** (leave empty for IP access) and an **admin e-mail**, or reads `CLUSEV_DOMAIN`
/ `CLUSEV_ADMIN_EMAIL` from the environment for an unattended install. If you give a domain it
checks whether DNS already points here: if so a certificate is obtained automatically; if not, it
warns you and lets you take the domain anyway (HTTP until DNS is correct) or continue on the IP.
4. Generates all secrets (once — never regenerated on re-run), builds the image, starts the stack,
runs the migrations, and creates the first administrator with a one-time random password.
5. Installs a themed host login banner (MOTD) showing the dashboard address.
## Production install
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
(e-mail + one-time password), and the **`clusev` host user** + its password. These passwords are shown
**only once** — note them down.
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**.
On first login the panel forces you to set your own password. 2FA is **optional but recommended**
enable TOTP and/or a security key from **Settings → Security** whenever you like.
## Access, domain & TLS
- **Bare IP (no domain):** served over plain HTTP at `http://<server-ip>`. This address always stays
reachable as a recovery path, even after a domain is configured.
- **With a domain:** set it during install or later under **System → Domain & TLS**. The panel obtains
and renews a Let's Encrypt certificate automatically and serves HTTPS — just point DNS at the server.
(Let's Encrypt needs publicly reachable ports 80/443.)
- **Behind your own reverse proxy** (one that already terminates TLS): switch *TLS-Terminierung* to
**Externer Reverse-Proxy** in System → Domain & TLS. The panel then serves HTTP only and trusts the
proxy's forwarded scheme — set `TRUSTED_PROXY_CIDR` to the proxy's address and firewall the HTTP
port so only the proxy can reach it.
Domain/TLS changes apply on a stack restart — use the **"Jetzt neu starten"** button in System (no
terminal needed; a small, scoped host service performs the restart).
## Updating
```bash
git clone https://git.bave.dev/boban/clusev.git && cd clusev
./install.sh
cd clusev
git pull
sudo ./install.sh # idempotent: rebuilds, restarts and migrates — secrets are preserved
```
Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment):
## Account recovery
```
Domain (empty = access by IP over HTTP): clusev.example.com
Admin e-mail (login + Let's Encrypt): admin@example.com
```
The **forgot-password** screen offers self-service recovery: an **e-mail reset link** (valid 15
minutes) when SMTP is configured, or an inline **2FA-proof reset** (e-mail + TOTP or a backup code +
new password) as a fallback.
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.
---
## Account recovery / Wiederherstellung
Forgot the admin password? The panel offers two self-service paths on the **forgot-password**
screen: an **e-mail reset link** (15 minutes valid) when SMTP is configured, and an inline
**2FA-proof reset** (e-mail + TOTP or a backup code + new password) as a fallback.
Locked out with **no 2FA and no SMTP**? As a last resort, SSH to the host and reset the admin
from the container:
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
```bash
cd clusev
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
```
This recovery command is documented in-panel under **Settings → Security** (admin-only) — it is
deliberately not advertised on the public, pre-login forgot-password screen.
---
This clears the second factor so you can set a new password on the next login. The bare-IP
`http://<server-ip>` address is also always available if a domain becomes unreachable. (This command
is documented in-panel under Settings → Security and is deliberately not shown on the public
forgot-password screen.)
## License
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules
(SSO/LDAP, RBAC, audit export, alerting) are separate. Project home:
<https://git.bave.dev/boban/clusev>.

View File

@ -11,10 +11,17 @@ use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
/** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';
/** Fixed throwaway bcrypt hash (cost 12) for the dummy password comparison on that branch. */
private const DUMMY_PASSWORD_HASH = '$2y$12$d9GooWgw17GrZ703OLFEDuuCXPvJs0vc/YLflA2JA70Mm6.I.3f.W';
public string $email = '';
public string $code = '';
@ -40,7 +47,15 @@ class ForgotPassword extends Component
}
$this->validate(['email' => ['required', 'email']]);
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
// The notification is queued (see User::sendPasswordResetNotification), so this only ever
// pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither
// latency nor an exception can distinguish a known address from an unknown one.
try {
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
} catch (\Throwable) {
// Intentionally ignored — the response is identical regardless (auth.reset_link_sent).
}
// Generic — never reveal whether the email exists.
$this->dispatch('notify', message: __('auth.reset_link_sent'));
@ -64,9 +79,15 @@ class ForgotPassword extends Component
$user = User::where('email', $this->email)->first();
$ok = $user
&& $user->hasTwoFactorEnabled()
&& ($user->verifyTotp($this->code) || $user->useRecoveryCode($this->code));
if ($user && $user->hasTwoFactorEnabled()) {
$ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
} else {
// No account, or one without 2FA: run equivalent dummy crypto so this branch costs
// about as much as a real wrong-code attempt — the verification path must not be a
// timing oracle that distinguishes a known address.
$this->burnVerificationTime();
$ok = false;
}
if (! $ok) {
RateLimiter::hit($key, 60);
@ -95,6 +116,24 @@ class ForgotPassword extends Component
return $this->redirect(route('login'), navigate: true);
}
/**
* Constant dummy verification for the no-account / no-2FA branch: mirrors the crypto work a
* real wrong-code attempt does (a Google2FA HMAC verify + a bcrypt comparison) so response
* time can't be used to enumerate accounts. The results are deliberately discarded.
*/
private function burnVerificationTime(): void
{
// Stand-in for User::verifyTotp() — a throwaway HMAC against a fixed dummy secret.
try {
(new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? '');
} catch (\Throwable) {
// ignore — verifyTotp swallows the same failure
}
// Stand-in for the recovery-code path's cost — a bcrypt compare against a fixed hash.
Hash::check($this->code, self::DUMMY_PASSWORD_HASH);
}
public function render()
{
return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@ -134,6 +135,16 @@ class User extends Authenticatable
}
}
/**
* Always queue the reset-password mail (never send synchronously), so the request that
* triggers it returns in constant time whether or not the address exists see
* {@see QueuedResetPassword} and ForgotPassword::sendResetLink().
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
{
$this->notify(new QueuedResetPassword($token));
}
public function webauthnCredentials(): HasMany
{
return $this->hasMany(WebauthnCredential::class);

View File

@ -0,0 +1,21 @@
<?php
namespace App\Notifications;
use App\Livewire\Auth\ForgotPassword;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
/**
* Queued variant of the framework reset-password notification.
*
* Sending the mail asynchronously keeps {@see ForgotPassword::sendResetLink()}
* constant-time regardless of whether the address belongs to a real account closing the
* SMTP-latency account-enumeration side channel (the mail is only ever pushed onto the queue,
* never delivered synchronously inside the request).
*/
class QueuedResetPassword extends ResetPassword implements ShouldQueue
{
use Queueable;
}

View File

@ -3,7 +3,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.9.1',
'version' => '0.9.2',
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable',

15
docker/motd/00-clusev Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Clusev themed MOTD — printed at host login (Debian/Ubuntu run-parts MOTD).
# Installed by install.sh; __CLUSEV_URL__ is substituted with the live access URL.
# Signal-orange brand, hairline rule, no emoji. Idempotent: install.sh only ever
# overwrites this single 00-clusev file, never other MOTD parts.
O=$'\033[38;5;208m' # signal-orange (brand)
D=$'\033[2m' # dim
B=$'\033[1m' # bold
R=$'\033[0m' # reset
printf '%s\n' ""
printf ' %s%sClus%sev%s %s· Fleet Control%s\n' "$B" "$O" "$R$B" "$R" "$D" "$R"
printf ' %s--------------------------------------%s\n' "$D" "$R"
printf ' Dashboard: %s%s%s\n' "$O" "__CLUSEV_URL__" "$R"
printf ' %sverwaltet vom Benutzer clusev · docker compose -f docker-compose.prod.yml ps%s\n' "$D" "$R"
printf '%s\n' ""

View File

@ -0,0 +1,81 @@
# Installer overhaul + themed MOTD — Design
**Date:** 2026-06-15 · **Branch:** `feat/v1-foundation` · **Status:** approved · Beta-prep #4 (+ #5 MOTD)
Make `install.sh` a true one-command bootstrap on a fresh Debian/Ubuntu host: install Docker, create
a dedicated `clusev` user, check the domain's DNS, bring the stack up, and end with a clean banner
(dedicated-user creds + admin creds + dashboard URL only). Also install a product-themed MOTD shown at
every host login. Builds on the existing 7-phase idempotent installer (secrets, stack, migrate, admin,
sentinel) — extend, don't rewrite.
## Operator-approved decisions
- **Docker auto-install:** Debian/Ubuntu (apt, official Docker repo) only; other OS → clear message + manual link.
- **Dedicated user:** login user `clusev` with a random password, in the `docker` group, owns the install dir.
- **Run mode:** `sudo ./install.sh` (root required for Docker install + user creation).
- **Domain:** DNS-check → if it resolves to this server, proceed with TLS; else warn + offer "take the domain anyway (HTTP until DNS points here)" or "continue on the IP".
## Flow (extends the current phases)
**Phase 0 — root + OS.** Require root (`[ "$(id -u)" = 0 ]` else `die "Bitte mit sudo ausführen: sudo ./install.sh"`).
Read `/etc/os-release`; set `OS_FAMILY` from `ID`/`ID_LIKE` (debian|ubuntu → apt). Non-apt + Docker missing → die with a manual-install hint.
**Phase 1 — preflight + Docker.** If `docker` + `docker compose` present → skip. Else on apt:
install via the official repo — `install -m0755 -d /etc/apt/keyrings`, fetch
`https://download.docker.com/linux/$ID/gpg` → keyring, add the `deb [signed-by=…]` repo, `apt-get update`,
`apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin`,
`systemctl enable --now docker`. Verify `docker compose version` after. Keep `openssl` check.
**Phase 2 — dedicated `clusev` user.** If `id clusev` fails → `useradd -m -s /bin/bash clusev`, set a
random password (`CLUSEV_USER_PW=$(openssl rand -base64 18)`, `chpasswd`), `usermod -aG docker clusev`.
Always: `chown -R clusev:clusev` the install dir. Set `HOST_UID`/`HOST_GID` in `.env` to clusev's
uid/gid (so containers run as clusev). Idempotent: existing user kept, password NOT regenerated (only
shown if freshly created — track `USER_CREATED=1`).
**Phase 3 — inputs + domain DNS check.** Keep the interactive domain/email prompts (+ env + bare-IP
fallback). When a domain is given: resolve it (`getent ahosts "$domain" | awk '{print $1}' | sort -u`)
and get the server's public IP (`curl -fsS https://api.ipify.org` with a `hostname -I` fallback). If the
domain's A/AAAA set contains the server IP → `DOMAIN_OK=1`, proceed with TLS. Else warn with the
resolved-vs-server IPs and offer (interactive): [1] take the domain anyway — "Cert kommt automatisch,
sobald DNS hierher zeigt; bis dahin nur HTTP", [2] continue on the IP (clear the domain). Non-interactive
default: take the domain anyway (on-demand TLS will issue once DNS is correct) — but print the warning.
**Phases 4-8 — unchanged in spirit:** `.env`/secrets (idempotent), session hardening, build image, start
stack, restart-sentinel units (render `User=clusev` + the real path now), wait for DB, migrate + caches,
first admin (one-time random password). The sentinel `chown`/unit-user becomes `clusev`.
**Phase 9 — MOTD (beta-prep #5).** Install a themed dynamic MOTD: write an executable
`/etc/update-motd.d/00-clusev` (Debian/Ubuntu run-parts MOTD) that prints the Clusev wordmark in the
product's signal-orange ANSI (256-color `\033[38;5;208m`), a hairline, and the live access line
(`https://<domain>` or `http://<server-ip>`), plus "verwaltet vom Benutzer clusev · docker compose -f
docker-compose.prod.yml ps". Ship the template under `docker/motd/00-clusev` and `install`-copy it
(chmod +x), substituting the URL. Idempotent (overwrite our file only; never touch other MOTD parts).
On non-Debian (no update-motd.d) write `/etc/motd` static as a fallback.
**Closing banner.** Print ONLY (signal-orange, no phase noise): "Installation erfolgreich." then a boxed
summary — Dashboard URL/IP · Admin-Login (email + the one-time admin password) · (if freshly created)
the `clusev` host user + its one-time password · "Diese Passwörter werden nur jetzt angezeigt." No other
output after. Secrets appear ONLY in this banner (never logged/echoed elsewhere).
## Files
- `install.sh` — phases 03 + 9 added, sentinel rendering → `clusev`, closing banner reworked.
- `docker/motd/00-clusev` (NEW) — the themed MOTD template (with a `__CLUSEV_URL__` placeholder).
- `.env` writes — `HOST_UID`/`HOST_GID` = clusev's; existing keys unchanged.
- (README documents all of this — beta-prep #3, separate.)
## Security
- Docker installed from the official GPG-verified apt repo (no `curl|bash get.docker.com`).
- `clusev` is in the `docker` group (docker = root-equivalent) — this IS the dedicated admin user, by design; documented.
- Random passwords via `openssl rand`, shown once in the closing banner, never logged.
- Root is required up-front and clearly stated; the script stays idempotent + `set -euo pipefail`.
- DNS check uses the resolved A/AAAA vs the server IP; a mismatch never silently enables a wrong-host cert (on-demand TLS already gates issuance to the configured domain).
## Testing
- `shellcheck install.sh` + `docker/motd/00-clusev` clean (no errors).
- The MOTD script renders the banner with a substituted URL (run it locally, check ANSI + the URL line).
- Idempotency: re-running on an already-installed host must not regenerate secrets/passwords or recreate
the user (dry-reason: the closing banner shows the freshly-created creds only on first run).
- A full root install can only be validated on a fresh Debian/Ubuntu VM (out of CI) — the operator runs
it there; the build verifies via shellcheck + a structured read-through + the MOTD render.
## Out of scope
- #3 professional README (documents this), #2 de-Claude history rewrite (last). Non-apt Docker
auto-install (RHEL/dnf). Unattended/cloud-init variants.

View File

@ -1,10 +1,13 @@
#!/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.
# Clusev installer — idempotent one-command bootstrap (root required).
# Installs Docker on Debian/Ubuntu (official apt repo), creates a dedicated
# `clusev` host user, checks the domain's DNS, generates secrets, brings up the
# prod stack, migrates the schema, creates the first admin with a one-time
# random password, and installs a themed MOTD. Safe to re-run: existing secrets
# and the clusev user 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
# sudo ./install.sh # interactive (prompts for domain + admin email)
# sudo CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh
set -euo pipefail
cd "$(dirname "$0")"
@ -43,12 +46,64 @@ set_kv() {
}
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."
# ── root + OS detection (before everything) ──────────────────────────
[ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./install.sh"
OS_ID=""; OS_LIKE=""; IS_APT=0
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
fi
case " ${OS_ID} ${OS_LIKE} " in
*debian*|*ubuntu*) IS_APT=1 ;;
esac
# ── [1/9] preflight + Docker ─────────────────────────────────────────
phase 1/9 "Voraussetzungen pruefen"
command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden."
info "docker + compose + openssl vorhanden"
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
info "docker + compose vorhanden"
elif [ "$IS_APT" = 1 ]; then
info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..."
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
# shellcheck disable=SC1091
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$(dpkg --print-architecture)" "$OS_ID" "$codename" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)."
info "Docker installiert + aktiviert"
else
die "Docker manuell installieren: https://docs.docker.com/engine/install/ , dann erneut ausfuehren."
fi
info "openssl vorhanden"
# ── [2/9] dedicated clusev user ──────────────────────────────────────
phase 2/9 "Benutzer clusev"
USER_CREATED=0
CLUSEV_USER_PW=""
if ! id -u clusev >/dev/null 2>&1; then
useradd -m -s /bin/bash clusev
CLUSEV_USER_PW="$(openssl rand -base64 18)"
printf 'clusev:%s\n' "$CLUSEV_USER_PW" | chpasswd
USER_CREATED=1
info "Benutzer clusev angelegt"
else
info "Benutzer clusev vorhanden — unveraendert"
fi
usermod -aG docker clusev
chown -R clusev:clusev .
info "Installationsverzeichnis gehoert clusev"
# ── inputs (interactive at a TTY, else env / bare-IP fallback) ───────
DOMAIN="${CLUSEV_DOMAIN:-}"
@ -67,6 +122,33 @@ if [ -t 0 ]; then
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HOST_IP="127.0.0.1"
SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
[ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP"
# ── domain DNS check (only when a domain is set) ─────────────────────
DOMAIN_OK=0
if [ -n "$DOMAIN" ]; then
RESOLVED="$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
case " $RESOLVED " in
*" $SERVER_IP "*) DOMAIN_OK=1 ;;
esac
if [ "$DOMAIN_OK" = 1 ]; then
info "DNS ok: ${DOMAIN} zeigt auf diesen Server (${SERVER_IP})"
else
warn "DNS-Pruefung: ${DOMAIN} aufgeloest auf [${RESOLVED:-<keine>}], dieser Server ist ${SERVER_IP}."
if [ -t 0 ]; then
echo " [1] Domain trotzdem uebernehmen (Cert kommt automatisch, sobald DNS hierher zeigt; bis dahin nur HTTP)"
echo " [2] Per IP weitermachen (Domain verwerfen -> bare-IP)"
printf '%s' " Auswahl [1]: "; read -r dns_choice || true
case "${dns_choice:-1}" in
2) DOMAIN=""; warn "Domain verworfen — Installation per IP." ;;
*) info "Domain uebernommen — TLS folgt automatisch, sobald DNS stimmt." ;;
esac
else
info "Nicht-interaktiv: Domain wird uebernommen (TLS folgt automatisch, sobald DNS stimmt)."
fi
fi
fi
# derive every proxy/url var from the single APP_DOMAIN knob
if [ -n "$DOMAIN" ]; then
@ -87,8 +169,8 @@ else
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"
# ── [3/9] .env + secrets (idempotent) ────────────────────────────────
phase 3/9 "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
@ -117,12 +199,13 @@ force_kv REVERB_SCHEME "$REVERB_SCHEME"
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)"
# Containers run as the dedicated clusev user (it owns the install dir + ./run).
force_kv HOST_UID "$(id -u clusev)"
force_kv HOST_GID "$(id -g clusev)"
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet"
# ── [3/7] image ──────────────────────────────────────────────────────
phase 3/7 "Image bauen"
# ── [4/9] image ──────────────────────────────────────────────────────
phase 4/9 "Image bauen"
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi
info "Image bereit"
@ -135,37 +218,37 @@ case "$cur_key" in
*) info "APP_KEY vorhanden" ;;
esac
# ── [4/7] stack ──────────────────────────────────────────────────────
phase 4/7 "Stack starten"
# ── [5/9] stack ──────────────────────────────────────────────────────
phase 5/9 "Stack starten"
$COMPOSE up -d
info "Container gestartet"
# ── restart sentinel (host watcher) — idempotent, best-effort ────────
# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button
# works (it writes ./run/restart.request; the host unit restarts the stack). The
# container never gets the Docker socket. Skipped (with a hint) when systemd or
# sudo are unavailable — see docker/restart-sentinel/README.md for the manual path.
# container never gets the Docker socket. Skipped (with a hint) when systemd is
# unavailable — see docker/restart-sentinel/README.md for the manual path. The
# unit runs as the dedicated `clusev` user (a member of the docker group).
install_restart_sentinel() {
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj sysd
local src="docker/restart-sentinel" dst="/etc/systemd/system" proj
proj="$(pwd)"
if ! command -v systemctl >/dev/null 2>&1; then
warn "systemd nicht gefunden — Neustart-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0
fi
sysd="sudo"; [ "$(id -u)" = 0 ] && sysd=""
if [ -n "$sysd" ] && ! sudo -n true 2>/dev/null; then
warn "Kein passwortloses sudo — Neustart-Watcher nicht installiert. Manuell: ${src}/README.md."; return 0
fi
# Render the units with THIS project's path + the invoking user (the units in the
# The container's ./run bind mount is owned by clusev; let clusev own it host-side too.
mkdir -p ./run
chown -R clusev:clusev ./run
# Render the units with THIS project's path + the clusev user (the units in the
# repo default to /home/nexxo/clusev; rewrite to the real deploy location).
local tmp_path tmp_svc
tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"
sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path"
sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=$(id -un)/" "${src}/clusev-restart.service" > "$tmp_svc"
if $sysd install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
&& $sysd install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
&& $sysd systemctl daemon-reload \
&& $sysd systemctl enable --now clusev-restart.path; then
info "Neustart-Watcher aktiv (clusev-restart.path)"
sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=clusev/" "${src}/clusev-restart.service" > "$tmp_svc"
if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \
&& install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \
&& systemctl daemon-reload \
&& systemctl enable --now clusev-restart.path; then
info "Neustart-Watcher aktiv (clusev-restart.path, User=clusev)"
else
warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md."
fi
@ -173,8 +256,8 @@ install_restart_sentinel() {
}
install_restart_sentinel
# ── [5/7] wait for the database ──────────────────────────────────────
phase 5/7 "Datenbank bereit"
# ── [6/9] wait for the database ──────────────────────────────────────
phase 6/9 "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
@ -184,15 +267,15 @@ for _ in $(seq 1 60); do
done
[ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit."
# ── [6/7] migrate + caches ───────────────────────────────────────────
phase 6/7 "Migrationen"
# ── [7/9] migrate + caches ───────────────────────────────────────────
phase 7/9 "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"
# ── [8/9] first admin ────────────────────────────────────────────────
phase 8/9 "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)"
@ -200,17 +283,40 @@ ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//
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)"
# ── [9/9] MOTD (themed, shown at host login) ─────────────────────────
phase 9/9 "MOTD installieren"
if [ -n "$DOMAIN" ] && [ "$DOMAIN_OK" = 1 ]; then
ACCESS_URL="https://${DOMAIN}"
elif [ -n "$DOMAIN" ]; then
# Domain taken but DNS not yet pointing here — until then it's HTTP on the IP.
ACCESS_URL="https://${DOMAIN}"
else
info "Passwort: <bereits installiert — kein neuer Admin angelegt>"
if [ "$HTTP_PORT" = "80" ]; then ACCESS_URL="http://${SERVER_IP}"; else ACCESS_URL="http://${SERVER_IP}:${HTTP_PORT}"; fi
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 "============================================================"
if [ -d /etc/update-motd.d ]; then
sed "s|__CLUSEV_URL__|${ACCESS_URL}|" docker/motd/00-clusev > /etc/update-motd.d/00-clusev
chmod +x /etc/update-motd.d/00-clusev
info "Dynamisches MOTD installiert (/etc/update-motd.d/00-clusev)"
else
sed "s|__CLUSEV_URL__|${ACCESS_URL}|" docker/motd/00-clusev | bash > /etc/motd 2>/dev/null || true
info "Statisches MOTD geschrieben (/etc/motd)"
fi
# ── closing banner (passwords shown ONLY here) ───────────────────────
echo
bold "Installation erfolgreich."
echo
info "+----------------------------------------------------------+"
info " Dashboard: ${ACCESS_URL}"
info " Admin-Login: ${ADMIN_MAIL:-<bestehend>}"
if [ -n "$ADMIN_PW" ]; then
info " Admin-Passwort: ${ADMIN_PW}"
else
info " Admin-Passwort: <bereits installiert — kein neuer Admin angelegt>"
fi
if [ "$USER_CREATED" = 1 ]; then
info " Host-Benutzer: clusev"
info " Host-Passwort: ${CLUSEV_USER_PW}"
fi
info "+----------------------------------------------------------+"
info "Diese Passwoerter werden nur jetzt angezeigt."

View File

@ -5,7 +5,7 @@ namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use App\Notifications\QueuedResetPassword as ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;

View File

@ -5,7 +5,7 @@ namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Settings\Security;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use App\Notifications\QueuedResetPassword as ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;

View File

@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use App\Notifications\QueuedResetPassword;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
/**
* Hardening of the two low-severity account-enumeration *timing* side channels on the
* forgot-password screen (the message-level leak is already closed via generic copy):
* (a) sendResetLink() must enqueue the reset mail (never send synchronously), so SMTP
* latency/exceptions can't distinguish a known address;
* (b) resetPassword() must spend comparable crypto time whether the account exists / has
* 2FA or not, so the verification branch isn't a timing oracle.
* These tests assert the *structural* equivalence of the branches (and that mail is queued),
* not wall-clock timing (which would be flaky).
*/
class ForgotPasswordTimingTest extends TestCase
{
use RefreshDatabase;
private function twoFactorUser(string $email = 'admin@clusev.local'): User
{
return User::factory()->create([
'email' => $email,
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
}
private function attemptReset(string $email, string $code = '123456'): Testable
{
return Livewire::test(ForgotPassword::class)
->set('email', $email)->set('code', $code)
->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123')
->call('resetPassword');
}
// ── (a) the reset-link mail is queued ────────────────────────────────
public function test_reset_link_notification_is_queued(): void
{
config(['mail.default' => 'smtp']);
Notification::fake();
$user = User::factory()->create(['email' => 'admin@clusev.local']);
Livewire::test(ForgotPassword::class)
->set('email', $user->email)
->call('sendResetLink')
->assertHasNoErrors();
Notification::assertSentTo(
$user,
QueuedResetPassword::class,
fn ($notification) => $notification instanceof ShouldQueue,
);
}
public function test_send_link_swallows_transport_errors_and_stays_generic(): void
{
config(['mail.default' => 'smtp']);
// No user exists → Password::sendResetLink hits the INVALID_USER path; the screen must
// still report the generic message with no error, exactly like the existing-user case.
Livewire::test(ForgotPassword::class)
->set('email', 'ghost@clusev.local')
->call('sendResetLink')
->assertHasNoErrors()
->assertDispatched('notify', message: __('auth.reset_link_sent'));
}
// ── (b) the verification branch does constant dummy work ─────────────
public function test_unknown_email_invalid_branch_burns_verification_time(): void
{
Hash::spy();
$this->attemptReset('ghost@clusev.local')->assertHasErrors('code');
// The no-account branch must still run a bcrypt comparison (dummy work), matching the
// crypto cost of a real wrong-code attempt — otherwise it returns measurably faster.
Hash::shouldHaveReceived('check')->atLeast()->once();
}
public function test_no_2fa_user_invalid_branch_burns_verification_time(): void
{
$user = User::factory()->create(['email' => 'plain@clusev.local']);
$this->assertFalse($user->hasTwoFactorEnabled());
Hash::spy();
$this->attemptReset('plain@clusev.local')->assertHasErrors('code');
Hash::shouldHaveReceived('check')->atLeast()->once();
}
public function test_two_factor_user_wrong_code_does_not_burn_dummy_time(): void
{
// The real-verification branch uses verifyTotp/useRecoveryCode, NOT the dummy Hash::check.
$this->twoFactorUser();
Hash::spy();
$this->attemptReset('admin@clusev.local', '000000')->assertHasErrors('code');
Hash::shouldNotHaveReceived('check');
}
// ── structural equivalence of the invalid branches ──────────────────
public function test_missing_and_no_2fa_branches_are_structurally_identical(): void
{
User::factory()->create(['email' => 'real@clusev.local']);
$missing = $this->attemptReset('ghost@clusev.local');
$no2fa = $this->attemptReset('real@clusev.local');
foreach ([$missing, $no2fa] as $component) {
$component->assertHasErrors('code');
$component->assertNoRedirect();
}
}
public function test_unknown_email_hits_the_rate_limiter_like_a_real_account(): void
{
RateLimiter::spy();
$this->attemptReset('ghost@clusev.local')->assertHasErrors('code');
// The no-account path must increment the limiter exactly like a real account would —
// no fast, un-throttled enumeration loop.
RateLimiter::shouldHaveReceived('hit')->atLeast()->once();
}
}