Repair two comment blocks the admin-hosts edit ran together
tests / pest (push) Failing after 8m1s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

An earlier edit removed the ADMIN_HOSTS example line and left its
"Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file
documented neither properly. SECRETS_KEY was never in here at all —
without it the console silently stores no credentials, which is exactly
the kind of thing an example file exists to prevent.
feat/granted-plans
nexxo 2026-07-28 23:20:43 +02:00
parent 75b4a47768
commit 669d0471a8
3 changed files with 159 additions and 8 deletions

View File

@ -38,7 +38,7 @@ REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -157,17 +157,19 @@ KUMA_TOTP=
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── Admin-Konsole: erlaubte Hostnamen ────────────────────────────────────
# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar
# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich);
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
# Erzeugen: head -c 32 /dev/urandom | base64
SECRETS_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1

42
app/Models/UserDevice.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A browser that has signed in before, and is therefore not news.
*
* Identified by a long-lived signed cookie carrying a random token, and by
* nothing else. The two obvious alternatives both produce warnings nobody
* reads: an IP changes on every train and every café, and a user-agent string
* changes on every browser update twelve false alarms a year, per customer,
* from Chrome's release schedule alone.
*
* `name` is for the person reading the list. It is recomputed on every sign-in
* so an upgraded browser relabels itself instead of raising an alarm, which
* only works because the name is not what identifies anything.
*
* Scoped by guard as well as by id: operators and customers are two tables and
* two guards (R21), and operator 1 is not customer 1.
*/
class UserDevice extends Model
{
use HasUuid;
protected $fillable = [
'guard', 'authenticatable_id', 'token_hash', 'name', 'last_ip', 'last_seen_at',
];
protected function casts(): array
{
return ['last_seen_at' => 'datetime'];
}
public function sessions(): HasMany
{
return $this->hasMany(LoginSession::class, 'device_id');
}
}

View File

@ -0,0 +1,107 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Sessions somebody can actually see and end, and the devices they belong to.
*
* Sessions lived in Redis, where they cannot be enumerated: one key per session
* and no index by user, so "show me where I am signed in" and "end that one"
* were not answerable at all. The database driver keeps a row per session and
* makes ending one a delete.
*
* Two tables of our own on top of it, and not for decoration:
*
* `user_devices` is what makes a new-device warning trustworthy. Recognising a
* device by IP means warning on every train journey; by user-agent means
* warning on every browser update. Neither is a signal anybody keeps reading.
* A device is a long-lived signed cookie carrying a random token present and
* known means known, absent means new, and nothing else is consulted.
*
* `login_sessions` is the link between the two, and it exists because Laravel's
* own sessions.user_id cannot carry it: that column is filled from the DEFAULT
* guard, while operators and customers are two guards and two tables (R21).
* Operator 1 and customer 1 would both write user_id = 1, and each would be
* offered the other's sessions to end.
*/
return new class extends Migration
{
public function up(): void
{
// Laravel's own, unchanged — the driver owns this table.
//
// Guarded: this database already had it, byte for byte and empty, with
// no migration anywhere that creates it — left behind by an early
// `session:table` whose file did not survive. Failing here would leave
// the two tables below uncreated over a table that is already correct.
if (! Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
// Written by the framework from the default guard. Deliberately
// NOT what anything here reads: see the note above.
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
Schema::create('user_devices', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// The guard IS the type. One guard, one identity table (R21), so a
// pair of columns says everything a polymorphic type column would
// and stays readable in a query somebody runs at 3am.
$table->string('guard', 32);
$table->unsignedBigInteger('authenticatable_id');
// The cookie's token, hashed. Stored the way a password is, for the
// same reason: a leaked database must not hand out working device
// cookies that skip the new-device warning.
$table->string('token_hash', 64)->unique();
// "Chrome auf macOS" — for the person reading the list, never for
// deciding whether the device is known. Recomputed on each sign-in
// so a browser upgrade updates the label instead of raising an alarm.
$table->string('name');
$table->string('last_ip', 45)->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->index(['guard', 'authenticatable_id']);
});
Schema::create('login_sessions', function (Blueprint $table) {
$table->id();
// Kept INSIDE the session payload, which is what makes this robust:
// Laravel rotates the session id on every sign-in and on every
// regenerate(), so a row keyed only by session id loses track of
// itself. The uuid travels with the session and the id is refreshed
// from it on each request.
$table->uuid()->unique();
$table->foreignId('device_id')->constrained('user_devices')->cascadeOnDelete();
$table->string('session_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('login_sessions');
Schema::dropIfExists('user_devices');
// `sessions` is deliberately left standing. This migration only creates
// it where it was missing, so dropping it unconditionally would destroy
// a table it never made — and on a rollback that is somebody already
// having a bad day.
}
};