Compare commits
5 Commits
02741539ca
...
3d86c8db8d
| Author | SHA1 | Date |
|---|---|---|
|
|
3d86c8db8d | |
|
|
217189598b | |
|
|
9035078ac0 | |
|
|
4a3c572a89 | |
|
|
c54932b532 |
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -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
151
README.md
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' ""
|
||||
|
|
@ -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 0–3 + 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.
|
||||
206
install.sh
206
install.sh
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue