diff --git a/CHANGELOG.md b/CHANGELOG.md index f307739..a147fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui ## [Unreleased] +_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ + +## [0.9.5] - 2026-06-17 + ### Geändert +- **Erst-Admin nutzt jetzt das Standard-Passwort `clusev`** (statt eines Einmal-Zufallspassworts) — + vorhersehbarer Login. **`must_change_password` erzwingt den Wechsel beim ersten Login**, und + Banner/MOTD/README weisen ausdrücklich darauf hin, es **sofort** zu ändern (ein bekanntes Default + ist nur durch den erzwungenen Wechsel vertretbar). Idempotenz unverändert (kein Admin bei Re-Run). - **Host-MOTD deutlich aufgewertet.** Statt zwei Zeilen jetzt eine orange CLUSEV-Wortmarke, die Version, ein **Live-Stack-Status** („X/Y Dienste aktiv", grün/rot — bei jedem Login frisch aus `docker compose ps`), die Dashboard-URL, eine Login-Hinweiszeile (woher die Zugangsdaten kommen — diff --git a/README.md b/README.md index da22e90..d74facc 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,18 @@ The installer is idempotent (safe to re-run) and, in one pass: 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. + runs the migrations, and creates the first administrator with the **default password `clusev`** + (you are forced to change it on first login). +5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status. 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. +(your e-mail + the default password **`clusev`**), and the **`clusev` host user** + its random +password (shown **only once** — note it down). -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. +Log in with your admin e-mail and the password **`clusev`** — the panel then **forces you to set a +new password immediately**. Do this right away: `clusev` is a known default, so the account is only +safe once you've changed it. 2FA is **optional but recommended** — enable TOTP and/or a security key +from **Settings → Security** whenever you like. ## Access, domain & TLS diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php index a373380..db8894f 100644 --- a/app/Console/Commands/Install.php +++ b/app/Console/Commands/Install.php @@ -4,23 +4,27 @@ 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. + * Creates the initial admin with the standard default password (operator decision: a + * known, memorable default so login is never blocked by a missed one-time secret). + * `must_change_password` is set, so the onboarding gate FORCES a new password on the + * first login — the default is only valid until then. Re-running once an admin exists is + * a hard no-op. NOTE: a known default credential is only acceptable because of the forced + * rotation; the operator must change it immediately (banner / MOTD / README say so). */ class Install extends Command { + /** Standard default admin password — forced to change on first login (must_change_password). */ + private const DEFAULT_PASSWORD = 'clusev'; + 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'; + protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Standard-Passwort an (Zwangswechsel beim ersten Login)'; public function handle(): int { @@ -39,22 +43,22 @@ class Install extends Command return self::FAILURE; } - $password = Str::password(20); + $password = self::DEFAULT_PASSWORD; $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 + 'must_change_password' => true, // forces a new password 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. + // install.sh greps these two lines for the closing banner. The password is the + // standard default and must be changed on first login (must_change_password). $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.'); + $this->info('Admin angelegt. Standard-Passwort — beim ersten Login sofort aendern.'); return self::SUCCESS; } diff --git a/config/clusev.php b/config/clusev.php index 68bfac8..3e1b35e 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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.4', + 'version' => '0.9.5', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/docker/motd/00-clusev b/docker/motd/00-clusev index 9b95fba..0e8aaec 100644 --- a/docker/motd/00-clusev +++ b/docker/motd/00-clusev @@ -40,7 +40,7 @@ printf ' %s%s▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ %s %sFlee printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R" printf ' %sDashboard%s %s%s%s\n' "$D" "$R" "$O" "$URL" "$R" printf ' %sStack%s %s\n' "$D" "$R" "$(stack_status)" -printf ' %sLogin%s %sAdmin-Zugang stand einmalig im Install-Banner · zuruecksetzen: clusev:reset-admin%s\n' "$D" "$R" "$D" "$R" +printf ' %sLogin%s %sStandard-Passwort: clusev — beim ersten Login aendern · Reset: clusev:reset-admin%s\n' "$D" "$R" "$D" "$R" printf ' %sVerwalten%s %sdocker compose -f docker-compose.prod.yml ps | logs -f | restart%s\n' "$D" "$R" "$D" "$R" printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R" printf '%s\n' "" diff --git a/install.sh b/install.sh index 9872840..fdc653c 100755 --- a/install.sh +++ b/install.sh @@ -320,7 +320,7 @@ info "+----------------------------------------------------------+" info " Dashboard: ${ACCESS_URL}" info " Admin-Login: ${ADMIN_MAIL:-}" if [ -n "$ADMIN_PW" ]; then - info " Admin-Passwort: ${ADMIN_PW}" + info " Admin-Passwort: ${ADMIN_PW} (Standard — beim ersten Login sofort aendern!)" else info " Admin-Passwort: " fi diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php new file mode 100644 index 0000000..a81c7be --- /dev/null +++ b/tests/Feature/InstallCommandTest.php @@ -0,0 +1,38 @@ +artisan('clusev:install', ['--email' => 'admin@example.test'])->assertSuccessful(); + + $user = User::where('email', 'admin@example.test')->first(); + $this->assertNotNull($user); + $this->assertTrue(Hash::check('clusev', $user->password), 'admin password must be the default "clusev"'); + $this->assertTrue((bool) $user->must_change_password, 'first login must be forced to change it'); + } + + public function test_is_a_noop_when_an_admin_already_exists(): void + { + User::factory()->create(); + + $this->artisan('clusev:install', ['--email' => 'second@example.test'])->assertSuccessful(); + + $this->assertSame(1, User::count()); + $this->assertNull(User::where('email', 'second@example.test')->first()); + } +}