Compare commits

..

No commits in common. "feat/v1-foundation" and "v0.9.2" have entirely different histories.

452 changed files with 8883 additions and 30296 deletions

View File

@ -8,6 +8,7 @@
.env
.env.*
!.env.example
.env.gitea
# Deps / build output (reinstalled or rebuilt in the image)
vendor
@ -23,10 +24,7 @@ storage/framework/sessions/*
storage/framework/views/*
database/*.sqlite
# Dev tooling / tests / docs (not needed at runtime). NOTE: docs/ is internal-only (export-ignored
# from the public git tree AND named in promote.sh's refuse-to-publish list) — it MUST also be kept
# out of the built image, or `COPY . .` bakes the private docs (with private host/URL refs) into the
# public GHCR image, which the git-tree leak-guard never inspects. Likewise art/ (README assets only).
# Dev tooling / tests / docs (not needed at runtime)
tests
phpunit.xml
.phpunit.cache
@ -36,24 +34,6 @@ kickoff-prompt.md
rules.md
CLAUDE.md
README.md
docs
art
# Dev-only release bridge — must NOT bake into the PUBLIC prod image (it carries the private
# upstream references). Also export-ignored from the public repo. Dev keeps it via the bind-mount;
# only the built (prod/public) image is stripped. The /release route is release_controls-gated
# (false in prod), so these classes are never loaded at runtime here.
app/Livewire/Release
app/Services/ReleaseBridge.php
app/Services/ReleasePlanner.php
app/Services/PipelineStatus.php
app/Services/PromotionService.php
resources/views/livewire/release
lang/de/release.php
lang/en/release.php
docker/release
scripts/promote.sh
scripts/promote.test.sh
# Compose files (not needed inside the image)
docker-compose.yml

View File

@ -2,7 +2,7 @@ APP_NAME=Clusev
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://10.10.90.136
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
@ -47,7 +47,7 @@ REVERB_APP_ID=changeme
REVERB_APP_KEY=changeme
REVERB_APP_SECRET=changeme
# client-facing (browser connects to the host-exposed port):
REVERB_HOST=localhost
REVERB_HOST=10.10.90.136
REVERB_PORT=8080
REVERB_SCHEME=http
# server bind (inside the container):
@ -65,11 +65,6 @@ FILESYSTEM_DISK=local
# ── Docker Compose knobs (host ports / uid / image) ──────────────────
APP_PORT=80
VITE_PORT=5173
# Dev HMR only: the address the BROWSER uses to reach this box's Vite dev server (the dev VM's LAN IP
# or hostname, e.g. 192.0.2.10). Needed when developing against a REMOTE box — otherwise the CSS/JS
# load from the wrong origin. Leave unset for purely local dev; Vite then infers it from the request
# host. Never used in prod (assets are built). Keep the real address in the untracked .env only.
# VITE_HMR_HOST=
REVERB_HOST_PORT=8080
DB_PORT=3306
# Containers run as this host user (must own the bind-mount). nexxo=1002 on this VM.
@ -86,22 +81,6 @@ IMAGE_TAG=latest
APP_DOMAIN=
APP_SCHEME=http
ACME_EMAIL=
# Deployed commit + branch, shown on the Version page. install.sh fills these from the host's
# .git (the prod image ships none) — leave empty; in dev the page reads .git directly.
CLUSEV_BUILD_SHA=
CLUSEV_BUILD_BRANCH=
# Update source for the in-app version check. install.sh sets this from the clone origin
# (git remote get-url origin). Left UNSET (keep this commented), it falls back to the public repo
# default in config/clusev.php — an empty value here would instead disable the check, so leave the
# key absent rather than blank. Never put a private repo URL in a tracked file — only in the .env.
# CLUSEV_REPOSITORY=https://github.com/clusev/clusev
# Dev-only release controls (dashboard Release page + host git-bridge + live pipeline). Leave unset on
# any non-dev install. Both values below are PRIVATE — never commit them, set only on the dev box:
# CLUSEV_RELEASE_CONTROLS=true # enables the /release page + watcher install
# GIT_STAGING_ACCESS_TOKEN= # GitHub PAT, READ only (Actions:Read + Contents:Read)
# CLUSEV_STAGING_SLUG=owner/private-mirror # the GitHub-private mirror repo (for live CI status)
# Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default.
APP_TIMEZONE=UTC
# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its
# X-Forwarded-Proto. Leave as 127.0.0.1/32 (trust nothing) when Caddy terminates TLS itself.
TRUSTED_PROXY_CIDR=127.0.0.1/32
@ -111,20 +90,6 @@ SITE_ADDRESS=:80
CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_KEY=
# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies
# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned
# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must
# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key.
# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers
# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
TERMINAL_SIDECAR_SECRET=
# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates
# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait).
CLUSEV_HONEYPOT_CANARY_APP=
CLUSEV_HONEYPOT_CANARY_DB=
CLUSEV_HONEYPOT_CANARY_API=
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,

32
.gitattributes vendored
View File

@ -7,35 +7,5 @@
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
# Internal-only — never published to the PUBLIC repo. promote.sh copies a release tree with
# `git archive`, which honours export-ignore, so these are dropped automatically. CHANGELOG.md is
# deliberately NOT export-ignored: the public Versions page fetches it from the public repo.
/CLAUDE.md export-ignore
/rules.md export-ignore
/handoff.md export-ignore
/kickoff-prompt.md export-ignore
/docs export-ignore
# Release tooling + the dev-only in-dashboard release bridge — private-CI / dev-box only.
/scripts/promote.sh export-ignore
/scripts/promote.test.sh export-ignore
/tests/scripts/promote-guard.test.sh export-ignore
/app/Livewire/Release export-ignore
/app/Services/ReleaseBridge.php export-ignore
/app/Services/ReleasePlanner.php export-ignore
/app/Services/PipelineStatus.php export-ignore
/app/Services/PromotionService.php export-ignore
/resources/views/livewire/release export-ignore
/lang/de/release.php export-ignore
/lang/en/release.php export-ignore
/docker/release export-ignore
# …and their tests (which reference the dev-only classes above). NOTE: ReleaseChecker.php,
# ReleaseCheckerTest.php and VersionUpdateCheckTest.php are PUBLIC (the update-available check) — do NOT ignore them.
/tests/Feature/PipelineStatusTest.php export-ignore
/tests/Feature/PromotionServiceTest.php export-ignore
/tests/Feature/ReleaseBridgeTest.php export-ignore
/tests/Feature/ReleaseGatingTest.php export-ignore
/tests/Feature/ReleasePageTest.php export-ignore
/tests/Unit/ReleasePlannerTest.php export-ignore

View File

@ -1,6 +0,0 @@
self-hosted-runner:
# Custom self-hosted runner label for the internal staging host (.165).
# The runner is not wired up yet; the deploy-staging job is gated behind
# the STAGING_ENABLED repo variable until it is.
labels:
- clusev-staging

View File

@ -1,179 +0,0 @@
name: CI + staging
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: cp .env.example .env && php artisan key:generate
- uses: actions/setup-node@v5
with:
node-version: '20'
# Build the Vite manifest BEFORE the tests: full-page tests render @vite layouts, which 500
# without public/build/manifest.json (it is gitignored, so absent on a fresh CI checkout).
- run: npm ci
- run: npm run build
- run: php artisan test
- run: ./vendor/bin/pint --test
- name: shellcheck
run: |
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
docker/wg/clusev-wg.sh docker/restart-sentinel/watch.sh install.sh update.sh scripts/*.sh tests/scripts/*.sh
- name: Script unit tests
run: |
bash tests/scripts/release-images.test.sh
bash tests/scripts/promote-guard.test.sh
# Build the two prod images and push them to the PRIVATE GHCR, tagged by the version. The promote
# job later copies the exact digests to the public GHCR — the image is only public once the release is.
images:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build + push app image (private GHCR, by version tag)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
- name: Build + push terminal image
uses: docker/build-push-action@v6
with:
context: ./docker/terminal
file: docker/terminal/Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
# Smoke: boot the framework inside the freshly built app image. `route:list` loads config +
# every service provider + registers all routes — so a class stripped by export-ignore that is
# still reachable on boot fails HERE, before the image can ever promote. No DB/Redis needed.
- name: Smoke-boot the app image
run: |
set -euo pipefail
docker run --rm \
-e APP_KEY="base64:$(head -c32 /dev/urandom | base64)" \
-e APP_ENV=production -e CLUSEV_RELEASE_CONTROLS=false \
"ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" \
php artisan route:list --json > /dev/null
echo "smoke: framework booted + routes registered"
# Staging (self-hosted runner) pulls the freshly built private image — the first real exercise of
# the pull path. Only runs when a self-hosted runner is wired up (vars.STAGING_ENABLED).
deploy-staging:
needs: images
if: ${{ vars.STAGING_ENABLED == 'true' }}
runs-on: [self-hosted, clusev-staging]
environment: staging
steps:
- name: Update the staging stack (pull the CI-built image)
env:
STAGING_PROJECT_DIR: ${{ vars.STAGING_PROJECT_DIR }}
CLUSEV_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
CLUSEV_TERMINAL_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
CLUSEV_PULL: '1'
run: |
cd "${STAGING_PROJECT_DIR:?STAGING_PROJECT_DIR not set}"
sudo -E ./update.sh
# Auto-promote to PUBLIC — only a finalized stable tag (no -rc), and only after test + images are
# green (needs + success()). Copies the exact image digests to the public GHCR and publishes a
# Gitea-free code tree; promote.sh's leak-guard is the last line of defence.
promote:
needs: [test, images]
# Stable = a clean vX.Y.Z with NO pre-release suffix. Excludes -rc AND any accidental pre-release suffix.
if: ${{ success() && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
# Route the tag through an env var instead of interpolating ${{ github.ref_name }} straight into
# run: shells (template-injection hardening / actionlint). The "Enforce a clean stable SemVer tag"
# step below still validates $GITHUB_REF_NAME before any step consumes it.
env:
TAG: ${{ github.ref_name }}
steps:
# Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above
# cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish.
- name: Enforce a clean stable SemVer tag
run: |
[[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $GITHUB_REF_NAME"; exit 1; }
- uses: actions/checkout@v5
with:
fetch-depth: 0
path: src
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
# Read-only: resolve the private image digests. No public side-effect yet.
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${TAG}" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${TAG}" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
# Build + guard the public tree LOCALLY. promote.sh refuses on an already-published tag or a
# leak; nothing is published yet, so a refusal cannot leave an orphaned public image.
- name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
run: |
set -euo pipefail
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
# release-images.lock — consumed by sub-project #2 (install/update pull).
printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \
"$TAG" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \
"${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock
git -C pub add -f release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "$TAG"
# Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release
# ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced,
# content-addressed image tags — re-running the promote republishes them idempotently and retries
# the push; it never advertises a release without an image (which the reverse order would risk).
- name: Publish images to public GHCR
run: |
set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}"
# Atomic: main + tag land together, or neither does.
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/${TAG}"

View File

@ -1,81 +0,0 @@
name: Promote to public (manual fallback)
# Primary promotion is the gated `promote` job in ci-staging.yml (auto on a green stable tag). This
# manual dispatch re-runs the same procedure for a specific tag (e.g. after a promote.sh fix). It
# mirrors the auto job: resolve the exact image digests, build+guard the public code tree, publish
# the images, then push atomically. promote.sh refuses if the tag is already public — yank first.
on:
workflow_dispatch:
inputs:
tag:
description: 'Stable tag to (re-)publish (e.g. v0.11.0)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
# The tag reaches every run-step via this env var (never inline `${{ inputs.tag }}` in a shell —
# that would be a command-injection sink for a hand-typed input).
env:
TAG: ${{ inputs.tag }}
steps:
- name: Enforce a clean stable SemVer tag
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $TAG"; exit 1; }
- uses: actions/checkout@v5
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
path: src
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:$TAG" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
APP_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: |
set -euo pipefail
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
printf '{ "version": "%s", "app": "%s", "terminal": "%s" }\n' "$TAG" "$APP_DIGEST" "$TERM_DIGEST" > pub/release-images.lock
git -C pub add -f release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "$TAG"
# Publish AFTER guards, BEFORE the push (publish-then-advertise): a release ref always has an
# image. A push failure leaves only unreferenced idempotent image tags; re-run to recover.
- name: Publish images to public GHCR
env:
APP_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: |
set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:$TAG" "$APP_SRC"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:$TAG" "$TERM_SRC"
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/$TAG"

View File

@ -1,34 +0,0 @@
name: Yank a public release
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to retract from the public repo (e.g. v0.10.0)'
required: true
permissions:
contents: read
jobs:
yank:
runs-on: ubuntu-latest
environment: production
steps:
- name: Validate the tag format
env:
TAG: ${{ inputs.tag }}
run: |
echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "refusing to yank an unrecognised tag: $TAG" >&2; exit 1; }
- name: Checkout public repo
uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Delete the tag on the public repo
env:
TAG: ${{ inputs.tag }}
run: |
git -C pub push origin --delete "refs/tags/${TAG}"

35
.gitignore vendored
View File

@ -14,13 +14,9 @@
.env
.env.*
!.env.example
.env.gitea
*.pem
# Self-update trust anchor: the SSH allowed-signers list update.sh checks the pulled HEAD against.
# MUST stay untracked so a pulled/MITM'd tree can never swap the trusted maintainer key (git pull
# leaves ignored files alone). Provisioned out-of-band; see the signature-verification spec.
/.clusev-allowed-signers
.phpunit.result.cache
Homestead.json
Homestead.yaml
@ -38,32 +34,3 @@ yarn-error.log
# local sqlite (we use MariaDB)
/database/*.sqlite*
# runtime host-bridge signal dir (request/result files written by the dashboard + host watchers);
# the dir itself is kept (bind-mount target) but its runtime contents are never committed
/run/*
!/run/.gitkeep
# host-side update transcript (written at the repo root — NOT under ./run — so a compromised
# container cannot symlink-redirect the root updater's tee; see docker/restart-sentinel/watch.sh)
/update.log
/install.log
# transient status temp files: created at the repo root then atomically renamed into ./run (same fs,
# container-inaccessible) so the root write can't be symlink-redirected — normally gone in an instant
/.phase.*
# internal / AI-collaboration docs — kept on the dev box, never committed, mirrored or shipped
/CLAUDE.md
/rules.md
/handoff.md
/kickoff-prompt.md
/docs/
# The release image pin is written ONLY into the promoted public tree by CI (git add -f there);
# a copy must never be committed to the source tree, or a dev install would wrongly switch to pull.
/release-images.lock
# The marketing site + docs (clusev.com / docs.clusev.com) are a SEPARATE project at ~/clusev-site
# with their own compose — they must NEVER land in the panel repo (else anyone could self-host the
# site). Belt-and-suspenders guard in case site files are ever copied in here by mistake.
/clusev-site/

View File

@ -7,824 +7,12 @@ Alle nennenswerten Änderungen an Clusev. Format nach
**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand
reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer
eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich
getaggte Releases (Kanal `stable`) — niemals Entwicklungs-Builds.
**Was hier steht:** nur, was Nutzer der Installation merken — knapp, ein Eintrag
pro Änderung, gruppiert nach _Hinzugefügt / Geändert / Behoben / Sicherheit_. Rein
interne Arbeit (Wartungs-Tooling, CI, Refactorings, Tests) gehört nicht hierher,
sondern in die git-Historie.
getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Builds.
## [Unreleased]
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.11.0] - 2026-07-05
### Geändert
- **Ein einziger stabiler Release-Kanal.** Nutzer erhalten ausschließlich finalisierte `vX.Y.Z`-Releases;
die interne Release-Steuerung schneidet jetzt Release Candidates statt Vorab-Kanälen.
## [0.11.0-rc1] - 2026-06-26
### Hinzugefügt
- **Web-Terminal.** Vollwertiges SSH-Terminal direkt im Browser — eines pro Server und eines für den
Clusev-Host selbst. Echtes PTY mit Tab-Autovervollständigung; ein eigener Reiter im Menü.
- **Geführte Einführungs-Tour.** Beim ersten Login zeigt ein Spotlight-Rundgang die wichtigsten
Bereiche. Überspringbar und jederzeit in den Einstellungen erneut startbar.
- **Server-Suche im Terminal.** Ab mehreren Servern lässt sich die Zielliste durchsuchen.
- **Eigenes Passwort beim Anlegen eines Kontos.** Beim Hinzufügen eines Admins kann direkt ein
Passwort vergeben werden (leer = sicheres Einmal-Passwort, das einmalig angezeigt wird).
### Geändert
- **Passwortwechsel ist optional.** Der erste Login erzwingt kein neues Passwort mehr — ein
Warnbanner erinnert daran, sperrt aber nicht aus. 2FA war und bleibt optional (nur die
Login-Texte waren irreführend und wurden korrigiert).
- **Passwort-Mindestlänge auf 6 Zeichen** gesenkt, ohne Komplexitätszwang — die Passwortstärke
liegt in der Verantwortung des Nutzers.
- **Zufälliges Erst-Passwort.** Der Installer erzeugt ein zufälliges Einmal-Passwort statt des
festen `clusev` und zeigt es nur einmal an.
- **Installer fragt nicht mehr nach der Domain.** Die Installation läuft über IP/HTTP; die Domain
(mit automatischem HTTPS) wird später im Dashboard unter System gesetzt.
- **2FA-Status in der Seitenleiste** als dezentes Schild-Symbol statt Text-Badge.
### Sicherheit
- **Host-Key-Pinning** für die Terminal-SSH-Verbindungen — eine geänderte Server-Kennung (möglicher
MITM) bricht die Verbindung ab, bevor Zugangsdaten übertragen werden.
- **Proxy-Vertrauen gehärtet:** nur noch private Netze werden vertraut, damit ein gefälschter
`X-Forwarded-For` die IP-basierten Brute-Force-Limits nicht mehr umgehen kann.
## [0.10.0-rc1] - 2026-06-25
### Hinzugefügt
- **Verlaufs-Graf auf der Server-Detailseite.** CPU, RAM und Disk über Zeit — Zeitraum wählbar
(1 h / 24 h / 7 d / 30 d), mit Hover-Tooltip; der gewählte Zeitraum bleibt in der URL erhalten.
- **SSH-Schlüssel benennen.** Beim Hinzufügen eines Schlüssels lässt sich ein Name vergeben, sodass
Schlüssel in der Liste zuordenbar sind.
### Behoben
- **Aussperrschutz beim SSH-Root-Login.** Solange Clusev sich als `root` verbindet, lässt sich
„SSH-Root-Login deaktivieren" nicht mehr ausführen — das hätte den eigenen Zugang gekappt.
- **fail2ban startet zuverlässig.** Die `sshd`-Jail liest jetzt das systemd-Journal statt
`/var/log/auth.log` — so läuft fail2ban auch auf minimalen Images ohne rsyslog an.
## [0.9.68] - 2026-06-23
### Intern
- **Release-Tooling im Dev-Dashboard + CI-Pipeline.** Release Candidate schneiden, Live-Pipeline-Status,
Veröffentlichen / Promote-to-Stable / Yank — für Nutzer der Installation nicht sichtbar.
Details in der git-Historie. (Sammelt die internen Versionen 0.9.590.9.68.)
## [0.9.58] - 2026-06-22
### Hinzugefügt
- **Update-Check unterstützt jetzt GitHub.** Public-Nutzer sehen Updates aus dem öffentlichen
GitHub-Repo (Tags + Changelog); interne Dev/Staging-Stufen bleiben privat.
### Geändert
- **Öffentliche Projekt-Adresse ist jetzt `github.com/clusev/clusev`.** README, Quelltext- und
Konsolen-Branding sowie die Standard-Update-Quelle zeigen aufs öffentliche Repo.
## [0.9.57] - 2026-06-22
### Hinzugefügt
- **Stabiler Release-Kanal `stable`.** Nutzer beziehen ausschließlich stabile Releases (`vX.Y.Z`) —
niemals Entwicklungs-Builds.
## [0.9.56] - 2026-06-22
### Hinzugefügt
- **Branding im Quelltext + in der Browser-Konsole.** Wer den Seitenquelltext ansieht, findet jetzt
einen freundlichen Clusev-Hinweis (wer wir sind, Projektlink) als HTML-Kommentar. Öffnet jemand die
Browser-Konsole, erscheint ein gestyltes „CLUSEV"-Branding mit Projektlink — **und eine
Self-XSS-Sicherheitswarnung**: bei einem Server-Control-Panel ist „Füg das mal in deine Konsole ein"
ein realer Social-Engineering-Angriff, daher der Hinweis genau dort. Erscheint auf jeder Seite (auch
vor dem Login).
### Behoben
- **Datei-Manager: Dateiname im Tablet-Modus vom Rechte-Block überdeckt.** Die Tabellenspalten
(Rechte/Größe/Geändert) + Aktions-Buttons quetschten die Namensspalte auf nahezu null. Die Liste
rendert jetzt **unterhalb großer Bildschirme als gestapelte Karte** (Name in voller Breite, Meta-Zeile
und Aktionen darunter); die Tabellenansicht erscheint erst auf breiten Displays. Die mobile Ansicht
wurde dabei mit aufgeräumt.
- **Zeiger-Cursor auf Buttons fehlte.** Tailwind v4 stellt Buttons standardmäßig auf den
Standard-Cursor; auf anklickbaren Elementen (z. B. Ordnernamen, alle Buttons) erscheint jetzt wieder
der Hand-Zeiger.
### Behoben
- **Datei-Manager: „undefined is not valid JSON" beim Öffnen mancher Dateien.** Öffnete man eine
**Binär-/Nicht-UTF-8-Datei**, landeten deren Rohbytes in einer Livewire-Eigenschaft. Livewire
JSON-kodiert seinen Zustand, und ungültiges UTF-8 lässt `json_encode` scheitern → der Client bekam
eine leere Antwort und warf den Fehler. Binär-/zu-große Dateien werden jetzt nicht mehr als Inhalt
gebunden (es erscheint der Binär-Hinweis), sodass der Zustand immer gültiges JSON bleibt — sowohl
im Editor als auch in der zugrunde liegenden Datei-Lese-Funktion abgesichert.
### Behoben
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
ohne stabilen `wire:key` ordnete Livewires DOM-Abgleich die Zeilen beim Neu-Rendern nach Position
zu und verlor dabei den Alpine-Zustand der Buttons (Modal-Auslöser) — die Konsole warf „pending is
not defined" und Aktionen klemmten. Alle betroffenen Listen haben jetzt einen stabilen `wire:key`:
Datei-Manager (Einträge + Pfad), Dienste, Release-Kanal-Auswahl, Benutzer, Sicherheitsschlüssel,
IP-Sperren sowie die Server-Detailseite (Härtung, Firewall-Regeln, SSH-Schlüssel, fail2ban-Ausnahmen).
### Behoben
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard
wurden noch als `wg.set-endpoint` / `deploy.update_request` angezeigt (die /audit-Seite war bereits
lesbar). Jetzt zeigt auch das Dashboard die verständlichen Bezeichnungen, und fehlgeschlagene /
sicherheitsrelevante Ereignisse erscheinen rot mit Warn-Symbol — konsistent mit der Audit-Seite.
### Behoben
- **„Was ist neu" wurde nicht angezeigt.** Der Vorschau-Block lud die `CHANGELOG.md` am Tag `0.9.50`,
die Release-Tags heißen aber `v0.9.50` — der Abruf lief ins Leere (404) und der Block blieb leer.
Jetzt wird die richtige `v`-präfixierte Tag-Referenz verwendet; die Änderungen der verfügbaren
Version erscheinen wieder vor dem Update.
### Hinzugefügt
- **Update-Hinweis in der Seitenleiste.** Ist eine neue Version verfügbar, zeigt der Menüpunkt
**Version** jetzt ein kleines **„1"-Abzeichen** — man sieht ein Update also auf jeder Seite, nicht
erst auf der Version-Seite. Der Status wird von einer geplanten Prüfung (alle 30 Min, anonym und
nur lesend gegen den Git-Host) im Cache warm gehalten; die Seitenleiste macht selbst keinen Netzabruf.
### Geändert
- Die Release-Prüfung (Version-Seite, Seitenleisten-Abzeichen, geplante Prüfung) nutzt jetzt einen
gemeinsamen `ReleaseChecker`-Dienst (vorher in der Version-Seite verkapselt).
## [0.9.50] - 2026-06-21
### Hinzugefügt
- **Audit-Log mit Seiten-Navigation.** Das Log war auf die letzten 50 Einträge begrenzt; jetzt ist es
paginiert (25 pro Seite), die aktuelle Seite steht in der URL (`?page=`) und ist verlinkbar.
### Geändert
- **Audit-Suche durchsucht den gesamten Verlauf.** Die Filterung läuft jetzt in der Datenbank über
alle Einträge (nicht nur die geladene Seite) — über Akteur, Aktion, Ziel, Server **und** die
lesbaren Bezeichnungen (z. B. findet „Endpoint geändert" die `wg.set-endpoint`-Einträge).
### Hinzugefügt
- **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite
jetzt ein „Das ist neu in vX.Y.Z"-Feld mit dem Änderungsprotokoll der **verfügbaren** Version(en) —
geladen aus der `CHANGELOG.md` des neuesten Release-Tags. So sieht man **vor** dem Aktualisieren,
was sich ändert, und muss das Update nicht erst durchführen. (Anonym + nur lesend, kurz gecacht;
bei fehlender Verbindung bleibt der Update-Knopf unverändert nutzbar.)
### Geändert
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
das Audit-Log jetzt verständliche Beschreibungen („Administrator · WireGuard-Endpoint geändert").
Alle Aktionen sind übersetzt (DE/EN); unbekannte/dynamische Codes werden sauber aufbereitet. Die
Suche findet auch die lesbaren Bezeichnungen.
### Hinzugefügt
- **Fehler im Audit-Log sichtbar.** Fehlgeschlagene oder sicherheitsrelevante Ereignisse (fehlge­schlagene
Anmeldung, IP-Sperre, fehlgeschlagene 2FA …) werden jetzt **rot mit Warn-Symbol** hervorgehoben.
Zusätzlich landen **fehlgeschlagene WireGuard-Aktionen** mit der Host-Fehlermeldung im Audit-Log
(statt nur als kurzer Hinweis), sodass man später nachlesen kann, was schiefging.
### Geändert
- **Endpoint-Feld ist jetzt „nur Host".** Es wirkte, als müsse man den Port zweimal eintragen (einmal
als Listen-Port, einmal im Endpoint). Tatsächlich genügt der Host: Label heißt jetzt „Öffentlicher
Endpoint (Host)", der Platzhalter zeigt nur den Host (ohne `:Port`), und der Hinweis erklärt, dass
der Port automatisch vom Listen-Port kommt. Ein abweichender Port ist nur bei Portweiterleitung
nötig (`host:port`). Funktion war seit 0.9.43 schon so — jetzt ist es auch in der Oberfläche klar.
### Behoben
- **Traffic-Kopfzeile zeigte 0 B, obwohl der Peer Traffic hatte.** „Empfangen/Gesendet" oben speiste
sich aus dem Sample-Durchsatz (0, solange wenig Traffic floss), während die Peer-Zeile den
Live-Zähler zeigte — inkonsistent. Die Kopfzeile zeigt jetzt die **Live-Summe der Peer-Zähler**
(passt damit immer zur Peer-Zeile). Das Diagramm darunter bleibt der zeitfenster-bezogene Durchsatz
(füllt sich über den minütlichen Sampler, sobald spürbarer Traffic fließt).
### Geändert
- **Klarstellung QR-Import am Handy.** Der Hinweis sagt jetzt eindeutig: die WireGuard-App fragt beim
QR-Scan **immer** nach einem Tunnel-Namen (App-Vorgabe, nicht abschaltbar). Für einen automatischen
Namen die `.conf` **herunterladen** und importieren (Dateiname = Tunnel-Name).
### Hinzugefügt
- **DNS individuell einstellbar.** Der DNS-Server in den Client-Configs war fest auf `1.1.1.1`. Jetzt
in der Server-Konfiguration frei wählbar (z. B. dein eigenes Gateway `10.0.0.1`, mehrere
kommagetrennt) — gilt für künftige Peer-Configs, host-seitig validiert (nur IPv4). Auch per CLI:
`clusev wg set-dns <ip[,ip]>`. Bestehende Tunnel bekommen den Wert beim ersten Setzen ergänzt.
- **WireGuard-Seite in Tabs aufgeteilt.** Zwei unabhängige Reiter: **Peers** (Liste, Anlegen,
Entfernen, Traffic) und **Server-Konfiguration** (Endpoint, DNS, Port, Subnetz, Gate, SSH-Sperre +
Server-Identität). Der aktive Tab ist deep-linkbar (`?tab=server`). So lassen sich Server-Einstellungen
getrennt von der Peer-Verwaltung ändern.
### Geändert
- **Offline-Erkennung schneller.** Das Handshake-Fenster für „online" wurde von 180 s auf 150 s
gesenkt — ein abgeschalteter Peer wird ~2,5 statt ~3 min nach dem Trennen offline (WireGuard ist
verbindungslos, schneller geht es ohne Flackern aktiver Peers nicht).
- **Endpoint-Hinweis klargestellt:** der Port ist optional (fehlt er, wird der Listen-Port ergänzt) —
man muss ihn nicht doppelt zum Listen-Port eintragen.
### Geändert
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
damit das Scannen am Handy zuverlässig bleibt.
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
`<endpoint-host>-<peer>.conf` (z. B. `203.0.113.10-laptop.conf`) statt `clusev-wireguard.conf`.
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
### Behoben
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein
Host ohne Port eingegeben (z. B. `203.0.113.10` statt `203.0.113.10:51820`), landete dieser
Wert unverändert in den Peer-Configs — WireGuard verlangt aber zwingend `host:port`, sodass die
WireGuard-App den (QR- oder Datei-)Import als „keine gültige WireGuard-Konfiguration" ablehnte.
Der Listen-Port wird jetzt automatisch ergänzt, wenn der Endpoint keinen Port enthält (sowohl bei
der Einrichtung als auch beim späteren Ändern). Bestehende Konfigurationen: einmal den Endpoint
unter Einstellungen neu speichern (oder mit Port angeben), dann einen neuen Peer anlegen.
### Behoben
- **Peer-QR-Code ließ sich am Handy nicht scannen („keine gültige WireGuard-Konfiguration").** Der
QR einer WireGuard-Client-Config ist dicht (~57 Module); er wurde im Einmal-Dialog mit nur 176 px
angezeigt (~2,7 px pro Modul) und kantengeglättet — zu klein/unscharf, sodass die Handy-Kamera ihn
fehlerhaft las. Der QR wird jetzt deutlich größer (bis ~300 px) auf weißem Rahmen mit Ruhezone und
pixelgenau (`shape-rendering: crispEdges`) gerendert. Alternativ bleibt der **Herunterladen**-Knopf
(Config als `.conf` am PC importieren).
### Behoben
- **WireGuard-Aktionen aus dem Dashboard liefen auf dem Host ins Leere („Keine Antwort vom Host").**
Der Host-Dienst, der die Dashboard-Anfragen verarbeitet (`clusev-wg.sh serve-request`), brach unter
`set -euo pipefail` ab, **bevor** er eine Antwort schrieb — sobald ein Feld leer oder gar nicht
vorhanden war: ein leeres Endpoint-Feld bei der Einrichtung („auto"), oder Aktionen ohne
bestimmte Felder (Gate ein/aus, Peer anlegen/entfernen). Dadurch wartete die Oberfläche ~30 s und
meldete „Keine Antwort vom Host", obwohl der Watcher korrekt lief. Zwei Ursachen behoben:
(1) die Feld-Extraktion per `grep` bricht bei fehlendem Treffer nicht mehr den ganzen Handler ab;
(2) jede Aktion läuft jetzt in einer Subshell, sodass ein interner Abbruch (`die` bei noch nicht
eingerichtetem Tunnel) immer eine saubere Fehlerantwort liefert statt eines Timeouts. Damit
funktionieren Einrichtung, Peer-Verwaltung, Gate und SSH-Sperre aus dem Dashboard auf einem echten
Host. Neuer Smoke-Test (`docker/wg/serve-request.test.sh`) sichert das ab.
## [0.9.40] - 2026-06-21
### Hinzugefügt
- **Peer-Konfiguration herunterladen.** Die Einmal-Anzeige eines neuen Clients hat neben dem
QR-Code jetzt einen **Herunterladen**-Knopf: Die Konfiguration lässt sich als
`clusev-wireguard.conf` speichern und am PC in die WireGuard-App importieren — alternativ zum
Scannen des QR-Codes am Handy. Ein Hinweis erklärt beide Wege direkt in der Anzeige.
- **SSH-Sperre aus dem Dashboard (Port 22).** In den WireGuard-Einstellungen lässt sich SSH
optional **zusätzlich** auf den Tunnel sperren — danach ist Port 22 nur noch über WireGuard bzw.
die Server-Konsole erreichbar. Die Aktion ist mit einer deutlichen **Aussperr-Warnung** versehen
(Inline-Hinweis **und** Bestätigungsdialog): Wer seinen WG-Zugang verliert, kommt per SSH nicht
mehr auf den Host — Empfehlung, vorher einen zweiten WG-Peer als Backup-Zugang anzulegen. Auf das
Dashboard kommt man auch ohne SSH weiter über den Tunnel. Bestehende SSH-Sitzungen bleiben
verbunden; ein nicht laufender Tunnel verhindert das Aktivieren. Neue CLI-Notausgänge:
`clusev wg ssh-lock` / `clusev wg ssh-unlock`, und `clusev wg down` (Server-Konsole) hebt alle
Sperren auf. Die Sperre übersteht Neustarts nur bei aktivem Tunnel — kommt wg0 nicht hoch, bleibt
SSH offen (kein Aussperren durch einen kaputten Boot).
## [0.9.39] - 2026-06-21
### Geändert
- **WireGuard-Einrichtung: SSH-Hinweis von der Seite entfernt.** Das `sudo clusev wg setup` steht
jetzt nur noch in der Hilfe — die Einrichtung läuft über das Dashboard-Formular.
### Behoben
- **Kein ewiges „Wird angewendet …" mehr.** Antwortet der Host-Dienst nicht (z. B. weil der Stack
noch nicht auf eine Version mit dem Host-Watcher aktualisiert wurde), bricht die Dashboard-Aktion
nach ~30 s mit einer klaren Meldung ab, statt unbegrenzt zu warten. (Hinweis: Peer-Verwaltung +
Einrichtung aus dem Dashboard benötigen den per `sudo clusev update` installierten Host-Watcher —
auf einem reinen Dev-Stack ohne diesen läuft die Brücke nicht.)
## [0.9.38] - 2026-06-21
### Hinzugefügt
- **WireGuard-Erst-Einrichtung aus dem Dashboard.** Der „nicht eingerichtet"-Zustand auf
`/wireguard` ist jetzt ein Einrichtungs-Formular (Subnetz / Port / Endpoint / erster Client):
WireGuard lässt sich **vollständig aus der UI** aufsetzen, ohne SSH. Der Host prüft das Subnetz
host-seitig auf Kollisionen, erzeugt die Schlüssel, startet den Tunnel und gibt die Konfiguration
des ersten Clients als Einmal-Anzeige (Text + QR) zurück. Das Gate bleibt dabei **aus** (kein
Aussperren). Damit ist WireGuard zu 100 % aus dem Dashboard steuerbar — Live-Status, Traffic,
Peers, Einstellungen, Gate **und** Erst-Einrichtung; `sudo clusev wg setup` bleibt als Alternative.
### Behoben
- Korrigiert, dass die Erst-Einrichtung in 0.9.37 fälschlich nur über die CLI möglich war.
## [0.9.37] - 2026-06-21
### Hinzugefügt
- **WireGuard-Server-Einstellungen + Gate aus dem Dashboard (SP2, Phase 4 — SP2 abgeschlossen).** Auf
`/wireguard` lassen sich jetzt das **Gate ein-/ausschalten** (mit Bestätigung — aus = Panel wieder
öffentlich, ein = nur über den Tunnel; SSH-Notausgang bleibt) sowie der **öffentliche Endpoint**,
der **Listen-Port** und das **Subnetz** ändern. Port- und Subnetz-Änderungen sind destruktiv
(Tunnel-Neustart bzw. Re-Adressierung — Clients/Peers müssen neu) und erfordern eine harte
Bestätigung; das Subnetz wird host-seitig auf Kollisionen geprüft. Alle Mutationen laufen über die
abgesicherte Schreib-Brücke (Whitelist + erneute host-seitige Validierung jedes Werts), sind
auditiert und ratenbegrenzt. Damit ist WireGuard vollständig aus dem Dashboard verwaltbar:
Live-Status, Traffic-Verlauf, Peer-Verwaltung und Server-Einstellungen.
## [0.9.36] - 2026-06-21
### Hinzugefügt
- **WireGuard-Peer-Verwaltung aus dem Dashboard (SP2, Phase 3).** Auf `/wireguard` lassen sich Clients
jetzt **anlegen** (Name eingeben → die Client-Konfiguration + QR-Code erscheinen **einmalig** in
einem Dialog und werden nie gespeichert) und **entfernen** (mit Bestätigung). Umgesetzt über eine
neue, abgesicherte **Schreib-Brücke**: die App schreibt eine validierte Anfrage nach
`run/wg-request.json`, ein Host-Watcher (`clusev-wg.sh serve-request`) prüft Aktion + Argumente
**erneut host-seitig** (Whitelist, strikte Muster — Container-Eingaben sind Daten, nie Code),
führt den festen Befehl als root aus und schreibt ein Ergebnis (0600, app-eigen, nach 60 s gelöscht).
Jede Aktion ist auditiert + pro Benutzer ratenbegrenzt.
### Sicherheit
- Ergebnis-/QR-Dateien (enthalten einen frischen privaten Schlüssel) werden via `umask 077` von Beginn
an mit `0600` erzeugt — kein kurzes world-readable-Fenster (durch ein adversariales Security-Review
gefunden + behoben).
## [0.9.35] - 2026-06-21
### Hinzugefügt
- **WireGuard-Traffic-Verlauf (SP2, Phase 2).** Die `/wireguard`-Seite zeigt jetzt einen
Durchsatz-Graphen (Empfangen/Gesendet) über ein wählbares Zeitfenster (1 Std / 24 Std / 7 Tage).
Ein periodischer Befehl (`clusev:wg-sample`, minütlich) speichert pro Peer Traffic-Stichproben in
der Datenbank; der Graph aggregiert daraus den Durchsatz (Deltas, Zähler-Resets sauber abgefangen)
und hält 7 Tage Historie vor. Server-seitig gerendert (SVG, kein JS-Chart) — passt zum bestehenden
Dashboard-Chart-Stil.
- **Scheduler-Dienst.** Der Prod-Stack führt jetzt den Laravel-Scheduler aus (`schedule:work`) —
Voraussetzung für die Traffic-Stichproben und der Auslöser für die bereits vorhandene tägliche
Audit-Log-Bereinigung (`clusev:prune-audit`), die zuvor mangels Scheduler nie lief.
## [0.9.34] - 2026-06-20
### Hinzugefügt
- **WireGuard-Dashboard — Live-Status (SP2, Phase 1).** Neue Seite `/wireguard` (Reiter „WireGuard"
in der Flotte) zeigt den Tunnel live: verbundene Peers (Name, online/offline, letzter Handshake,
Endpoint, ↓/↑-Traffic), Gate-Zustand (an/aus + SSH-Notausgang-Hinweis) und Server-Info (Subnetz,
Server-IP, Port, Endpoint, Dienst). Gespeist von einem periodischen Host-Collector
(`clusev wg collect` via systemd-Timer → `run/wg-status.json`), alle 5 s aktualisiert; ein veralteter
Status (Collector offline) wird als solcher angezeigt. Read-only — Peer-Verwaltung + Einstellungen
aus der UI folgen in den nächsten Phasen. Erscheint, sobald WireGuard auf dem Host via
`sudo clusev wg setup` eingerichtet ist.
## [0.9.33] - 2026-06-20
### Hinzugefügt
- **WireGuard-Zugang (SP1) — Panel hinter einem Tunnel.** Optionaler Netzwerk-Schutz *zusätzlich* zu
2FA und Anmeldeschutz: Der Server wird zum WireGuard-Server, und die Panel-Ports 80/443 lassen sich
auf das WG-Subnetz beschränken — komplett über die Host-CLI
`clusev wg setup|up|down|status|add-peer|remove-peer` (per SSH). Standardmäßig **aus**. Sicherheit:
SSH (22) und der WG-Port sind von der Sperre nie betroffen, `clusev wg down` (per SSH) ist der
Notausgang, und ein nach Neustart fehlgeschlagenes `wg0` lässt das Panel offen statt auszusperren.
Die Sperre ist eine isolierte iptables-Chain (`CLUSEV-WG-GATE`) an `DOCKER-USER`; eine systemd-Unit
wendet sie nach Boot/Docker-Neustart wieder an — nur wenn Tunnel **und** Marker aktiv sind.
`install.sh` installiert `wireguard-tools`+`qrencode` (inert, bis der Operator `clusev wg setup`
ausführt). Neuer Hilfe-Eintrag „WireGuard-Zugang" (DE+EN).
*Rein host-seitig (WireGuard/Firewall laufen außerhalb des Containers). Die Shell-Skripte sind
shellcheck-sauber und mehrfach reviewt, aber nicht durch die PHPUnit-Suite testbar — die Laufzeit
(setup/up/down/Gate) ist per Runbook auf einer VM zu verifizieren (`docs/superpowers/runbooks/`).*
## [0.9.32] - 2026-06-20
### Hinzugefügt
- **Serie & Seite als URL-Parameter.** Die gewählte Versionsreihe und die Seite des Änderungs­
protokolls stehen jetzt in der Adresse (`?series=0.8&page=2`) — teilbar und per Reload/Zurück-Taste
erhalten.
- **Echter Update-Fortschritt statt Zeit-Schätzung.** Der Host-Updater meldet seine aktuelle Phase
über eine Datei (`run/update-phase.json`); die Update-Seite liest sie und schaltet die Schritte
**abrufen → Image bauen → Dienste neu starten → migrieren** der Reihe nach echt weiter, statt alle
vier am Ende auf einmal grün springen zu lassen. Abschluss erfolgt über das reale „done"-Signal des
Updaters (Fallback: Versions-Wechsel). Greift — wie der 502-Fix — erst beim Update, das von einer
bereits mit 0.9.32 laufenden Instanz gestartet wird (die während eines Updates angezeigte Seite ist
stets noch die der alten Version); ohne Feed bleibt die bisherige Zeit-Heuristik als Fallback.
### Geändert
- Update-Phasen in realer Reihenfolge (Dienste-Neustart vor Datenbank-Migration).
## [0.9.31] - 2026-06-20
### Geändert
- **Versions-Verlauf als Serien-Browser statt langer Liste.** Releases sind jetzt nach
Hauptversion (major.minor, z. B. „0.9") gruppiert: eine Serien-Navigation (ab großen Bildschirmen
eine seitliche Leiste, darunter horizontale Pills) wählt die Reihe, rechts erscheinen deren
Releases als Accordion (das neueste offen) mit **Seitenblätterung** — kein endloses Scrollen über
alle Versionen mehr. Die installierte Reihe ist markiert, jede Zeile zeigt die Änderungs-Kategorien
als farbige Punkte auf einen Blick.
### Behoben
- **Aufklapp-Pfeil & „Weiter"-Knopf sichtbar.** Das `chevron-right`-Icon fehlte im Icon-Satz, sodass
der Accordion-Pfeil jeder Zeile und der „Weiter"-Knopf der Seitenblätterung als leeres SVG
gerendert wurden (unsichtbare Bedienelemente). Icon ergänzt.
- **Aufgeklappter Eintrag bleibt offen.** Ein selbst aufgeklapptes Release wird nicht mehr durch
einen Hintergrund-Re-Render (Update-Prüfung) wieder zugeklappt (`wire:ignore.self`).
- **Touch-Zielgrößen.** Serien-Pills und Seiten-Knöpfe erfüllen auf kleinen Bildschirmen jetzt die
44-px-Touch-Mindestgröße.
## [0.9.30] - 2026-06-20
### Behoben
- **Update löst keinen 502 mehr aus.** Die Fortschritts-Seite erkannte „fertig" bislang an zwei
aufeinanderfolgenden `200` auf der Health-Route `/up` — doch der **alte** Container antwortet dort
während des minutenlangen Neuaufbaus weiter mit `200`, sodass zu früh zurückgeleitet wurde,
mitten in den Stack-Neustart hinein → 502/Weißbild. Die Seite pollt jetzt einen schlanken
Versions-Endpoint (`/version.json`) und gilt erst dann als fertig, wenn die **laufende Version
tatsächlich über die Ausgangsversion hinaus** gewechselt ist (bzw. nach einem beobachteten
down→up-Zyklus bei gleicher Version). Erst dann erfolgt der automatische Rücksprung.
## [0.9.29] - 2026-06-20
### Geändert
- **Versions-Verlauf als Accordion.** Das Änderungsprotokoll zeigt pro Eintrag nur noch
Versionsnummer + Datum; ältere Versionen sind eingeklappt und öffnen per Klick, die neueste ist
immer offen — bei vielen Releases deutlich übersichtlicher.
- **„Update verfügbar"-Karte responsive entstaucht** — auf schmalen Bildschirmen stapeln Version,
Badges, Aktion (Button volle Breite) und Prüfzeitpunkt sauber untereinander statt gequetscht.
- **Professionellere Phasen-Beschriftung** auf der Update-Fortschritts-Seite („Update wird
abgerufen / Image wird erstellt / Datenbank wird migriert / Dienste werden neu gestartet").
### Hinzugefügt
- **Direkter Server-Link im Dashboard** — Name und IP der aktiven Maschine verlinken jetzt direkt
auf deren Detailseite (vorher: erst „Server", dann auswählen).
## [0.9.28] - 2026-06-20
### Behoben
- **Update bricht nicht mehr mit 502 / Weißbild ab.** Beim Auslösen eines Updates erscheint jetzt
eine eigenständige Fortschritts-Seite (`/update-progress`, ohne Livewire), die den Neuaufbau des
Stacks im Browser übersteht: sie pollt die Health-Route, zeigt die Phasen
(Holen/Bauen/Migrieren/Neustart) + verstrichene Zeit und leitet nach der Wiederherstellung
automatisch dorthin zurück, wo man war. (Open-Redirect-sicher: das Rücksprung-Ziel wird auf einen
gleich-origin Relativpfad beschränkt.)
- **Checkbox im Reiter „Anmeldeschutz"** nutzt jetzt das Design-System (Signal-Orange + sichtbarer
Haken über eine wiederverwendbare `x-checkbox`-Komponente; ebenso „Angemeldet bleiben" im Login)
statt der blauen Browser-Standard-Checkbox.
- **2FA-Status-Badge vereinheitlicht.** Identity-Header, Benutzer-Liste und Sidebar zeigen den
„2FA aktiv/aus"-Status jetzt im selben Stil (eine `x-two-factor-badge`-Komponente) statt zuvor
drei verschiedener Darstellungen.
### Hinweis
- **Security-Key (WebAuthn/YubiKey) erfordert eine Domain mit HTTPS.** Bei Bare-IP-Zugriff ohne
konfigurierte Domain ist ein Security-Key nicht nutzbar (eine IP kann keine WebAuthn-rpId sein);
ein Schlüssel-only-Konto wird dann korrekt auf den Backup-Code geleitet. Für den
Security-Key-Button das Panel unter einer Domain über HTTPS betreiben.
## [0.9.27] - 2026-06-20
### Hinzugefügt
- **Anmeldeschutz — Brute-Force-Sperre für das Panel-Login selbst.** Nach zu vielen fehlgeschlagenen
Anmelde- oder 2FA-Versuchen wird die Quell-IP zeitlich gesperrt (Standard: 10 Versuche in 10 Min →
60 Min Sperre). Durchgesetzt auf HTTP-Ebene für **nicht angemeldete** Anfragen — eingeloggte
Operatoren werden nie geblockt und können ihre eigene IP weiterhin im Panel entsperren. (Eigenständig
von der Flotten-fail2ban-Verwaltung, die entfernte Server härtet.)
- **Neuer Einstellungs-Reiter „Anmeldeschutz"** (Einstellungen → Anmeldeschutz): An/Aus, max. Versuche,
Zeitfenster, Bann-Dauer, Whitelist (IP/CIDR), Liste aktiver Sperren mit Entsperren / Alle entsperren,
plus „Meine IP zur Whitelist hinzufügen".
- **Host-Befehl `clusev unban <ip>` (`--all`)** als Notfall-Ausweg, falls man sich aussperrt — analog
zu `clusev reset-admin`.
- Fehlgeschlagene Logins und 2FA-Versuche sowie Sperren/Entsperrungen landen jetzt im **Audit-Log**
(`auth.login_failed`, `auth.2fa_failed`, `auth.ip_banned`, `auth.ip_unbanned`).
### Sicherheit
- Loopback und private Netze sind standardmäßig ausgenommen (gegen Selbst-Aussperrung). Der
IP-Vergleich erfolgt kanonisch (inet_pton; IPv4-mapped-IPv6 wird auf die IPv4-Form zusammengefasst),
damit eine Sperre nicht über zwei Schreibweisen derselben Adresse umgangen werden kann. Sperren sind
immer zeitlich begrenzt — es gibt keinen permanenten Lockout.
## [0.9.26] - 2026-06-20
### Hinzugefügt
- **Eigene Backup-Code-Seite im Zwei-Faktor-Login** (`/two-factor-challenge/backup`). Über den Button
„Backup-Code verwenden" auf dem Bestätigungs-Screen erreichbar; trennt den seltenen
Wiederherstellungs-Fall sauber vom Alltags-Login.
### Geändert
- **Der 2FA-Bestätigungs-Screen zeigt nur noch die primäre Methode** (Authenticator-Code *oder*
Security-Key) — der Backup-Code ist nicht mehr inline neben der Eingabe, sondern auf der eigenen Seite.
- **Security-Key-only ohne Secure-Context** (bare-IP/HTTP, wo WebAuthn nicht läuft): der Login leitet
direkt zur Backup-Code-Seite weiter, statt einen Screen ohne nutzbare Methode zu zeigen.
- Gemeinsame Challenge-Logik (Pending-User, Brute-Force-Zähler, Login-Abschluss) in einen Trait
ausgelagert; beide Ansichten teilen denselben Topf — das Backup-Code-Rate-Limit (5/60 s pro
Nutzer+IP, 20/900 s pro Nutzer) gilt unverändert auch auf der neuen Seite.
## [0.9.25] - 2026-06-19
### Hinzugefügt
- **Host-Befehl `clusev`** (vom Installer nach `/usr/local/bin` gelegt): kurze Wrapper für
`update`, `reset-admin`, `restart`, `logs`, `ps`, `migrate`, `artisan`, `version`. Statt langer
`docker compose`-Aufrufe mit der Prod-Compose-Datei genügt z. B. `sudo clusev update` oder
`clusev reset-admin`. `clusev help` zeigt die Übersicht.
- **Hilfe-Thema „Befehle / CLI" (DE/EN):** dokumentiert jeden `clusev`-Befehl ausführlich und zeigt,
was im Hintergrund läuft.
### Geändert
- **Hilfe-Tabs stehen in der URL** (`#[Url]` am Thema) — Reload/Lesezeichen/Teilen behalten das aktive
Tab, wie bei den Einstellungen.
- Alle Operator-Flächen (Update-Hinweis unter „Version & Releases", Hilfe-Wiederherstellung/-Updates/
-Domain-TLS, Reset-Hinweise in Einstellungen/System, Sentinel-Fehlermeldungen, MOTD) zeigen jetzt die
kurzen `clusev …`-Befehle; der lange Compose-Dateiname taucht in der Oberfläche nicht mehr auf.
## [0.9.24] - 2026-06-19
### Hinzugefügt
- **In-Panel-Hilfe-Seite (`/help`, Nav „Hilfe", `g h`).** Linke Themen-Navigation wie die
Einstellungen, zweisprachig (DE/EN, folgt dem Umschalter). Themen: Überblick, Domain/TLS &
Reverse-Proxy, Sicherheit & 2FA, Updates & Versionen, Server & SSH, Sitzungen & Benutzer, E-Mail,
Audit-Log, Konto-Wiederherstellung. Enthält u. a. eine **generische Schritt-für-Schritt-Anleitung
für den Betrieb hinter einem externen Reverse-Proxy** (was im Proxy eintragen: HTTP-Ziel,
`X-Forwarded-Proto: https`, WebSocket, `TRUSTED_PROXY_CIDR`) und den **2FA-Zugangspfad-Hinweis**
(Security-Key nur über die HTTPS-Domain; über Bare-IP/HTTP Backup-Code nötig; TOTP überall).
### Geändert
- TLS-Modus-Hinweis nennt keinen Produktnamen mehr — „ein vorgelagerter Proxy Manager terminiert TLS".
## [0.9.23] - 2026-06-19
### Behoben
- **Konsolen-Warnung „resource was preloaded but not used".** Vite legte zu jeder Seite einen
`<link rel="preload">` für CSS/JS zusätzlich zum normalen Stylesheet/Script an. Bei nur einem
CSS- und einem JS-Bundle bringt der Preload nichts (die Tags stehen ohnehin direkt daneben), löste
aber Chromes „preloaded but not used"-Warnung aus — verstärkt durch DevTools „Cache deaktivieren",
das die CSS doppelt lädt. Die Preload-Hints werden jetzt weggelassen (`Vite::usePreloadTagAttributes(false)`);
Stylesheet und Script laden unverändert.
## [0.9.22] - 2026-06-19
### Behoben
- **Passwortmanager (Bitwarden) kaperte die Security-Key-Registrierung.** Trotz `cross-platform`
fing Bitwardens Browser-Erweiterung `navigator.credentials.create` ab und bot „Passkey speichern"
an. Die Optionen enthalten jetzt zusätzlich den **WebAuthn-L3-Hint `hints: ['security-key']`** (bei
Registrierung und Login), den moderne Browser/Manager respektieren und daher beiseite treten — der
YubiKey wird direkt abgefragt. (Ältere Bitwarden-Versionen, die den Hint nicht kennen, kapern
evtl. weiter — dann in Bitwarden „Nach Passkeys fragen" für die Seite/global deaktivieren.)
## [0.9.21] - 2026-06-19
### Behoben
- **Security-Key-Registrierung zielte nicht auf einen Hardware-Schlüssel** → der Browser bot das
Passkey-Speichern (z. B. Bitwarden) an statt den YubiKey abzufragen. Die Registrierung setzt jetzt
`authenticatorSelection`: **cross-platform** (externer/roaming Schlüssel statt Plattform-/
Passwortmanager-Passkey), **residentKey discouraged** (nicht-auffindbarer Zweitfaktor — genau das,
was die Passkey-Manager-UI vermeidet) und **userVerification discouraged** (nur Anwesenheit = ein
Tippen, kein PIN/Biometrie). Login fragt den Schlüssel ebenfalls mit „discouraged" ab → **einstecken,
einmal tippen, fertig**.
## [0.9.20] - 2026-06-19
### Behoben
- **Security-Keys (WebAuthn/YubiKey) waren hinter einem externen TLS-Proxy gesperrt.** Die
Verfügbarkeit hing an `request()->isSecure()` — das im Externer-Proxy-Modus `false` ist (der
weitergereichte HTTPS-Scheme wird ohne `TRUSTED_PROXY_CIDR` nicht vertraut), obwohl das Panel
über die Domain per HTTPS läuft. Security-Keys werden jetzt freigeschaltet, sobald das Panel über
die **aktive Domain** erreicht wird (immer HTTPS über die Vordertür), unabhängig vom hier
erkannten Request-Scheme. Der Bare-IP-Recovery-Pfad (kein Secure-Context) bleibt korrekt gesperrt.
Ein Kern-Sicherheitsfeature hängt damit nicht mehr an einer optionalen Proxy-Einstellung.
## [0.9.19] - 2026-06-19
### Behoben
- **Caddyfile-Änderungen wurden bei einem Update nicht übernommen.** Die Caddy-Config war als
**einzelne Datei** gemountet; ein `git pull` ersetzt die Datei (neue Inode), aber der laufende
Container hängt weiter an der alten — und `docker compose up -d` baut Caddy bei reiner
Inhaltsänderung nicht neu. Dadurch kamen Caddy-Fixes (z. B. der WebSocket-Fix aus 0.9.18) ohne
manuelles Eingreifen nie an. Jetzt wird das **Verzeichnis** `docker/caddy` gemountet (spiegelt die
Datei live) und `install.sh` baut den Caddy-Container bei jedem Deploy gezielt neu
(`up -d --force-recreate caddy`), sodass Caddyfile-Änderungen zuverlässig wirksam werden.
## [0.9.18] - 2026-06-19
### Behoben
- **Realtime (WebSocket) hinter einem externen TLS-Proxy lief nicht.** Der Echo/Reverb-Endpunkt
wurde aus dem (hinter dem Proxy als `http:80` erscheinenden) Request abgeleitet → `wss://host:80`,
was scheiterte. Bei aktiver Domain wird der Reverb-Client jetzt fest auf **`wss://<domain>:443`**
(die HTTPS-Vordertür) gesetzt; der Bare-IP-Recovery-Pfad bleibt einfaches `ws`. Zusätzlich leitet
Clusevs Caddy die WS-Pfade (`/app/*`, `/apps/*`) **nicht mehr auf https um, wenn der Upstream
bereits TLS terminiert hat** (`X-Forwarded-Proto: https`) — sonst brach der Redirect die
WebSocket-Verbindung im Externer-Proxy-Modus.
- **TLS-Status zeigte im Externer-Proxy-Modus fälschlich „Let's Encrypt".** Die „TLS aktiv"-Karte
meldet jetzt korrekt „TLS aktiv — über externen Reverse-Proxy; das Panel stellt kein Zertifikat
aus" statt einer automatischen Let's-Encrypt-Ausstellung, die in diesem Modus nicht stattfindet.
## [0.9.17] - 2026-06-19
### Behoben
- **Panel über die Domain hinter einem externen TLS-Proxy war unbenutzbar (CSS/JS per CSP
blockiert).** Im Externer-Proxy-Modus terminiert der Upstream HTTPS und reicht das Schema per
`X-Forwarded-Proto` weiter; Clusevs eigener Caddy vertraut dem aber nur, wenn `TRUSTED_PROXY_CIDR`
gesetzt ist. War es das nicht, sah die App `http`, erzeugte `http://`-Asset-URLs — und die über
HTTPS ausgelieferte Seite (CSP `'self'`) blockierte diese → kein CSS/JS, kein Login. Bei aktiver
Domain wird die URL-Erzeugung (Assets, Routen) jetzt fest auf `https://<domain>` gezwungen
(`URL::forceRootUrl`), unabhängig vom (evtl. nicht vertrauten) Request-Schema. Der Bare-IP-
Recovery-Pfad (HTTP) bleibt unberührt. Hinweis: für korrekte `Secure`-Cookies im Externer-Proxy-
Modus weiterhin `TRUSTED_PROXY_CIDR` auf die Proxy-Adresse setzen.
## [0.9.16] - 2026-06-19
### Behoben
- **Neustart-Knopf meldet einen fehlgeschlagenen Sentinel-Schreibvorgang** — konsistent mit dem
Update-Knopf (0.9.15). Konnte die Sentinel-Datei nicht geschrieben werden (z. B. bei einem
uid-Mismatch vor dem Fix), zeigte „Jetzt neu starten" nur „Neustart wird ausgeführt …" ohne dass
etwas passierte; jetzt erscheint stattdessen ein Fehler-Toast mit dem Hinweis auf `sudo ./update.sh`.
`DeploymentService::requestRestart()` gibt nun `bool` zurück.
## [0.9.15] - 2026-06-19
### Behoben
- **Dashboard-Update/-Neustart hingen auf Hosts, deren `clusev`-Nutzer nicht uid 1002 hat.** Das
Prod-Image baute den `app`-Nutzer fest mit uid 1002, während `install.sh` das bind-gemountete
`./run` dem `clusev`-Nutzer (z. B. uid 1000) übergibt. Dadurch konnte der App-Prozess die
Sentinel-Datei nicht schreiben — der Knopf zeigte „läuft", aber auf dem Server passierte nichts.
Das Image wird jetzt mit `APP_UID/APP_GID = HOST_UID/HOST_GID` (dem `clusev`-Nutzer) gebaut, sodass
App-Prozess und `./run` übereinstimmen. **Einmalig per `sudo ./update.sh` neu bauen**, danach
funktioniert der Knopf (der konnte sich nicht selbst reparieren, da genau das Schreiben kaputt war).
- **Fehlgeschlagenes Sentinel-Schreiben wird jetzt gemeldet** statt eines ewigen „läuft": geht das
Schreiben schief, erscheint ein Fehler-Toast mit dem Hinweis auf `sudo ./update.sh`.
## [0.9.14] - 2026-06-19
### Geändert
- **Updates werden beim Öffnen der Seite automatisch geprüft** (System → Version & Releases). Bisher
zeigte nur das Badge eine neuere Version; der „Jetzt aktualisieren"-Knopf erschien erst nach Klick
auf „Nach Updates suchen". Die Seite prüft jetzt direkt beim Laden selbst (asynchron via
`wire:init`, blockiert das Rendern nicht) und zeigt ein verfügbares Update samt Knopf sofort an —
kein manuelles Suchen mehr. Während eines laufenden Updates wird der Auto-Check übersprungen, damit
er den „läuft"-Zustand nicht überschreibt.
## [0.9.13] - 2026-06-19
### Behoben
- **Update-Status übersteht jetzt einen Reload (in Redis statt Sentinel gespeichert).** In 0.9.12
wurde der laufende Zustand aus der Sentinel-Datei abgeleitet — die der Host-Watcher aber **vor**
dem Update verbraucht, sodass ein Reload mitten im Update nichts mehr anzeigte. Der „läuft"-Status
wird jetzt als Redis-Marker (mit der Ausgangsversion) persistiert: er übersteht sowohl den Reload
als auch den Container-Neustart. Nach einem Reload wird entweder „Update läuft" wieder aufgenommen
(inkl. Polling) oder — falls das Update inzwischen durch ist — direkt „Aktuell — vX" mit
Erfolgsmeldung angezeigt und der Marker gelöscht (15-Min-Backstop-TTL, damit nichts hängen bleibt).
## [0.9.12] - 2026-06-19
### Hinzugefügt
- **Fertig-Rückmeldung beim Dashboard-Update.** Nach „Jetzt aktualisieren" zeigte die Seite nur
„Update läuft" ohne Ende. Sie pollt jetzt (alle 5 s) und erkennt den Abschluss daran, dass der
neu gebaute Container eine höhere Version meldet als beim Start des Updates — dann springt die
Karte automatisch zurück auf „Aktuell — vX" und ein Erfolgs-Toast erscheint. Übersteht die
Neustart-Downtime (fehlgeschlagene Polls werden einfach wiederholt) und bricht nach ~5 Min mit
einem Hinweis ab, falls das Update hängt — statt ewig zu drehen. Ein laufendes Update wird auch
nach einem Seiten-Reload wieder aufgenommen (solange die Sentinel-Datei besteht).
### Geändert
- **Verfügbares Update wird sofort beim Laden angezeigt.** Stand der neueste Tag bereits aus einer
vorherigen Prüfung im Cache, zeigte nur das Badge die neue Version — der „Jetzt aktualisieren"-
Knopf erschien aber erst nach erneutem „Nach Updates suchen". Jetzt erscheint er direkt beim
Seitenaufbau (ohne Netzwerkanfrage — rein aus dem Cache).
### Behoben
- **Zeitstempel in lokaler Zeit statt UTC.** `app.timezone` ist jetzt env-gesteuert; `install.sh`
ermittelt die Zeitzone des Hosts (systemd / `/etc/timezone` / `/etc/localtime`) und schreibt sie
als `APP_TIMEZONE` in die `.env`. Datenbank-Zeitstempel bleiben UTC; nur die Anzeige (z. B. die
„geprüft"-Zeit) folgt der Host-Zeitzone. Default UTC, wenn nicht ermittelbar.
## [0.9.11] - 2026-06-19
### Behoben
- **„Neuestes Release" zeigt nie mehr eine ältere Version als die installierte.** Direkt nach einem
Update konnte der 30-Minuten-Cache der Update-Prüfung noch den vorherigen Stand halten, sodass die
Karte „Installiert" z. B. „Neuestes Release v0.9.9" neben „installiert v0.9.10" zeigte (wirkte
rückwärts). Ist der gecachte Wert kleiner als die installierte Version, wird er jetzt verworfen
(Anzeige „—"), bis „Nach Updates suchen" den echten neuesten Tag auflöst.
## [0.9.10] - 2026-06-19
### Behoben
- **Build-Commit & Branch werden jetzt auch in Produktion angezeigt** (System → Version & Releases,
Karte „Installiert"). Sie wurden zur Laufzeit aus `.git` gelesen — das im Docker-Prod-Image
fehlt — also stand dort „Build —" und als Branch der Kanal. `install.sh` bäckt den deployten
Commit (`git rev-parse --short HEAD`) und Branch jetzt aus dem `.git` des Hosts in die `.env`
(`CLUSEV_BUILD_SHA`/`CLUSEV_BUILD_BRANCH`); die Versions-Seite bevorzugt diese und liest im Dev
weiterhin direkt aus `.git`.
## [0.9.9] - 2026-06-19
### Hinzugefügt
- **„Jetzt aktualisieren" im Dashboard (System → Version & Releases).** Ist ein neueres Release im
Kanal verfügbar, lässt sich das Update jetzt per Knopf auslösen — ganz ohne SSH. Gleiches sichere
Muster wie der Neustart-Knopf: der Container schreibt nur eine Sentinel-Datei (kein Docker-Socket),
ein **als root laufender** Host-Watcher (`clusev-update.path`/`.service`) führt `update.sh` aus
(`git pull` + idempotenter Re-Install) und verbraucht die Sentinel **vor** dem Lauf, damit ein
dauerhafter Fehler nicht endlos wiederholt. Der Knopf erscheint nur, wenn eine neuere Version
gefunden wurde, ist per-Nutzer gedrosselt (3/10 Min, auto-ablaufend) und auditiert
(`deploy.update_request`). Hinweis im UI: das Dashboard ist während des Rebuilds ein bis zwei
Minuten nicht erreichbar; danach neu laden. `install.sh` installiert die neuen Host-Units mit.
## [0.9.8] - 2026-06-19
### Hinzugefügt
- **`update.sh` — Ein-Befehl-Update (`sudo ./update.sh`).** Holt den neuesten Stand
(`git pull --ff-only`, verwirft nie lokale Änderungen), **aktualisiert sich bei Bedarf selbst**
(hat sich `update.sh` im Pull geändert, startet es die neue Version neu) und übergibt dann an den
idempotenten `install.sh` (Build, Neustart, Migration, MOTD). Setzt für root automatisch
`safe.directory` (das Verzeichnis gehört dem `clusev`-Nutzer, sonst verweigert ein root-`git pull`)
und ruft `install.sh` **nicht-interaktiv** auf, sodass nichts neu abgefragt wird.
### Behoben
- **Re-Run von `install.sh` bewahrt jetzt Domain & ACME-E-Mail.** Bislang schrieb der Installer
`APP_DOMAIN`/`ACME_EMAIL` bei jedem Lauf — ein nicht-interaktiver Re-Run (also ein Update) ohne
gesetzte `CLUSEV_DOMAIN` hätte eine konfigurierte Domain **gelöscht**. Beide defaulten jetzt auf den
bestehenden `.env`-Wert; Erst-Installation (leer = Bare-IP) unverändert.
### Geändert
- **README-Update-Abschnitt** auf `sudo ./update.sh` umgestellt (der alte Zweischritt
`git pull && sudo ./install.sh` funktioniert weiterhin); MOTD zeigt den Update-Befehl an.
## [0.9.7] - 2026-06-19
### Behoben
- **Update-Prüfung (System → Version & Releases) funktioniert jetzt in Produktion.** Sie las das
neueste Release aus dem lokalen `.git` — das es im Docker-Prod-Image gar nicht gibt, daher meldete
„Nach Updates suchen" dort **immer** „Noch kein Release getaggt", selbst wenn längst eine neuere
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet ausschließlich `vX.Y.Z` an.
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
## [0.9.6] - 2026-06-19
### Geändert
- **Erst-Admin nutzt jetzt eine feste Standard-E-Mail `admin@clusev.local`** (statt einer aus dem
Hostnamen abgeleiteten Adresse, die man hätte raten müssen). Zusammen mit dem Standard-Passwort
`clusev` ist der Erst-Login auf jeder frischen Installation vorhersehbar (`admin@clusev.local` /
`clusev`, Zwangswechsel beim ersten Login). `--email` / `CLUSEV_ADMIN_EMAIL` überschreiben weiter;
später unter Einstellungen → Profil änderbar. Installer-Prompt, README und Banner aktualisiert.
### Behoben
- **Command-Palette öffnete sich nach dem ersten Login von selbst und ließ sich nicht schließen**
(nur im Prod-Build). Ursache: `app.js` baute `window.Echo` auf Top-Level; auf Auth-Seiten fehlt die
Laufzeit-Reverb-Config (`window.__clusev`) und der Prod-Build bäckt absichtlich keine
`VITE_REVERB_*`-Fallbacks ein, also warf der Echo-Konstruktor — und brach das Skript **vor** der
`alpine:init`-Registrierung ab. Dadurch waren `cmdk`/`modalTrigger`/`dualChart` nicht registriert,
Alpine konnte `x-data="cmdk(…)"` nicht auflösen, entfernte aber `x-cloak`, und das Palette-Overlay
fiel auf seine sichtbare Default-Darstellung zurück (undismissbar). Echo wird jetzt nur bei
vorhandener Config konstruiert, in `try/catch` gekapselt (ein Realtime-Fehler legt die UI nie lahm)
und bei `livewire:navigated` nachgezogen (Realtime auch auf dem per-SPA erreichten Dashboard). Im
Dev mit Vite trat es nie auf, da Vite die `VITE_REVERB_*` aus der Dev-`.env` einbäckt.
## [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 —
ohne Secret) und die Verwaltungs-Befehle. `install.sh` setzt URL, Version und den Compose-Pfad ein;
fällt bei fehlendem Docker robust auf „Status unbekannt" zurück (bricht nie einen Login ab).
### Behoben
- **README-Voraussetzungen:** `git` ist auf minimalen Debian/Ubuntu-Images nicht vorinstalliert, der
`git clone`-Schritt scheiterte dort vor dem Installer. Der Install-Block installiert `git` jetzt
explizit, und „Voraussetzungen" nennt es (ein Full-VM-Installtest hat das aufgedeckt).
## [0.9.4] - 2026-06-17
### Hinzugefügt
- **„DNS prüfen & Zertifikat anfordern" im Dashboard (System → Domain & TLS).** Wurde eine Domain
konfiguriert, deren DNS noch nicht auf den Server zeigte, lässt sich das Let's-Encrypt-Zertifikat
jetzt per Knopf anstoßen, sobald das DNS stimmt: eine DNS-Vorabprüfung (zeigt die Domain hierher?)
und — nur ohne klare Fehlanpassung — ein interner Handshake, der Caddys On-Demand-Ausstellung
auslöst, plus ein persistierter Status (aktiv / DNS zeigt woanders / ausstehend). Sichtbar nur im
eingebauten-TLS-Modus mit aktiver Domain (im Externer-Proxy-Modus ein Hinweis, bei Bare-IP nichts).
Per-Nutzer gedrosselt (5/10 Min, auto-ablaufend — sperrt die Control-Plane nie aus) + auditiert; der
Handshake ist fest auf den `caddy`-Dienst gepinnt (kein SSRF). Hinweis: das automatische On-Demand
greift weiterhin von selbst beim ersten HTTPS-Aufruf — der Knopf gibt nur explizite Kontrolle +
Statusrückmeldung.
## [0.9.3] - 2026-06-17
### Behoben
- **Modal-Trigger zuverlässig + mit Feedback.** Modale gingen direkt nach einer Navigation teils
erst beim 2.3. Klick auf (Konsole: „Could not find Livewire component in DOM tree"), und der
Button gab kein Signal. Ursache: der globale Modal-Manager wurde bei jedem `wire:navigate` ab-
und neu aufgebaut, sodass sein `openModal`-Listener kurz fehlte. Er bleibt jetzt über `@persist`
für die ganze Sitzung bestehen — Modale öffnen beim ersten Klick. Zusätzlich zeigt **jeder**
Modal-Trigger (in allen Views) ab Klick sofort einen Spinner und blendet einen Fehler-Toast ein,
falls nach kurzer Zeit nichts aufgeht (neues `<x-modal-trigger>`-Element; der Toaster kennt jetzt
eine rote Fehler-Variante). So kann ein Klick nie tot wirken und ein echtes Fehlschlagen ist nie
stumm.
### Sicherheit
- **Passwort-Reset-Timing weiter abgeflacht.** Der Dummy-Verifikationspfad für unbekannte Konten
spiegelt jetzt exakt die Kosten der echten Recovery-Code-Prüfung (gesperrtes `SELECT` in einer
Transaktion statt eines schwereren bcrypt-Vergleichs), und Transportfehler beim Reset-Link werden
geloggt statt stillschweigend verschluckt.
- **Brute-Force-/Rate-Limiting-Härtung (Audit-Nachgang).** Alle neuen Limits laufen automatisch ab
und sperren die Control-Plane nie dauerhaft (Bare-IP-Recovery + `clusev:reset-admin` bleiben offen):
- **Login** zusätzlich pro IP (20/Min) und pro Konto (30/15 Min) begrenzt — ein verteilter
Mehr-IP-Angriff auf das eine Admin-Konto wird gedeckelt statt linear zu skalieren; und der
Login-Zweig für unbekannte E-Mails führt jetzt denselben bcrypt aus (Konstantzeit) und schließt
so das Konten-Enumerations-Timing-Orakel.
- **TOTP-Replay-Schutz:** jeder 6-stellige Code ist genau einmal gültig (zuletzt akzeptierter
Zeit-Schritt wird persistiert), ein abgefangener Code lässt sich nicht im 30-Sek-Fenster
wiederverwenden.
- **2FA-Challenge & 2FA-Beweis-Reset** zusätzlich pro Konto begrenzt (IP-unabhängiger Backstop),
sodass die TOTP-/Backup-Code-Brute-Force-Fläche auch verteilt gedeckelt ist.
- **Globaler `/livewire/update`-Throttle** (180/Min pro Identität) als Flut-Deckel über den
Per-Aktion-Limitern.
- **Re-Auth gedrosselt** (Passwortänderung/-Profil, 5/Min pro Nutzer) gegen Passwort-Brute-Force
über eine gekaperte Session; **SMTP-Testversand** auf 3/10 Min pro Nutzer begrenzt (kein
Spam-Relay).
- **DoS-Schutz:** SSH bekommt einen kurzen, separaten Connect-Timeout (~5 s), damit ein toter/
langsamer Host einen Worker nicht über mehrere sequentielle Reads × 15 s blockiert; die
Profil-Passwort-Policy auf 12 Zeichen + Groß/Klein + Ziffern angehoben (war 10).
## [0.9.2] - 2026-06-15
### Hinzugefügt
@ -1301,10 +489,10 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force-
Drossel, Session-Cookie-Härtung.
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
pro Version, Kanal `stable` (kein nutzer-seitiges `dev`).
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
[Unreleased]: https://github.com/clusev/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/clusev/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/clusev/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/clusev/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/clusev/clusev/releases/tag/v0.1.0
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://git.bave.dev/boban/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0

266
CLAUDE.md Normal file
View File

@ -0,0 +1,266 @@
# CLAUDE.md — Clusev
Project context for any agent/developer. **Read `rules.md` first — those rules are
non-negotiable.** This file is the fast ramp-up: what we're building, the stack, where things
live, how to run them, and the conventions. Source of truth for decisions: `handoff.md`.
---
## 1. Product
**Clusev** — a self-hosted control panel to administer a **fleet of Linux servers from one
dashboard**, connecting **agentless over SSH**. The operator sees and steers the whole fleet
(metrics, services, files, audit) in one security-first UI (2FA, full audit log).
- **Backend reality:** Laravel is the **control-plane** (UI + API + provisioning). It does **not**
reimplement SSH/daemons — it orchestrates real servers over SSH via **phpseclib** (exec + SFTP).
- **Audience:** developers, self-hosters, sysadmins.
- **Differentiator:** multi-server fleet management with a modern, polished UI. **Multi-server is
free and never paywalled.** Paid = Team/Enterprise (SSO/LDAP, RBAC, audit export, alerting,
backups) + optional managed control-plane. **License:** AGPL core + commercial Pro modules —
architect open-core from day one (clean core, Pro as separate modules/flags).
### v1 scope (build this first — do NOT boil the ocean)
- **Foundation (once):** auth + **2FA**, **audit log**, encrypted **SSH-credential vault**,
**SSH layer** (exec + SFTP via phpseclib), **Reverb** realtime channel, **queue** workers,
**multi-server switcher**.
- **Features:** (1) **Dashboard / live metrics**, (2) **systemd services** (list + start/stop/
restart + logs), (3) **SFTP file manager**, plus the **Server-Details** page (identity,
resource gauges, specs, volumes, interfaces, security hardening, SSH keys).
### Deferred (keep OUT of v1)
- **Web terminal** (xterm.js + ws↔SSH sidecar — PHP can't hold an interactive PTY).
- **Package management** (apt/dnf), firewall / users / cron, app store, push-metrics agent.
---
## 2. Tech stack (hard requirements)
| Layer | Choice |
|---|---|
| Framework | **Laravel 13** |
| UI / interactivity | **Livewire v3** — class-based, **NOT Volt** |
| CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css`, **no `tailwind.config.js`**) |
| Build | **Vite** |
| Modals | **wire-elements/modal** |
| Realtime | **Laravel Reverb** + Echo (live metrics, broadcasts) |
| Queue / cache | **Redis** |
| DB | **MariaDB** *(confirmed)* |
| SSH | **phpseclib** (exec + SFTP) |
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) |
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
**Composer packages:** `livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb`.
**NPM (Tailwind v4):** `tailwindcss @tailwindcss/vite` (+ Echo + a charts lib).
---
## 3. Architecture notes
- **Pages = full-page class-based Livewire components** mapped directly in `routes/web.php`
(R1/R2). No page controllers.
- **SSH layer** (`app/Support/Ssh/`): `SshClient` (exec), `Sftp`, `CredentialVault`
(encrypted credentials). Domain services in `app/Services/` (`FleetService`, `MetricsPoller`,
`Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`.
- **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over
**Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates
the chart island. **Mock data first, real SSH after.** **Convention: every broadcast channel is
PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the
Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare
events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the
header comment there); per-server channels also scope to records the user may see.
- **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one.
---
## 4. Folder map (§5 — follow exactly, R6)
```
app/
Livewire/ # class-based components (full-page = routes, + nested)
Dashboard.php
Servers/{Index.php, Show.php} # Show = Server-Details page
Services/Index.php
Files/Index.php
Audit/Index.php
Auth/{Login.php, TwoFactor.php}
Modals/ # wire-elements/modal components (ConfirmDelete, ServerForm, …)
Concerns/ # shared traits (e.g. WithFleetContext)
Models/ # Server, AuditEvent, User, …
Support/Ssh/ # phpseclib wrappers: SshClient, Sftp, CredentialVault
Services/ # FleetService, MetricsPoller, Provisioner
Events/ # broadcast events (MetricsTicked, …)
Jobs/ # queued ops
resources/
views/
livewire/ # mirrors app/Livewire (kebab-case)
dashboard.blade.php
servers/{index,show}.blade.php
services/index.blade.php
files/index.blade.php
audit/index.blade.php
modals/…
components/ # Blade UI: panel, kpi, status-pill, status-dot, badge,
… # sidebar, topbar, server-item, nav-item, icon, ring
layouts/app.blade.php
css/app.css # @import "tailwindcss"; + @theme { tokens } + base layer
js/app.js # Echo/Reverb bootstrap, Alpine islands (charts; xterm later)
routes/web.php # full-page Livewire routes
config/livewire.php # class_namespace=App\Livewire, view_path=views/livewire
docker/ # Dockerfile bits, php-fpm/nginx config, entrypoint
docker-compose.yml # DEV (build, bind-mount, Vite)
docker-compose.prod.yml # PROD (image from GHCR, no mount)
.dockerignore
handoff.md rules.md CLAUDE.md
```
---
## 5. Design system → Tailwind v4 `@theme`
Direction: **"Tactical Terminal"** — dark graphite, **signal-orange** brand (softened on
selection/active tints), **cyan** counter-accent, ops status triad, **mono for every
number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
- **Surfaces:** `void, base, surface, raised, inset``bg-surface`, `bg-raised`, …
- **Brand:** `accent, accent-bright, accent-deep, accent-text``text-accent`, `bg-accent/10`
(use **opacity utilities** for tints, e.g. `bg-accent/10`, `border-accent/25` — no `--accent-dim`).
- **Counter-accent:** `cyan, cyan-bright`.
- **Status:** `online (#35D07F)`, `warning (#E8B931)`, `offline (#FF5247)``text-online`, …
- **Text ramp:** `ink, ink-2, ink-3, ink-4``text-ink`, `text-ink-2`, …
- **Borders (hairline):** `line, line-soft, line-strong``border-line`, …
- **Fonts:** `--font-display` (Chakra Petch), `--font-sans` (Space Grotesk), `--font-mono`
(JetBrains Mono) → `font-display`, `font-mono`.
- **Radii:** `--radius-xs/sm/md/lg``rounded-sm`, `rounded-lg`.
> Full token values are in `handoff.md` §6 — port them verbatim into `app.css`. Selection
> (`::selection`) and active nav/server tints use `bg-accent/25` and `bg-accent/10` + `border-accent/25`
> (keep subtle). **`../bastion` mockup is NOT present on this machine** → rebuild from §6 tokens +
> the §11 component list; pull Lucide SVG paths directly from Lucide.
**Blade components to build:** `x-panel`, `x-kpi`, `x-status-pill`, `x-status-dot`, `x-badge`,
`x-icon` (Lucide), `x-ring`, `x-server-item`, `x-nav-item`, plus `sidebar` + `topbar` partials.
---
## 6. Conventions
- **UI copy is localized (DE default + EN)** — never hard-code visible strings; use
`__('group.key')` with translations in `lang/{de,en}/<group>.php` (one group per feature +
shared `common`), keys identical in both languages. Register: terse/operational, **no emoji**
(status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16)
- **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3)
- **Inline styles:** forbidden except a progress bar's `width`. (R4)
- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show`
(kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`.
- **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2)
- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens
`@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17)
- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5)
- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer
PK (`getRouteKeyName(): 'uuid'`). (R11)
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
- **Fonts self-hosted only**`.woff2` in `resources/fonts/` (Vite-bundled, relative
`url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `<link>` or
`@import`. (R14)
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
- **Docs language:** these meta-docs are in English; **UI strings are German**.
---
## 7. Commands (everything in containers — R8)
> Dev stack services: `app` (php-fpm + nginx + **vite** via supervisor — ONE container),
> `reverb`, `queue`, `mariadb`, `redis`. App on **:80**, Vite HMR on **:5173**
> (`hmr.host=10.10.90.136`). Vite is **not** a separate service. Containers run as `HOST_UID`
> (1002 on this VM); run write-tooling as
> `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app …`.
```bash
# ── Stack lifecycle (DEV) ────────────────────────────────────────────────
docker compose up -d # start the dev stack
docker compose logs -f app # tail app logs
docker compose down # stop
# ── Run tooling INSIDE the container ─────────────────────────────────────
docker compose exec app php artisan migrate
docker compose exec app php artisan make:livewire Servers/Show # class + view (never make:volt)
docker compose exec app composer require some/package
docker compose exec app php artisan tinker
# ── One-time bootstrap (host has no PHP/Composer/Node) ───────────────────
# run in a throwaway container or the app image:
# composer create-project laravel/laravel .
# composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb
# npm install -D tailwindcss @tailwindcss/vite
# php artisan livewire:publish --config # class-based, view_path set
# ── Realtime / queue (run as their own services; manual run if needed) ───
docker compose exec app php artisan reverb:start
docker compose exec app php artisan queue:work
# ── Prod deploy (plain Docker Compose — no Portainer) ────────────────────
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
```
---
## 8. Infrastructure & deploy (§8)
- **Target VM:** Debian 13, **`10.10.90.136`**, **only Docker** + a sudo user. Everything in
containers. **No Portainer, no external orchestrator.** Dev happens on this same VM (prod-parity).
- **`docker-compose.yml` (DEV):** `build:`, **bind-mounts** source. The `app` container runs
php-fpm + nginx + Vite (HMR) via supervisor (one unit). Ports/UID are **env-driven**
(`APP_PORT`, `VITE_PORT`, `REVERB_HOST_PORT`, `DB_PORT`, `HOST_UID`/`HOST_GID` in `.env`) —
nothing hardcoded. App on **:80**, Vite remote HMR (`server.host=0.0.0.0`,
`hmr.host=10.10.90.136`), MariaDB only on `127.0.0.1:3306`.
- **`docker-compose.prod.yml` (PROD):** `image: ghcr.io/OWNER/clusev:<tag>` *(GHCR namespace is a
**placeholder `OWNER`** — set via `.env` before the first prod push)*, **no bind-mount**, assets
baked into the image, no Vite. Reverse proxy + TLS (Caddy/Traefik) is just another service.
- **Deploy:** wrap pull+up+migrate in `deploy.sh` (or a CI job that SSHes in). Image flow:
push → CI builds + pushes to **GHCR**`deploy.sh` pulls onto the VM. Bootstrap before CI:
build on the VM. Use `docker buildx` (multi-arch) only if any dev happens on arm64; prod is amd64.
---
## 9. Repo & workflow
- **Project root = `/home/nexxo/clusev`** (its own git repo). The parent `$HOME` holds only
`.env.gitea` + home dotfiles. Host user **nexxo = uid 1002** (`wp-test`=1000, `testwp`=1001
pre-existed) → `HOST_UID/HOST_GID=1002` in `.env`.
- **Remote:** Gitea `git.bave.dev/boban/clusev.git`. Credentials live in **`.env.gitea`**
(untracked) — the `GIT_ACCESS_TOKEN` must later move into the app `.env`.
- **Work on a feature branch, not `main`.** Commit in sensible steps.
- **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the
Gitea token (read it only at push time). See `rules.md` → Appendix.
- **Verify before "done" (R12):** load **every touched page in a real browser** (headless ok) →
**HTTP 200** (no 500) + **zero console/network errors**; for lazy `wire:init` pages check the
**loaded** state, not the skeleton. A green `Livewire::test` is **not** enough. Test the 3 breakpoints.
---
## 10. Before you code — checklist
1. **Read `rules.md`.** R1R11 are non-negotiable; on any conflict, **stop and ask**.
2. New page? → full-page **class-based** Livewire component + route in `web.php` (R1/R2). Never Volt.
3. Need a color/spacing? → an **`@theme` token utility** (R3). No raw hex, no inline style except
progress `width` (R4).
4. Destructive/confirm? → **wire-elements/modal** (R5).
5. Place files per the **§4 folder map** (R6). Class ↔ mirrored kebab view.
6. Build **responsive**: 375 / 768 / 1280; ≥44px touch targets (R7).
7. Run all tooling **inside the container** (R8).
8. UI copy **German, no emoji**; status via color/dots/pills (R9).
9. **Reuse** the token set + Blade component kit (R10).
10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console
errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough;
**inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key`
text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit.
11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and
no security issues**. Fix + re-run until clean. The task is not done until Codex passes.

225
README.md
View File

@ -1,198 +1,87 @@
<div align="center">
# Clusev
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
**Self-hosted control panel for a fleet of Linux servers — one dashboard, agentless over SSH.**
<br>
**One dashboard for your whole fleet. Agentless over SSH. Self-hosted, security-first, open core.**
[![License](https://img.shields.io/badge/license-AGPL--3.0-FF6B2C?style=flat-square&labelColor=0F1318)](#license)
[![Laravel](https://img.shields.io/badge/Laravel-13-9DAAB6?style=flat-square&labelColor=0F1318)](https://laravel.com)
[![Livewire](https://img.shields.io/badge/Livewire-3-9DAAB6?style=flat-square&labelColor=0F1318)](https://livewire.laravel.com)
[![Docker](https://img.shields.io/badge/Docker-ready-43C1D8?style=flat-square&labelColor=0F1318)](#quick-start)
[![Self-hosted](https://img.shields.io/badge/self--hosted-Debian%20%2F%20Ubuntu-35D07F?style=flat-square&labelColor=0F1318)](#requirements)
[**Features**](#features) · [**Architecture**](#architecture) · [**Quick start**](#quick-start) · [**Domain &amp; TLS**](#access-domain--tls) · [**Security**](#security--recovery) · [**License**](#license)
</div>
---
## What is Clusev
**Clusev** is the control plane for a fleet of Linux servers — a modern, security-first web UI that
administers your machines by talking to them **directly over SSH** (exec + SFTP via
[phpseclib](https://phpseclib.com)). It installs **nothing** on the target servers and never
reimplements their daemons; it orchestrates the real ones.
> **Multi-server management is free and never paywalled.** Optional Pro modules (SSO/LDAP, RBAC,
> audit export, alerting) are separate — the core stays open.
<div align="center">
| | | |
|:--|:--|:--|
| **Agentless** | **Security-first** | **Self-hosted** |
| Pure SSH + SFTP. No daemon, no package, no push-agent on your servers. | 2FA, encrypted credential vault, tamper-evident audit log, per-device sessions. | One Docker stack on one VM. Your servers, your data, your keys. |
</div>
---
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.
## Features
| Capability | What you get |
|:--|:--|
| **Dashboard &amp; live metrics** | CPU, memory, load and disk per server, streamed in real time over WebSockets. |
| **systemd services** | List, start / stop / restart, and tail the live journal — per service, per server. |
| **SFTP file manager** | Browse the remote filesystem, edit text files in place, preview images. |
| **Web terminal** | A full SSH terminal in the browser — one per server, and one for the Clusev host itself. Real PTY with tab-completion; the connection's host key is pinned against MITM. |
| **Server details &amp; hardening** | Resource gauges, volumes, interfaces and SSH keys; UFW / firewalld rules and fail2ban status with one-click controls; a guided _"generate an SSH key and disable password login, safely"_ flow. |
| **Security** | Pluggable 2FA (TOTP and/or hardware 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 &amp; e-mail** | Run on a bare IP over HTTP, set a domain for automatic Let's Encrypt HTTPS, or sit behind your own reverse proxy. In-panel SMTP for password-reset mail. |
| **WireGuard (built-in)** | Stand up a WireGuard tunnel from the dashboard, manage peers and live status, and optionally gate the panel — and even SSH — to the tunnel only. |
- **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.
The interface is **German by default** (English available). Status is shown the operational way —
colour, dots and pills — never emoji.
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).
---
## Requirements
## Architecture
- 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.
<div align="center">
<img src="art/clusev-architecture.svg" alt="Operator → Clusev control plane → your fleet" width="100%">
</div>
Laravel is the **control plane**, not a re-implementation of your servers. The browser talks to it
over HTTPS and a Reverb WebSocket for live telemetry; it reaches each server over **SSH exec + SFTP**
using credentials sealed in an encrypted vault that never leaves the control plane. Everything runs as
one Docker stack — `app` (php-fpm + nginx + Vite), `reverb`, `queue`, `scheduler`, `mariadb`,
`redis` — on a single VM.
| Layer | Technology |
|:--|:--|
| Framework | **Laravel 13** |
| UI / interactivity | **Livewire 3** (class-based) |
| Styling | **Tailwind v4** (CSS-first, `@theme`) |
| Realtime | **Laravel Reverb** + Echo |
| Queue / cache | **Redis** |
| Database | **MariaDB** |
| SSH transport | **phpseclib** (exec + SFTP) |
| Reverse proxy / TLS | **Caddy** (automatic Let's Encrypt) |
| Packaging | **Docker** + Docker Compose |
---
## Quick start
## Install
```bash
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
git clone https://github.com/clusev/clusev.git
git clone https://git.bave.dev/boban/clusev.git
cd clusev
sudo ./install.sh
```
The installer is **idempotent** (safe to re-run) and, in one pass:
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. Installs over the server's **IP address (plain HTTP)** — no domain is asked for. Prompts only for
the **HTTP port** (default 80) and an **admin e-mail** (default **`admin@clusev.local`**), or reads
`CLUSEV_ADMIN_EMAIL` / an optional `CLUSEV_DOMAIN` from the environment for an unattended install. A
domain with automatic HTTPS is set later in the dashboard — see [Access, domain &amp; TLS](#access-domain--tls).
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 **random initial password** printed
in the closing summary.
5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status.
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.
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
(your e-mail — `admin@clusev.local` if you left it empty — plus a **random initial password**), and
the **`clusev` host user** + its own random password. Both passwords are shown **only once** — note
them down.
(e-mail + one-time password), and the **`clusev` host user** + its password. These passwords are shown
**only once** — note them down.
### First login
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 **initial password from the installer's summary**. Setting your
own password is **recommended** — a banner keeps reminding you until you do — but no longer forced, so a
missed reminder can never lock you out of your own panel. You can change the login e-mail later under
**Settings → Profile**. 2FA is **optional but recommended** — enable TOTP and/or a security key from
**Settings → Security** whenever you like. A short **guided tour** highlights the key areas on first
login (replay it any time from Settings).
---
## Requirements
**The Clusev panel host** — the one VM that runs the dashboard:
- **OS:** Debian 12/13 or Ubuntu 22.04/24.04, with **root** access.
- **CPU / RAM:** 1 vCPU and **1 GB RAM** minimum (**2 GB recommended**) — the whole Docker stack
(app, MariaDB, Redis, Reverb, queue) runs here.
- **Disk:** ~**5 GB** free minimum (**10 GB+** recommended — image and database grow over time).
- **Network:** a **public IP**; optionally a domain whose DNS points at the server (for automatic
HTTPS — Let's Encrypt needs ports **80/443** reachable).
- **Tools:** only **git** to fetch the repo — the installer sets up Docker and everything else.
**The servers you manage** need **nothing installed** — just **SSH** reachable from the panel and a
standard Linux `/proc`. Full control (service management + hardening) additionally needs **systemd**
and one of `apt` / `dnf` / `zypper`. See [**Supported operating systems**](#supported-operating-systems).
---
## Supported operating systems
Clusev is **agentless** — it manages whatever it can reach over SSH. How much works depends on the
target's init system and package manager:
| OS family | Metrics | systemd services | Hardening<br>(SSH · fail2ban · firewall · auto-updates) |
|:--|:-:|:-:|:-:|
| **Debian · Ubuntu** (and derivatives) | ✅ | ✅ | ✅ |
| **RHEL · Fedora · Rocky · AlmaLinux · CentOS Stream · Amazon Linux** | ✅ | ✅ | ✅ |
| **openSUSE · SLES** | ✅ | ✅ | ✅ |
| **Arch** | ✅ | ✅ | ⚠️ not yet (`pacman` not wired) |
| **Alpine** (OpenRC) | ✅ | ❌ no systemd | ❌ |
| **Other / unidentified Linux** | ✅ if `/proc` present | ❌ | ❌ |
**Metrics** (CPU, memory, disk, load) read from `/proc` and work on essentially any Linux. **Service
control** needs **systemd**. **Hardening** uses the host's firewall (`ufw` / `firewalld`) and package
manager (`apt` / `dnf` / `zypper`); where those aren't present, the action is shown as unavailable
rather than failing silently.
---
## Access, domain &amp; TLS
## 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 in the dashboard under **System → Domain &amp; TLS** at any time (or preset
`CLUSEV_DOMAIN` at install for an unattended setup). 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 &amp; 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
- **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
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
cd clusev
git pull
sudo ./install.sh # idempotent: rebuilds, restarts and migrates — secrets are preserved
```
`sudo clusev update` fast-forwards the repo (it never discards local changes), re-runs the idempotent
installer non-interactively, and updates itself if the script changed. Secrets and the configured
domain / e-mail are preserved. (The older two-step `git pull && sudo ./install.sh` still works.)
---
## Security &amp; recovery
## Account recovery
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 +
@ -201,7 +90,8 @@ new password) as a fallback.
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
```bash
clusev reset-admin
cd clusev
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
```
This clears the second factor so you can set a new password on the next login. The bare-IP
@ -209,15 +99,8 @@ This clears the second factor so you can set a new password on the next login. T
is documented in-panel under Settings → Security and is deliberately not shown on the public
forgot-password screen.)
---
## License
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules (SSO/LDAP,
RBAC, audit export, alerting) are separate.
<div align="center">
**Project home — [github.com/clusev/clusev](https://github.com/clusev/clusev)**
</div>
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

@ -1,25 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\ReleaseChecker;
use Illuminate\Console\Command;
/**
* Refresh the cached latest-release tag so the global sidebar update badge stays accurate without
* the operator first opening the Versions page. Anonymous + read-only against the public Git host.
*/
class CheckUpdate extends Command
{
protected $signature = 'clusev:check-update';
protected $description = 'Refresh the cached latest-release tag (keeps the sidebar update badge warm).';
public function handle(ReleaseChecker $checker): int
{
$tag = $checker->refresh();
$this->info($tag !== null ? "latest release: v{$tag}" : 'remote unreachable — keeping the previous cache');
return self::SUCCESS;
}
}

View File

@ -9,27 +9,18 @@ use Illuminate\Support\Str;
/**
* Idempotent first-run bootstrap (called by install.sh, phase 7).
*
* Creates the initial admin with a RANDOM initial password (printed once by install.sh from the
* CLUSEV_ADMIN_PASSWORD line below) rather than a guessable literal rotation is optional now, so a
* well-known default like "clusev" would be a standing takeover credential. `must_change_password` is
* still set (the banner nudges a rotation), but even if kept, the generated secret is not guessable.
* Re-running once an admin exists is a hard no-op.
* 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.
*/
class Install extends Command
{
/**
* Standard default admin e-mail (login + Let's-Encrypt contact) when none is given. A fixed,
* predictable value so a fresh, unattended install always logs in at admin@clusev.local
* not a host-derived address the operator would have to guess. Override with --email /
* CLUSEV_ADMIN_EMAIL, or change it later under Settings Profile.
*/
private const DEFAULT_EMAIL = 'admin@clusev.local';
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 zufälligem Initialpasswort an (Wechsel empfohlen, nicht erzwungen)';
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Einmal-Zufallspasswort an';
public function handle(): int
{
@ -39,7 +30,8 @@ class Install extends Command
return self::SUCCESS;
}
$email = (string) ($this->option('email') ?: self::DEFAULT_EMAIL);
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost';
$email = (string) ($this->option('email') ?: "admin@{$host}");
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Ungueltige Admin-E-Mail: {$email}");
@ -47,24 +39,22 @@ class Install extends Command
return self::FAILURE;
}
// Random, non-guessable initial password (printed once below). Satisfies any policy.
$password = Str::password(16);
$password = Str::password(20);
$user = User::create([
'name' => (string) ($this->option('name') ?: 'Administrator'),
'email' => $email,
'password' => $password, // 'hashed' cast hashes on save
'must_change_password' => true, // nudges a rotation on first login (optional)
'role' => 'admin', // the first/root account is a full administrator
'must_change_password' => true, // forces rotate + 2FA on first login
]);
// install.sh greps these two lines for the closing banner — this is the ONLY time the
// generated password is shown, so the operator must note it now.
// One-time password marker — the ONLY place the plaintext appears.
// install.sh greps these two lines for the closing banner; never stored.
$this->newLine();
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
$this->line('CLUSEV_ADMIN_PASSWORD='.$password);
$this->newLine();
$this->info('Admin angelegt. Generiertes Passwort jetzt notieren — es wird nur einmal angezeigt.');
$this->info('Admin angelegt. Passwort erscheint nur jetzt und wird nicht gespeichert.');
return self::SUCCESS;
}

View File

@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Events\MetricsTicked;
use App\Models\Server;
use App\Services\AlertEvaluator;
use App\Services\FleetService;
use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\SshClient;
@ -29,7 +28,7 @@ class PollMetrics extends Command
protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast';
public function handle(FleetService $fleet, CredentialVault $vault, AlertEvaluator $alerts): int
public function handle(FleetService $fleet, CredentialVault $vault): int
{
$interval = max(2, (int) $this->option('interval'));
@ -37,22 +36,10 @@ class PollMetrics extends Command
$clients = [];
do {
// Only servers with a PRESENT, non-disabled credential. A credential disabled
// between ticks drops the server here, so its cached connection is no longer polled
// (the vault refuses a fresh connect, but a reused long-lived session would not
// re-check disabled_at). Prune any cached client whose server just left the set so
// the revoked server's open session is closed promptly, not left idle until exit.
$servers = Server::withActiveCredential()->get();
$activeIds = $servers->pluck('id')->all();
foreach (array_keys($clients) as $id) {
if (! in_array($id, $activeIds, true)) {
$clients[$id]->disconnect();
unset($clients[$id]);
}
}
$servers = Server::has('credential')->get();
if ($servers->isEmpty()) {
$this->warn('Keine Server mit aktivem Credential — nichts zu pollen.');
$this->warn('Keine Server mit Credential — nichts zu pollen.');
}
foreach ($servers as $server) {
@ -60,9 +47,8 @@ class PollMetrics extends Command
$clients[$server->id] ??= (new SshClient($vault, timeout: 12))->connect($server);
$m = $fleet->readMetrics($clients[$server->id]);
$fleet->applyMetrics($server, $m); // forceFills the fresh status onto $server
$fleet->applyMetrics($server, $m);
broadcast(new MetricsTicked($m['cpu'], $m['mem'], $server->name));
$this->evaluateAlerts($alerts, $server, $m, $server->status);
$this->line("ok {$server->name} cpu={$m['cpu']} mem={$m['mem']} disk={$m['disk']} load={$m['load']}");
} catch (Throwable $e) {
@ -72,7 +58,6 @@ class PollMetrics extends Command
unset($clients[$server->id]);
}
$server->forceFill(['status' => 'offline'])->save();
$this->evaluateAlerts($alerts, $server, [], 'offline'); // an offline rule fires here
$this->warn("offline {$server->name}: ".$e->getMessage());
}
}
@ -90,19 +75,4 @@ class PollMetrics extends Command
return self::SUCCESS;
}
/**
* Run the alert rules for a freshly-polled server. Best-effort: an alerting/notification error
* must NEVER kill the poll loop, so any throwable is caught and logged as a warning line.
*
* @param array<string, int|float|string> $metrics
*/
private function evaluateAlerts(AlertEvaluator $alerts, Server $server, array $metrics, string $status): void
{
try {
$alerts->evaluate($server, $metrics, $status);
} catch (Throwable $e) {
$this->warn("alert eval failed {$server->name}: ".$e->getMessage());
}
}
}

View File

@ -40,9 +40,6 @@ class ResetAdmin extends Command
'password' => Hash::make($password),
'must_change_password' => false,
'remember_token' => Str::random(60),
// Restore full admin rights — this is a lockout-recovery path, so the operator must
// regain the control plane regardless of the account's prior role.
'role' => 'admin',
]);
if ($this->option('disable-2fa')) {
@ -60,7 +57,7 @@ class ResetAdmin extends Command
$this->info("Passwort für {$user->email} zurückgesetzt.");
if (! $this->option('password')) {
$this->warn("Neues Passwort (jetzt notieren): {$password}");
$this->warn("Einmal-Passwort (jetzt notieren): {$password}");
}
if ($this->option('disable-2fa')) {
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');

View File

@ -1,61 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\MetricSample;
use App\Models\Server;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Persist a once-a-minute resource sample per server the long history the Server-Details graph
* plots. The source of truth is the `servers` DB ROW the 15s poller forceFills every tick (cpu/mem/
* disk + last_seen_at), NOT a cache: the poller and this command run in SEPARATE containers in prod,
* so a cache-backed read only worked when the cache store happened to be shared. Reading the row
* means history is collected whenever the live gauges have data (they read the same row), regardless
* of the cache driver. A server not freshly polled (never polled / offline last_seen_at stale)
* simply gets no sample (an honest gap). `load` is not on the row, so it is taken from the live cache
* best-effort when present (stored but not plotted). Prunes beyond the retention window. Scheduled
* everyMinute in routes/console.php.
*/
class SampleMetrics extends Command
{
protected $signature = 'clusev:sample-metrics {--retention=30 : Days of history to keep}';
protected $description = 'Persist a one-per-minute metric history sample per server (and prune old samples)';
public function handle(): int
{
$now = now();
// Only servers the 15s poller updated recently — a stale row means offline / no credential,
// and sampling it would plot a flatline of its last-known values. 90s tolerates a missed tick.
$fresh = $now->copy()->subSeconds(90);
$rows = [];
foreach (Server::query()->get(['id', 'cpu', 'mem', 'disk', 'last_seen_at']) as $server) {
if ($server->last_seen_at === null || $server->last_seen_at->lt($fresh)) {
continue;
}
// load is not persisted on the row; best-effort from the live cache when the store is shared.
$cached = Cache::get("metrics:latest:{$server->id}");
$rows[] = [
'server_id' => $server->id,
// Percentages — clamp to 0..100 so a stray reading can never plot outside the chart.
'cpu' => min(100, max(0, (int) $server->cpu)),
'mem' => min(100, max(0, (int) $server->mem)),
'disk' => min(100, max(0, (int) $server->disk)),
'load' => is_array($cached) ? round((float) ($cached['load'] ?? 0), 2) : 0.0,
'sampled_at' => $now,
];
}
if ($rows !== []) {
MetricSample::insert($rows);
}
$retention = max(1, (int) $this->option('retention'));
MetricSample::where('sampled_at', '<', now()->subDays($retention))->delete();
return self::SUCCESS;
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\BruteforceGuard;
use Illuminate\Console\Command;
class UnbanIp extends Command
{
protected $signature = 'clusev:unban {ip? : Zu entsperrende IP-Adresse} {--all : Alle aktiven Bänne entfernen}';
protected $description = 'Entfernt App-Level-IP-Bänne des Anmeldeschutzes per Shell (Aussperr-Rettung)';
public function handle(BruteforceGuard $guard): int
{
if ($this->option('all')) {
$count = $guard->unbanAll();
$this->info("{$count} Bann/Bänne entfernt.");
return self::SUCCESS;
}
$ip = (string) $this->argument('ip');
if ($ip === '') {
$this->error('Bitte eine IP angeben oder --all verwenden.');
return self::FAILURE;
}
$guard->unban($ip);
$this->info("Bann für {$ip} entfernt (falls vorhanden).");
return self::SUCCESS;
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\WgTrafficSample;
use App\Services\WgStatus;
use Illuminate\Console\Command;
class WgSample extends Command
{
protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';
protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';
public function handle(WgStatus $wg): int
{
$status = $wg->read();
if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) {
$now = now();
$rows = [];
foreach ($status['peers'] ?? [] as $peer) {
$rows[] = [
'peer_pubkey' => (string) ($peer['pubkey'] ?? ''),
'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null,
'rx' => (int) ($peer['rx'] ?? 0),
'tx' => (int) ($peer['tx'] ?? 0),
'sampled_at' => $now,
];
}
if ($rows !== []) {
WgTrafficSample::insert($rows);
}
}
$retention = max(1, (int) $this->option('retention'));
WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete();
return self::SUCCESS;
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Enums;
/**
* Account role. Hierarchy: admin > operator > viewer. admin has every ability; operator can run
* day-to-day operations (services, files, terminal to managed servers) but not the dangerous
* control-plane actions; viewer is read-only.
*/
enum Role: string
{
case Admin = 'admin';
case Operator = 'operator';
case Viewer = 'viewer';
/** Numeric rank for hierarchy comparisons. */
public function rank(): int
{
return match ($this) {
self::Admin => 3,
self::Operator => 2,
self::Viewer => 1,
};
}
/** True when this role is at least as privileged as $other. */
public function atLeast(self $other): bool
{
return $this->rank() >= $other->rank();
}
/** Localised label (lang/{de,en}/roles.php key role_<value>). */
public function label(): string
{
return (string) __('roles.role_'.$this->value);
}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Thrown by DockerService::containers() when the `docker` binary is absent on the target (a native
* server with no container runtime), as opposed to a daemon-down / permission fault. Detecting this
* from the single `docker ps` call lets the pages render the honest "not installed" state WITHOUT a
* second SSH round-trip just to probe availability.
*/
class DockerNotInstalled extends RuntimeException {}

View File

@ -1,350 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditEvent;
use App\Services\BruteforceGuard;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* Honeypot deception layer. Handles requests to decoy paths (routes/web.php, above the guest
* group) that no legitimate client ever visits /.env, /wp-login.php, /phpmyadmin, A hit is
* proof of a hostile probe, so we: audit it, instant-ban the source IP (BruteforceGuard::banNow,
* exempt/loopback are skipped), and return a small, self-contained DECEPTIVE body so the attacker
* keeps chasing the bait instead of noticing a real panel behind it. Attacker-facing strings are
* intentionally NOT localized (they impersonate generic third-party software).
*/
class HoneypotController extends Controller
{
/**
* Per-IP cap on decoy audit rows written in a rolling hour. The decoy routes run WITHOUT
* BlockBannedIp (an already-banned prober still gets the bait), so a single source can loop
* over wildcard paths indefinitely this bounds how far it can grow audit_events. The ban +
* the deception still happen past the cap; only the (redundant) extra audit rows are dropped.
*/
private const AUDIT_CAP_PER_HOUR = 60;
public function trap(Request $request): Response
{
// Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit).
if (! config('clusev.honeypot.enabled')) {
abort(404);
}
// An AUTHENTICATED user hitting a decoy is a stray request, NOT an attacker: behave like an
// ordinary 404 (no ban, no deception, no audit) so a logged-in operator/admin never self-bans.
if (auth()->check()) {
abort(404);
}
$ip = (string) $request->ip();
// An active (non-safe) method is a real attack attempt; a safe read (GET/HEAD/OPTIONS) is merely
// viewing a decoy. Only an attempt bans, so simply opening a fake page never locks anyone out of
// the real panel (an operator testing the honeypot, or a mistaken click, stays free).
//
// Use getRealMethod() — the RAW REQUEST_METHOD — NOT isMethod()/getMethod(): those honor the
// `_method` body/query field and the X-HTTP-Method-Override header, so an attacker could send a
// real POST spoofed as `_method=PUT` to make isMethod('post') false and dodge the instant-ban
// entirely (the decoys are Route::any, so the override still routes here). Ban any non-safe verb.
$isAttempt = ! in_array($request->getRealMethod(), ['GET', 'HEAD', 'OPTIONS'], true);
// BAN FIRST, and independently of the audit. The instant-ban IS the protection; it must never be
// skipped because a later step throws on hostile input. Guarded on its own so that even a ban
// failure (e.g. a momentary DB outage) still serves the deception below.
//
// We deliberately do NOT try to spare a "cross-site forged" POST by inspecting the Origin header:
// the header is attacker-controlled, so any Origin-based skip is a trivial UNIVERSAL ban-evasion
// (a scanner just sends a foreign Origin) — strictly worse than the narrow reflected-griefing it
// would prevent. Honeypot bans are time-limited and unban-able from the Threats page.
if ($isAttempt) {
try {
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
} catch (\Throwable $e) {
report($e);
}
}
// Audit the probe (best-effort, rate-capped). Fully isolated: a logging failure must not affect
// the ban above or the response — an unhandled throw here would otherwise surface a Laravel 500
// that UNMASKS the fake.
try {
$this->auditProbe($request, $ip, $isAttempt);
} catch (\Throwable $e) {
report($e);
}
return $this->deceive($request);
}
/** Record the probe as an audit event, rate-capped per source IP (see AUDIT_CAP_PER_HOUR). */
private function auditProbe(Request $request, string $ip, bool $isAttempt): void
{
// Bound audit growth per source IP WITHOUT a check-then-act race: `add` seeds the counter with a
// 1-hour TTL on the first hit only, then the atomic `increment` both counts and returns the new
// total (Redis INCR keeps the TTL). Concurrent hits can at most overshoot by the concurrency
// count — fine for a DoS bound. Past the cap we drop the redundant row (the ban already happened).
$capKey = 'honeypot:audit:'.md5($ip);
Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) > self::AUDIT_CAP_PER_HOUR) {
return;
}
// Attacker-controlled strings are scrubbed to valid UTF-8 before they reach the array-cast
// `meta` column: a raw invalid byte (e.g. a lone 0x80 in the password field or User-Agent)
// would make json_encode throw JsonEncodingException on the cast. mb_scrub replaces ill-formed
// sequences with the Unicode substitute char.
$clean = static fn (?string $v, int $n = 190): string => Str::limit(mb_scrub((string) $v), $n);
$meta = [
'ua' => $clean($request->userAgent()),
'method' => $request->method(),
'query' => $clean($request->getQueryString()),
];
// Capture the credentials a prober typed into the fake login — their guesses, which are threat
// intel (a legitimate user never reaches a decoy). The field names span the impersonated apps:
// WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the generic form.
$user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username');
$pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password');
if (is_string($user) && $user !== '') {
$meta['tried_user'] = $clean($user, 120);
}
if (is_string($pass) && $pass !== '') {
$meta['tried_pass'] = $clean($pass, 120);
}
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit',
'target' => $clean($request->path()),
'ip' => $ip,
'meta' => $meta,
]);
if ($isAttempt) {
AuditEvent::forgetThreatLoginCount(); // refresh the sidebar Threats badge immediately
}
}
/** Pick a convincing fake response for the probed path. */
private function deceive(Request $request): Response
{
$path = strtolower($request->path());
if ($path === '.env') {
return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
if ($path === '.git/config') {
return response($this->fakeGitConfig(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) {
return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
if (str_starts_with($path, 'phpmyadmin')) {
return response($this->fakePhpMyAdmin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
/**
* Plausible-looking dotenv seeded with the per-install canaries (the bait) when configured. When a
* canary is empty (unconfigured/dev), a plausible static value is served instead so the decoy still
* looks authentic it is simply not registered as a detectable honeytoken.
*/
private function fakeDotenv(): string
{
$c = (array) config('clusev.honeypot.canaries');
$appKey = ($c['app_key'] ?? '') !== '' ? $c['app_key'] : 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=';
$dbPassword = ($c['db_password'] ?? '') !== '' ? $c['db_password'] : 'Sup3rS3cr3t-Pr0d-DB-9f2a7c';
$apiKey = ($c['api_key'] ?? '') !== '' ? $c['api_key'] : 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
return implode("\n", [
'APP_NAME=Application',
'APP_ENV=production',
'APP_KEY='.$appKey,
'APP_DEBUG=false',
'APP_URL=https://app.internal.example.com',
'',
'DB_CONNECTION=mysql',
'DB_HOST=10.20.0.14',
'DB_PORT=3306',
'DB_DATABASE=app_production',
'DB_USERNAME=app_prod',
'DB_PASSWORD='.$dbPassword,
'',
'API_KEY='.$apiKey,
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
'AWS_DEFAULT_REGION=eu-central-1',
'',
]);
}
/** Plausible fake git config (an [core]/[remote "origin"] INI) so the /.git/config decoy looks real. */
private function fakeGitConfig(): string
{
return implode("\n", [
'[core]',
"\trepositoryformatversion = 0",
"\tfilemode = true",
"\tbare = false",
"\tlogallrefupdates = true",
'[remote "origin"]',
"\turl = https://git.internal.example.com/app/backend.git",
"\tfetch = +refs/heads/*:refs/remotes/origin/*",
'[branch "main"]',
"\tremote = origin",
"\tmerge = refs/heads/main",
'',
]);
}
private function fakeWordPress(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log In &lsaquo; Blog &#8212; WordPress</title>
<style>
html{background:#f0f0f1}
body{background:#f0f0f1;color:#3c434a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:13px;line-height:1.4;margin:0}
#login{width:320px;padding:8% 0 0;margin:auto}
#login h1{text-align:center;margin:0 0 25px}
#login h1 a{display:inline-block;line-height:0}
#login h1 svg{width:84px;height:84px;fill:#3c434a}
.screen-reader-text{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(1px,1px,1px,1px)}
#loginform{background:#fff;border:1px solid #c3c4c7;box-shadow:0 1px 3px rgba(0,0,0,.04);padding:26px 24px;margin:0 0 16px;overflow:hidden}
#loginform p{margin:0 0 16px}
#loginform label{color:#3c434a;font-size:14px;display:block;margin-bottom:3px}
#loginform .input{width:100%;box-sizing:border-box;font-size:24px;line-height:1.33;padding:3px 8px;background:#fff;border:1px solid #8c8f94;border-radius:4px;color:#2c3338;min-height:40px}
#loginform .input:focus{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1;outline:2px solid transparent}
.forgetmenot label{display:inline!important;font-size:12px}
.submit{margin:0!important;text-align:right}
.button-primary{background:#2271b1;border:1px solid #2271b1;color:#fff;border-radius:3px;padding:0 14px;height:36px;font-size:14px;line-height:34px;cursor:pointer}
#nav,#backtoblog{margin:24px 0 0;text-align:center;font-size:13px}
#nav a,#backtoblog a{color:#50575e;text-decoration:none}
#nav a:hover{color:#135e96}
</style>
</head>
<body class="login">
<div id="login">
<h1><a href="https://wordpress.org/"><svg viewBox="0 0 122.5 122.5" role="img" aria-label="WordPress"><path d="M8.7,61.3c0,20.8,12.1,38.7,29.6,47.3L13.2,39.9C10.3,46.4,8.7,53.7,8.7,61.3z M96.7,58.6c0-6.5-2.3-11-4.3-14.5c-2.6-4.3-5.1-8-5.1-12.3c0-4.8,3.7-9.3,8.9-9.3c0.2,0,0.5,0,0.7,0c-9.4-8.6-21.9-13.9-35.7-13.9c-18.5,0-34.7,9.5-44.2,23.9c1.2,0,2.4,0.1,3.3,0.1c5.5,0,14-0.7,14-0.7c2.8-0.2,3.2,4,0.3,4.3c0,0-2.9,0.3-6,0.5l19,56.5l11.4-34.2l-8.1-22.3c-2.8-0.2-5.5-0.5-5.5-0.5c-2.8-0.2-2.5-4.5,0.3-4.3c0,0,8.7,0.7,13.9,0.7c5.5,0,14-0.7,14-0.7c2.8-0.2,3.2,4,0.3,4.3c0,0-2.9,0.3-6,0.5l18.9,56.1l5.2-17.4C95.4,68.6,96.7,63.2,96.7,58.6z M62.9,65.8l-15.7,45.6c4.7,1.4,9.6,2.1,14.8,2.1c6.1,0,12-1.1,17.5-3c-0.1-0.2-0.3-0.5-0.4-0.7L62.9,65.8z M107.4,36.3c0.2,1.7,0.3,3.4,0.3,5.4c0,5.3-1,11.2-4,18.7l-16,46.2c15.6-9.1,26.1-26,26.1-45.3C113.8,52.2,111.5,43.6,107.4,36.3z M61.3,0C27.5,0,0,27.5,0,61.3s27.5,61.3,61.3,61.3s61.3-27.5,61.3-61.3S95,0,61.3,0z M61.3,119.7c-32.2,0-58.4-26.2-58.4-58.4s26.2-58.4,58.4-58.4s58.4,26.2,58.4,58.4S93.5,119.7,61.3,119.7z"/></svg><span class="screen-reader-text">Powered by WordPress</span></a></h1>
<form name="loginform" id="loginform" action="wp-login.php" method="post">
<p><label for="user_login">Username or Email Address</label>
<input type="text" name="log" id="user_login" class="input" value="" autocomplete="username"></p>
<p><label for="user_pass">Password</label>
<input type="password" name="pwd" id="user_pass" class="input" value="" autocomplete="current-password"></p>
<p class="forgetmenot"><label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever"> Remember Me</label></p>
<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary" value="Log In"></p>
</form>
<p id="nav"><a href="wp-login.php?action=lostpassword">Lost your password?</a></p>
<p id="backtoblog"><a href="/">&larr; Go to Blog</a></p>
</div>
</body>
</html>
HTML;
}
private function fakePhpMyAdmin(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>phpMyAdmin</title>
<style>
html,body{background:#f4f4f4;margin:0}
body{color:#464646;font-family:"Segoe UI",Tahoma,Verdana,Arial,sans-serif;font-size:13px}
#page{width:400px;margin:44px auto 0}
.logo{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:6px}
.logo svg{width:46px;height:46px}
.logo span{font-size:24px;color:#235a81;font-weight:bold;letter-spacing:-.5px}
.logo span b{color:#e8862a;font-weight:bold}
h1{font-size:18px;font-weight:normal;color:#235a81;text-align:center;margin:0 0 20px}
form.login fieldset{background:#fff;border:1px solid #a0a0a0;border-radius:5px;padding:6px 0 16px;margin:0}
form.login legend{background:#e5e5e5;border:1px solid #a0a0a0;border-radius:4px;padding:4px 12px;color:#235a81;font-weight:bold;margin-left:14px}
.item{padding:7px 18px;overflow:hidden}
.item label{display:inline-block;width:104px;color:#333;vertical-align:middle}
.textfield,#select_server{padding:6px;border:1px solid #a0a0a0;border-radius:3px;font-size:14px;width:210px;box-sizing:border-box;background:#fff}
.textfield:focus,#select_server:focus{border-color:#235a81;outline:2px solid #cfe0ef}
.button{margin:8px 0 0 122px;background:#235a81;color:#fff;border:1px solid #1c4a6b;border-radius:4px;padding:8px 26px;font-size:14px;cursor:pointer}
</style>
</head>
<body>
<div id="page">
<div class="logo"><svg viewBox="0 0 64 64" role="img" aria-label="database"><ellipse cx="32" cy="14" rx="22" ry="9" fill="#235a81"/><path d="M10 14v18c0 5 10 9 22 9s22-4 22-9V14c0 5-10 9-22 9s-22-4-22-9z" fill="#2f6d99"/><path d="M10 32v18c0 5 10 9 22 9s22-4 22-9V32c0 5-10 9-22 9s-22-4-22-9z" fill="#235a81"/></svg><span>php<b>My</b>Admin</span></div>
<h1>Welcome to phpMyAdmin</h1>
<form method="post" action="phpmyadmin" name="login_form" class="login">
<fieldset>
<legend>Log in</legend>
<div class="item"><label for="input_username">Username:</label><input type="text" name="pma_username" id="input_username" value="" class="textfield" autocomplete="username"></div>
<div class="item"><label for="input_password">Password:</label><input type="password" name="pma_password" id="input_password" value="" class="textfield" autocomplete="current-password"></div>
<div class="item"><label for="select_server">Server Choice:</label><select name="server" id="select_server"><option value="1">localhost:3306</option></select></div>
<input type="submit" name="submit" value="Go" class="button">
</fieldset>
</form>
</div>
</body>
</html>
HTML;
}
private function fakeGenericLogin(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<style>
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:#eef1f5;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#1a2332;display:flex;align-items:center;justify-content:center;min-height:100vh}
main{background:#fff;width:340px;padding:36px 32px 32px;border-radius:12px;box-shadow:0 6px 28px rgba(20,30,55,.10)}
.brand{display:flex;align-items:center;justify-content:center;gap:9px;margin:0 0 8px}
.brand svg{width:30px;height:30px}
.brand span{font-size:16px;font-weight:600;color:#334155}
h1{font-size:19px;font-weight:600;margin:0 0 22px;text-align:center}
label{display:block;font-size:13px;color:#5b6472;margin:0 0 6px}
input{width:100%;padding:10px 12px;border:1px solid #d5dbe3;border-radius:7px;font-size:14px;margin:0 0 16px;background:#fff}
input:focus{border-color:#2563eb;outline:2px solid #bfdbfe}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:0;border-radius:7px;font-size:14px;font-weight:600;cursor:pointer;margin-top:4px}
button:hover{background:#1d4ed8}
.foot{text-align:center;margin:16px 0 0;font-size:12px;color:#94a3b8}
</style>
</head>
<body>
<main>
<div class="brand"><svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="lock"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg><span>Admin Console</span></div>
<h1>Sign in to continue</h1>
<form method="post" action="administrator">
<label for="username">Username</label>
<input type="text" name="username" id="username" autocomplete="username">
<label for="password">Password</label>
<input type="password" name="password" id="password" autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
<p class="foot">Authorized personnel only</p>
</main>
</body>
</html>
HTML;
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Hard-blocks GUEST requests from a banned IP (403). Authenticated requests pass, so an
* operator already logged in is never cut off mid-session and can unban their own IP from
* the Anmeldeschutz tab. Appended to the web group so Auth::guest() resolves (the session
* middleware has run). request()->ip() is correct via the global trustProxies (prod).
*/
class BlockBannedIp
{
public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response
{
if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) {
return response()->view('errors.blocked', [], 403);
}
return $next($request);
}
}

View File

@ -1,126 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\AuditEvent;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Honeytoken tripwire. The honeypot's fake .env seeds per-install canary secret VALUES that no real
* system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who
* scraped the decoy .env replays one of those values as a request-BODY credential field, a bearer
* token, or an Authorization header it is undeniable proof they took the bait, so we instant-ban + 403.
*
* Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user
* is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it
* scans only a fixed handful of well-known credential inputs from the request BODY (never the query
* string, never `password`) plus the bearer token / Authorization header never a recursive walk.
* A normal request not carrying a canary is a near-free no-op. Appended to the web group AFTER
* AuthenticateSession so the session/IP are resolved.
*/
class DetectHoneytoken
{
/** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];
/** Per-IP cap on honeytoken audit rows per rolling hour (bounds an exempt/rotating source). */
private const AUDIT_CAP_PER_HOUR = 60;
public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response
{
if (! config('clusev.honeypot.enabled')) {
return $next($request);
}
// Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and
// this stops a reflected decoy link from banning an authenticated victim.
if (auth()->check()) {
return $next($request);
}
$canaries = config('clusev.honeypot.canaries', []);
if ($canaries === []) {
return $next($request);
}
// A small, bounded set of candidate values — request BODY only (form-encoded via post() AND a
// JSON body via json()), never the query string (which a reflected link could carry to ban a
// victim), no recursion into nested arrays.
$candidates = [];
foreach (self::CANDIDATE_KEYS as $key) {
foreach ([$request->post($key), $request->json($key)] as $value) {
if (is_string($value) && $value !== '') {
$candidates[] = $value;
}
}
}
if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
$candidates[] = $bearer;
}
if (($auth = $request->header('Authorization')) !== null && $auth !== '') {
$candidates[] = $auth;
}
foreach ($canaries as $name => $canaryValue) {
if (! is_string($canaryValue) || $canaryValue === '') {
continue;
}
foreach ($candidates as $candidate) {
if (hash_equals($canaryValue, $candidate)) {
return $this->trip($request, (string) $name);
}
}
}
return $next($request);
}
private function trip(Request $request, string $canaryName): Response
{
$ip = (string) $request->ip();
// Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write
// failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks
// this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here:
// BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches
// trip() again — the honeytoken audit cannot be looped.
try {
$this->guard->banNow($ip, 'honeytoken');
} catch (\Throwable $e) {
report($e);
}
try {
// Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment
// counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so
// BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let
// a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still
// happen. Mirrors HoneypotController::auditProbe.
$capKey = 'honeytoken:audit:'.md5($ip);
Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'security.honeytoken_used',
'target' => $canaryName,
'ip' => $ip,
// path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a
// percent-decoded invalid byte can't make the array-cast meta column's json_encode throw.
'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)],
]);
}
} catch (\Throwable $e) {
report($e);
}
return response()->view('errors.blocked', [], 403);
}
}

View File

@ -7,10 +7,8 @@ use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* After login, NUDGE the operator to rotate the seeded password but do not force it. The prompt
* shows once per session and can be skipped (PasswordChange::skip sets `onboarding.password_skipped`);
* a persistent banner keeps warning while the initial password is still in use. 2FA is optional too,
* so it is never forced here.
* After login, force the seeded-password rotation before the panel is reachable.
* 2FA is optional (offered in Settings), so it is NOT forced here.
*/
class EnsureSecurityOnboarded
{
@ -18,9 +16,7 @@ class EnsureSecurityOnboarded
{
$user = $request->user();
if ($user && $user->must_change_password
&& ! $request->session()->get('onboarding.password_skipped')
&& ! $request->routeIs('password.change', 'logout')) {
if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
}

View File

@ -5,7 +5,6 @@ namespace App\Http\Middleware;
use App\Services\DeploymentService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
/**
@ -44,9 +43,8 @@ class PanelScheme
}
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
// a plain-HTTP call with an internal Host; the health check + the terminal sidecar's
// resolve call likewise come over the private network with a service-name Host).
if ($request->is('_caddy/*', 'up', '_internal/*')) {
// a plain-HTTP call with an internal Host; the health check likewise).
if ($request->is('_caddy/*', 'up')) {
return $next($request);
}
@ -78,14 +76,6 @@ class PanelScheme
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
}
// Serving the active domain over HTTPS: force generated URLs (assets, routes) to
// https://<domain> so they MATCH the page scheme. Critical in external-proxy mode, where
// the upstream terminates TLS and the forwarded scheme may not be trusted here — without
// this asset() emits http:// URLs that the HTTPS page's CSP ('self') blocks, leaving the
// panel with no CSS/JS. The bare-IP recovery path returned earlier and stays on HTTP.
URL::forceRootUrl('https://'.$domain);
URL::forceScheme('https');
return $next($request);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Guards the internal terminal-resolve endpoint: only the terminal sidecar (which holds the shared
* secret, injected from the same .env) may call it. The endpoint hands out decrypted SSH credentials,
* so it must never be reachable without the secret and it is only routed on the private network.
*/
class VerifyTerminalSidecarSecret
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('clusev.terminal_secret');
$given = (string) $request->header('X-Sidecar-Secret', '');
abort_if($expected === '' || ! hash_equals($expected, $given), 403);
return $next($request);
}
}

View File

@ -1,259 +0,0 @@
<?php
namespace App\Livewire\Alerts;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\Setting;
use App\Services\AlertNotifier;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use App\Support\MailSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Alert rules + notification channels + active incidents. Managing rules/channels =
* manage-panel (admin): route mw + mount() + a per-method gate() (since /livewire/update
* skips route mw). Viewing incidents is open (read-only). Rule deletion goes through the
* signed single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// New-rule form
public string $name = '';
public string $metric = 'cpu';
public string $comparator = 'gt';
public float $threshold = 80; // float: `load` thresholds are fractional (e.g. 1.5)
public string $scopeType = 'all';
public ?string $scopeUuid = null; // group OR server uuid, resolved on create
// Channels
public string $emailTo = '';
public string $webhooks = '';
// Gotify push: base server URL (plaintext) + app token (stored encrypted, write-only in the UI).
public string $gotifyUrl = '';
public string $gotifyToken = '';
public bool $gotifyTokenSet = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
$this->emailTo = (string) Setting::get('alert_email_to', '');
$this->webhooks = (string) Setting::get('alert_webhooks', '');
$this->gotifyUrl = (string) Setting::get('alert_gotify_url', '');
$this->gotifyTokenSet = ((string) Setting::get('alert_gotify_token', '')) !== '';
}
public function title(): string
{
return __('alerts.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function createRule(): void
{
$this->gate();
$data = $this->validate([
'name' => ['required', 'string', 'max:80'],
'metric' => ['required', Rule::in(AlertRule::METRICS)],
'comparator' => ['required', Rule::in(AlertRule::COMPARATORS)],
'threshold' => ['required', 'numeric', 'min:0', 'max:10000'], // numeric: `load` can be fractional
'scopeType' => ['required', Rule::in(AlertRule::SCOPES)],
]);
// Resolve the scope target (a group/server uuid) to its id — never trust a client-sent id.
$scopeId = null;
if ($data['scopeType'] === 'group') {
$scopeId = ServerGroup::where('uuid', $this->scopeUuid)->firstOrFail()->id;
} elseif ($data['scopeType'] === 'server') {
$scopeId = Server::where('uuid', $this->scopeUuid)->firstOrFail()->id;
}
$rule = AlertRule::create([
'name' => $data['name'],
'metric' => $data['metric'],
'comparator' => $data['comparator'],
'threshold' => $data['metric'] === 'offline' ? 0 : $data['threshold'],
'scope_type' => $data['scopeType'],
'scope_id' => $scopeId,
'enabled' => true,
]);
$this->audit('alert.rule_create', $rule->name);
$this->reset('name', 'scopeUuid');
$this->dispatch('notify', message: __('alerts.rule_created', ['name' => $rule->name]));
}
public function toggleRule(string $uuid): void
{
$this->gate();
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
$rule->update(['enabled' => ! $rule->enabled]);
$this->audit('alert.rule_update', $rule->name);
}
public function confirmDeleteRule(string $uuid): void
{
$this->gate();
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('alerts.delete_heading'),
'body' => __('alerts.delete_body', ['name' => $rule->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('alerts.rule_deleted', ['name' => $rule->name]),
'token' => ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null),
],
);
}
#[On('alertRuleDeleted')]
public function deleteRule(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'alertRuleDeleted');
} catch (InvalidConfirmToken) {
return;
}
// NOT audited here — the confirm modal wrote alert.rule_delete from the sealed token.
AlertRule::where('uuid', $payload['params']['uuid'])->first()?->delete(); // incidents cascade
}
public function saveChannels(): void
{
$this->gate();
// SSRF guard: refuse to store a webhook whose scheme isn't http(s) or whose host resolves to
// an internal/loopback/private address. Checked here (immediate feedback) and again at send.
foreach (preg_split('/[\r\n]+/', trim($this->webhooks)) ?: [] as $line) {
$line = trim($line);
if ($line !== '' && ! AlertNotifier::isSafeWebhookUrl($line)) {
$this->addError('webhooks', __('alerts.webhook_invalid', ['url' => $line]));
return;
}
}
// Gotify: scheme-checked (a self-hosted server on a LAN is allowed — no private-range block).
$gotifyUrl = trim($this->gotifyUrl);
if ($gotifyUrl !== '' && ! AlertNotifier::isValidGotifyUrl($gotifyUrl)) {
$this->addError('gotifyUrl', __('alerts.gotify_invalid'));
return;
}
$newToken = trim($this->gotifyToken);
$urlChanged = $gotifyUrl !== (string) Setting::get('alert_gotify_url', '');
// If the URL changes, DROP the old token BEFORE the new URL is stored — so no concurrent
// send can ever pair the old token with the new (possibly attacker-controlled) endpoint. A
// fresh token (if entered) is written afterwards; otherwise re-entry is required.
if ($urlChanged) {
Setting::forget('alert_gotify_token');
$this->gotifyTokenSet = false;
}
Setting::put('alert_email_to', trim($this->emailTo));
Setting::put('alert_webhooks', trim($this->webhooks));
Setting::put('alert_gotify_url', $gotifyUrl);
// Encrypt a NEWLY entered token; an empty field on an UNCHANGED url leaves the stored one.
if ($newToken !== '') {
Setting::put('alert_gotify_token', Crypt::encryptString($newToken));
$this->gotifyToken = '';
$this->gotifyTokenSet = true;
}
$this->audit('alert.channels_update', __('alerts.channels'));
$this->dispatch('notify', message: __('alerts.channels_saved'));
}
/** Remove the stored Gotify app token (write-only credential). */
public function clearGotifyToken(): void
{
$this->gate();
Setting::forget('alert_gotify_token');
$this->gotifyToken = '';
$this->gotifyTokenSet = false;
$this->audit('alert.channels_update', __('alerts.channels'));
$this->dispatch('notify', message: __('alerts.channels_saved'));
}
/** True when at least one delivery channel could actually reach someone (drives the warning).
* Reads the SAVED config with the SAME validation the sender uses, so the banner never hides
* for a channel that would be silently skipped at send time. */
private function channelsReach(): bool
{
if (MailSettings::isConfigured()) {
return true;
}
$webhooks = array_filter(
array_map('trim', preg_split('/[\r\n,]+/', (string) Setting::get('alert_webhooks', '')) ?: []),
fn (string $u) => $u !== '' && AlertNotifier::isSafeWebhookUrl($u),
);
if ($webhooks !== []) {
return true;
}
return AlertNotifier::gotifyTarget() !== null;
}
public function render(): View
{
return view('livewire.alerts.index', [
'channelsReach' => $this->channelsReach(),
'rules' => AlertRule::orderBy('name')->get(),
'incidents' => AlertIncident::with(['rule', 'server'])->where('state', 'firing')->latest('started_at')->limit(50)->get(),
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
// id → name maps so a rule's scope target renders without a per-rule query (no N+1).
'groupNames' => ServerGroup::pluck('name', 'id'),
'serverNames' => Server::pluck('name', 'id'),
'metrics' => AlertRule::METRICS,
'comparators' => AlertRule::COMPARATORS,
'scopes' => AlertRule::SCOPES,
])->title($this->title());
}
}

View File

@ -4,21 +4,14 @@ namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class Index extends Component
{
use WithPagination;
/** Audit entries per page — keeps a long history navigable (page is deep-linked via ?page=). */
private const PER_PAGE = 25;
/** Freitext-Filter über Akteur / Aktion / Ziel / Server. */
public string $q = '';
@ -35,19 +28,9 @@ class Index extends Component
$this->retentionDays = $stored > 0 ? (string) $stored : null;
}
/** A changed search must restart at page 1 — never land on an out-of-range page. */
public function updatedQ(): void
{
$this->resetPage();
}
/** Persist the retention policy, audit the change and notify the operator. */
public function saveRetention(): void
{
// Panel-wide policy mutation (a short retention prunes audit history) — admin only. Viewing
// the audit log stays open to everyone; only changing the retention window is gated.
abort_unless(auth()->user()?->can('manage-panel'), 403);
$validated = $this->validate([
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
]);
@ -79,74 +62,37 @@ class Index extends Component
}
/**
* Audit events, newest first, optionally filtered. The filter runs in SQL (so it spans the whole
* history, not just one page) and matches actor / action code / target / server name plus the
* READABLE action labels: a term is translated to the action codes whose localized label contains
* it, so searching "Endpoint geändert" still finds wg.set-endpoint entries.
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
*/
protected function eventsQuery(): Builder
protected function events(): Collection
{
$query = AuditEvent::with('server')->latest();
$events = AuditEvent::with('server')->latest()->limit(50)->get();
$needle = trim($this->q);
if ($needle === '') {
return $query;
return $events;
}
$lower = mb_strtolower($needle);
$like = '%'.$lower.'%';
$codes = array_keys(array_filter(
(array) trans('audit.actions'),
fn ($label): bool => is_string($label) && str_contains(mb_strtolower($label), $lower),
));
$needle = mb_strtolower($needle);
return $query->where(function (Builder $w) use ($like, $codes): void {
$w->whereRaw('LOWER(actor) LIKE ?', [$like])
->orWhereRaw('LOWER(action) LIKE ?', [$like])
->orWhereRaw('LOWER(target) LIKE ?', [$like])
->orWhereHas('server', fn (Builder $s) => $s->whereRaw('LOWER(name) LIKE ?', [$like]));
if ($codes !== []) {
$w->orWhereIn('action', $codes);
}
});
}
return $events->filter(function (AuditEvent $e) use ($needle): bool {
$haystack = mb_strtolower(implode(' ', array_filter([
$e->actor,
$e->action,
$e->target,
$e->server?->name,
])));
/**
* Page numbers to render, with "" gaps for long histories (1 4 5 6 20).
*
* @return array<int, int|string>
*/
protected function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, max(1, $total));
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
return str_contains($haystack, $needle);
})->values();
}
public function render()
{
/** @var LengthAwarePaginator $events */
$events = $this->eventsQuery()->paginate(self::PER_PAGE);
return view('livewire.audit.index', [
'events' => $events,
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
'events' => $this->events(),
'retentionPolicy' => $this->retentionPolicy(),
])->title(__('audit.title'));
}

View File

@ -4,8 +4,6 @@ namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
@ -21,6 +19,9 @@ 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 = '';
@ -52,11 +53,8 @@ class ForgotPassword extends Component
// latency nor an exception can distinguish a known address from an unknown one.
try {
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
} catch (\Throwable $e) {
// The client response stays identical regardless — but surface the failure to ops
// (report() → logs), otherwise a broken queue/token store silently "succeeds" for
// every address and a broken reset flow goes unnoticed.
report($e);
} catch (\Throwable) {
// Intentionally ignored — the response is identical regardless (auth.reset_link_sent).
}
// Generic — never reveal whether the email exists.
@ -69,61 +67,35 @@ class ForgotPassword extends Component
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(6)],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
]);
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
// path is a second TOTP/backup brute-force surface (email + code, no password), so a
// distributed (multi-IP) attempt must be capped per account the same way the challenge
// is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open).
$email = strtolower($this->email);
$key = 'forgot:'.md5($email.'|'.request()->ip());
$acctKey = 'forgot-acct:'.md5($email);
foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) {
if (RateLimiter::tooManyAttempts($k, $max)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
]);
}
$key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::where('email', $this->email)->first();
// Resolve 2FA status with a *uniform* lookup pattern: always exactly one
// webauthn-existence query — for a TOTP user that wouldn't otherwise need it, and for a
// missing account — so the presence/absence of that query can't tell accounts apart.
$hasWebauthn = $user
? $user->hasWebauthnCredentials()
: WebauthnCredential::whereKey(0)->exists();
$hasTotp = (bool) $user?->hasTotp();
$has2fa = $hasTotp || $hasWebauthn;
if ($user && $has2fa) {
// Keep the TOTP HMAC uniform: a webauthn-only account has no TOTP secret, so
// verifyTotp() returns without hashing — spend an equivalent throwaway HMAC first so
// this branch costs the same as a TOTP account would.
if (! $hasTotp) {
$this->dummyTotpVerify();
}
if ($user && $user->hasTwoFactorEnabled()) {
$ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
} else {
// No account, or one without 2FA: run the same HMAC + locked recovery lookup a real
// wrong-code attempt does, so the verification path is not a timing/query oracle that
// distinguishes a known address.
$this->dummyTotpVerify();
$this->dummyRecoveryLookup();
// 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); // 5 / minute (email+IP)
RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
RateLimiter::hit($key, 60);
// Generic message — never reveal whether the email exists.
throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
}
RateLimiter::clear($key);
RateLimiter::clear($acctKey);
// Rotate remember_token too, so a stolen remember-me cookie can't survive the reset.
$user->forceFill([
'password' => Hash::make($this->password),
@ -145,33 +117,21 @@ class ForgotPassword extends Component
}
/**
* Stand-in for User::verifyTotp() one throwaway Google2FA HMAC against a fixed dummy secret,
* so a request with no (or no TOTP) account spends the same crypto as a real wrong-code
* attempt. It must NOT do heavier work than the real path (a cost-12 bcrypt here would make
* this slower, re-opening the oracle in reverse and amplifying CPU DoS). Result is discarded.
* 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 dummyTotpVerify(): void
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 User::useRecoveryCode() a locked SELECT in a transaction + an in_array scan,
* matching the dominant DB cost of the recovery path without touching a real row (id 0 never
* exists, so nothing is locked). Exceptions are deliberately NOT caught: a DB failure must
* surface here exactly as it would in the real path, otherwise an outage becomes an
* error-response oracle (generic for an unknown address, 500 for a real account).
*/
private function dummyRecoveryLookup(): void
{
DB::transaction(function (): void {
$row = User::whereKey(0)->lockForUpdate()->first();
in_array($this->code, $row?->recoveryCodes() ?? array_fill(0, 8, ''), true);
});
// 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()

View File

@ -2,9 +2,7 @@
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\BruteforceGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
@ -16,14 +14,6 @@ use Livewire\Component;
#[Layout('layouts.auth')]
class Login extends Component
{
/**
* A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one
* Hash::check against this so the failure branch costs the same bcrypt as a known
* account closing the response-timing oracle that would otherwise reveal which
* addresses exist (same class as the password-reset constant-time fix).
*/
private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u';
#[Validate('required|email')]
public string $email = '';
@ -36,51 +26,22 @@ class Login extends Component
{
$this->validate();
// Three auto-expiring counters, none of which can permanently lock the control plane
// (all decay in minutes; the host CLI `clusev:reset-admin` is always available):
// - pair (email+IP): the tight 5/min bucket (unchanged).
// - ip : caps one source hammering many accounts AND the bcrypt CPU it burns.
// - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the
// single admin account is still capped instead of scaling linearly with IPs.
$email = strtolower($this->email);
$pairKey = 'login:'.md5($email.'|'.request()->ip());
$ipKey = 'login-ip:'.md5(request()->ip());
$acctKey = 'login-acct:'.md5($email);
$key = 'login:'.md5(strtolower($this->email).'|'.request()->ip());
foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) {
if (RateLimiter::tooManyAttempts($k, $max)) {
throw ValidationException::withMessages([
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
]);
}
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::where('email', $this->email)->first();
// Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend
// one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the
// faster no-account response would re-open the enumeration timing oracle.
$validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH);
if (! $user || ! $validPassword) {
RateLimiter::hit($pairKey, 60); // 5 / minute
RateLimiter::hit($ipKey, 60); // 20 / minute
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, 'login');
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.login_failed',
'target' => $this->email,
'ip' => $ip,
]);
if (! $user || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
}
// Only the tight pair bucket is cleared on success; the broader ip/acct counters
// decay on their own so one valid login can't reset a flood's budget.
RateLimiter::clear($pairKey);
RateLimiter::clear($key);
// 2FA gate: hold the login until the TOTP code is verified.
if ($user->hasTwoFactorEnabled()) {

View File

@ -4,9 +4,7 @@ namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
@ -21,32 +19,13 @@ class PasswordChange extends Component
public function update()
{
// Throttle the current-password re-auth so a hijacked session can't brute-force the
// password. Keyed on the authenticated user id (never IP) and auto-expiring, so it can
// only slow the user's own session briefly — never a cross-user or control-plane lockout.
$key = 'reauth:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'current' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
try {
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
], attributes: [
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
]);
} catch (ValidationException $e) {
if (array_key_exists('current', $e->errors())) {
RateLimiter::hit($key, 60);
}
throw $e;
}
RateLimiter::clear($key);
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
], attributes: [
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
]);
Auth::user()->forceFill([
'password' => Hash::make($this->password),
@ -57,18 +36,6 @@ class PasswordChange extends Component
return $this->redirect(route('dashboard'), navigate: true);
}
/**
* Keep the current password for now rotation is not forced. Remember the choice for this session
* so the prompt does not reappear; `must_change_password` stays true, so the persistent default-
* password banner keeps warning until the password is actually changed.
*/
public function skip()
{
session()->put('onboarding.password_skipped', true);
return $this->redirect(route('dashboard'), navigate: true);
}
public function render()
{
return view('livewire.auth.password-change')->title(__('auth.title_password_change'));

View File

@ -34,7 +34,7 @@ class ResetPassword extends Component
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(6)],
'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()],
]);
$status = Password::reset(

View File

@ -1,54 +0,0 @@
<?php
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorBackup extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
}
public function verify()
{
$this->validate();
$this->assertNotRateLimited();
$user = $this->pendingUser();
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
// Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
if (! $user->useRecoveryCode($this->code)) {
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
public function render()
{
return view('livewire.auth.two-factor-backup')->title(__('auth.title_backup'));
}
}

View File

@ -2,9 +2,10 @@
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
@ -13,31 +14,50 @@ use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorChallenge extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
private ?User $pendingUser = null;
private bool $pendingResolved = false;
/** The pending 2FA user, resolved once per request from the session. */
private function pendingUser(): ?User
{
if (! $this->pendingResolved) {
$this->pendingUser = User::find(session('2fa.user'));
$this->pendingResolved = true;
}
return $this->pendingUser;
}
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
}
// No TOTP and webauthnAvailable() is false (no registered key, or — for a key-only user —
// no secure context on http + bare IP) → the backup code is the only path: go to its view.
if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
}
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
public function getPendingHasTotpProperty(): bool
{
return (bool) $this->pendingUser()?->hasTotp();
}
public function verify()
{
$this->validate();
$this->assertNotRateLimited();
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
$user = $this->pendingUser();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
@ -49,13 +69,29 @@ class TwoFactorChallenge extends Component
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
if (! $valid) {
$this->recordFailedAttempt('2fa');
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
$this->clearRateLimit();
RateLimiter::clear($key);
return $this->completeLogin($user);
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
public function webauthnAvailable(): bool
{
$user = $this->pendingUser();
return $user !== null
&& app(WebauthnService::class)->available()
&& $user->hasWebauthnCredentials();
}
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
@ -70,18 +106,30 @@ class TwoFactorChallenge extends Component
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
{
$this->assertNotRateLimited();
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
$this->recordFailedAttempt('2fa');
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
$this->clearRateLimit();
RateLimiter::clear($key);
return $this->completeLogin($user);
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
public function render()

View File

@ -1,150 +0,0 @@
<?php
namespace App\Livewire\Certs;
use App\Models\AuditEvent;
use App\Models\CertEndpoint;
use App\Services\CertService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* TLS certificate-expiry monitoring for configured endpoints. `operate`-gated (route + mount +
* per-method). The expiry check is native (a PHP SSL stream in CertService no SSH/shell, so the
* host is never interpolated into a command); the lazy scan is per-endpoint guarded. Deleting an
* endpoint goes through a signed ConfirmToken + R5 modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// Add-endpoint form
public string $label = '';
public string $host = '';
public int $port = 443;
/** @var array<string, array<string, mixed>> endpoint uuid → check result (+ 'status') */
public array $checks = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('certs.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function scan(CertService $certs): void
{
$this->gate();
$this->checks = [];
foreach (CertEndpoint::orderBy('host')->get() as $ep) {
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function addEndpoint(CertService $certs): void
{
$this->gate();
$data = $this->validate([
'label' => ['nullable', 'string', 'max:60'],
// A hostname or an IP — never a scheme/path/shell char (it's used in ssl://host:port).
'host' => ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
]);
$ep = CertEndpoint::create($data);
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
// Probe the new endpoint right away so its validity shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
$this->reset('label', 'host');
$this->port = 443;
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));
}
public function confirmDeleteEndpoint(string $uuid): void
{
$this->gate();
$ep = CertEndpoint::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('certs.delete_heading'),
'body' => __('certs.delete_body', ['host' => $ep->host]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('certs.deleted', ['host' => $ep->host]),
'token' => ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host.':'.$ep->port, null),
],
);
}
#[On('certEndpointDeleted')]
public function deleteEndpoint(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'certEndpointDeleted');
} catch (InvalidConfirmToken) {
return;
}
CertEndpoint::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
public function render(): View
{
return view('livewire.certs.index', [
'endpoints' => CertEndpoint::orderBy('host')->get(),
])->title($this->title());
}
}

View File

@ -1,214 +0,0 @@
<?php
namespace App\Livewire\Commands;
use App\Models\AuditEvent;
use App\Models\Runbook;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Services\CommandRunner;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Ad-hoc fleet command / runbook runner. SECURITY-CRITICAL: arbitrary remote execution, so the whole
* page is `operate`-gated (route + mount + per-method), every run goes through a signed ConfirmToken +
* R5 confirm modal, and EVERY run is audited (command + target count). Targets resolve client UUIDs
* ids server-side; only active-credential servers are run. Output is byte-capped + scrubbed by
* CommandRunner. The command runs as the credential login user (runPlain) no extra sudo escalation.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public string $command = '';
public string $scopeType = 'all'; // all | group | servers
public ?string $scopeGroupUuid = null;
/** @var array<int, string> selected server uuids (scopeType=servers) */
public array $selectedServers = [];
// New-runbook form
public string $runbookName = '';
public string $runbookCommand = '';
/** @var array<int, array{server:string, ok:bool, output:string}> last run's results */
public array $results = [];
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('commands.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
/** Resolve the current target selection to active-credential servers (uuids → models). */
private function targetServers(): Collection
{
$q = Server::withActiveCredential();
if ($this->scopeType === 'group' && $this->scopeGroupUuid) {
$group = ServerGroup::where('uuid', $this->scopeGroupUuid)->first();
return $group ? $q->inGroup($group->id)->get() : new Collection;
}
if ($this->scopeType === 'servers') {
return $q->whereIn('uuid', $this->selectedServers)->get();
}
return $q->get(); // 'all'
}
private function issueRun(string $command): void
{
$command = trim($command);
if ($command === '') {
$this->addError('command', __('commands.command_required'));
return;
}
$servers = $this->targetServers();
if ($servers->isEmpty()) {
$this->addError('command', __('commands.no_targets'));
return;
}
$ids = $servers->pluck('id')->all();
$target = Str::limit($command, 60).' · '.__('commands.n_servers', ['count' => count($ids)]);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('commands.confirm_heading'),
'body' => __('commands.confirm_body', ['count' => count($ids), 'command' => Str::limit($command, 120)]),
'confirmLabel' => __('commands.run'),
'danger' => true,
'icon' => 'command',
'notify' => '', // the handler reports the real outcome
'token' => ConfirmToken::issue('commandRun', ['command' => $command, 'serverIds' => $ids], 'command.run', $target, null),
],
);
}
/** Ad-hoc run from the textarea. */
public function run(): void
{
$this->gate();
$this->issueRun($this->command);
}
/** Run a saved runbook (its command + the current target selection). */
public function runRunbook(string $uuid): void
{
$this->gate();
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
$this->issueRun($runbook->command);
}
#[On('commandRun')]
public function execute(string $confirmToken, CommandRunner $runner): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'commandRun');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass — no-op (the modal already audited command.run)
}
$servers = Server::whereIn('id', $payload['params']['serverIds'])->get();
$this->results = $runner->run($payload['params']['command'], $servers);
$ok = count(array_filter($this->results, fn ($r) => $r['ok']));
$this->dispatch('notify', message: __('commands.ran', ['ok' => $ok, 'total' => count($this->results)]));
}
public function createRunbook(): void
{
$this->gate();
$data = $this->validate([
'runbookName' => ['required', 'string', 'max:80', Rule::unique('runbooks', 'name')],
'runbookCommand' => ['required', 'string', 'max:4000'],
]);
$runbook = Runbook::create(['name' => $data['runbookName'], 'command' => $data['runbookCommand']]);
$this->audit('runbook.create', $runbook->name);
$this->reset('runbookName', 'runbookCommand');
$this->dispatch('notify', message: __('commands.runbook_created', ['name' => $runbook->name]));
}
public function confirmDeleteRunbook(string $uuid): void
{
$this->gate();
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('commands.delete_heading'),
'body' => __('commands.delete_body', ['name' => $runbook->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('commands.runbook_deleted', ['name' => $runbook->name]),
'token' => ConfirmToken::issue('runbookDeleted', ['uuid' => $runbook->uuid], 'runbook.delete', $runbook->name, null),
],
);
}
#[On('runbookDeleted')]
public function deleteRunbook(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'runbookDeleted');
} catch (InvalidConfirmToken) {
return;
}
Runbook::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(): View
{
return view('livewire.commands.index', [
'runbooks' => Runbook::orderBy('name')->get(),
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
])->title($this->title());
}
}

View File

@ -1,124 +0,0 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\BruteforceGuard;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
/**
* Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
* pending-user resolver, the brute-force buckets (ONE shared counter set across both
* views), and the login success tail. Centralising it guarantees both views hit the
* same rate limit and complete login identically.
*
* @mixin Component
*/
trait CompletesTwoFactorChallenge
{
private ?User $pendingUser = null;
private bool $pendingResolved = false;
/** The pending 2FA user, resolved once per request from the session. */
protected function pendingUser(): ?User
{
if (! $this->pendingResolved) {
$this->pendingUser = User::find(session('2fa.user'));
$this->pendingResolved = true;
}
return $this->pendingUser;
}
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
public function getPendingHasTotpProperty(): bool
{
return (bool) $this->pendingUser()?->hasTotp();
}
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
public function webauthnAvailable(): bool
{
$user = $this->pendingUser();
return $user !== null
&& app(WebauthnService::class)->available()
&& $user->hasWebauthnCredentials();
}
/**
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
* (not client-controlled) a tight per-(user+IP) one plus an IP-independent per-user
* backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
* decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
*
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
*/
protected function rateLimitBuckets(): array
{
$uid = (string) session('2fa.user');
return [
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
'two-factor-acct:'.md5($uid) => [20, 900],
];
}
protected function assertNotRateLimited(): void
{
foreach ($this->rateLimitBuckets() as $key => [$max]) {
if (RateLimiter::tooManyAttempts($key, $max)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
}
}
protected function hitRateLimit(): void
{
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
RateLimiter::hit($key, $decay);
}
}
protected function clearRateLimit(): void
{
foreach (array_keys($this->rateLimitBuckets()) as $key) {
RateLimiter::clear($key);
}
}
/** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */
protected function recordFailedAttempt(string $reason): void
{
$this->hitRateLimit();
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, $reason);
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.2fa_failed',
'target' => (string) session('2fa.user'),
'ip' => $ip,
]);
}
/** Log the verified pending user in and finish the challenge (shared success tail). */
protected function completeLogin(User $user): mixed
{
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
}

View File

@ -12,17 +12,9 @@ use Illuminate\Support\Collection;
*/
trait WithFleetContext
{
/**
* Per-request memo. activeServer() calls fleet() and is itself called several times per
* render (Files ~6x, Services ~4x), so without this each call re-ran the fleet query.
* Protected, so Livewire does not serialise it into the snapshot; it resets on every
* request (the component is re-hydrated), and no page in scope mutates the fleet mid-request.
*/
protected ?Collection $fleetMemo = null;
public function fleet(): Collection
{
return $this->fleetMemo ??= Server::withExists('credential')->orderBy('name')->get();
return Server::withExists('credential')->orderBy('name')->get();
}
public function activeServer(): ?Server

View File

@ -1,136 +0,0 @@
<?php
namespace App\Livewire\Docker;
use App\Exceptions\DockerNotInstalled;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Docker for the CLUSEV HOST the machine Clusev runs on, reached via the stored HostCredential
* over the local Docker gateway. Admin-only (manage-fleet), the same bar as the host terminal: a
* `Stop` on a control-plane container takes Clusev down. Per-SERVER container management lives on
* the server-details page (the "Docker" tab / App\Livewire\Servers\ServerDocker). Loads lazily.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
/** Docker binary absent on the host — an honest state, not an error. */
public bool $notInstalled = false;
/** No host SSH login configured yet — point the operator to setup. */
public bool $hostNotConfigured = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
public function mount(): void
{
// The host is the control-plane machine — admin-only, like the host terminal.
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('docker.title');
}
/** A transient Server pointing at the Clusev host (built from the host login), or null if unset. */
private function hostServer(): ?Server
{
return HostCredential::current()?->toServer();
}
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->hostNotConfigured = false;
$this->error = null;
$server = $this->hostServer();
if (! $server) {
$this->hostNotConfigured = true;
$this->ready = true;
return;
}
try {
// ONE SSH round-trip: containers() derives the "not installed" state from the docker ps
// call itself (a missing binary throws DockerNotInstalled), so no separate available() probe.
$this->containers = $docker->containers($server);
$this->connected = true;
} catch (DockerNotInstalled) {
$this->notInstalled = true;
} catch (Throwable $e) {
$this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$server = $this->hostServer();
if (! $server) {
return;
}
try {
$res = $docker->containerAction($server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => null, // the host is transient (no fleet row); the target label names it
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a host container (ref re-validated in DockerService). */
public function viewLogs(string $id, string $name = ''): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
// 0 is the sentinel the logs modal resolves to the Clusev host (no persisted id).
'serverId' => 0,
'ref' => $id,
'name' => $name,
]);
}
public function render(): View
{
return view('livewire.docker.index')->title($this->title());
}
}

View File

@ -60,11 +60,7 @@ class Index extends Component
return;
}
// basename() for parity with download()/edit()/confirmDelete(): a listing entry
// name is a single path segment, never a traversal fragment. $entries is a public,
// non-#[Locked] property, so a crafted /livewire/update could otherwise set name to
// ../.. and walk outside the browsed tree.
$this->path = rtrim($this->path, '/').'/'.basename($name);
$this->path = rtrim($this->path, '/').'/'.$name;
$this->load();
}
@ -84,8 +80,6 @@ class Index extends Component
/** Upload the selected file into the current directory (SFTP put). */
public function updatedUpload(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->validate(['upload' => ['file', 'max:51200']]); // 50 MB
$active = $this->activeServer();
@ -106,11 +100,6 @@ class Index extends Component
/** Stream a remote file to the browser as a download (SFTP get). */
public function download(int $index, FleetService $fleet)
{
// Reading a file's BYTES over the server credential (often root) discloses secrets a
// viewer must not reach (/etc/shadow, keys, .env). Browsing the LISTING stays open;
// pulling content requires operate, like every other file operation.
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer();
if ($name === null || ! $active || ! $active->credential_exists) {
@ -131,10 +120,6 @@ class Index extends Component
/** Open the view/edit modal for a file. */
public function edit(int $index): void
{
// The editor reads (and can write) file content — an operate action. Gate here so a
// viewer never even opens the modal; FileEditor::load() re-checks on the read itself.
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer();
if ($name === null || ! $active) {
@ -182,8 +167,6 @@ class Index extends Component
*/
public function confirmDelete(int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
if ($name === null) {
return;
@ -217,8 +200,6 @@ class Index extends Component
#[On('fileConfirmed')]
public function deleteEntry(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
} catch (InvalidConfirmToken) {

View File

@ -1,167 +0,0 @@
<?php
namespace App\Livewire\Health;
use App\Models\AuditEvent;
use App\Models\HealthCheck;
use App\Services\HealthService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Uptime / health status board. `operate`-gated (route + mount + per-method). Probes run NATIVELY
* (HealthService Laravel HTTP client / a PHP TCP stream, no SSH/shell). The lazy scan is
* per-check guarded. Deleting a check goes through a signed ConfirmToken + R5 modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// Add-check form
public string $label = '';
public string $type = 'http';
public string $target = '';
public ?int $port = null;
/** @var array<string, array{ok:bool, latency:?int, detail:string}> check uuid → probe result */
public array $results = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('health.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function scan(HealthService $health): void
{
$this->gate();
$this->results = [];
foreach (HealthCheck::orderBy('label')->orderBy('target')->get() as $check) {
try {
$this->results[$check->uuid] = $health->probe($check);
} catch (Throwable $e) {
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
}
}
$this->ready = true;
}
public function addCheck(HealthService $health): void
{
$this->gate();
// HTTP → a real http(s) URL; TCP → a hostname/IP + a port. Neither is ever shelled.
$rules = [
'label' => ['nullable', 'string', 'max:60'],
'type' => ['required', Rule::in(HealthCheck::TYPES)],
];
if ($this->type === 'tcp') {
$rules['target'] = ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'];
$rules['port'] = ['required', 'integer', 'min:1', 'max:65535'];
} else {
$rules['target'] = ['required', 'url:http,https', 'max:2048'];
$rules['port'] = ['nullable'];
}
$data = $this->validate($rules);
$check = HealthCheck::create([
'label' => $data['label'] ?? null,
'type' => $data['type'],
'target' => $data['target'],
'port' => $this->type === 'tcp' ? $data['port'] : null,
]);
$this->audit('health.check_add', $data['target']);
// Probe the new check right away so its status/latency shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$this->results[$check->uuid] = $health->probe($check);
} catch (Throwable $e) {
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
}
$this->reset('label', 'target', 'port');
$this->type = 'http';
$this->dispatch('notify', message: __('health.added'));
}
public function confirmDeleteCheck(string $uuid): void
{
$this->gate();
$check = HealthCheck::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('health.delete_heading'),
'body' => __('health.delete_body', ['target' => $check->target]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('health.deleted'),
'token' => ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null),
],
);
}
#[On('healthCheckDeleted')]
public function deleteCheck(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'healthCheckDeleted');
} catch (InvalidConfirmToken) {
return;
}
HealthCheck::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
public function render(): View
{
$checks = HealthCheck::orderBy('label')->orderBy('target')->get();
$up = count(array_filter($this->results, fn ($r) => $r['ok'] ?? false));
return view('livewire.health.index', [
'checks' => $checks,
'up' => $up,
'total' => count($this->results),
])->title($this->title());
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Livewire\Help;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* In-panel help/documentation. Pure display: a left topic nav (like Settings) and a per-locale
* content partial. No persistence, no external calls. Topic keys are also the content-partial
* filenames at resources/views/livewire/help/content/{de,en}/<topic>.blade.php.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Help topics in display order. */
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates', 'commands',
'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery',
];
#[Url]
public string $topic = 'overview';
// mount() handles the test/parameter-injection path (Livewire::test($class, ['topic' => …])).
// Browser deep links populate $topic via the #[Url] attribute above and bypass mount on
// hydration. Both paths are validated by the render() clamp below.
public function mount(?string $topic = null): void
{
if ($topic !== null) {
$this->topic = $topic; // clamped to a known topic in render()
}
}
public function render()
{
// Authoritative guard: any unknown topic (deep link, tampered property) → overview, so
// the view never @includes a missing content partial.
if (! in_array($this->topic, self::TOPICS, true)) {
$this->topic = 'overview';
}
$labels = [
'overview' => __('help.topic_overview'),
'domain-tls' => __('help.topic_domain_tls'),
'security' => __('help.topic_security'),
'updates' => __('help.topic_updates'),
'commands' => __('help.topic_commands'),
'servers' => __('help.topic_servers'),
'sessions' => __('help.topic_sessions'),
'email' => __('help.topic_email'),
'audit' => __('help.topic_audit'),
'wireguard' => __('help.topic_wireguard'),
'recovery' => __('help.topic_recovery'),
];
$topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS);
return view('livewire.help.index', ['topics' => $topics])->title(__('help.title'));
}
}

View File

@ -18,8 +18,6 @@ class AddSshKey extends ModalComponent
{
public int $serverId;
public string $keyName = '';
public string $publicKey = '';
public ?string $generatedPrivate = null;
@ -28,9 +26,6 @@ class AddSshKey extends ModalComponent
public function mount(int $serverId): void
{
// Installing an authorized_keys entry over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
}
@ -45,39 +40,12 @@ class AddSshKey extends ModalComponent
{
$this->error = null;
$key = EC::createKey('ed25519');
// The comment is what identifies the key in the server's list — use the operator's name.
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => $this->keyComment() ?: 'clusev-key']);
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']);
$this->generatedPrivate = (string) $key->toString('OpenSSH');
}
/** Sanitised key label, safe as an authorized_keys comment (single line, ≤64 chars), or '' if none. */
private function keyComment(): string
{
$name = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $this->keyName) ?? '';
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
return mb_substr($name, 0, 64);
}
/** Set the OpenSSH comment of a public-key line to $comment (only when a name was given). */
private function withComment(string $pub, string $comment): string
{
if ($comment === '') {
return $pub;
}
$parts = preg_split('/\s+/', trim($pub), 3);
if (count($parts) < 2) {
return $pub; // not a recognisable key line — let the SSH layer reject it
}
return $parts[0].' '.$parts[1].' '.$comment;
}
public function save(FleetService $fleet): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);
@ -87,11 +55,8 @@ class AddSshKey extends ModalComponent
return;
}
$name = $this->keyComment();
try {
// Force the key's comment to the operator's name so it is identifiable in the list.
$fleet->addAuthorizedKey($server, $this->withComment($this->publicKey, $name));
$fleet->addAuthorizedKey($server, $this->publicKey);
} catch (Throwable $e) {
$this->error = $e->getMessage();
@ -103,7 +68,7 @@ class AddSshKey extends ModalComponent
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'ssh_key.add',
'target' => $name !== '' ? $server->name.' · '.$name : $server->name,
'target' => $server->name,
'ip' => request()->ip(),
]);

View File

@ -1,76 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/** Read-only container log tail. Loads lazily; the container ref is re-validated in DockerService. */
class ContainerLogs extends ModalComponent
{
public int $serverId;
public string $ref;
/** Display name for the modal title (the real ref stays the docker target). */
public string $name = '';
public string $logs = '';
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId, string $ref, string $name = ''): void
{
$this->serverId = $serverId;
$this->ref = $ref;
$this->name = $name;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function load(DockerService $docker): void
{
// Re-gate the actual read (not just the opener): logs can leak secrets → operate only.
abort_unless(Auth::user()?->can('operate'), 403);
// serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server.
// The host is admin-only (manage-fleet), matching the host terminal + the Docker host target.
if ($this->serverId === 0) {
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
$server = $this->serverId === 0
? HostCredential::current()?->toServer()
: Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->loaded = true;
return;
}
try {
$this->logs = $docker->logs($server, $this->ref, 200);
} catch (InvalidArgumentException) {
$this->error = __('docker.invalid_ref');
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
$this->loaded = true;
}
public function render()
{
return view('livewire.modals.container-logs');
}
}

View File

@ -36,12 +36,6 @@ class CreateServer extends ModalComponent
public string $credentialName = '';
public function mount(): void
{
// Registering a server + depositing its SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
@ -93,10 +87,6 @@ class CreateServer extends ModalComponent
public function save(): void
{
// Re-gate on save: mount()'s guard is not enough — a demotion mid-session or a
// hand-crafted /livewire/update must still be refused at the write.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
// Atomic: create server + credential, then VERIFY the SSH login. A failed

View File

@ -2,22 +2,20 @@
namespace App\Livewire\Modals;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts
* default to `operator` least privilege). Restricted to admins (`manage-users`). The creator
* may set a password directly, or leave it blank then a strong initial password is generated, the
* account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal
* (never persisted or logged). An operator-set password is the user's own, so no forced rotation.
* Form modal: add a new administrator account. Every account is a full admin
* (no roles). We generate a strong one-time password, store it hashed, and flag
* the account `must_change_password` so the new admin must rotate it on first
* sign-in. The clear-text temp password is revealed ONCE in the modal and never
* persisted or logged.
*/
class CreateUser extends ModalComponent
{
@ -25,23 +23,12 @@ class CreateUser extends ModalComponent
public string $email = '';
/** New account role (admin > operator > viewer). Defaults to operator — least privilege. */
public string $role = 'operator';
/** Optional operator-set password; blank = generate an initial password and reveal it once. */
public string $password = '';
/** Clear-text temp password, held in component state only to reveal it once. */
public ?string $tempPassword = null;
/** Name of the account just created, used in the reveal copy. */
public string $createdName = '';
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
@ -55,9 +42,6 @@ class CreateUser extends ModalComponent
return [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
// Optional — when set it must meet the same strength as a self-chosen password.
'password' => ['nullable', 'string', Password::min(6)],
'role' => ['required', Rule::enum(Role::class)],
];
}
@ -69,15 +53,11 @@ class CreateUser extends ModalComponent
return [
'name' => __('accounts.name_label'),
'email' => __('accounts.email_label'),
'password' => __('accounts.password_label'),
'role' => __('accounts.role_label'),
];
}
public function save(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$data = $this->validate();
// Re-check uniqueness server-side regardless of the live validator state.
@ -88,17 +68,13 @@ class CreateUser extends ModalComponent
return;
}
// Operator-set password = the user's own login (no forced rotation). Blank = generate a strong
// initial password that the user is prompted to rotate, and reveal it once below.
$operatorSet = $this->password !== '';
$password = $operatorSet ? $this->password : Str::password(16);
$temp = Str::password(16);
$user = User::create([
'name' => trim($data['name']),
'email' => $email,
'password' => Hash::make($password),
'must_change_password' => ! $operatorSet,
'role' => $data['role'],
'password' => Hash::make($temp),
'must_change_password' => true,
]);
AuditEvent::create([
@ -109,18 +85,9 @@ class CreateUser extends ModalComponent
'ip' => request()->ip(),
]);
// Operator already knows the password they set → no reveal; just confirm and close.
if ($operatorSet) {
$this->dispatch('usersChanged');
$this->dispatch('notify', message: __('accounts.created_notify', ['name' => $user->name]));
$this->closeModal();
return;
}
// Reveal the generated initial password ONCE — swaps the form for the reveal panel.
// Reveal the temp password ONCE — swaps the form for the reveal panel.
$this->createdName = $user->name;
$this->tempPassword = $password;
$this->tempPassword = $temp;
}
/** "Fertig": reload the list and close. */

View File

@ -29,9 +29,6 @@ class EditCredential extends ModalComponent
public function mount(int $serverId): void
{
// Depositing/updating a server's SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
if ($cred = Server::find($serverId)?->credential) {
@ -49,9 +46,6 @@ class EditCredential extends ModalComponent
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);

View File

@ -32,8 +32,6 @@ class Fail2banBan extends ModalComponent
*/
public function mount(int $serverId, array $jails = []): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->jails = array_values($jails);
$this->jail = $this->jails[0] ?? 'sshd';
@ -46,8 +44,6 @@ class Fail2banBan extends ModalComponent
public function save(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);

View File

@ -35,8 +35,6 @@ class Fail2banBans extends ModalComponent
public function mount(int $serverId): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
// Store the id ONLY so the modal opens instantly; the SSH read happens in load().
$this->serverId = $serverId;
}
@ -79,8 +77,6 @@ class Fail2banBans extends ModalComponent
*/
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');

View File

@ -35,8 +35,6 @@ class Fail2banConfig extends ModalComponent
public function mount(int $serverId, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
@ -66,8 +64,6 @@ class Fail2banConfig extends ModalComponent
public function save(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
// Never overwrite the remote policy with defaults the operator never saw —
// saving is only allowed once the current values were read successfully.
if (! $this->loaded) {

View File

@ -78,11 +78,6 @@ class FileEditor extends ModalComponent
public function load(FleetService $fleet, Sftp $sftp): void
{
// The actual content read (SFTP get / readFile). Gate it directly — not just the
// Files::edit entry point — so file bytes the server credential can reach (root →
// /etc/shadow, keys, .env) are never disclosed to a read-only viewer.
abort_unless(auth()->user()?->can('operate'), 403);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
@ -96,12 +91,9 @@ class FileEditor extends ModalComponent
$this->loadImage($server, $sftp);
} else {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
// Never bind raw non-UTF-8 bytes to a public property: Livewire JSON-encodes the
// snapshot and invalid UTF-8 makes json_encode fail, so the client receives an empty
// response ("undefined is not valid JSON"). Binary/oversize files show a notice instead.
$this->content = ($r['binary'] || $r['tooBig']) ? '' : $r['content'];
}
} catch (Throwable $e) {
$this->error = $e->getMessage();
@ -141,8 +133,6 @@ class FileEditor extends ModalComponent
public function save(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->error = null;
// Images are a read-only preview — never write them back.

View File

@ -33,8 +33,6 @@ class FirewallRule extends ModalComponent
public function mount(int $serverId, string $tool = 'ufw'): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw';
}
@ -46,8 +44,6 @@ class FirewallRule extends ModalComponent
public function save(FirewallService $firewall): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);

View File

@ -41,8 +41,6 @@ class HardeningAction extends ModalComponent
public function mount(int $serverId, string $action, bool $enable): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->action = $action;
$this->enable = $enable;
@ -70,8 +68,6 @@ class HardeningAction extends ModalComponent
public function apply(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if ($this->error !== null || $this->done) {
return;
}
@ -95,10 +91,8 @@ class HardeningAction extends ModalComponent
$this->done = true;
$this->ok = $result['ok'];
// Clean result only — no raw command output on success; a bounded reason on failure. 400,
// not 200: our own guard messages (e.g. backend.ssh_root_self_lockout, 202 chars) must
// never be cut mid-sentence — the box wraps with break-words, so length is fine.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 400);
// Clean result only — no raw command output on success; a short reason on failure.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200);
AuditEvent::create([
'user_id' => Auth::id(),

View File

@ -1,162 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: configure the SSH login for the "Clusev host" terminal. The target is ALWAYS the local
* machine Clusev runs on (the Docker host, reached at host.docker.internal fixed, not a free-form
* host, because an arbitrary machine is what fleet servers are for). The operator only supplies the
* login (user + password/key, and the port). Stores a single encrypted HostCredential; on edit a
* blank secret keeps the stored one, and the clear-text secret is never echoed back into the form.
*/
class HostShell extends ModalComponent
{
/** The host terminal always targets the local machine; not operator-editable. */
private const HOST = 'host.docker.internal';
public int $port = 22;
public string $username = 'root';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
/** True when a HostCredential already exists — then a blank secret keeps the stored one. */
public bool $configured = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// The host SSH login is the Clusev machine itself — the most dangerous credential.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$this->configured = true;
$this->port = $hc->port;
$this->username = $hc->username;
$this->authType = $hc->auth_type;
// secret/passphrase stay blank — never reveal the stored login back into the form.
}
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'username' => ['required', 'string', 'max:120'],
'authType' => ['required', Rule::in(['password', 'key'])],
// On first setup the secret is required; when editing an existing login a blank
// secret means "keep the stored one".
'secret' => [$this->configured ? 'nullable' : 'required', 'string'],
'passphrase' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'port' => __('terminal.host_field_port'),
'username' => __('terminal.host_field_user'),
'authType' => __('terminal.host_field_auth'),
'secret' => $this->authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password'),
];
}
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
$existing = HostCredential::current();
// Switching the auth method (password ↔ key) requires a fresh secret: keeping the stored one
// would feed e.g. an old password into key auth (or vice versa) and silently break the login.
// A fresh secret is required whenever the auth method OR the target (username/port) changes:
// keeping the stored secret would feed an old password into key auth (or vice versa) and
// silently break the login, or reuse one account's secret against a different account/port.
$targetChanged = $existing !== null && (
$this->authType !== $existing->auth_type
|| trim($this->username) !== $existing->username
|| (int) $data['port'] !== $existing->port
);
if ($this->secret === '' && $targetChanged) {
throw ValidationException::withMessages([
'secret' => __('terminal.host_secret_required_for_auth_change'),
]);
}
$hc = $existing ?? new HostCredential;
$hc->host = self::HOST; // always the local machine — not operator-editable
$hc->port = (int) $data['port'];
$hc->username = trim($data['username']);
$hc->auth_type = $data['authType'] === 'key' ? 'key' : 'password';
// Only overwrite the secret when a new one was entered (blank = keep the stored one).
if ($this->secret !== '') {
$hc->secret = $this->secret;
$hc->passphrase = $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null;
}
$hc->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.configure',
'target' => $hc->username.'@'.$hc->host.':'.$hc->port,
'ip' => request()->ip(),
]);
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_saved'));
$this->closeModal();
}
public function remove(): void
{
// Re-gate on remove: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$target = $hc->username.'@'.$hc->host.':'.$hc->port;
HostCredential::query()->delete(); // singleton — clear any/all rows, not just the first
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.remove',
'target' => $target,
'ip' => request()->ip(),
]);
}
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_removed'));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.host-shell');
}
}

View File

@ -31,10 +31,6 @@ class SshKeyProvision extends ModalComponent
public function mount(int $serverId): void
{
// Rotating to key-only access (installs a key, switches the credential, disables
// password login) over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$this->serverName = Server::find($serverId)?->name ?? '';
}
@ -46,9 +42,6 @@ class SshKeyProvision extends ModalComponent
public function run(SshKeyProvisioner $provisioner): void
{
// Re-gate on run: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->done) {
return;
}
@ -66,8 +59,7 @@ class SshKeyProvision extends ModalComponent
$this->done = true;
$this->ok = false;
$this->error = $e->getMessage();
// credentialChanged already triggers the FULL page reload on Show (key list +
// credential + security state) — no extra hardeningApplied needed.
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
@ -80,6 +72,7 @@ class SshKeyProvision extends ModalComponent
$this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok

View File

@ -42,9 +42,6 @@ class SystemUpdate extends ModalComponent
public function mount(int $serverId, MaintenanceService $maintenance): void
{
// Applying apt/dnf/zypper package updates on a managed host over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
@ -72,9 +69,6 @@ class SystemUpdate extends ModalComponent
public function upgrade(MaintenanceService $maintenance): void
{
// Re-gate on upgrade: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->error !== null || $this->done || ! $this->supported) {
return;
}

View File

@ -1,55 +0,0 @@
<?php
namespace App\Livewire\Onboarding;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
/**
* First-run onboarding tour. Renders a dimmed spotlight overlay (the Alpine island in the view)
* that walks the operator through the panel. It auto-opens once when the account has never
* dismissed it and can be relaunched any time from Settings (a client-side window event, no
* server round-trip). `markSeen` stamps the account so it never auto-opens again.
*/
class Tour extends Component
{
/** True on the first panel load for an account that has never finished/skipped the tour. */
public bool $autostart = false;
public function mount(): void
{
$this->autostart = Auth::user()?->onboarding_tour_completed_at === null;
}
/** Persist that the tour has been finished or skipped, so it won't auto-open again. */
public function markSeen(): void
{
$user = Auth::user();
if ($user !== null && $user->onboarding_tour_completed_at === null) {
$user->forceFill(['onboarding_tour_completed_at' => now()])->save();
}
}
/**
* The tour steps. A null `target` is a centred card (welcome / finish); a non-null `target`
* matches a `data-tour="…"` attribute on a sidebar nav item and is spotlighted.
*
* @return array<int, array<string, string|null>>
*/
public function steps(): array
{
return [
['target' => null, 'icon' => 'command', 'eyebrow' => __('onboarding.welcome_eyebrow'), 'title' => __('onboarding.welcome_title'), 'body' => __('onboarding.welcome_body')],
['target' => 'dashboard', 'icon' => 'dashboard', 'eyebrow' => __('onboarding.dashboard_eyebrow'), 'title' => __('onboarding.dashboard_title'), 'body' => __('onboarding.dashboard_body')],
['target' => 'servers', 'icon' => 'server', 'eyebrow' => __('onboarding.servers_eyebrow'), 'title' => __('onboarding.servers_title'), 'body' => __('onboarding.servers_body')],
['target' => 'terminal', 'icon' => 'terminal', 'eyebrow' => __('onboarding.terminal_eyebrow'), 'title' => __('onboarding.terminal_title'), 'body' => __('onboarding.terminal_body')],
['target' => 'settings', 'icon' => 'settings', 'eyebrow' => __('onboarding.settings_eyebrow'), 'title' => __('onboarding.settings_title'), 'body' => __('onboarding.settings_body')],
['target' => null, 'icon' => 'shield', 'eyebrow' => __('onboarding.done_eyebrow'), 'title' => __('onboarding.done_title'), 'body' => __('onboarding.done_body')],
];
}
public function render()
{
return view('livewire.onboarding.tour', ['steps' => $this->steps()]);
}
}

View File

@ -1,130 +0,0 @@
<?php
namespace App\Livewire\Patch;
use App\Models\Server;
use App\Services\MaintenanceService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Fleet patch view: pending + security update counts per server, one-click patch. `operate`-gated
* (route + mount + per-method). Counts scan is lazy + per-server guarded. Applying upgrades is a
* stateful action a signed ConfirmToken + R5 modal (the modal audits patch.apply from the sealed
* token; the handler does not re-audit). Targets resolve a client uuid the sealed server id.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array{pending:?int, security:?int}|array{error:string}> uuid → counts */
public array $counts = [];
/** @var array<string, array{ok:bool, output:string}> uuid → last patch result */
public array $results = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('patch.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function scan(MaintenanceService $maint): void
{
$this->gate();
$this->counts = [];
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable $e) {
$this->counts[$server->uuid] = ['error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function patch(string $uuid): void
{
$this->gate();
$server = Server::withActiveCredential()->where('uuid', $uuid)->firstOrFail();
$c = $this->counts[$uuid] ?? [];
$pending = $c['pending'] ?? '?';
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('patch.confirm_heading'),
'body' => __('patch.confirm_body', ['server' => $server->name, 'count' => $pending]),
'confirmLabel' => __('patch.apply'),
'danger' => true,
'icon' => 'rotate',
'notify' => '', // the handler reports the outcome
'token' => ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id),
],
);
}
#[On('patchApply')]
public function applyPatch(string $confirmToken, MaintenanceService $maint): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'patchApply');
} catch (InvalidConfirmToken) {
return; // forged / replayed — the modal already audited patch.apply
}
$server = Server::where('uuid', $payload['params']['uuid'])->first();
if (! $server) {
return;
}
$res = $maint->applyUpgrades($server);
$res['output'] = mb_scrub($res['output'], 'UTF-8'); // remote output into a Livewire prop → scrub invalid bytes
$this->results[$server->uuid] = $res;
// Refresh this server's counts so the badge reflects the post-patch state.
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable) {
// keep the old counts if the re-scan fails
}
$this->dispatch('notify', message: $res['ok']
? __('patch.patched', ['server' => $server->name])
: __('patch.patch_failed', ['error' => $res['output']]));
}
public function render(): View
{
// Fleet roll-up (only over successfully-scanned servers).
$scored = array_filter($this->counts, fn ($c) => isset($c['pending']) && $c['pending'] !== null);
$totalPending = array_sum(array_map(fn ($c) => $c['pending'], $scored));
$totalSecurity = array_sum(array_map(fn ($c) => $c['security'] ?? 0, $scored));
return view('livewire.patch.index', [
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
'totalPending' => (int) $totalPending,
'totalSecurity' => (int) $totalSecurity,
])->title($this->title());
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace App\Livewire\Posture;
use App\Models\Server;
use App\Services\PostureService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Fleet security-posture scoreboard. Scores each active-credential server from its hardening state
* (PostureService). Reading a server's posture exposes its hardening GAPS, so the page is
* `operate`-gated (route + mount). The scan is lazy (wire:init) and per-server guarded one
* unreachable host shows an error row instead of blocking the whole scan.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array<string, mixed>> server uuid → score result (or ['error' => msg]) */
public array $scores = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('posture.title');
}
public function scan(PostureService $posture): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$this->scores = [];
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
try {
$this->scores[$server->uuid] = $posture->score($server);
} catch (Throwable $e) {
$this->scores[$server->uuid] = ['error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function render(): View
{
$scored = array_filter($this->scores, fn ($s) => isset($s['score']));
$avg = $scored !== [] ? (int) round(array_sum(array_column($scored, 'score')) / count($scored)) : null;
return view('livewire.posture.index', [
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
'average' => $avg,
])->title($this->title());
}
}

View File

@ -1,295 +0,0 @@
<?php
namespace App\Livewire\Release;
use App\Models\AuditEvent;
use App\Services\PipelineStatus;
use App\Services\PromotionService;
use App\Services\ReleaseBridge;
use App\Services\ReleasePlanner;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Dev-only Release control. "Deploy to Staging" cuts a release candidate of an allowed target
* version via the host release bridge (bump commit tag push to Gitea). The page is reachable only when
* config('clusev.release_controls') is true; mount() re-guards as defence-in-depth.
*
* Deploy-to-Staging is irreversible (it pushes a tag the host CI acts on), so it is gated behind the
* shared R5 wire-elements/modal confirm: a button calls confirmDeployStaging($target) openConfirm,
* and applyDeployStaging consumes the sealed ConfirmToken and runs deployStaging(). deployStaging()
* stays the executor (the tests call it directly).
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** The request id of an in-flight staging release; non-null drives the wire:poll. */
public ?string $pendingId = null;
/** Unix seconds when the request was issued, to time out a silent host. */
public ?int $pendingSince = null;
/** The target X.Y.Z being staged (for display while it runs). */
public ?string $pendingTarget = null;
/** The tag of the last successfully-pushed staging release candidate (shown until the next deploy). */
public ?string $lastTag = null;
public function mount(): void
{
abort_unless((bool) config('clusev.release_controls'), 404);
abort_unless(auth()->user()?->can('manage-panel'), 403);
}
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging release candidate of $target. */
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$this->openConfirm('releaseStaged', ['target' => $target],
__('release.confirm_title'), __('release.confirm_body', ['target' => $target]),
__('release.confirm_action'), danger: true, icon: 'tag');
}
/** Apply handler — called after the ConfirmAction modal confirms the staging release. */
#[On('releaseStaged')]
public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releaseStaged');
} catch (InvalidConfirmToken) {
return;
}
$target = (string) ($payload['params']['target'] ?? '');
if ($target === '') {
return;
}
$this->deployStaging($target, $planner, $bridge);
}
/** Cut + push a release candidate of $target via the host bridge. $target must be one of the proposed values. */
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$key = 'release-stage:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600);
$id = $bridge->requestStaging($target);
if ($id === null) {
$this->dispatch('notify', message: __('release.write_failed'), level: 'error');
return;
}
$this->pendingId = $id;
$this->pendingSince = time();
$this->pendingTarget = $target;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release',
'target' => '→ '.$target,
'ip' => request()->ip(),
]);
}
/** Poll the host result while a release runs (driven by wire:poll). Times out a silent host. */
public function pollResult(ReleaseBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time();
$res = $bridge->result($this->pendingId);
if ($res === null) {
if (time() - $this->pendingSince > 60) {
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
$this->dispatch('notify', message: __('release.no_host_response'), level: 'error');
}
return;
}
if ($res['ok']) {
$this->lastTag = (string) $res['tag'];
$this->dispatch('notify', message: __('release.staged', ['tag' => (string) $res['tag']]));
} else {
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release_failed',
'target' => (string) ($this->pendingTarget ?? '').' ('.$res['message'].')',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('release.failed', ['reason' => $res['message']]), level: 'error');
}
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
}
/** wire:poll target while the pipeline is in flight drops the cached step results so the next
* render re-fetches the live GitHub status. */
public function pollPipeline(PipelineStatus $pipeline): void
{
$pipeline->refresh();
}
/** The current in-flight release-candidate tag (vX.Y.Z-rcN), or null when the running version is stable. */
private function currentRcTag(): ?string
{
$v = (string) config('clusev.version');
return str_contains($v, '-rc') ? 'v'.ltrim($v, 'vV') : null;
}
/** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */
private function throttlePromote(): bool
{
$key = 'promote:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return false;
}
RateLimiter::hit($key, 600);
return true;
}
/** Record a promotion outcome (action on success, action.'_failed' on failure). */
private function auditPromotion(string $action, string $target, bool $ok): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $ok ? $action : $action.'_failed',
'target' => $target,
'ip' => request()->ip(),
]);
}
public function confirmDeployPublic(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$tag = $this->currentRcTag();
if ($tag === null) {
return;
}
$this->openConfirm('releasePublic', ['tag' => $tag],
__('release.public_confirm_title'), __('release.public_confirm_body', ['tag' => $tag]),
__('release.public_action'), danger: false, icon: 'tag');
}
#[On('releasePublic')]
public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releasePublic');
} catch (InvalidConfirmToken) {
return;
}
$tag = (string) ($payload['params']['tag'] ?? '');
if ($tag === '' || $this->currentRcTag() !== $tag || ! $this->throttlePromote()) {
return;
}
$ok = $promotion->deployPublic($tag);
$this->auditPromotion('deploy.public', $tag, $ok);
$this->dispatch('notify',
message: $ok ? __('release.public_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
level: $ok ? 'info' : 'error');
}
public function confirmYank(string $tag, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($tag, $promotion->publicTags(), true)) {
$this->dispatch('notify', message: __('release.yank_unknown'), level: 'error');
return;
}
$this->openConfirm('releaseYank', ['tag' => $tag],
__('release.yank_confirm_title'), __('release.yank_confirm_body', ['tag' => $tag]),
__('release.yank_action'), danger: true, icon: 'trash');
}
#[On('releaseYank')]
public function applyYank(string $confirmToken, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releaseYank');
} catch (InvalidConfirmToken) {
return;
}
$tag = (string) ($payload['params']['tag'] ?? '');
if ($tag === '' || ! in_array($tag, $promotion->publicTags(), true) || ! $this->throttlePromote()) {
return;
}
$ok = $promotion->yank($tag);
$this->auditPromotion('deploy.yank', $tag, $ok);
$this->dispatch('notify',
message: $ok ? __('release.yank_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
level: $ok ? 'info' : 'error');
}
/**
* Open the shared ConfirmAction modal (R5) for the staging release. The issued token carries NO
* audit descriptor applyDeployStaging deployStaging audits exactly once itself.
*
* @param array<string, string> $params
*/
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => $body,
'confirmLabel' => $confirmLabel,
'danger' => $danger,
'icon' => $icon,
'notify' => '',
'token' => ConfirmToken::issue($event, $params),
],
);
}
public function render()
{
$current = (string) config('clusev.version');
return view('livewire.release.index', [
'current' => $current,
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
'pipeline' => app(PipelineStatus::class)->forTrackedRc(),
'publicTags' => app(PromotionService::class)->publicTags(),
])->title(__('release.title'));
}
}

View File

@ -1,159 +0,0 @@
<?php
namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Manage server groups (create / rename / recolour / delete + membership). Admin-only
* (manage-fleet, matching server create/delete): the route mw gates it AND every mutating
* method re-checks, since /livewire/update does not re-run route middleware. Every change is
* audited; deletion goes through the signed, single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Groups extends Component
{
/** New-group form. */
public string $name = '';
public string $color = 'accent';
public function mount(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('groups.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function create(): void
{
$this->gate();
$data = $this->validate([
'name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')],
'color' => ['required', Rule::in(ServerGroup::COLORS)],
]);
$group = ServerGroup::create($data);
$this->audit('group.create', $group->name);
$this->reset('name');
$this->color = 'accent';
$this->dispatch('notify', message: __('groups.created', ['name' => $group->name]));
}
public function rename(string $uuid, string $name): void
{
$this->gate();
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$name = trim($name);
$validated = validator(
['name' => $name],
['name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')->ignore($group->id)]],
)->validate();
$group->update(['name' => $validated['name']]);
$this->audit('group.update', $group->name);
}
public function setColor(string $uuid, string $color): void
{
$this->gate();
if (! in_array($color, ServerGroup::COLORS, true)) {
return; // saving() would coerce it anyway; refuse the bad token outright
}
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$group->update(['color' => $color]);
$this->audit('group.update', $group->name);
}
/** Add or remove a server from a group (checkbox toggle in the assignment grid). */
public function toggleMember(string $groupUuid, string $serverUuid): void
{
$this->gate();
$group = ServerGroup::where('uuid', $groupUuid)->firstOrFail();
$server = Server::where('uuid', $serverUuid)->firstOrFail();
$group->servers()->toggle($server->id);
$this->audit('group.members', $group->name);
}
public function confirmDelete(string $uuid): void
{
$this->gate();
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('groups.delete_heading'),
'body' => __('groups.delete_body', ['name' => $group->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('groups.deleted', ['name' => $group->name]),
// The confirm modal writes the group.delete AuditEvent from this sealed descriptor,
// so deleteGroup() must NOT audit again (avoids a double row).
'token' => ConfirmToken::issue('groupConfirmed', ['uuid' => $group->uuid], 'group.delete', $group->name, null),
],
);
}
#[On('groupConfirmed')]
public function deleteGroup(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'groupConfirmed');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$group = ServerGroup::where('uuid', $payload['params']['uuid'])->first();
if (! $group) {
return;
}
// NOT audited here — the confirm modal already wrote group.delete from the sealed token.
$group->delete(); // pivot rows cascade; servers are untouched
}
public function render(): View
{
return view('livewire.servers.groups', [
'groups' => ServerGroup::withCount('servers')->with('servers:id,uuid')->orderBy('name')->get(),
'servers' => Server::orderBy('name')->get(['id', 'uuid', 'name']),
'colors' => ServerGroup::COLORS,
])->title($this->title());
}
}

View File

@ -3,11 +3,9 @@
namespace App\Livewire\Servers;
use App\Models\Server;
use App\Models\ServerGroup;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
@ -16,10 +14,6 @@ class Index extends Component
/** Live search over name / IP. */
public string $search = '';
/** Optional group filter (a ServerGroup uuid, shareable via the query string). */
#[Url]
public ?string $group = null;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
@ -37,11 +31,7 @@ class Index extends Component
{
$term = trim($this->search);
// Resolve the group filter; an unknown/removed uuid falls back to "all" (never errors).
$activeGroup = $this->group ? ServerGroup::where('uuid', $this->group)->first() : null;
$servers = Server::orderBy('name')
->when($activeGroup, fn ($query) => $query->inGroup($activeGroup->id))
->when($term !== '', function ($query) use ($term) {
$query->where(function ($q) use ($term) {
$q->where('name', 'like', "%{$term}%")
@ -58,8 +48,6 @@ class Index extends Component
return view('livewire.servers.index', [
'servers' => $servers,
'groups' => ServerGroup::withCount('servers')->orderBy('name')->get(),
'activeGroup' => $activeGroup,
'total' => (int) $counts->sum(),
'online' => (int) ($counts['online'] ?? 0),
'warning' => (int) ($counts['warning'] ?? 0),

View File

@ -1,106 +0,0 @@
<?php
namespace App\Livewire\Servers;
use App\Exceptions\DockerNotInstalled;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Component;
use Throwable;
/**
* Per-server container management, rendered as the "Docker" tab of the server-details page (the
* sidebar Docker page targets the Clusev host instead). Viewing the list is open to any role;
* actions (start/stop/restart) require `operate`, matching systemd service gating. Loads lazily.
*/
class ServerDocker extends Component
{
public Server $server;
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
/** Docker binary absent on the server (e.g. a native mail server) — honest state, not an error. */
public bool $notInstalled = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->error = null;
// credential_exists is a withExists() query attribute that does NOT survive Livewire model
// hydration, so check the relation directly here.
if ($this->server->credential()->exists()) {
try {
// ONE SSH round-trip: a missing docker binary throws DockerNotInstalled (derived from
// the docker ps call), so no separate available() probe is needed.
$this->containers = $docker->containers($this->server);
$this->connected = true;
} catch (DockerNotInstalled) {
$this->notInstalled = true;
} catch (Throwable $e) {
$this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
}
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('operate'), 403);
if (! $this->server->credential()->exists()) {
return;
}
try {
$res = $docker->containerAction($this->server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $this->server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$this->server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a container on this server (ref re-validated in DockerService). */
public function viewLogs(string $id, string $name = ''): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
'serverId' => (int) $this->server->id,
'ref' => $id,
'name' => $name,
]);
}
public function render()
{
return view('livewire.servers.server-docker');
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Livewire\Servers;
use App\Models\Server;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Component;
/**
* Per-server web terminal, rendered as the "Terminal" tab of the server-details page (the sidebar
* Terminal page is the Clusev HOST shell instead). Mints a single-use token that the xterm.js island
* uses to open a WebSocket to the terminal sidecar; this component never touches SSH or credentials.
* A server shell is a day-to-day operator action (the host shell is the admin-only sidebar page).
*/
class ServerTerminal extends Component
{
public Server $server;
/** Mint a single-use token for this server and hand it to the xterm island via an event. */
public function open(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$token = Str::random(48);
TerminalSession::create([
'token' => $token,
'user_id' => Auth::id(),
'kind' => 'server',
'server_id' => $this->server->id,
'expires_at' => now()->addSeconds(60),
'created_at' => now(),
]);
$this->dispatch('terminal-open', token: $token, title: $this->server->name);
}
public function render()
{
return view('livewire.servers.server-terminal');
}
}

View File

@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
@ -25,10 +24,6 @@ class Show extends Component
/** Route-model-bound by uuid (R11). */
public Server $server;
/** Active detail tab: 'overview' | 'docker' | 'terminal' (deep-linkable via ?tab=). */
#[Url]
public string $tab = 'overview';
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
@ -161,10 +156,6 @@ class Show extends Component
/** Refresh the live gauges from the poller-updated row (wire:poll). */
public function pollMetrics(): void
{
// Only the Übersicht tab shows live metrics; skip the refresh/re-render on Docker/Terminal.
if ($this->tab !== 'overview') {
return;
}
$this->server->refresh();
}
@ -174,8 +165,6 @@ class Show extends Component
*/
public function confirmKeyRemoval(int $index): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$key = $this->sshKeys[$index] ?? null;
if ($key === null) {
return;
@ -208,10 +197,6 @@ class Show extends Component
#[On('keyRemoved')]
public function removeKey(string $confirmToken, FleetService $fleet): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'keyRemoved');
} catch (InvalidConfirmToken) {
@ -268,8 +253,6 @@ class Show extends Component
*/
public function toggleCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -297,8 +280,6 @@ class Show extends Component
*/
public function confirmDeleteCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -328,10 +309,6 @@ class Show extends Component
#[On('credentialDeleted')]
public function deleteCredential(string $confirmToken): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'credentialDeleted');
} catch (InvalidConfirmToken) {
@ -350,45 +327,11 @@ class Show extends Component
* checklist + firewall row reflect the new state.
*/
#[On('hardeningApplied')]
public function reloadSnapshot(): void
public function reloadSnapshot(FleetService $fleet): void
{
// A hardening/fail2ban toggle changes only the SECURITY reads. Re-probe the OS profile
// (a firewall "Aktivieren" may have just INSTALLED the tool the panels key off) and
// re-read the three security states — but keep the page rendered (no skeleton flip)
// and skip the full metrics/volumes/keys snapshot: that halves the SSH round-trips
// and the operator sees the page update in place instead of a long blank reload.
// The Sicherheit/Firewall panels show a wire:loading cue targeting this method.
$this->server->refresh();
try {
$profile = app(OsDetector::class)->detect($this->server, fresh: true);
$this->os = [
'familyLabel' => $profile->familyLabel(),
'packageManager' => $profile->packageManager,
'firewallTool' => $profile->firewallTool,
'serviceManager' => $profile->serviceManager,
];
} catch (Throwable) {
// keep the current profile on failure
}
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
try {
$this->firewall = app(FirewallService::class)->status($this->server);
} catch (Throwable) {
$this->firewall = [];
}
try {
$this->fail2ban = app(Fail2banService::class)->status($this->server);
} catch (Throwable) {
$this->fail2ban = [];
}
$this->ready = false;
$this->load($fleet);
}
/**
@ -397,8 +340,6 @@ class Show extends Component
*/
public function confirmDeleteRule(int $index): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$rule = $this->firewall['rules'][$index] ?? null;
if ($rule === null) {
return;
@ -430,10 +371,6 @@ class Show extends Component
#[On('firewallRuleDelete')]
public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the network must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete');
} catch (InvalidConfirmToken) {
@ -494,8 +431,6 @@ class Show extends Component
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$res = $fail2ban->unban($this->server, $jail, $ip);
} catch (Throwable $e) {
@ -517,8 +452,6 @@ class Show extends Component
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
public function addIgnore(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$ip = trim($this->newIgnoreIp);
if ($ip === '') {
return;
@ -552,8 +485,6 @@ class Show extends Component
/** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
} catch (Throwable $e) {

View File

@ -3,10 +3,10 @@
namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
@ -105,8 +105,6 @@ class Index extends Component
*/
public function confirm(string $op, int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
return;
@ -142,8 +140,6 @@ class Index extends Component
#[On('serviceConfirmed')]
public function applyService(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed');
} catch (InvalidConfirmToken) {
@ -166,27 +162,9 @@ class Index extends Component
return;
}
if ($res['ok']) {
$this->dispatch('notify', message: __('services.action_done', ['name' => $name, 'op' => $op]));
} else {
// A toast is ephemeral and truncated — persist the FULL failure reason so the operator
// can re-read WHY a start failed (e.g. a service that crashes on launch). The confirm
// modal already logged the neutral attempt (service.<op>); this red entry records the
// outcome with the complete systemctl output in meta, shown expandable in the audit log.
AuditEvent::create([
'user_id' => auth()->id(),
'server_id' => $active->id,
'actor' => auth()->user()?->name ?? 'system',
'action' => 'service.'.$op.'.failed',
'target' => $name.' · '.$active->name,
'ip' => request()->ip(),
'meta' => ['output' => mb_substr(mb_scrub(trim((string) ($res['output'] ?: __('services.no_permission')))), 0, 4000)],
]);
$this->dispatch('notify',
message: __('services.action_failed_short', ['name' => $name]),
level: 'error');
}
$this->dispatch('notify', message: $res['ok']
? __('services.action_done', ['name' => $name, 'op' => $op])
: __('services.action_failed', ['name' => $name, 'output' => Str::limit($res['output'] ?: __('services.no_permission'), 90)]));
try {
$this->services = $fleet->systemd($active)['services'];

View File

@ -7,7 +7,6 @@ use App\Models\Setting;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Component;
use Throwable;
@ -44,7 +43,6 @@ class Email extends Component
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->mail_host = Setting::get('mail_host', '') ?? '';
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
$this->mail_username = Setting::get('mail_username', '') ?? '';
@ -69,23 +67,8 @@ class Email extends Component
public function save(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate();
// Retarget guard: if the SMTP HOST or USERNAME changes and no new password was entered, DROP
// the stored password BEFORE the new host is written — so the old credential can never be
// sent to a new (possibly attacker-controlled) SMTP server; re-entry is then required. Same
// class as the alert Gotify token guard. uncached() reads the committed state (no stale cache).
$targetChanged = $this->mail_host !== (string) Setting::uncached('mail_host', '')
|| $this->mail_username !== (string) Setting::uncached('mail_username', '');
if ($targetChanged) {
// Drop the old password BEFORE any target field is written — regardless of whether a
// fresh one was typed — so no concurrent MailSettings::apply() can ever read the new
// host paired with the old password. A freshly typed password is re-written below.
Setting::forget('mail_password');
$this->passwordSet = false;
}
Setting::put('mail_host', $this->mail_host);
Setting::put('mail_port', (string) $this->mail_port);
Setting::put('mail_username', $this->mail_username);
@ -113,38 +96,6 @@ class Email extends Component
$this->dispatch('notify', message: __('mail.notify_saved'));
}
/**
* Fully clear the SMTP configuration removes every mail_* setting (incl. the encrypted
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
*/
public function clearConfig(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
Setting::forget($key);
}
$this->mail_host = '';
$this->mail_port = 587;
$this->mail_username = '';
$this->mail_password = '';
$this->mail_encryption = 'tls';
$this->mail_from_address = '';
$this->mail_from_name = '';
$this->passwordSet = false;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.reset',
'target' => '—',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('mail.notify_reset'));
}
/**
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
* from the saved settings. Never targets anyone else; never leaks the password any
@ -152,35 +103,12 @@ class Email extends Component
*/
public function sendTest(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! $this->configured()) {
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
return;
}
// Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay.
// Per-user + auto-expiring: only ever limits the operator's own test button briefly.
$key = 'mail-test:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user
// Retarget guard: never test-send the STORED password against a host/username the operator
// just changed in the form but hasn't saved — that could leak the old credential to a new
// (possibly hostile) server. Require the password to be re-entered for a changed target.
if ($this->mail_password === ''
&& ($this->mail_host !== (string) Setting::uncached('mail_host', '')
|| $this->mail_username !== (string) Setting::uncached('mail_username', ''))) {
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.test_retarget_password_required')]), level: 'error');
return;
}
$to = Auth::user()->email;
try {
@ -211,20 +139,13 @@ class Email extends Component
*/
private function applyMailerConfig(): void
{
// Prefer a FRESHLY typed password (test-before-save); fall back to the stored one only when
// the field is blank — and the sendTest() retarget guard has already ensured that fallback
// is used only when host+username are unchanged from what the stored password was set for.
$password = '';
if ($this->mail_password !== '') {
$password = $this->mail_password;
} else {
$stored = Setting::uncached('mail_password');
if ($stored !== null && $stored !== '') {
try {
$password = Crypt::decryptString($stored);
} catch (Throwable) {
$password = '';
}
$stored = Setting::get('mail_password');
if ($stored !== null && $stored !== '') {
try {
$password = Crypt::decryptString($stored);
} catch (Throwable) {
$password = '';
}
}

View File

@ -1,170 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Rules\ValidIpOrCidr;
use App\Services\BruteforceGuard;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\On;
use Livewire\Component;
class LoginProtection extends Component
{
public bool $enabled = true;
public int $maxretry = 10;
public int $findtime = 10;
public int $bantime = 60;
public string $whitelist = '';
public function mount(BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->enabled = $guard->enabled();
$this->maxretry = $guard->maxretry();
$this->findtime = $guard->findtime();
$this->bantime = $guard->bantime();
$this->whitelist = implode("\n", $guard->whitelist());
}
public function save(BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate([
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
'bantime' => ['required', 'integer', 'min:1', 'max:43200'],
'whitelist' => ['nullable', 'string'],
]);
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
foreach ($lines as $line) {
if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) {
$this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line]));
return;
}
}
Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0');
Setting::put('bruteforce_maxretry', (string) $this->maxretry);
Setting::put('bruteforce_findtime', (string) $this->findtime);
Setting::put('bruteforce_bantime', (string) $this->bantime);
Setting::put('bruteforce_whitelist', implode("\n", $lines));
// Widening the whitelist immediately clears any now-exempt active bans.
foreach (BannedIp::active()->get() as $ban) {
if ($guard->isExempt($ban->ip)) {
$guard->unban($ban->ip);
}
}
$this->audit('auth.protection_updated', null);
$this->dispatch('notify', message: __('settings.lp_saved'));
}
public function whitelistMyIp(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$ip = (string) request()->ip();
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
if (! in_array($ip, $lines, true)) {
$lines[] = $ip;
}
$this->whitelist = implode("\n", $lines);
}
public function confirmUnban(string $ip): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_heading'),
'body' => __('settings.lp_unban_body', ['ip' => $ip]),
'confirmLabel' => __('settings.lp_unban'),
'danger' => false,
'icon' => 'shield',
'notify' => __('settings.lp_unban_notify'),
'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip),
],
);
}
#[On('banCleared')]
public function unban(string $confirmToken, BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
} catch (InvalidConfirmToken) {
return;
}
$ip = (string) $payload['params']['ip'];
$guard->unban($ip);
$this->audit('auth.ip_unbanned', $ip);
}
public function confirmUnbanAll(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_all_heading'),
'body' => __('settings.lp_unban_all_body'),
'confirmLabel' => __('settings.lp_unban_all'),
'danger' => true,
'icon' => 'shield',
'notify' => __('settings.lp_unban_all_notify'),
'token' => ConfirmToken::issue('bansClearedAll'),
],
);
}
#[On('bansClearedAll')]
public function unbanAll(string $confirmToken, BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
ConfirmToken::consume($confirmToken, 'bansClearedAll');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$guard->unbanAll();
$this->audit('auth.ip_unbanned', 'all');
}
private function audit(string $action, ?string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(BruteforceGuard $guard)
{
$ip = (string) request()->ip();
return view('livewire.settings.login-protection', [
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(),
'currentIp' => $ip,
'currentIpExempt' => $guard->isExempt($ip),
// Read-only status of the env-driven honeypot deception layer (no toggle here).
'honeypotEnabled' => (bool) config('clusev.honeypot.enabled'),
]);
}
}

View File

@ -4,10 +4,8 @@ namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Profile extends Component
@ -42,28 +40,10 @@ class Profile extends Component
public function updatePassword(): void
{
// Throttle the current-password re-auth (hijacked-session brute-force protection).
// User-id keyed + auto-expiring: only ever slows the user's own session briefly.
$key = 'reauth:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'current_password' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
try {
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
]);
} catch (ValidationException $e) {
if (array_key_exists('current_password', $e->errors())) {
RateLimiter::hit($key, 60);
}
throw $e;
}
RateLimiter::clear($key);
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(10)],
]);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');

View File

@ -57,7 +57,6 @@ class Sessions extends Component
*/
public function confirmLogoutAll(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -82,7 +81,6 @@ class Sessions extends Component
#[On('sessionsLogoutAll')]
public function logoutAll(string $confirmToken, SessionService $sessions)
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
} catch (InvalidConfirmToken) {

View File

@ -2,7 +2,6 @@
namespace App\Livewire\Settings;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
@ -14,15 +13,11 @@ use Livewire\Attributes\On;
use Livewire\Component;
/**
* Multi-user account management. Accounts now carry an RBAC role (admin > operator >
* viewer); the mutating actions here are gated behind the `manage-users` ability (admin
* only). Destructive actions (remove, force-logout) and role changes go through the R5
* confirm modal, which writes one AuditEvent and re-dispatches the apply event back here
* Multi-user account management. Every account is a full administrator there are
* no roles. Destructive actions (remove, force-logout) go through the R5 confirm
* modal, which writes one AuditEvent and re-dispatches the apply event back here
* (mirrors Settings\Security / Settings\Sessions).
*
* The last-admin invariant is enforced everywhere: the sole remaining admin can neither
* be removed nor demoted, so the panel can never be stranded with zero administrators.
*
* The current operator is always recorded as the actor on each audit row.
*/
class Users extends Component
@ -30,8 +25,6 @@ class Users extends Component
/** Open the CreateUser modal; it dispatches `usersChanged` on success. */
public function create(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal', component: 'modals.create-user');
}
@ -42,8 +35,6 @@ class Users extends Component
*/
public function confirmRemove(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! $this->canRemove($user)) {
return;
@ -71,8 +62,6 @@ class Users extends Component
#[On('userRemoved')]
public function remove(string $confirmToken, SessionService $sessions): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userRemoved');
} catch (InvalidConfirmToken) {
@ -85,8 +74,6 @@ class Users extends Component
// Atomic: lock the user rows + recount inside the transaction so two concurrent
// deletes can't both pass the "more than one account" check and strand the
// install with zero users (a lock-out). The last account is never removable.
// Sentinel: null = silent no-op (self / last account / gone); 'last_admin' =
// refused because it would strand zero admins → surface the guard toast.
$email = DB::transaction(function () use ($userId, $self, $sessions): ?string {
if (User::lockForUpdate()->count() <= 1) {
return null;
@ -97,14 +84,6 @@ class Users extends Component
return null;
}
// Last-admin invariant: removing the sole administrator would leave the panel
// with zero admins (no one can manage users again). Lock + recount admins in
// the same transaction so concurrent removes can't both slip past the check.
if ($user->role === Role::Admin
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
return 'last_admin';
}
$email = $user->email;
// Kill every session + rotate the remember_token BEFORE deleting, so a
@ -120,12 +99,6 @@ class Users extends Component
return;
}
if ($email === 'last_admin') {
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
return;
}
$this->audit('user.delete', $email);
$this->dispatch('notify', message: __('accounts.remove_notify'));
}
@ -136,8 +109,6 @@ class Users extends Component
*/
public function confirmLogout(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || $user->is(Auth::user())) {
return;
@ -164,8 +135,6 @@ class Users extends Component
#[On('userLoggedOut')]
public function logout(string $confirmToken, SessionService $sessions): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userLoggedOut');
} catch (InvalidConfirmToken) {
@ -184,103 +153,6 @@ class Users extends Component
$this->dispatch('notify', message: __('accounts.logout_notify'));
}
/**
* Sensitive (R5): open the confirm modal for changing another account's role. The
* new role must be a valid Role case; the apply handler re-checks the last-admin
* invariant so a stale event can never demote the sole admin after the modal opens.
*/
public function confirmRoleChange(int $userId, string $role): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! in_array($role, array_column(Role::cases(), 'value'), true)) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.role_change_confirm_heading'),
'body' => __('accounts.role_change_confirm_body', [
'name' => $user->name,
'role' => Role::from($role)->label(),
]),
'confirmLabel' => __('accounts.role_change_confirm'),
'danger' => false,
'icon' => 'shield',
// Defer the toast: the apply handler reports the real outcome.
'notify' => '',
'token' => ConfirmToken::issue(
'userRoleChanged',
['userId' => $userId, 'role' => $role],
auditTarget: $user->email,
),
],
);
}
#[On('userRoleChanged')]
public function applyRoleChange(string $confirmToken): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userRoleChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$role = $payload['params']['role'];
// Atomic: lock the target + recount admins inside the transaction so two concurrent
// demotions can't both pass the last-admin check and strand zero administrators.
// Sentinel: null = silent no-op (gone / same role); 'last_admin' = refused because
// it would demote the sole admin → surface the guard toast.
$result = DB::transaction(function () use ($userId, $role): ?string {
$user = User::whereKey($userId)->lockForUpdate()->first();
if (! $user) {
return null;
}
$newRole = Role::from($role);
// Last-admin invariant: never demote the sole administrator away from admin.
if ($user->role === Role::Admin
&& $newRole !== Role::Admin
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
return 'last_admin';
}
$user->role = $newRole;
$user->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name,
'action' => 'user.role_change',
'target' => $user->email,
'ip' => request()->ip(),
'meta' => ['to' => $role],
]);
return $user->email;
});
if ($result === null) {
return;
}
if ($result === 'last_admin') {
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
return;
}
$this->dispatch('usersChanged');
$this->dispatch('notify', message: __('accounts.role_changed_toast'));
}
/** Reload the list after the CreateUser modal adds an account. */
#[On('usersChanged')]
public function refreshList(): void
@ -289,10 +161,9 @@ class Users extends Component
}
/**
* A user may be offered the remove control unless it is the current operator or the
* last remaining account (no lock-out). This is only the UI gate; the removal itself
* is restricted to admins (`manage-users`) and the apply handler additionally refuses
* to strand zero administrators (last-admin invariant).
* A user may be removed unless it is the current operator or the last
* remaining account (no lock-out). Mirrors the create-user "equal admins"
* model anyone can remove anyone else.
*/
private function canRemove(User $user): bool
{

View File

@ -1,21 +0,0 @@
<?php
namespace App\Livewire\Shell;
use App\Models\Server;
use Livewire\Component;
/**
* Topbar "X / Y online" pill. A tiny polled Livewire island so the fleet-online count reflects a
* server going up or down live (like the dashboard), instead of only on a page navigate/refresh.
*/
class FleetStatus extends Component
{
public function render()
{
return view('livewire.shell.fleet-status', [
'online' => Server::where('status', 'online')->count(),
'total' => Server::count(),
]);
}
}

View File

@ -3,27 +3,33 @@
namespace App\Livewire\System;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* System settings dashboard-configurable Domain + TLS.
* System settings dashboard-configurable Domain + TLS and the release channel.
*
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
* mid-session that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
@ -39,8 +45,19 @@ class Index extends Component
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
@ -49,6 +66,9 @@ class Index extends Component
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
@ -62,8 +82,6 @@ class Index extends Component
/** Validate, then confirm the domain change via the shared modal (audited there). */
public function confirmDomain(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate($this->domainRules(), [
'domainInput.regex' => __('system.domain_invalid'),
'domainInput.max' => __('system.domain_invalid'),
@ -99,10 +117,6 @@ class Index extends Component
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
} catch (InvalidConfirmToken) {
@ -130,71 +144,58 @@ class Index extends Component
*/
public function restartNow(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
// Surface a failed sentinel write instead of a "restarting" state that never resolves.
if (! $deployment->requestRestart()) {
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
return;
}
$deployment->requestRestart();
$this->restartRequested = true;
$this->dispatch('notify', message: __('system.restart_running'));
}
/**
* Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS
* pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user
* throttled (5 / 10 min, auto-expiring never a control-plane lockout) so it can't hammer
* ACME, and audited. Only meaningful in caddy mode with an active domain.
*/
public function requestCertificate(DeploymentService $deployment): void
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$domain = $deployment->domain();
if ($domain === null || $deployment->externalTls()) {
$this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error');
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$key = 'cert-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
'token' => ConfirmToken::issue(
'channelChanged',
['channel' => $channel],
'system.settings_updated',
'release_channel='.$channel,
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $confirmToken): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$channel = $payload['params']['channel'];
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
RateLimiter::hit($key, 600);
$result = $deployment->requestCertificate($domain);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'tls.cert_request',
'target' => $domain.' → '.$result['status'],
'ip' => request()->ip(),
]);
[$message, $level] = match ($result['status']) {
'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'],
'dns_mismatch' => [__('system.cert_dns_mismatch', [
'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—',
'server' => $result['serverIp'] ?? '?',
]), 'error'],
default => [__('system.cert_failed'), 'error'],
};
$this->dispatch('notify', message: $message, level: $level);
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
return;
}
@ -221,10 +222,6 @@ class Index extends Component
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
} catch (InvalidConfirmToken) {
@ -257,9 +254,7 @@ class Index extends Component
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'isOverridden' => $deployment->domainIsOverridden(),
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
'certStatus' => $deployment->certStatus(),
'channels' => $this->channelDescriptions(),
])->title(__('system.title'));
}
}

View File

@ -1,82 +0,0 @@
<?php
namespace App\Livewire\Terminal;
use App\Models\HostCredential;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Host terminal a real SSH login into the CLUSEV HOST machine, admin-only (manage-fleet). Per-SERVER
* shells live on the server-details page (the "Terminal" tab / App\Livewire\Servers\ServerTerminal).
* On connect it mints a single-use token the xterm.js island uses to open a WebSocket to the terminal
* sidecar; this component never touches the SSH connection or credentials.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public function mount(): void
{
// The host shell is a real login into the Clusev machine — the most dangerous target.
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('terminal.title');
}
/** Mint a single-use token for the host shell and hand it to the xterm island via an event. */
public function open(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
// With no host login configured yet, open the setup form instead of minting a dead session.
if (HostCredential::current() === null) {
$this->configureHost();
return;
}
$token = Str::random(48);
TerminalSession::create([
'token' => $token,
'user_id' => Auth::id(),
'kind' => 'host',
'server_id' => null,
'expires_at' => now()->addSeconds(60),
'created_at' => now(),
]);
$this->dispatch('terminal-open', token: $token, title: __('terminal.host_label'));
}
/** Open the modal that stores the Clusev host SSH login. */
public function configureHost(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$this->dispatch('openModal', component: 'modals.host-shell');
}
/** The host-login modal saved/removed — re-render so the tile reflects the new state. */
#[On('hostShellSaved')]
public function hostShellSaved(): void
{
// No state to set; the empty handler simply triggers a re-render.
}
public function render()
{
$host = HostCredential::current();
return view('livewire.terminal.index', [
'hostConfigured' => $host !== null,
'hostTarget' => $host?->username,
])->title($this->title());
}
}

View File

@ -1,166 +0,0 @@
<?php
namespace App\Livewire\Threats;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* THREATS dashboard the operator-facing frontend for the honeypot deception layer. Surfaces the
* hostile-probe feed (security.honeypot_hit / security.honeytoken_used / auth.ip_banned audit
* events), KPI counters, and the active IP bans with an unban control. Read-only intelligence plus
* one mutation (unban); the honeypot itself is env-driven and has no toggle here.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithPagination;
/** Threat entries per page — page is deep-linked via ?page= (Livewire WithPagination). */
private const PER_PAGE = 25;
/** The honeypot / ban action codes that make up the threat feed. */
private const FEED_ACTIONS = [
'security.honeypot_hit',
'security.honeypot_login',
'security.honeytoken_used',
'auth.ip_banned',
];
/** Freitext-Filter über IP / Ziel des Bedrohungs-Ereignisses. */
#[Url]
public string $q = '';
public function mount(): void
{
// Defense-in-depth: the route already carries can:manage-panel, but a persisted Livewire
// component could be re-hydrated after a role change — re-check on mount.
abort_unless(Auth::user()?->can('manage-panel'), 403);
}
/** A changed search must restart at page 1 — never land on an out-of-range page. */
public function updatedQ(): void
{
$this->resetPage();
}
/**
* Honeypot / ban events, newest first, optionally filtered. The filter runs in SQL (so it spans
* the whole history, not just one page) and matches the source IP and the probed target path.
* Mirrors Audit\Index::eventsQuery() but scoped to the honeypot action set.
*/
protected function eventsQuery(): Builder
{
$query = AuditEvent::query()->whereIn('action', self::FEED_ACTIONS)->latest();
$needle = trim($this->q);
if ($needle === '') {
return $query;
}
$like = '%'.mb_strtolower($needle).'%';
return $query->where(function (Builder $w) use ($like): void {
$w->whereRaw('LOWER(ip) LIKE ?', [$like])
->orWhereRaw('LOWER(target) LIKE ?', [$like]);
});
}
/** Currently active bans (not yet expired), newest first — a short list for the ban panel. */
protected function bannedIps(): Collection
{
return BannedIp::active()->latest('banned_until')->limit(50)->get();
}
/**
* Page numbers to render, with "" gaps for long histories (1 4 5 6 20).
*
* @return array<int, int|string>
*/
protected function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, max(1, $total));
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
/**
* Lift an active ban. Reuses the exact unban path + audit code + toast convention from
* Settings\LoginProtection so the two management surfaces stay consistent.
*/
public function unban(string $ip, BruteforceGuard $guard): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
$guard->unban($ip);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'auth.ip_unbanned',
'target' => $ip,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('threats.unbanned_toast', ['ip' => $ip]));
}
public function render()
{
/** @var LengthAwarePaginator $events */
$events = $this->eventsQuery()->paginate(self::PER_PAGE);
// Most active source across all honeypot/token events (null when there is no traffic yet).
$topIp = AuditEvent::query()
->whereIn('action', ['security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used'])
->whereNotNull('ip')
->selectRaw('ip, COUNT(*) as hits')
->groupBy('ip')
->orderByDesc('hits')
->limit(1)
->value('ip');
return view('livewire.threats.index', [
'events' => $events,
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
'bannedIps' => $this->bannedIps(),
'probes_24h' => AuditEvent::query()
->where('action', 'security.honeypot_hit')
->where('created_at', '>=', now()->subDay())
->count(),
// POST fake-login attempts in the last 24h — the "someone tried to log in" signal.
'login_attempts_24h' => AuditEvent::query()
->where('action', 'security.honeypot_login')
->where('created_at', '>=', now()->subDay())
->count(),
'banned_total' => BannedIp::active()->count(),
'honeytoken_trips' => AuditEvent::query()->where('action', 'security.honeytoken_used')->count(),
'top_ip' => $topIp,
])->title(__('threats.title'));
}
}

View File

@ -2,48 +2,23 @@
namespace App\Livewire\Versions;
use App\Models\AuditEvent;
use App\Services\DeploymentService;
use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the installed version comes from
* config, the latest published release is the newest git TAG in the channel read from the
* project's public Git host over its read-only tag API (the production image ships no .git, so
* a local read would always be empty; local .git is only a dev fallback) and the release
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
* the check honestly compares the installed version against the newest tag in the channel.
* Version & releases page. Everything shown here is real: the installed version
* comes from config, the latest published release is the newest git TAG read from
* .git at runtime, and the release history is the project's own CHANGELOG.md parsed
* BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no
* fake release server the update check honestly compares the installed version
* against the newest tag in the configured channel.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/**
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
* when the update was requested. Persisted in Redis NOT the sentinel file (which the host
* watcher consumes before update.sh runs) so the "update läuft" state survives BOTH a page
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
* backstop so a failed update can never wedge the banner. Completion = running version > base.
*/
private const UPDATE_FLAG = 'clusev:update-in-progress';
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
private const CHANGELOG_PER_PAGE = 8;
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
* Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
#[Url(as: 'series')]
public string $series = '';
/** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
#[Url(as: 'page')]
public int $changelogPage = 1;
/** Only these channels are ever offered to users — there is no 'dev' channel. */
private const CHANNELS = ['stable', 'beta'];
public ?string $lastChecked = null;
@ -52,101 +27,6 @@ class Index extends Component
/** 'current' | 'update' | null — drives the banner tone. */
public ?string $updateState = null;
/** Set once an update has been requested this session — the stack is rebuilding. */
public bool $updateRequested = false;
/** Running version captured WHEN the update was requested — completion = version moved past it. */
public ?string $updateBaseVersion = null;
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
public int $updatePolls = 0;
/**
* On load, in order:
* 1. If an update is in flight (Redis marker): either announce it just finished (running
* version now past the marked base success toast + clear) or resume the "läuft" state +
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
* Redis, surviving both the reload and the app rebuild).
* 2. Otherwise surface an available update straight from the cached check (no network), so the
* button shows immediately when the badge already knows a newer version no re-click.
*/
public function mount(): void
{
$installed = (string) config('clusev.version');
$marker = Cache::get(self::UPDATE_FLAG);
if (is_array($marker) && isset($marker['base'])) {
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
// Finished while the operator was away (or reloaded) — announce + clear.
Cache::forget(self::UPDATE_FLAG);
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
return;
}
// Still running — resume the notice + polling.
$this->updateRequested = true;
$this->updateBaseVersion = (string) $marker['base'];
return;
}
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
if ($latest !== null && $this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
}
}
/**
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
* detected purely from the running version moving PAST the value captured at request time
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
*/
public function pollUpdate(): void
{
if (! $this->updateRequested) {
return;
}
$this->updatePolls++;
$current = (string) config('clusev.version');
$this->updateBaseVersion ??= $current;
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
return;
}
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
}
}
/**
* Auto-check on page load (wire:init) so an available update + button appear without the
* operator clicking "check" first. No-op while an update is running, so it never clobbers the
* "läuft"/poll state. Async (wire:init), so it never blocks the initial render.
*/
public function autoCheck(): void
{
if ($this->updateRequested) {
return;
}
$this->checkUpdates();
}
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
@ -157,8 +37,7 @@ class Index extends Component
$channel = $this->channel();
$installed = (string) config('clusev.version');
// Force a fresh remote lookup — this is the explicit "check for updates" action.
$latest = $this->resolveLatestTag($channel, forceRemote: true);
$latest = $this->latestTag();
if ($latest === null) {
$this->updateState = 'current';
@ -172,118 +51,20 @@ class Index extends Component
}
}
/**
* Operator-triggered update: write the update sentinel a host watcher reacts to by running
* update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
* installed one is actually published in the channel (never fires blindly), is per-user
* throttled (auto-expiring never a control-plane lockout) and audited. The container never
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
* say so and do not await a result.
*/
public function requestUpdate(DeploymentService $deployment): mixed
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$channel = $this->channel();
$installed = (string) config('clusev.version');
$latest = $this->resolveLatestTag($channel, forceRemote: true);
// Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
$this->lastChecked = now()->format('H:i');
if ($latest === null || ! $this->isNewer($latest, $installed)) {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
return null;
}
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
$key = 'update-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return null;
}
RateLimiter::hit($key, 600);
// Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by
// the app user), surface that instead of a "running" state that would never resolve.
if (! $deployment->requestUpdate()) {
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
return null;
}
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.update_request',
'target' => $installed.' → '.$latest.' ('.$channel.')',
'ip' => request()->ip(),
]);
$this->updateRequested = true;
$this->updateBaseVersion = $installed; // completion = the running version moves past this
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_started'));
// Redirect to the self-contained progress page BEFORE the container goes down.
// navigate:false forces a full browser navigation (the page must be Livewire-independent
// to survive the teardown — see resources/views/update-progress.blade.php).
$prev = url()->previous();
$parsedPrev = parse_url($prev, PHP_URL_PATH);
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
? $parsedPrev
: route('dashboard', absolute: false);
// Pass the pre-update version: the progress page finishes only when the running version
// moves past it (the old container answers /up throughout the build — see the page).
return $this->redirect(
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
navigate: false,
);
}
/** Resolve the configured channel, clamped to the user-facing set. */
private function channel(): string
{
return app(ReleaseChecker::class)->channel();
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
}
/**
* Newest release tag for the channel. Prefers the public Git host's tag API (the
* production image ships NO .git, so a local read would always be empty there), and
* falls back to local .git tags for dev. The remote result is cached so a page render
* never makes a network call only the explicit "check for updates" action does, by
* passing $forceRemote. Returns null when nothing usable is found anywhere.
* Newest release tag from .git, without needing the git binary. Reads loose
* refs under refs/tags plus packed-refs, normalises a leading "v", and sorts
* by semantic version. Returns null when nothing is tagged yet.
*/
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
$checker = app(ReleaseChecker::class);
if ($forceRemote) {
$remote = $checker->refresh($channel);
if ($remote !== null) {
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
}
/**
* Newest release tag from local .git, without needing the git binary. Reads loose refs
* under refs/tags plus packed-refs. Empty in the production image (no .git) that is why
* the remote API is the primary source above.
*/
private function localLatestTag(string $channel): ?string
private function latestTag(): ?string
{
$root = base_path('.git');
$tags = [];
@ -303,7 +84,22 @@ class Index extends Component
}
}
return app(ReleaseChecker::class)->newestVersion($tags, $channel);
// Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v".
$versions = [];
foreach (array_unique($tags) as $tag) {
$v = ltrim($tag, 'vV');
if (preg_match('/^\d+\.\d+\.\d+/', $v)) {
$versions[] = $v;
}
}
if ($versions === []) {
return null;
}
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
return $versions[0];
}
/** Is the given tag a newer semantic version than the installed one? */
@ -312,19 +108,9 @@ class Index extends Component
return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
}
/**
* Resolve the deployed commit + branch. In production this comes from config (install.sh
* baked it into .env from the host's .git the prod image ships none); in dev it falls back
* to a live .git read. Branch defaults to the channel only when nothing is known.
*/
/** Resolve the current commit from .git without needing the git binary. */
private function build(): array
{
$sha = config('clusev.build.sha');
$branch = config('clusev.build.branch');
if ($sha || $branch) {
return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
}
$root = base_path('.git');
$head = @file_get_contents($root.'/HEAD');
@ -355,106 +141,6 @@ class Index extends Component
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
}
/** Switch the visible major.minor series and jump back to its first page. */
public function selectSeries(string $series): void
{
$this->series = $series;
$this->changelogPage = 1;
}
/** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
public function gotoChangelogPage(int $page): void
{
$this->changelogPage = max(1, $page);
}
/**
* Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
* keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
* if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
* show a compact series index + paginated detail instead of one endless list.
*
* @param array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}> $releases
* @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
*/
private function groupBySeries(array $releases): array
{
$buckets = [];
foreach ($releases as $rel) {
if ($rel['unreleased']) {
$key = 'unreleased';
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
// Prefix the series key with a non-numeric 'v' so the ?series= URL value is never a
// bare decimal — Livewire's #[Url] otherwise type-juggles "0.10" → 0.1 (the trailing
// zero is lost), silently switching the selected series on reload. The label strips it.
$key = 'v'.$m[1].'.'.$m[2];
} else {
$key = 'other';
}
$buckets[$key][] = $rel;
}
$series = [];
foreach ($buckets as $key => $rels) {
$dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
$series[] = [
'key' => $key,
'label' => match ($key) {
'unreleased' => __('versions.unreleased'),
'other' => __('versions.series_other'),
default => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
},
'unreleased' => $key === 'unreleased',
'count' => count($rels),
'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
'releases' => $rels,
];
}
// Unreleased first, then series by version descending, 'other' last.
usort($series, static function (array $a, array $b): int {
$rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
$ra = $rank($a);
$rb = $rank($b);
if ($ra !== $rb) {
return $ra <=> $rb;
}
return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
});
return $series;
}
/**
* Compact pagination window the full range up to 7 pages, otherwise first + last plus a
* 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
*
* @return array<int, int|string>
*/
private function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, $total);
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
/**
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
* version, optional date, and the changes grouped by Keep-a-Changelog
@ -470,22 +156,11 @@ class Index extends Component
return [];
}
return $this->parseChangelog((string) file_get_contents($path));
}
/**
* Parse a Keep-a-Changelog document into release nodes (shared by the local history and the
* remote "what's new" preview).
*
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
private function parseChangelog(string $content): array
{
$releases = [];
$current = null;
$group = null;
foreach (preg_split('/\R/', $content) as $line) {
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
if ($current !== null) {
@ -538,132 +213,19 @@ class Index extends Component
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
}
/**
* The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the
* latest tag, parsed and filtered to released versions newer than the installed one so the
* "what's new" can be read BEFORE applying the update. Empty when there is no update or the
* fetch fails (degrade gracefully; the update button is unaffected).
*
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
private function availableChangelog(string $installed, ?string $latestTag): array
{
if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
return [];
}
// Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
$content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV'));
if ($content === null) {
return [];
}
$inst = ltrim($installed, 'vV');
return array_values(array_filter(
$this->parseChangelog($content),
static fn (array $r): bool => ! $r['unreleased']
&& preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1
&& version_compare(ltrim($r['version'], 'vV'), $inst, '>'),
));
}
/**
* Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The
* dev/release-control box ALSO lists its actual update source first (which may be the private
* upstream) staging and stable never do, so a non-dev server can never expose a private source
* regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still
* config('clusev.repository'); this only governs the displayed links.)
*
* release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the
* host release bridge, so a staging/stable box never has it on and even if it did, that box's
* CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug
* ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public
* repo never to a private host.
*
* @return list<array{url: string, host: string, path: string}>
*/
private function projectLinks(): array
{
$slug = trim((string) config('clusev.public_slug'), '/');
if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
$slug = 'clusev/clusev';
}
$urls = ['https://github.com/'.$slug];
// Dev box only: ALSO surface the actual update source (e.g. the private upstream), shown first.
// Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather
// than rendered as an empty link — the public repo still shows.
if (config('clusev.release_controls')) {
$source = (string) config('clusev.repository');
if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) {
array_unshift($urls, $source);
}
}
return array_map(static fn (string $u): array => [
'url' => $u,
'host' => (string) parse_url($u, PHP_URL_HOST),
'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'),
], $urls);
}
public function render()
{
$releases = $this->releases();
$seriesList = $this->groupBySeries($releases);
// Active series: keep the operator's choice while it still exists, else the newest.
$keys = array_column($seriesList, 'key');
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
$activeSeries = null;
foreach ($seriesList as $s) {
if ($s['key'] === $activeKey) {
$activeSeries = $s;
break;
}
}
// Paginate the active series' releases so a long history never becomes an endless scroll.
$activeReleases = $activeSeries['releases'] ?? [];
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
$page = min(max(1, $this->changelogPage), $totalPages);
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
// Mark the series that holds the running version (a small "installed" cue in the rail).
$installed = ltrim((string) config('clusev.version'), 'vV');
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? 'v'.$m[1].'.'.$m[2] : null;
// Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel());
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
// update, before the next check). Never present yourself as behind a release you've
// already passed — hide it until a fresh check resolves the true latest.
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
$latestTag = null;
}
$latestTag = $this->latestTag();
return view('livewire.versions.index', [
'version' => config('clusev.version'),
'channel' => $this->channel(),
'repositories' => $this->projectLinks(),
'repository' => config('clusev.repository'),
'license' => config('clusev.license'),
'build' => $this->build(),
'seriesList' => $seriesList,
'activeKey' => $activeKey,
'activeSeries' => $activeSeries,
'pagedReleases' => $pagedReleases,
'changelogPage' => $page,
'totalPages' => $totalPages,
'pageWindow' => $this->pageWindow($page, $totalPages),
'currentSeries' => $currentSeries,
'totalReleases' => count($releases),
'releases' => $releases,
'latestTag' => $latestTag,
// What the operator would gain by updating — shown BEFORE applying (only when behind).
'availableUpdates' => $this->updateState === 'update'
? $this->availableChangelog((string) config('clusev.version'), $latestTag)
: [],
])->title(__('versions.title'));
}
}

View File

@ -1,530 +0,0 @@
<?php
namespace App\Livewire\Wireguard;
use App\Models\AuditEvent;
use App\Models\WgTrafficSample;
use App\Services\WgBridge;
use App\Services\WgStatus;
use App\Services\WgTraffic;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* WireGuard dashboard live status (P1) + traffic (P2) + peer management (P3). Reads the
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public const WINDOWS = [3600, 86400, 604800];
/** Peer-name charset, shared by every app-side validation site (the host re-validates too). */
private const PEER_NAME_RE = '/^[A-Za-z0-9._-]{1,64}$/';
public int $window = self::WINDOWS[0];
/** Active tab when configured: 'peers' (list + traffic) or 'server' (settings). Deep-linkable. */
#[Url]
public string $tab = 'peers';
public string $newPeer = '';
public string $newEndpoint = '';
public string $newPort = '';
public string $newSubnet = '';
public string $newDns = '';
// first-time setup form
public string $setupSubnet = '10.99.0.0/24';
public string $setupPort = '51820';
public string $setupEndpoint = '';
public string $setupPeer = 'client-1';
/** id of an in-flight write-request we are polling for. */
public ?string $pendingId = null;
public ?string $pendingAction = null;
/** Unix time the current request started polling — used to time out a non-responding host. */
public ?int $pendingSince = null;
/** Show-once add-peer result (config text + QR svg). Never persisted. */
public ?string $resultConfig = null;
public ?string $resultQr = null;
/** Peer name of the in-flight request, carried to the result so the download gets a meaningful filename. */
public ?string $pendingName = null;
public ?string $resultName = null;
/**
* Page guard (RBAC): WireGuard is a manage-network capability. The route carries a
* `can:manage-network` middleware for the initial GET, but a Livewire component update
* (POST /livewire/update) is NOT re-run through the route middleware so this mount()
* check is the real per-request guard for the page itself.
*/
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
}
public function setWindow(int $seconds): void
{
$this->window = $this->clampWindow($seconds);
}
public function setTab(string $tab): void
{
$this->tab = in_array($tab, ['peers', 'server'], true) ? $tab : 'peers';
}
public function addPeer(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
'newPeer.regex' => __('wireguard.peer_name_invalid'),
'newPeer.required' => __('wireguard.peer_name_invalid'),
]);
if (! $this->throttle()) {
return;
}
$name = $this->newPeer;
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
$this->pendingAction = 'add-peer';
$this->pendingName = $name;
$this->newPeer = '';
$this->audit('wg.add-peer', $name);
}
public function setupWg(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate([
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
'setupPeer' => ['required', 'regex:'.self::PEER_NAME_RE],
], [
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
'setupPort.regex' => __('wireguard.port_invalid'),
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
]);
if (! $this->portInRange($this->setupPort)) {
$this->addError('setupPort', __('wireguard.port_invalid'));
return;
}
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('setup', [
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
]);
$this->pendingAction = 'setup';
$this->pendingName = $this->setupPeer;
$this->audit('wg.setup', $this->setupSubnet);
}
/** Opens the wire-elements/modal confirm dialog for peer removal. */
public function confirmRemovePeer(string $name): void
{
if (preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
$this->openConfirm('wgPeerRemoved', ['name' => $name],
__('wireguard.remove_confirm_title'), __('wireguard.remove_confirm_body'),
__('wireguard.remove'), danger: true, icon: 'trash');
}
/** Apply handler — called after the ConfirmAction modal confirms the removal. */
#[On('wgPeerRemoved')]
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
{
// Re-gate on consume: a token issued while the user still had rights must be
// refused if the role was demoted before it was confirmed (tokens are uid-bound).
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
} catch (InvalidConfirmToken) {
return;
}
$name = $payload['params']['name'] ?? '';
if ($name === '' || preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
$this->removePeer($bridge, $name);
}
public function removePeer(WgBridge $bridge, string $name): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
$this->pendingAction = 'remove-peer';
$this->audit('wg.remove-peer', $name);
}
public function setEndpoint(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
'newEndpoint.required' => __('wireguard.endpoint_invalid'),
]);
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]);
$this->pendingAction = 'set-endpoint';
$this->audit('wg.set-endpoint', $this->newEndpoint);
$this->newEndpoint = '';
}
/** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */
public function setDns(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newDns' => ['required', 'regex:/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/']], [
'newDns.regex' => __('wireguard.dns_invalid'),
'newDns.required' => __('wireguard.dns_invalid'),
]);
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-dns', ['dns' => $this->newDns]);
$this->pendingAction = 'set-dns';
$this->audit('wg.set-dns', $this->newDns);
$this->newDns = '';
}
public function confirmGate(bool $on): void
{
$this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'],
$on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
$on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
$on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'),
danger: ! $on, icon: 'shield');
}
#[On('wgGateToggle')]
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
} catch (InvalidConfirmToken) {
return;
}
$this->runGate($this->onFlag($payload), $bridge);
}
public function runGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
$bridge ??= app(WgBridge::class);
$this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []);
$this->pendingAction = $on ? 'gate-up' : 'gate-down';
$this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
}
/**
* The SSH lock (port 22). Turning it ON is the most dangerous action on this page: a user with no
* surviving WG access locks themselves out of SSH entirely (only the server console / `clusev wg
* down` recovers it). The confirm body spells that out and recommends a backup peer first.
*/
public function confirmSshGate(bool $on): void
{
$this->openConfirm('wgSshGate', ['on' => $on ? '1' : '0'],
$on ? __('wireguard.ssh_lock_on_title') : __('wireguard.ssh_lock_off_title'),
$on ? __('wireguard.ssh_lock_on_body') : __('wireguard.ssh_lock_off_body'),
$on ? __('wireguard.ssh_lock_turn_on') : __('wireguard.ssh_lock_turn_off'),
danger: $on, icon: 'alert');
}
#[On('wgSshGate')]
public function applySshGate(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
} catch (InvalidConfirmToken) {
return;
}
$this->runSshGate($this->onFlag($payload), $bridge);
}
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
$bridge ??= app(WgBridge::class);
$this->pendingId = $bridge->request($on ? 'gate-ssh-on' : 'gate-ssh-off', []);
$this->pendingAction = $on ? 'gate-ssh-on' : 'gate-ssh-off';
$this->audit($on ? 'wg.ssh-lock' : 'wg.ssh-unlock', $on ? 'on' : 'off');
}
public function confirmSetPort(): void
{
$this->validate(
['newPort' => ['required', 'regex:/^\d{1,5}$/']],
['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')],
);
if (! $this->portInRange($this->newPort)) {
$this->addError('newPort', __('wireguard.port_invalid'));
return;
}
$this->openConfirm('wgSetPort', ['port' => $this->newPort],
__('wireguard.port_confirm_title'), __('wireguard.port_confirm_body'),
__('wireguard.apply'), danger: true, icon: 'alert');
}
#[On('wgSetPort')]
public function applySetPort(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$port = (string) ($payload['params']['port'] ?? '');
$this->pendingId = $bridge->request('set-port', ['port' => $port]);
$this->pendingAction = 'set-port';
$this->audit('wg.set-port', $port);
$this->newPort = '';
}
public function confirmSetSubnet(): void
{
$this->validate(
['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']],
['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')],
);
$this->openConfirm('wgSetSubnet', ['subnet' => $this->newSubnet],
__('wireguard.subnet_confirm_title'), __('wireguard.subnet_confirm_body'),
__('wireguard.apply'), danger: true, icon: 'alert');
}
#[On('wgSetSubnet')]
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$subnet = (string) ($payload['params']['subnet'] ?? '');
$this->pendingId = $bridge->request('set-subnet', ['subnet' => $subnet]);
$this->pendingAction = 'set-subnet';
$this->audit('wg.set-subnet', $subnet);
$this->newSubnet = '';
}
public function pollResult(WgBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time(); // first poll after a request — start the clock
$res = $bridge->result($this->pendingId);
if ($res === null) {
// No host response after a while → the host watcher (systemd) likely isn't running yet.
// Stop spinning forever and tell the operator instead of leaving "Wird angewendet …".
if (time() - $this->pendingSince > 30) {
$this->pendingId = null;
$this->pendingAction = null;
$this->pendingSince = null;
$this->dispatch('notify', message: __('wireguard.no_host_response'), level: 'error');
}
return;
}
$this->pendingId = null;
$this->pendingSince = null;
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
$this->resultConfig = $res['config'];
$this->resultQr = $res['qr'];
$this->resultName = $this->pendingName;
} elseif (! $res['ok']) {
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
// Persist the failure so it's readable later in the audit log, not just a transient toast.
$this->audit('wg.action-failed', trim(($this->pendingAction ?? '').': '.$msg));
$this->dispatch('notify', message: $msg, level: 'error');
} else {
$this->dispatch('notify', message: __('wireguard.action_done'));
}
$this->pendingAction = null;
$this->pendingName = null;
}
public function dismissResult(): void
{
$this->resultConfig = null;
$this->resultQr = null;
$this->resultName = null;
}
/**
* Download the show-once client config as a .conf file. The filename becomes the tunnel name when
* imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer
* name e.g. "203.0.113.10-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
*/
public function downloadConfig(): ?StreamedResponse
{
if ($this->resultConfig === null) {
return null;
}
$cfg = $this->resultConfig;
$host = preg_match('/^Endpoint\s*=\s*([^:\s]+)/m', $cfg, $m) === 1 ? $m[1] : 'clusev';
$parts = array_filter([$host, (string) $this->resultName]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '-', implode('-', $parts));
$base = trim((string) $base, '-.') ?: 'clusev-wireguard';
return response()->streamDownload(function () use ($cfg) {
echo $cfg;
}, $base.'.conf', ['Content-Type' => 'text/plain; charset=UTF-8']);
}
/** Clamp a traffic window to one of the allowed values, falling back to the shortest. */
private function clampWindow(int $seconds): int
{
return in_array($seconds, self::WINDOWS, true) ? $seconds : self::WINDOWS[0];
}
/** True when a numeric-string port is within the valid UDP range. */
private function portInRange(string $port): bool
{
return (int) $port >= 1 && (int) $port <= 65535;
}
/** Decode the sealed on/off flag a gate-toggle confirm token carries. */
private function onFlag(array $payload): bool
{
return ($payload['params']['on'] ?? '0') === '1';
}
/**
* Open the shared ConfirmAction modal (R5) for one WG action. The issued token carries NO audit
* descriptor on purpose each apply handler audits exactly once itself, so auditing here too
* would double-count. Heading/body/label/danger/icon/params are the only per-action differences.
*
* @param array<string, string> $params
*/
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => $body,
'confirmLabel' => $confirmLabel,
'danger' => $danger,
'icon' => $icon,
'notify' => '',
'token' => ConfirmToken::issue($event, $params),
],
);
}
private function throttle(): bool
{
$key = 'wg-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 10)) {
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
return false;
}
RateLimiter::hit($key, 60);
return true;
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(WgStatus $wg, WgTraffic $traffic)
{
$window = $this->clampWindow($this->window);
// Traffic samples land at most once per minute (clusev:wg-sample), but the page polls
// every 5s (wire:poll) — so re-bucketing the whole WgTrafficSample history on every poll
// is ~11/12 wasted work. Cache the aggregate, keyed on the window AND a data fingerprint
// (MAX(id) busts on any insert, COUNT busts on a prune) so new/changed data invalidates it
// immediately — MAX(sampled_at) alone could collide for two sample sets in the same second.
$fp = WgTrafficSample::query()->selectRaw('COALESCE(MAX(id), 0) AS mid, COUNT(*) AS cnt')->first();
$series = Cache::remember(
"wg:traffic:series:{$window}:{$fp->mid}:{$fp->cnt}",
60,
fn () => $traffic->series($window),
);
return view('livewire.wireguard.index', [
'status' => $wg->read(),
'traffic' => $series,
'windows' => self::WINDOWS,
])->title(__('wireguard.title'));
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Mail;
use App\Models\AlertIncident;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Queued alert e-mail — one per fire/resolve transition. Plain text (no external assets). */
class AlertNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public AlertIncident $incident,
public bool $resolved,
) {}
public function envelope(): Envelope
{
$server = $this->incident->server?->name ?? '—';
$rule = $this->incident->rule?->name ?? '—';
$prefix = $this->resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing');
return new Envelope(subject: "[{$prefix}] {$rule}{$server}");
}
public function content(): Content
{
// Branded HTML (table layout, inline styles, CID-embedded logo — no external assets, so it
// renders in Gmail/Outlook without image blocking) + the plain-text part as fallback.
return new Content(
html: 'mail.alert-html',
text: 'mail.alert',
with: [
'incident' => $this->incident,
'resolved' => $this->resolved,
'server' => $this->incident->server?->name ?? '—',
'rule' => $this->incident->rule?->name ?? '—',
'metric' => $this->incident->rule?->metric ?? '—',
'value' => $this->incident->value,
'threshold' => $this->incident->rule?->threshold ?? 0,
],
);
}
}

View File

@ -1,51 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
class AlertIncident extends Model
{
/** Sidebar firing-count badge cache key — busted by forgetFiringCount() on every fire/resolve. */
public const FIRING_COUNT_CACHE = 'alerts:firing_count';
protected $guarded = [];
protected $casts = [
'value' => 'float',
'started_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function rule(): BelongsTo
{
return $this->belongsTo(AlertRule::class, 'alert_rule_id');
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function isFiring(): bool
{
return $this->state === 'firing';
}
/**
* Firing-incident count for the sidebar badge. Cached (the sidebar renders on every page), but
* the cache is busted the instant an incident fires or resolves (forgetFiringCount), so the badge
* matches the content on the next render instead of lagging up to the TTL.
*/
public static function firingCount(): int
{
return (int) Cache::remember(self::FIRING_COUNT_CACHE, 60, fn () => static::where('state', 'firing')->count());
}
public static function forgetFiringCount(): void
{
Cache::forget(self::FIRING_COUNT_CACHE);
}
}

View File

@ -1,77 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* A threshold rule over a server metric. When a targeted server crosses it, the AlertEvaluator
* opens a firing AlertIncident and notifies; when it recovers, the incident resolves.
*/
class AlertRule extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'threshold' => 'float', // % metrics are whole, but `load` is fractional (see the migration)
];
/** Metrics a rule can watch. `offline` fires when a server can't be polled (status offline). */
public const METRICS = ['cpu', 'mem', 'disk', 'load', 'offline'];
public const COMPARATORS = ['gt', 'lt'];
public const SCOPES = ['all', 'group', 'server'];
protected static function booted(): void
{
static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function incidents(): HasMany
{
return $this->hasMany(AlertIncident::class);
}
/** Whether this rule applies to the given server (all / a group's members / one server). */
public function targets(Server $server): bool
{
return match ($this->scope_type) {
'server' => (int) $this->scope_id === $server->id,
'group' => $server->groups()->where('server_groups.id', $this->scope_id)->exists(),
default => true, // 'all'
};
}
/**
* Does the reading breach this rule? For numeric metrics, compare against the threshold with the
* rule's comparator; for `offline`, the breach is simply that the server is not online.
*
* @param array<string, int|string> $metrics cpu/mem/disk/load ints; status when offline
*/
public function breached(array $metrics, string $status): bool
{
if ($this->metric === 'offline') {
return $status === 'offline'; // only a truly-down server, not a reachable "warning"
}
$value = (float) ($metrics[$this->metric] ?? 0);
$threshold = (float) $this->threshold;
return $this->comparator === 'lt' ? $value < $threshold : $value > $threshold;
}
/** The reading to record on the incident (the breached value; 0 for offline). Float for `load`. */
public function reading(array $metrics): float
{
return $this->metric === 'offline' ? 0.0 : (float) ($metrics[$this->metric] ?? 0);
}
}

View File

@ -4,14 +4,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AuditEvent extends Model
{
/** Sidebar threats-count badge cache key — busted by forgetThreatLoginCount() on each fake login. */
public const THREAT_COUNT_CACHE = 'threats:login_attempts_24h';
protected $guarded = [];
protected $casts = ['meta' => 'array'];
@ -35,57 +31,4 @@ class AuditEvent extends Model
{
return $this->belongsTo(Server::class);
}
/**
* Fake-login (honeypot) attempts in the last 24h for the sidebar Threats badge. Cached (the
* sidebar renders on every page) but busted the instant one is recorded (forgetThreatLoginCount),
* so the badge stays in step with the Threats page instead of lagging up to the TTL.
*/
public static function threatLoginCount24h(): int
{
return (int) Cache::remember(
self::THREAT_COUNT_CACHE,
60,
fn () => static::where('action', 'security.honeypot_login')->where('created_at', '>', now()->subDay())->count(),
);
}
public static function forgetThreatLoginCount(): void
{
Cache::forget(self::THREAT_COUNT_CACHE);
}
/** Actions that record a failure or security event — surfaced with a warning style. */
private const ERROR_ACTIONS = [
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
'security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used',
'service.start.failed', 'service.stop.failed', 'service.restart.failed',
];
/**
* Human-readable, localized label for the raw action code (e.g. "wg.set-endpoint" ->
* "WireGuard-Endpoint geändert"). Falls back to a tidied form for unmapped/dynamic codes
* (e.g. the dynamic "harden.*.on|off") so nothing ever shows a bare machine code awkwardly.
*/
public function getActionLabelAttribute(): string
{
$labels = (array) trans('audit.actions');
return $labels[$this->action] ?? $this->prettyAction();
}
/** True when the entry represents a failure / security alert (drives the warning styling). */
public function getIsErrorAttribute(): bool
{
return in_array($this->action, self::ERROR_ACTIONS, true);
}
private function prettyAction(): string
{
$s = str_replace(['_', '-'], ' ', str_replace('.', ' · ', (string) $this->action));
return Str::ucfirst(trim($s));
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class BannedIp extends Model
{
protected $guarded = [];
protected $casts = ['banned_until' => 'datetime'];
/** Non-expired bans only. */
public function scopeActive(Builder $query): Builder
{
return $query->where('banned_until', '>', now());
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A TLS endpoint (host:port) whose certificate expiry Clusev monitors. */
class CertEndpoint extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
protected static function booted(): void
{
static::creating(fn (self $e) => $e->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** An uptime probe: an HTTP URL or a TCP host:port whose reachability Clusev monitors. */
class HealthCheck extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
public const TYPES = ['http', 'tcp'];
protected static function booted(): void
{
static::creating(fn (self $c) => $c->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* The SSH login for the "Clusev host" terminal (a singleton one host). Stored encrypted; only
* ever read over the internal network by the terminal resolve endpoint, never sent to the browser.
* When no row exists the host terminal is "not configured" and the tile opens the setup form.
*/
class HostCredential extends Model
{
/** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */
protected $fillable = ['host', 'port', 'username', 'auth_type', 'secret', 'passphrase'];
/** Never serialize the credential fields (mirrors SshCredential). */
protected $hidden = ['secret', 'passphrase'];
protected $casts = [
'port' => 'integer',
'secret' => 'encrypted',
'passphrase' => 'encrypted',
];
/** The single configured host login, or null when the operator hasn't set one up yet. */
public static function current(): ?self
{
return static::query()->orderBy('id')->first();
}
/**
* A TRANSIENT Server (never persisted) that points SSH tooling FleetService, DockerService
* at the Clusev host itself, reusing the encrypted host login. The SshCredential is likewise
* transient; its encrypted 'secret' cast round-trips the plaintext back for CredentialVault.
* verifyHostKey skips pinning a non-persisted server, so this creates no fleet row.
*/
public function toServer(): Server
{
$cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]);
$cred->secret = $this->secret;
if ($this->passphrase !== null && $this->passphrase !== '') { // not `if ($this->passphrase)` — a "0" passphrase is valid
$cred->passphrase = $this->passphrase;
}
$server = new Server([
'name' => 'Clusev Host',
'ip' => $this->host,
'ssh_port' => $this->port,
'status' => 'online',
]);
$server->setRelation('credential', $cred);
return $server;
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One persisted point of a server's resource history (cpu/mem/disk %, load), sampled once a minute
* by clusev:sample-metrics. The live 15s view stays cache-backed; this table is the LONG history
* the Server-Details graph plots over selectable ranges. Pruned to a retention window.
*/
class MetricSample extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'cpu' => 'integer',
'mem' => 'integer',
'disk' => 'integer',
'load' => 'float',
'sampled_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A saved, reusable fleet command. */
class Runbook extends Model
{
protected $guarded = [];
protected static function booted(): void
{
static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -2,9 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
@ -32,26 +30,4 @@ class Server extends Model
{
return $this->hasOne(SshCredential::class);
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(ServerGroup::class);
}
/** Servers that belong to the given group (by group id). */
public function scopeInGroup(Builder $query, int $groupId): void
{
$query->whereHas('groups', fn (Builder $q) => $q->where('server_groups.id', $groupId));
}
/**
* Servers whose stored SSH credential is present AND not disabled the live poll set.
* A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect
* time, but the poller reuses a long-lived connection, so revocation only takes effect if
* the server also drops OUT of this query on the next tick.
*/
public function scopeWithActiveCredential(Builder $query): void
{
$query->whereHas('credential', fn (Builder $q) => $q->whereNull('disabled_at'));
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class ServerGroup extends Model
{
protected $guarded = [];
/** Allowed @theme colour token keys for a group's dot/pill (R3 — never a raw hex). */
public const COLORS = ['accent', 'cyan', 'online', 'warning', 'offline', 'ink'];
protected static function booted(): void
{
static::creating(fn (self $g) => $g->uuid ??= (string) Str::uuid());
// Fall back to the brand colour if an out-of-allow-list token slips in (defence in depth;
// the component validates too — this guarantees the token is always a real @theme colour).
static::saving(function (self $g): void {
if (! in_array($g->color, self::COLORS, true)) {
$g->color = 'accent';
}
});
}
// R11: addressed by uuid in URLs, never the bigint id.
public function getRouteKeyName(): string
{
return 'uuid';
}
public function servers(): BelongsToMany
{
return $this->belongsToMany(Server::class);
}
}

View File

@ -26,27 +26,9 @@ class Setting extends Model
return $value ?? $default;
}
/**
* Read a setting DIRECTLY from the DB, bypassing the read-through cache. For SECURITY-SENSITIVE
* values (e.g. an alert token) where a concurrent cache-miss could re-populate a STALE value just
* after a change and pair an old secret with a new target the cache-aside race the plain get()
* is exposed to. Costs one query; use only where staleness is a correctness/security bug.
*/
public static function uncached(string $key, ?string $default = null): ?string
{
return static::query()->find($key)?->value ?? $default;
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget("setting:{$key}");
}
/** Remove a setting entirely (so get() falls back to its default). */
public static function forget(string $key): void
{
static::query()->whereKey($key)->delete();
Cache::forget("setting:{$key}");
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A single-use, short-lived handle the browser passes to the terminal sidecar; the sidecar exchanges
* it (over the internal network, authenticated by a shared secret) for a connection spec. Burned on
* first resolve. Never holds credentials only points at the user + target.
*/
class TerminalSession extends Model
{
public const UPDATED_AT = null;
/** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */
protected $fillable = ['token', 'user_id', 'kind', 'server_id', 'expires_at', 'used_at', 'created_at'];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
'created_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Models;
use App\Enums\Role;
use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@ -16,7 +15,7 @@ use Illuminate\Support\Str;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
@ -32,8 +31,6 @@ class User extends Authenticatable
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
'must_change_password' => 'boolean',
'onboarding_tour_completed_at' => 'datetime',
'role' => Role::class,
];
}
@ -106,26 +103,7 @@ class User extends Authenticatable
}
try {
// Replay protection: verifyKeyNewer rejects any code from a time-step <= the last
// accepted one and returns the matched step on success. Persisting it makes each
// 30s code single-use, so an intercepted/replayed code can't be used twice.
//
// Pass 0 (never null) as the old timestamp: google2fa returns the bare bool `true`
// — not the matched step — when oldTimestamp is null, which would break the
// comparison on the very first use. With 0 it always returns the integer step.
$step = (new Google2FA)->verifyKeyNewer(
$this->two_factor_secret,
preg_replace('/\s+/', '', $code) ?? '',
(int) ($this->two_factor_last_used_step ?? 0),
);
if ($step === false) {
return false;
}
$this->forceFill(['two_factor_last_used_step' => $step])->save();
return true;
return (new Google2FA)->verifyKey($this->two_factor_secret, preg_replace('/\s+/', '', $code) ?? '');
} catch (Google2FAException) {
return false;
}
@ -146,18 +124,6 @@ class User extends Authenticatable
return ! $this->must_change_password;
}
/** True when the account is a full administrator. */
public function isAdmin(): bool
{
return $this->role === Role::Admin;
}
/** True when the account's role is at least $role in the admin>operator>viewer hierarchy. */
public function roleAtLeast(Role $role): bool
{
return $this->role?->atLeast($role) ?? false;
}
/**
* When neither factor remains (no TOTP, no keys), 2FA is fully off so drop the backup
* codes (nothing left to recover into). Call after removing any factor.

View File

@ -1,18 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WgTrafficSample extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'rx' => 'integer',
'tx' => 'integer',
'sampled_at' => 'datetime',
];
}

View File

@ -2,20 +2,13 @@
namespace App\Providers;
use App\Enums\Role;
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\User;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\MailSettings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
@ -39,12 +32,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(DeploymentService $deployment): void
{
// Drop Vite's <link rel="preload"> hints: with only one CSS + one JS entry the preload
// sits right next to the matching stylesheet/script tags, so it adds no real value — but
// it triggers Chrome's "preloaded but not used" console warning (especially with DevTools
// "Disable cache", which double-fetches the CSS so the preloaded copy is never consumed).
Vite::usePreloadTagAttributes(false);
// Re-apply the onboarding gate to EVERY Livewire component update, not just the
// initial page GET. Livewire only re-runs route middleware that is registered as
// persistent; without this, a component mounted while onboarded could keep running
@ -54,27 +41,6 @@ class AppServiceProvider extends ServiceProvider
EnsureSecurityOnboarded::class,
]);
// RBAC abilities. admin has every dangerous control-plane ability; operator can run
// day-to-day operations; viewer is read-only. Gates are the single source consumed by route
// `can:` middleware, component mount() abort_unless, per-action guards and @can in Blade.
foreach (['manage-panel', 'manage-users', 'manage-network', 'manage-fleet'] as $ability) {
Gate::define($ability, fn (User $u) => $u->isAdmin());
}
Gate::define('operate', fn (User $u) => $u->roleAtLeast(Role::Operator));
// Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint
// (/livewire/update carries every component request, including the auth actions). The
// per-action limiters in the components are the precise gate; this is the blunt cap so a
// single source (authed user id, else guest IP) can't flood workers regardless of which
// action it targets. 180/min sits far above normal polling + interaction yet well below a
// flood; it auto-expires, so — like every limiter here — it can never permanently lock the
// control plane (the bare-IP recovery host and clusev:reset-admin are unaffected).
RateLimiter::for('livewire-update', fn (Request $request) => Limit::perMinute(180)
->by($request->user()?->id ?: $request->ip()));
Livewire::setUpdateRoute(fn ($handle) => Route::post('/livewire/update', $handle)
->middleware(['web', 'throttle:livewire-update']));
config([
'broadcasting.connections.reverb.options.host' => 'reverb',
'broadcasting.connections.reverb.options.port' => 8080,
@ -87,12 +53,50 @@ class AppServiceProvider extends ServiceProvider
config(['app.url' => 'https://'.$domain]);
}
// Operator SMTP settings → runtime mail config. Shared helper (MailSettings::apply) because
// THREE process shapes need it fresh: web requests (here, per request), the queue worker
// (Queue::before, per job — boot() runs only once in that long-lived process), and the
// long-lived metrics poller (AlertNotifier applies it right before queueing, since the
// default MAILER NAME is baked into a mailable at queue time).
MailSettings::apply();
Queue::before(fn () => MailSettings::apply());
$this->applyMailSettings();
}
/**
* Apply the operator's SMTP settings (Settings\Email) to runtime config so
* password-reset mails and notifications actually send. boot() runs every request,
* so a saved change takes effect on the next request.
*
* Only switches to the `smtp` mailer when host + from-address are configured;
* otherwise the safe install default (`log`) is left untouched, so nothing breaks
* while unconfigured. The stored SMTP password is encrypted at rest (APP_KEY) and
* decrypted here. Wrapped in try/catch like DeploymentService the settings table
* may not exist yet during install/migrate, and that must never break boot.
*/
private function applyMailSettings(): void
{
try {
$host = Setting::get('mail_host');
$from = Setting::get('mail_from_address');
if ($host === null || $host === '' || $from === null || $from === '') {
return; // unconfigured -> keep the safe default mailer
}
$encryption = Setting::get('mail_encryption', 'tls');
$storedPassword = Setting::get('mail_password');
$password = null;
if ($storedPassword !== null && $storedPassword !== '') {
$password = Crypt::decryptString($storedPassword);
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $host,
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
'mail.from.address' => $from,
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
]);
} catch (Throwable) {
// Settings table not migrated yet (install/migrate) or an undecryptable value —
// leave the default mailer untouched. Never break boot.
}
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidIpOrCidr implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$v = is_string($value) ? trim($value) : '';
if (filter_var($v, FILTER_VALIDATE_IP) !== false) {
return;
}
if (! str_contains($v, '/')) {
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
return;
}
[$ip, $mask] = explode('/', $v, 2);
$max = str_contains($ip, ':') ? 128 : 32;
if (filter_var($ip, FILTER_VALIDATE_IP) === false || ! ctype_digit($mask) || (int) $mask < 0 || (int) $mask > $max) {
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
}
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Services;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use Illuminate\Database\QueryException;
/**
* The alert state machine. Given a server's fresh reading, opens a firing incident on a new breach
* (and notifies) and resolves an open incident on recovery (and notifies). Idempotent: a sustained
* breach with an already-open incident does nothing, so one breach = one alert, not a flood.
*/
class AlertEvaluator
{
public function __construct(private AlertNotifier $notifier) {}
/**
* @param array<string, int|string> $metrics cpu/mem/disk/load ints (empty when offline)
* @param string $status the server's fresh status (online|warning|offline)
*/
public function evaluate(Server $server, array $metrics, string $status): void
{
$rules = AlertRule::where('enabled', true)->get();
foreach ($rules as $rule) {
if (! $rule->targets($server)) {
continue;
}
// Numeric metrics need a fresh reading; skip them only when the server is truly OFFLINE
// (stale numbers). A reachable "warning" server still has fresh metrics, so it evaluates.
if ($rule->metric !== 'offline' && $status === 'offline') {
continue;
}
$this->transition($rule, $server, $rule->breached($metrics, $status), $rule->reading($metrics));
}
}
private function transition(AlertRule $rule, Server $server, bool $breached, float $reading): void
{
$open = AlertIncident::where('alert_rule_id', $rule->id)
->where('server_id', $server->id)
->where('state', 'firing')
->first();
if ($breached && ! $open) {
try {
$incident = AlertIncident::create([
'alert_rule_id' => $rule->id,
'server_id' => $server->id,
'state' => 'firing',
'value' => $reading,
// Unique while firing → a concurrent tick that also tries to open this incident
// hits a unique violation instead of creating a duplicate (see the migration).
'firing_key' => $rule->id.':'.$server->id,
'started_at' => now(),
]);
} catch (QueryException) {
return; // another tick opened it first — dedup, no duplicate incident/notification
}
AlertIncident::forgetFiringCount(); // keep the sidebar badge in step with this new incident
$this->notifier->notify($incident, resolved: false);
return;
}
if (! $breached && $open) {
// Clear firing_key on resolve so a later re-breach can claim the unique key again.
$open->update(['state' => 'resolved', 'firing_key' => null, 'resolved_at' => now()]);
AlertIncident::forgetFiringCount();
$this->notifier->notify($open, resolved: true);
}
// else: still firing with an open incident, or fine with none → dedup, no-op.
}
}

View File

@ -1,225 +0,0 @@
<?php
namespace App\Services;
use App\Enums\Role;
use App\Mail\AlertNotification;
use App\Models\AlertIncident;
use App\Models\Setting;
use App\Models\User;
use App\Support\MailSettings;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Fans one alert transition (fire / resolve) out to the configured channels. Every channel is
* BEST-EFFORT: a broken SMTP config or an unreachable webhook is logged, never thrown, so a
* notification failure can never break the poll loop that triggered it.
*/
class AlertNotifier
{
public function notify(AlertIncident $incident, bool $resolved): void
{
// Re-apply the DB SMTP settings RIGHT BEFORE queueing: the metrics poller calling this is a
// long-lived process, and Laravel bakes the current default MAILER NAME into the mailable at
// queue time — without this, a poller that booted before SMTP was configured would bake the
// `log` mailer into every alert e-mail forever (they'd land in laravel.log, never the inbox).
MailSettings::apply();
$this->email($incident, $resolved);
$this->webhooks($incident, $resolved);
$this->gotify($incident, $resolved);
}
/**
* Gotify push (self-hostable push server): POST /message with the app token in the X-Gotify-Key
* header + a {title, message, priority} body the shape Gotify expects (the generic webhook's
* bare `text` field would not render). A firing alert uses a high priority (8) so it pushes/rings;
* a resolve is low (3). Best-effort like every channel. Unlike the SSRF-guarded webhooks a Gotify
* server is COMMONLY on a private LAN, so only the scheme is enforced (isValidGotifyUrl), not the
* private-range block that is the operator's deliberate, admin-only choice.
*/
private function gotify(AlertIncident $incident, bool $resolved): void
{
// Read + validate + decrypt ONCE and send exactly that pair — no re-read of the settings
// between the check and the POST (which could otherwise pair a new URL with an old token).
$target = self::gotifyTarget();
if ($target === null) {
return;
}
$url = $target['url'];
$token = $target['token'];
$server = $incident->server?->name ?? '—';
$rule = $incident->rule?->name ?? '—';
$title = ($resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing'))."{$server}";
try {
Http::timeout(5)->withoutRedirecting()
->withHeaders(['X-Gotify-Key' => $token])
->asJson()
->post(rtrim($url, '/').'/message', [
'title' => $title,
'message' => "{$rule}: {$incident->rule?->metric} = {$incident->value} (Limit {$incident->rule?->threshold})",
'priority' => $resolved ? 3 : 8,
]);
} catch (Throwable $e) {
Log::warning('alert gotify failed: '.$e->getMessage());
}
}
/** http(s) + a parseable host. NO private-range block (a self-hosted Gotify is usually on a LAN). */
public static function isValidGotifyUrl(string $url): bool
{
$p = parse_url(trim($url));
return is_array($p) && isset($p['scheme'], $p['host'])
&& in_array(strtolower($p['scheme']), ['http', 'https'], true);
}
/**
* The validated Gotify target the sender will use ['url' => string, 'token' => plaintext] or
* null when Gotify can't actually send (missing/invalid URL, or a missing/undecryptable token).
* ONE place reads + validates + decrypts, so the "no delivery channel" banner (which checks for
* null) and the send path can never diverge, and the POST uses exactly the pair that was
* validated in the same read no check-then-reread TOCTOU.
*
* @return array{url: string, token: string}|null
*/
public static function gotifyTarget(): ?array
{
// Setting::uncached (DB-direct, cache-bypass): a stale cached token read just after a URL change
// could otherwise pair an old token with a new endpoint (token exfil). Reads the committed
// state, which — with saveChannels dropping the token before writing the new URL — can never
// present a new-url + old-token pair.
$url = trim((string) Setting::uncached('alert_gotify_url', ''));
$stored = (string) Setting::uncached('alert_gotify_token', '');
if ($url === '' || $stored === '' || ! self::isValidGotifyUrl($url)) {
return null;
}
try {
return ['url' => $url, 'token' => Crypt::decryptString($stored)];
} catch (Throwable) {
return null; // undecryptable (rotated APP_KEY) — treat as not configured
}
}
private function email(AlertIncident $incident, bool $resolved): void
{
// ONE message per recipient — NOT a single To: with everyone. A mixed To that contains an
// undeliverable address (e.g. a seeded admin on a non-routable domain) reads as spam to
// providers like Gmail and gets the WHOLE message silently dropped for the valid recipients
// too. Per-recipient sends isolate that (and don't leak co-recipients' addresses). Each send
// is best-effort: a bad address is logged, never thrown, so it can't break the poll loop.
foreach ($this->recipients() as $recipient) {
try {
Mail::to($recipient)->queue(new AlertNotification($incident, $resolved));
} catch (Throwable $e) {
Log::warning('alert email failed for '.$recipient.': '.$e->getMessage());
}
}
}
private function webhooks(AlertIncident $incident, bool $resolved): void
{
$payload = $this->payload($incident, $resolved);
foreach ($this->webhookUrls() as $url) {
try {
// withoutRedirecting: a 30x could otherwise bounce the POST to an internal target that
// the SSRF host-check below never saw. timeout keeps a slow hook from stalling the loop.
Http::timeout(5)->withoutRedirecting()->asJson()->post($url, $payload);
} catch (Throwable $e) {
Log::warning('alert webhook failed: '.$e->getMessage());
}
}
}
/**
* SSRF guard for an operator-configured webhook URL: exactly http(s), and the host must NOT
* resolve to a loopback/private/link-local/reserved address (so a hook can't be pointed at an
* internal service). Admin-only config, but defence in depth checked here AND at save time.
*/
public static function isSafeWebhookUrl(string $url): bool
{
$parts = parse_url(trim($url));
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
return false;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
// IP literal → itself; hostname → its A records (IPv4). Unresolvable / IPv6-only → refuse.
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
if ($ips === []) {
return false;
}
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false; // loopback/private/link-local/reserved → blocked
}
}
return true;
}
/** @return list<string> */
private function recipients(): array
{
$configured = $this->splitLines((string) Setting::get('alert_email_to', ''));
$emails = array_values(array_filter($configured, fn (string $e) => filter_var($e, FILTER_VALIDATE_EMAIL) !== false));
if ($emails !== []) {
return $emails;
}
// Fall back to every admin's e-mail so a fresh install still alerts someone.
return User::query()->where('role', Role::Admin->value)
->whereNotNull('email')
->pluck('email')->all();
}
/** @return list<string> */
private function webhookUrls(): array
{
return array_values(array_filter(
$this->splitLines((string) Setting::get('alert_webhooks', '')),
fn (string $u) => self::isSafeWebhookUrl($u),
));
}
/** @return array<string, mixed> */
private function payload(AlertIncident $incident, bool $resolved): array
{
$server = $incident->server?->name ?? '—';
$rule = $incident->rule?->name ?? '—';
$state = $resolved ? 'resolved' : 'firing';
$text = $resolved
? __('alerts.mail_subject_resolved')."{$rule} ({$server})"
: __('alerts.mail_subject_firing')."{$rule} ({$server})";
// A single `text` field satisfies Slack/Discord/Mattermost/Telegram-relay incoming webhooks;
// the structured fields let a custom consumer route on server/metric/state.
return [
'text' => $text,
'state' => $state,
'server' => $server,
'rule' => $rule,
'metric' => $incident->rule?->metric,
'value' => $incident->value,
'threshold' => $incident->rule?->threshold,
];
}
/** @return list<string> */
private function splitLines(string $raw): array
{
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
}

View File

@ -1,270 +0,0 @@
<?php
namespace App\Services;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
/**
* Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
* the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
* not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
*/
class BruteforceGuard
{
/** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.0/104'];
/** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";
/** Ban-check cache TTL (seconds). Stale window on natural expiry; keep <= bantime()/2. */
private const CHECK_TTL = 30;
public function enabled(): bool
{
return Setting::get('bruteforce_enabled', '1') === '1';
}
public function maxretry(): int
{
return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
}
/** findtime in MINUTES. */
public function findtime(): int
{
return max(1, (int) Setting::get('bruteforce_findtime', '10'));
}
/** bantime in MINUTES. */
public function bantime(): int
{
return max(1, (int) Setting::get('bruteforce_bantime', '60'));
}
/** @return string[] */
public function whitelist(): array
{
$raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
public function isExempt(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return true; // unknown/malformed IP → never count or ban
}
$ip = $this->canonical($ip); // fold IPv4-mapped IPv6 so exemption matches the canonical form
foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) {
if ($this->matchesCidr($ip, $cidr)) {
return true;
}
}
return false;
}
public function record(?string $ip, string $reason): void
{
if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
return;
}
$canonical = $this->canonical($ip);
$key = 'bruteforce:'.md5($canonical);
$hits = RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds; hit() returns the count
if ($hits < $this->maxretry()) {
return;
}
// Ban + audit at most once per IP per window even if two requests cross the threshold
// concurrently (the upsert is idempotent; this also de-dupes the audit row).
if (! Cache::add('bruteforce:banning:'.$canonical, true, 60)) {
return;
}
// Atomic upsert keyed on the unique ip index. Eloquent upsert needs explicit timestamps
// for the bulk path; a single $now keeps created_at/updated_at identical.
$now = now();
BannedIp::upsert(
[[
'ip' => $canonical,
'banned_until' => $now->copy()->addMinutes($this->bantime()),
'reason' => $reason,
'attempts' => $hits,
'created_at' => $now,
'updated_at' => $now,
]],
['ip'],
['banned_until', 'reason', 'attempts', 'updated_at'],
);
RateLimiter::clear($key);
Cache::forget('bruteforce:banned:'.$canonical);
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason, 'attempts' => $hits],
]);
}
/**
* Ban an IP on the FIRST hit no maxretry threshold, no RateLimiter window. Used by the
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
* throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
* trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
* disabled brute-force protection must not silently disable honeypot bans). Returns true when a
* ban was written, false when it no-op'd (invalid / exempt IP) the caller stays a cheap no-op
* for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
*/
public function banNow(?string $ip, string $reason): bool
{
if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
return false;
}
$canonical = $this->canonical($ip);
$now = now();
BannedIp::upsert(
[[
'ip' => $canonical,
'banned_until' => $now->copy()->addMinutes($this->bantime()),
'reason' => $reason,
'attempts' => $this->maxretry(),
'created_at' => $now,
'updated_at' => $now,
]],
['ip'],
['banned_until', 'reason', 'attempts', 'updated_at'],
);
Cache::forget('bruteforce:banned:'.$canonical);
// Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
// looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
// once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
// includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
// honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
// rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
// which is minutes of human time later, well past this window (so it is not tied to unban()).
$auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason;
if (Cache::add($auditKey, 1, 60)) {
try {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason],
]);
} catch (\Throwable $e) {
// The Cache::add above atomically CLAIMED the dedup slot before the write. If the
// write throws (a transient DB blip), release the slot so the 60s window is not
// burned with no audit row — the next attempt re-claims and re-audits this ban.
Cache::forget($auditKey);
throw $e;
}
}
return true;
}
/**
* Whether $ip has an active ban. Does NOT check enabled() the BlockBannedIp middleware
* gates on enabled() before calling this, so disabling the feature stops enforcement while
* existing rows persist and resume if it is re-enabled (spec §7).
*/
public function isBanned(string $ip): bool
{
if ($this->isExempt($ip)) {
return false; // exempt short-circuits — whitelisting unblocks immediately
}
$canonical = $this->canonical($ip);
return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query()
->where('ip', $canonical)
->where('banned_until', '>', now())
->exists());
}
public function unban(string $ip): void
{
$canonical = $this->canonical($ip);
BannedIp::query()->where('ip', $canonical)->delete();
Cache::forget('bruteforce:banned:'.$canonical);
}
/** Delete every ban (active or expired) and bust each cache key. Returns the count removed. */
public function unbanAll(): int
{
$ips = BannedIp::query()->pluck('ip');
foreach ($ips as $ip) {
Cache::forget('bruteforce:banned:'.$this->canonical($ip));
}
BannedIp::query()->delete();
return $ips->count();
}
/** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */
public function matchesCidr(string $ip, string $cidr): bool
{
if (! str_contains($cidr, '/')) {
$a = @inet_pton($ip);
$b = @inet_pton($cidr);
return $a !== false && $b !== false && $a === $b;
}
[$subnet, $maskStr] = explode('/', $cidr, 2);
$pip = @inet_pton($ip);
$psub = @inet_pton($subnet);
if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) {
return false; // v4-vs-v6 mismatch or junk mask
}
$mask = (int) $maskStr;
if ($mask > strlen($pip) * 8) {
return false; // mask wider than the address family (e.g. IPv4 /33)
}
$bytes = intdiv($mask, 8);
$rem = $mask % 8;
if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) {
return false;
}
if ($rem === 0) {
return true;
}
$b = 0xFF & (0xFF << (8 - $rem));
return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b);
}
private function canonical(string $ip): string
{
$packed = @inet_pton($ip);
if ($packed === false) {
return $ip;
}
// Fold an IPv4-mapped IPv6 (::ffff:a.b.c.d) down to plain IPv4 so the same client can't
// dodge a ban by switching between the two forms (one ban row + one cache key per IP).
if (strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) {
$packed = substr($packed, 12);
}
return (string) inet_ntop($packed);
}
}

View File

@ -1,87 +0,0 @@
<?php
namespace App\Services;
/**
* Reads a TLS endpoint's certificate expiry natively (a PHP SSL stream + openssl_x509_parse) no
* SSH, no shell, so `host` is never interpolated into a command. Trust is intentionally NOT verified
* (verify_peer off): the point is to report expiry even for a self-signed or already-expired cert.
* Monitoring internal endpoints is a legitimate use, so private/internal hosts are allowed.
*/
class CertService
{
/**
* @return array{ok:bool, subject?:string, issuer?:string, expiresAt?:int, daysLeft?:?int, error?:string}
*/
public function check(string $host, int $port, int $timeout = 5): array
{
$ctx = stream_context_create(['ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
'SNI_enabled' => true,
'peer_name' => $host,
]]);
$errno = 0;
$errstr = '';
// @ — a refused/timed-out/unresolved endpoint is a normal result here, not a PHP warning.
$client = @stream_socket_client(
'ssl://'.$host.':'.$port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx,
);
if (! $client) {
return ['ok' => false, 'error' => $errstr !== '' ? $errstr : 'connection failed'];
}
$params = stream_context_get_params($client);
fclose($client);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if ($cert === null) {
return ['ok' => false, 'error' => 'no certificate presented'];
}
return ['ok' => true] + $this->certInfo($cert);
}
/**
* Extract subject/issuer/expiry from an openssl cert (resource or PEM). Split out so it is unit-
* testable against a generated cert without a live network connection.
*
* @param \OpenSSLCertificate|string $cert
* @return array{subject:string, issuer:string, expiresAt:?int, daysLeft:?int}
*/
public function certInfo($cert): array
{
$parsed = openssl_x509_parse($cert) ?: [];
$expiresAt = isset($parsed['validTo_time_t']) ? (int) $parsed['validTo_time_t'] : null;
return [
'subject' => (string) ($parsed['subject']['CN'] ?? ($parsed['subject']['O'] ?? '—')),
'issuer' => (string) ($parsed['issuer']['CN'] ?? ($parsed['issuer']['O'] ?? '—')),
'expiresAt' => $expiresAt,
'daysLeft' => $expiresAt !== null ? (int) floor(($expiresAt - time()) / 86400) : null,
];
}
/** expired · critical (<7d) · warning (<30d) · ok — mapped to the status triad in the UI. */
public function status(?int $daysLeft): string
{
if ($daysLeft === null) {
return 'unknown';
}
return match (true) {
$daysLeft < 0 => 'expired',
$daysLeft < 7 => 'critical',
$daysLeft < 30 => 'warning',
default => 'ok',
};
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Services;
use App\Models\Server;
use Illuminate\Support\Collection;
use Throwable;
/**
* Runs one (arbitrary, user-supplied) command across a set of servers and collects a per-server
* result. The command is INTENTIONALLY arbitrary it runs via FleetService::runPlain (the
* credential's login user, base64 transport, no extra sudo) exactly as the web terminal would.
* One server failing (connect/exec) never aborts the batch. Remote output is byte-capped +
* mb_scrub'd so it can safely land in a Livewire property.
*/
class CommandRunner
{
private const MAX_BYTES = 262144; // 256 KiB per server
public function __construct(private FleetService $fleet) {}
/**
* @param Collection<int, Server> $servers
* @return array<int, array{server:string, ok:bool, output:string}>
*/
public function run(string $command, Collection $servers, int $timeout = 60): array
{
$results = [];
foreach ($servers as $server) {
try {
$res = $this->fleet->runPlain($server, $command, $timeout);
$results[] = ['server' => $server->name, 'ok' => (bool) $res['ok'], 'output' => $this->clean($res['output'])];
} catch (Throwable $e) {
$results[] = ['server' => $server->name, 'ok' => false, 'output' => $this->clean($e->getMessage())];
}
}
return $results;
}
private function clean(string $output): string
{
return mb_scrub(mb_strcut($output, 0, self::MAX_BYTES), 'UTF-8');
}
}

Some files were not shown because too many files have changed in this diff Show More