chore(release): v0.9.5

- Initial admin now uses the standard default password "clusev" (operator decision) with
  must_change_password forcing a new password on first login; banner/MOTD/README tell the
  operator to change it immediately. Idempotent no-op when an admin already exists.
- Richer host MOTD (CLUSEV wordmark, version, live stack status, login + management hints).
- README: install block now installs git (minimal Debian/Ubuntu lack it) and lists it as a
  prerequisite — found by a full fresh-VM install test (install + idempotent re-run verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.5
boban 2026-06-19 16:43:04 +02:00
parent b9b4b333e1
commit 9215a5b908
7 changed files with 73 additions and 20 deletions

View File

@ -11,7 +11,15 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
## [Unreleased] ## [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 ### 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 - **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 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 — `docker compose ps`), die Dashboard-URL, eine Login-Hinweiszeile (woher die Zugangsdaten kommen —

View File

@ -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 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. 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, 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. runs the migrations, and creates the first administrator with the **default password `clusev`**
5. Installs a themed host login banner (MOTD) showing the dashboard address. (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** 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 (your e-mail + the default password **`clusev`**), and the **`clusev` host user** + its random
**only once** — note them down. password (shown **only once** — note it down).
On first login the panel forces you to set your own password. 2FA is **optional but recommended** Log in with your admin e-mail and the password **`clusev`** — the panel then **forces you to set a
enable TOTP and/or a security key from **Settings → Security** whenever you like. 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 ## Access, domain & TLS

View File

@ -4,23 +4,27 @@ namespace App\Console\Commands;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str;
/** /**
* Idempotent first-run bootstrap (called by install.sh, phase 7). * Idempotent first-run bootstrap (called by install.sh, phase 7).
* *
* Creates the initial admin with a one-time random password that is printed * Creates the initial admin with the standard default password (operator decision: a
* exactly once on stdout never written to .env, a file, or the database in * known, memorable default so login is never blocked by a missed one-time secret).
* plaintext. Re-running once an admin exists is a hard no-op (INSTALL_LOCK * `must_change_password` is set, so the onboarding gate FORCES a new password on the
* idiom): no default credential, no "first-visitor-wins" claim window. * 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 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 protected $signature = "clusev:install
{--email= : Admin-Login + Let's-Encrypt-E-Mail} {--email= : Admin-Login + Let's-Encrypt-E-Mail}
{--name=Administrator : Anzeigename des Admins}"; {--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 public function handle(): int
{ {
@ -39,22 +43,22 @@ class Install extends Command
return self::FAILURE; return self::FAILURE;
} }
$password = Str::password(20); $password = self::DEFAULT_PASSWORD;
$user = User::create([ $user = User::create([
'name' => (string) ($this->option('name') ?: 'Administrator'), 'name' => (string) ($this->option('name') ?: 'Administrator'),
'email' => $email, 'email' => $email,
'password' => $password, // 'hashed' cast hashes on save '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. The password is the
// install.sh greps these two lines for the closing banner; never stored. // standard default and must be changed on first login (must_change_password).
$this->newLine(); $this->newLine();
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email); $this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
$this->line('CLUSEV_ADMIN_PASSWORD='.$password); $this->line('CLUSEV_ADMIN_PASSWORD='.$password);
$this->newLine(); $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; return self::SUCCESS;
} }

View File

@ -3,7 +3,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash // 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). // 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. // Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable', 'channel' => 'stable',

View File

@ -40,7 +40,7 @@ printf ' %s%s▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ %s %sFlee
printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R" printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R"
printf ' %sDashboard%s %s%s%s\n' "$D" "$R" "$O" "$URL" "$R" printf ' %sDashboard%s %s%s%s\n' "$D" "$R" "$O" "$URL" "$R"
printf ' %sStack%s %s\n' "$D" "$R" "$(stack_status)" 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 ' %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──────────────────────────────────────────────────────────%s\n' "$D" "$R"
printf '%s\n' "" printf '%s\n' ""

View File

@ -320,7 +320,7 @@ info "+----------------------------------------------------------+"
info " Dashboard: ${ACCESS_URL}" info " Dashboard: ${ACCESS_URL}"
info " Admin-Login: ${ADMIN_MAIL:-<bestehend>}" info " Admin-Login: ${ADMIN_MAIL:-<bestehend>}"
if [ -n "$ADMIN_PW" ]; then if [ -n "$ADMIN_PW" ]; then
info " Admin-Passwort: ${ADMIN_PW}" info " Admin-Passwort: ${ADMIN_PW} (Standard — beim ersten Login sofort aendern!)"
else else
info " Admin-Passwort: <bereits installiert — kein neuer Admin angelegt>" info " Admin-Passwort: <bereits installiert — kein neuer Admin angelegt>"
fi fi

View File

@ -0,0 +1,38 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* clusev:install (install.sh phase 8) first-admin bootstrap. The operator chose a known
* default password ('clusev') for predictable login; must_change_password forces a new one
* on first login, which is what keeps the default acceptable.
*/
class InstallCommandTest extends TestCase
{
use RefreshDatabase;
public function test_creates_admin_with_default_password_and_forced_change(): void
{
$this->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());
}
}