Compare commits

..

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

536 changed files with 3241 additions and 46371 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
@ -26,7 +26,7 @@ DB_PASSWORD=change-me-strong
# mariadb container only (compose), not read by Laravel:
DB_ROOT_PASSWORD=change-me-strong-root
# ── Cache / Queue via Redis · Session via database ───────────────────
# ── Cache / Queue / Session via Redis ────────────────────────────────
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
@ -34,8 +34,7 @@ REDIS_PASSWORD=null
CACHE_STORE=redis
QUEUE_CONNECTION=redis
# database = sessions are listable/revocable in Settings (Redis stays for cache/queue)
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -47,7 +46,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 +64,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.
@ -81,50 +75,16 @@ IMAGE_TAG=latest
# ── Reverse proxy / TLS (prod — written by install.sh) ───────────────
# Single knob: APP_DOMAIN empty -> Caddy serves plain HTTP on APP_PORT (bare-IP).
# APP_DOMAIN set -> Caddy auto-issues a Let's Encrypt cert + forces HTTPS.
# Changing the panel domain later = re-run install.sh with the new domain.
# APP_DOMAIN set -> Caddy gets a Let's Encrypt cert + forces HTTPS.
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
# Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"):
SITE_ADDRESS=:80
# Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io/<owner>/clusev@sha256:..).
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}"

36
.gitignore vendored
View File

@ -6,7 +6,6 @@
/public/hot
/public/storage
/storage/*.key
/storage/framework/active-domain
/storage/pail
/vendor
@ -14,13 +13,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 +33,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/

File diff suppressed because it is too large Load Diff

257
CLAUDE.md Normal file
View File

@ -0,0 +1,257 @@
# 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.**
- **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: German**, terse/operational, **no emoji** (status = color/dots/pills). Native
technical tokens stay as-is (`nginx.service`, `chmod 600`, `SSH`, `2FA`). (R9)
- **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)
- **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;
build + 3 breakpoints; then `git status` for stray secrets, commit on the feature branch.
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.

242
README.md
View File

@ -1,223 +1,83 @@
<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 to administer a **fleet of Linux servers from one dashboard**,
agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers
with **phpseclib** (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted
SSH-credential vault, and a full audit log. Multi-server is free and never paywalled.
<br>
> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details,
> auth + 2FA. UI copy is German; meta-docs are English.
**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>
**Stack:** Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3.
Everything runs in Docker. See `CLAUDE.md` for architecture and `rules.md` for the hard conventions.
---
## What is Clusev
## Development
**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>
---
## 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. |
The interface is **German by default** (English available). Status is shown the operational way —
colour, dots and pills — never emoji.
---
## Architecture
<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
The host needs only Docker (no PHP/Composer/Node). The dev `app` container runs php-fpm + nginx +
Vite via supervisor; `reverb`, `queue`, `mariadb`, `redis` are their own services.
```bash
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
git clone https://github.com/clusev/clusev.git
cd clusev
sudo ./install.sh
cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build # app on :80, Vite HMR on :5173
docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate
docker compose exec app php artisan migrate --seed
```
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).
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.
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.
### First login
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).
Run any tooling inside the container, e.g.
`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt).
---
## Requirements
## Production install
**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
- **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
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
One host, only Docker + a sudo user. **Caddy** is the single host-exposed service: auto-TLS when a
domain is set, plain HTTP on the bare IP otherwise. `install.sh` is idempotent — it generates
secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin
with a **one-time random password printed only on the terminal**.
```bash
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
git clone https://git.bave.dev/boban/clusev.git && cd clusev
./install.sh
```
`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.)
Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment):
---
```
Domain (empty = access by IP over HTTP): clusev.example.com
Admin e-mail (login + Let's Encrypt): admin@example.com
```
## Security &amp; recovery
The closing banner shows the URL + the one-time admin password. On first login Clusev **forces** a
password change and 2FA enrolment before the panel unlocks.
The **forgot-password** screen offers self-service recovery: an **e-mail reset link** (valid 15
minutes) when SMTP is configured, or an inline **2FA-proof reset** (e-mail + TOTP or a backup code +
new password) as a fallback.
### One knob: `APP_DOMAIN`
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
| `APP_DOMAIN` | proxy | URL | Reverb |
|---|---|---|---|
| *(empty)* | Caddy serves plain HTTP on `APP_PORT` | `http://<ip>` | `ws://<ip>/app/*` |
| `clusev.example.com` | Caddy gets a Let's Encrypt cert, forces HTTPS | `https://…` | `wss://…/app/*` |
`SITE_ADDRESS`, `APP_URL`, `REVERB_*` are **derived** from it by the installer — nothing hardcoded.
Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs
publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead.
### Manual deploy (for operators who don't `curl | bash`)
```bash
clusev reset-admin
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com
```
This clears the second factor so you can set a new password on the next login. The bare-IP
`http://<server-ip>` address is also always available if a domain becomes unreachable. (This command
is documented in-panel under Settings → Security and is deliberately not shown on the public
forgot-password screen.)
> In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified,
> 2FA-gated, with backup + rollback) are designed in `docs/install-update-design.md` and land in
> v1.x — they are intentionally **not** shipped yet.
---
## 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>
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.

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

@ -1,38 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Console\Command;
/**
* Enforce the audit-log retention policy: delete audit_events older than the
* configured number of days. The policy lives in the `audit_retention_days`
* setting (managed from the Audit page); empty or 0 means "keep forever".
*
* Scheduled daily in routes/console.php.
*/
class PruneAudit extends Command
{
protected $signature = 'clusev:prune-audit';
protected $description = 'Prune audit_events older than the configured retention window';
public function handle(): int
{
$days = (int) Setting::get('audit_retention_days', '0');
if ($days <= 0) {
$this->info('Aufbewahrung unbegrenzt — nichts zu tun');
return self::SUCCESS;
}
$deleted = AuditEvent::where('created_at', '<', now()->subDays($days))->delete();
$this->info("Aufbewahrung {$days} Tage — {$deleted} Audit-Einträge gelöscht");
return self::SUCCESS;
}
}

View File

@ -1,71 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Emergency, shell-only recovery: resets the admin password (and optionally 2FA)
* when the operator is locked out (lost password + authenticator + backup codes).
* Requires server access, so it is a safe last resort.
*/
class ResetAdmin extends Command
{
protected $signature = 'clusev:reset-admin
{--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
{--password= : Neues Passwort (zufällig erzeugt, falls leer)}
{--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';
protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';
public function handle(): int
{
$email = (string) $this->option('email');
$user = $email !== ''
? User::where('email', $email)->first()
: (User::count() === 1 ? User::first() : null);
if (! $user) {
$this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.');
return self::FAILURE;
}
$password = (string) ($this->option('password') ?: Str::password(20));
// Rotate remember_token so any previously issued remember-me cookie is revoked.
$user->forceFill([
'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')) {
$user->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
'two_factor_recovery_codes' => null,
]);
// Drop security keys too — a key that prompted the emergency reset must not
// silently revive when 2FA is re-enabled.
$user->webauthnCredentials()->delete();
}
$user->save();
$this->info("Passwort für {$user->email} zurückgesetzt.");
if (! $this->option('password')) {
$this->warn("Neues Passwort (jetzt notieren): {$password}");
}
if ($this->option('disable-2fa')) {
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
}
return self::SUCCESS;
}
}

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,55 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\DeploymentService;
use Illuminate\Console\Command;
/**
* Freeze the currently-configured panel domain as the ACTIVE one for this container's
* lifetime. Run by the entrypoint at every container start, so a dashboard domain change
* takes effect on restart (and never mid-session).
*
* The snapshot itself is the readiness probe: it returns false until the setting can be
* read authoritatively (database reachable AND the settings table migrated neither is
* guaranteed at startup: depends_on health isn't honored on host reboot, and on a fresh
* install the app starts before install.sh migrates). We retry until it succeeds, and only
* if it never does do we freeze the install-time env fallback so a snapshot file always
* exists and the active domain is never a live value that could shift after startup.
*/
class SnapshotDomain extends Command
{
protected $signature = 'clusev:snapshot-domain {--tries=30}';
protected $description = 'Snapshot the configured panel domain as the active serving domain (run at container start)';
public function handle(DeploymentService $deployment): int
{
$tries = max(1, (int) $this->option('tries'));
for ($i = 1; $i <= $tries; $i++) {
if ($deployment->snapshotActiveDomain()) {
$this->info('Active domain: '.($deployment->domain() ?? '(bare IP / HTTP)'));
return self::SUCCESS;
}
if ($i < $tries) {
sleep(1);
}
}
// Settings never became readable in time. Keep any existing snapshot (last-known-good,
// frozen); only if none exists, freeze the install-time env fallback — so the active
// domain is always a FIXED snapshot and never falls back to a live value that could
// change once the DB/migrations catch up (which would break the restart boundary).
if ($deployment->hasSnapshot()) {
$this->warn('Settings not reachable; kept the existing active-domain snapshot.');
} else {
$deployment->snapshotFallback();
$this->warn('Settings not reachable; froze the install-time domain (dashboard override applies on the next ready restart).');
}
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

@ -2,8 +2,8 @@
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@ -22,11 +22,9 @@ class MetricsTicked implements ShouldBroadcast
public string $server = 'web-01',
) {}
public function broadcastOn(): PrivateChannel
public function broadcastOn(): Channel
{
// PRIVATE: requires /broadcasting/auth (see routes/channels.php). Infrastructure
// metrics must not be subscribable by anyone holding the public app key.
return new PrivateChannel('metrics');
return new Channel('metrics');
}
public function broadcastAs(): string

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 security onboarding before the panel is reachable:
* 1) rotate the seeded password, then 2) enrol 2FA.
*/
class EnsureSecurityOnboarded
{
@ -18,10 +16,16 @@ class EnsureSecurityOnboarded
{
$user = $request->user();
if ($user && $user->must_change_password
&& ! $request->session()->get('onboarding.password_skipped')
&& ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
if ($user) {
$allowed = $request->routeIs('password.change', 'two-factor.setup', 'logout');
if ($user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
}
if (! $user->must_change_password && ! $user->hasTwoFactorEnabled() && ! $allowed) {
return redirect()->route('two-factor.setup');
}
}
return $next($request);

View File

@ -1,91 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Services\DeploymentService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
/**
* Ties cookie security, HTTPS enforcement and host enforcement to the ACTUAL request so
* a panel-domain change can never lock the operator out nor expose the panel on the wrong
* host. Runs behind TrustProxies (prod), so isSecure()/getHost() reflect Caddy's
* X-Forwarded-* headers.
*
* - The session cookie's `Secure` flag follows the real request scheme, so the bare-IP /
* plain-HTTP recovery path always receives its cookie and can log in.
* - When a domain is active, ONLY that domain (over HTTPS) and the literal server IP
* (over HTTP, the recovery path) may serve the panel. Any other hostname a stale/old
* domain whose certificate Caddy may still hold, or an attacker's domain pointed at this
* server is refused, so the authenticated panel is never served in plaintext or on an
* unintended host. Caddy's TLS-ask + the health check are exempt.
* - In bare-IP mode app.url is kept in step with the request, so generated absolute URLs
* never point at a removed/old domain left in .env.
*/
class PanelScheme
{
public function __construct(private DeploymentService $deployment) {}
public function handle(Request $request, Closure $next): Response
{
// Cookie security follows the real request scheme (never a static .env flag).
config(['session.secure' => $request->isSecure()]);
$domain = $this->deployment->domain();
// Bare-IP mode: no TLS, no host restriction. Keep app.url on the current request
// so URLs don't point at a domain that was removed from service.
if ($domain === null) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// 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/*')) {
return $next($request);
}
$host = strtolower($request->getHost());
// The literal server IP is the plaintext recovery path — always served over HTTP.
// Pin app.url to the IP request too, so login/onboarding redirects (absolute
// route() URLs) stay on the reachable IP instead of bouncing to the (possibly
// unreachable) domain that AppServiceProvider set as app.url.
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// Only the active domain may serve the panel by hostname.
if ($host !== $domain) {
abort(404);
}
// Force HTTPS for the active domain — on the default TLS port (443). Build the URL
// explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL
// (Caddy serves TLS only on 443; https://domain:8080 would be unreachable).
//
// In external-proxy mode the upstream terminates TLS and owns HTTP->HTTPS; this Caddy is
// HTTP-only, so the app must NOT bounce to https://domain:443 (unreachable). The forwarded
// scheme still drives cookie-Secure/HSTS above.
if (! $this->deployment->externalTls() && ! $request->isSecure()) {
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,47 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Resolve the active UI language for each request: the signed-in user's saved
* preference wins, then the session choice (so a guest on the login page can switch),
* then the app default. Only known locales are honoured.
*/
class SetLocale
{
/** Languages Clusev ships. Keep in sync with the lang/<code> directories + R16. */
public const SUPPORTED = ['de', 'en'];
public function handle(Request $request, Closure $next): Response
{
self::apply($request);
return $next($request);
}
/**
* Resolve + set the active locale: signed-in user's preference, then the session
* choice, then the app default. Safe to call before the session is started e.g.
* from the exception handler for errors thrown before this middleware (CSRF 419,
* unmatched-route 404, maintenance 503) a missing session is simply skipped, so
* those custom error pages still honour the user's language where it is known.
*/
public static function apply(Request $request): void
{
$locale = config('app.locale');
if ($request->hasSession()) {
$locale = $request->user()?->locale
?? $request->session()->get('locale')
?? $locale;
}
if (in_array($locale, self::SUPPORTED, true)) {
app()->setLocale($locale);
}
}
}

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

@ -3,151 +3,50 @@
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\Facades\Auth;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('Audit-Log — Clusev')]
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 = '';
/**
* Aufbewahrungsdauer in Tagen (Formularfeld). Leer/0 = unbegrenzt; ein
* positiver Wert N löscht beim täglichen Prune Einträge älter als N Tage.
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
*/
public ?string $retentionDays = null;
public function mount(): void
protected function events(): Collection
{
// Empty/0 means "keep forever" — surface that as a blank field, not "0".
$stored = (int) Setting::get('audit_retention_days', '0');
$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'],
]);
$days = $validated['retentionDays'] !== null && $validated['retentionDays'] !== ''
? (int) $validated['retentionDays']
: 0;
Setting::put('audit_retention_days', $days > 0 ? (string) $days : null);
$this->retentionDays = $days > 0 ? (string) $days : null;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'audit.retention_set',
'target' => 'audit_retention_days='.$days,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('audit.retention_saved_notify'));
}
/** Effective retention in days (0 = unlimited) for the policy summary. */
protected function retentionPolicy(): int
{
return $this->retentionDays !== null && $this->retentionDays !== ''
? (int) $this->retentionDays
: 0;
}
/**
* 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.
*/
protected function eventsQuery(): Builder
{
$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()),
'retentionPolicy' => $this->retentionPolicy(),
])->title(__('audit.title'));
'events' => $this->events(),
]);
}
}

View File

@ -1,181 +0,0 @@
<?php
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;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
/** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';
public string $email = '';
public string $code = '';
public string $password = '';
public string $password_confirmation = '';
/** Reveals the secondary inline 2FA-proof form when a mailer is configured. */
public bool $showCodeReset = false;
/** True when a real mailer is configured (then the email-link option is offered too). */
public function mailEnabled(): bool
{
return ! in_array(config('mail.default'), ['log', 'array', null], true);
}
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
public function sendResetLink(): void
{
if (! $this->mailEnabled()) {
return;
}
$this->validate(['email' => ['required', 'email']]);
// The notification is queued (see User::sendPasswordResetNotification), so this only ever
// pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither
// latency nor an exception can distinguish a known address from an unknown one.
try {
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
} catch (\Throwable $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);
}
// Generic — never reveal whether the email exists.
$this->dispatch('notify', message: __('auth.reset_link_sent'));
}
/** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */
public function resetPassword()
{
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(6)],
]);
// 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)]),
]);
}
}
$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();
}
$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();
$ok = false;
}
if (! $ok) {
RateLimiter::hit($key, 60); // 5 / minute (email+IP)
RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
// 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),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
AuditEvent::create([
'user_id' => $user->id,
'actor' => $user->name ?? 'system',
'action' => 'password.reset',
'target' => $user->email,
'ip' => request()->ip(),
]);
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
/**
* 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.
*/
private function dummyTotpVerify(): void
{
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);
});
}
public function render()
{
return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));
}
}

View File

@ -2,28 +2,20 @@
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;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Anmelden — Clusev')]
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 +28,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' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
]);
}
$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,
]);
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
if (! $user || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => 'Diese Zugangsdaten passen nicht.']);
}
// 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()) {
@ -98,6 +61,6 @@ class Login extends Component
public function render()
{
return view('livewire.auth.login')->title(__('auth.title_login'));
return view('livewire.auth.login');
}
}

View File

@ -4,13 +4,13 @@ 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\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Passwort ändern — Clusev')]
class PasswordChange extends Component
{
public string $current = '';
@ -21,56 +21,25 @@ 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' => 'aktuelles Passwort',
'password' => 'neues Passwort',
]);
Auth::user()->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
])->save();
// 2FA is optional now — go straight to the panel; 2FA is offered in Settings.
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);
// onboarding continues: the middleware sends an un-enrolled user to 2FA setup.
return $this->redirect(route('two-factor.setup'), navigate: true);
}
public function render()
{
return view('livewire.auth.password-change')->title(__('auth.title_password_change'));
return view('livewire.auth.password-change');
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class ResetPassword extends Component
{
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
/** Consume the emailed reset token and set a new password. */
public function resetPassword()
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(6)],
]);
$status = Password::reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
AuditEvent::create([
'user_id' => $user->id,
'actor' => $user->name ?? 'system',
'action' => 'password.reset',
'target' => $user->email,
'ip' => request()->ip(),
]);
event(new PasswordReset($user));
},
);
if ($status !== Password::PasswordReset) {
throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]);
}
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.auth.reset-password')->title(__('auth.title_forgot'));
}
}

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,19 +2,20 @@
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\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('Bestätigung — Clusev')]
class TwoFactorChallenge extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
@ -23,69 +24,47 @@ class TwoFactorChallenge extends Component
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);
}
}
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' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
]);
}
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']);
}
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
// secret would break verifyKey); always allow a one-time backup (recovery) code.
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
if (! $valid) {
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => 'Ungültiger Code.']);
}
$this->clearRateLimit();
RateLimiter::clear($key);
return $this->completeLogin($user);
}
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
public function assertionOptions(WebauthnService $webauthn): array
{
$user = User::find(session('2fa.user'));
abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);
Auth::login($user, $remember);
session()->regenerate();
return $webauthn->assertionOptions($user);
}
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
{
$this->assertNotRateLimited();
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
return $this->redirectIntended(route('dashboard'), navigate: true);
}
public function render()
{
return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
return view('livewire.auth.two-factor-challenge');
}
}

View File

@ -5,12 +5,13 @@ namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('2FA einrichten — Clusev')]
class TwoFactorSetup extends Component
{
public string $secret = '';
@ -20,8 +21,8 @@ class TwoFactorSetup extends Component
public function mount()
{
if (Auth::user()->hasTotp()) {
return $this->redirect(route('settings'), navigate: true);
if (Auth::user()->hasTwoFactorEnabled()) {
return $this->redirect(route('dashboard'), navigate: true);
}
$this->secret = (new Google2FA)->generateSecretKey();
@ -31,14 +32,8 @@ class TwoFactorSetup extends Component
{
$this->validate();
try {
$matches = (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code) ?? '');
} catch (Google2FAException $e) {
$matches = false;
}
if (! $matches) {
throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]);
if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']);
}
Auth::user()->forceFill([
@ -46,21 +41,7 @@ class TwoFactorSetup extends Component
'two_factor_confirmed_at' => now(),
])->save();
// First factor enrolled and no codes yet → generate the single recovery set and
// ask Settings to pop the modal (the app layout hosts wire-elements/modal).
if (! Auth::user()->hasRecoveryCodes()) {
Auth::user()->replaceRecoveryCodes();
session()->flash('open_recovery_modal', true);
// put (not flash): must survive the redirect AND the later openModal Livewire
// request; the modal pulls it on mount to reveal the freshly generated codes once.
session()->put('2fa.codes_fresh', now()->timestamp);
// One-time download grant (a timestamp) — the download route requires + consumes
// it within a short window, so the fresh set can be downloaded once and a later
// direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
}
return $this->redirect(route('settings'), navigate: true);
return $this->redirect(route('dashboard'), navigate: true);
}
public function qrCode(): string
@ -70,6 +51,6 @@ class TwoFactorSetup extends Component
public function render()
{
return view('livewire.auth.two-factor-setup')->title(__('auth.title_setup'));
return view('livewire.auth.two-factor-setup');
}
}

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

@ -8,22 +8,18 @@ use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Clusev — Dashboard')]
class Dashboard extends Component
{
use WithFleetContext;
public bool $ready = false;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('dashboard.page_title');
}
/** @var array<int, array> notable systemd units (lazy-loaded over SSH) */
public array $svcRows = [];
@ -128,9 +124,9 @@ class Dashboard extends Component
'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0
? $latest['disk_used'].' / '.$latest['disk_total'].' GB'
: null,
'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')),
'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
])->title($this->title());
]);
}
}

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

@ -4,15 +4,15 @@ namespace App\Livewire\Files;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dateien — Clusev')]
class Index extends Component
{
use WithFileUploads, WithFleetContext;
@ -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();
@ -93,9 +87,9 @@ class Index extends Component
try {
$name = basename($this->upload->getClientOriginalName());
$fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath());
$this->dispatch('notify', message: __('files.uploaded', ['name' => $name]));
$this->dispatch('notify', message: "{$name}“ hochgeladen.");
} catch (Throwable $e) {
$this->dispatch('notify', message: __('files.upload_failed', ['error' => $e->getMessage()]));
$this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage());
}
}
@ -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) {
@ -120,7 +109,7 @@ class Index extends Component
try {
$content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name));
} catch (Throwable $e) {
$this->dispatch('notify', message: __('files.download_failed', ['error' => $e->getMessage()]));
$this->dispatch('notify', message: $e->getMessage());
return null;
}
@ -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,62 +167,39 @@ 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;
}
// Seal the FULL path + the server id so the token cannot be retargeted to
// another directory (client-held $path) or host (session-selected server).
$fullPath = rtrim($this->path, '/')."/{$name}";
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('files.delete_heading'),
'body' => __('files.delete_body', ['name' => $name, 'path' => $this->path]),
'confirmLabel' => __('common.delete'),
'heading' => 'Datei löschen',
'body' => "{$name}“ wird unwiderruflich aus {$this->path} entfernt.",
'confirmLabel' => 'Löschen',
'danger' => true,
'icon' => 'trash',
'notify' => __('files.delete_notify', ['name' => $name]),
'token' => ConfirmToken::issue(
'fileConfirmed',
['path' => $fullPath],
'file.delete',
$fullPath,
$this->activeServer()?->id,
),
'auditAction' => 'file.delete',
'auditTarget' => rtrim($this->path, '/')."/{$name}",
'event' => 'fileConfirmed',
'params' => ['name' => $name],
'notify' => "{$name}“ gelöscht.",
],
);
}
/** Applies the confirmed deletion over SFTP, then reloads the directory. */
#[On('fileConfirmed')]
public function deleteEntry(string $confirmToken, FleetService $fleet): void
public function deleteEntry(string $name, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$active = $this->activeServer();
if (! $active || ($payload['serverId'] ?? null) !== $active->id) {
return; // token sealed for a different server — refuse to retarget
}
// Delete the SEALED path, never a client-recombined one.
$path = $payload['params']['path'];
if ($active->credential_exists) {
if ($active && $active->credential_exists) {
try {
$fleet->deleteFile($active, $path);
$fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()]));
$this->dispatch('notify', message: 'Löschen fehlgeschlagen: '.$e->getMessage());
return;
}
@ -248,6 +210,6 @@ class Index extends Component
public function render()
{
return view('livewire.files.index')->title(__('files.title'));
return view('livewire.files.index');
}
}

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,53 +40,23 @@ 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);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
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,12 +68,12 @@ 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(),
]);
$this->dispatch('keyChanged');
$this->dispatch('notify', message: __('modals.add_ssh_key.notify_added'));
$this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.');
$this->closeModal();
}

View File

@ -3,114 +3,68 @@
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
* Generic R5 confirmation dialog for destructive / stateful actions.
*
* The opener mints a signed, single-use {@see ConfirmToken} that seals the follow-up
* event + its params + the audit descriptor, and passes ONLY that token (plus display
* copy) to the modal. On confirm we re-verify the token, persist exactly one
* AuditEvent from the SEALED descriptor (never from client input), and re-dispatch the
* sealed event so the originating page can apply its own state change handing the
* same token on so the apply handler can burn it (single-use). The modal stays
* The opener passes copy + an audit descriptor + a follow-up event. On confirm
* we persist exactly one AuditEvent and re-dispatch the opener's event so the
* originating page can apply its own state change. The modal stays
* domain-agnostic and is reused for every confirm in the app (R5).
*
* Routing lives only in the token: the modal exposes no client-mutable event/params/
* audit fields, and the token itself is #[Locked] so it cannot be swapped after open.
*/
class ConfirmAction extends ModalComponent
{
public string $heading = '';
public string $heading = 'Aktion bestätigen';
public string $body = '';
public string $confirmLabel = '';
public string $confirmLabel = 'Bestätigen';
public bool $danger = false;
public string $icon = '';
/** Toast shown on confirm. Pass '' to defer the notification to the handler
* (e.g. when the real outcome is only known after a remote command runs);
* leave null to fall back to the generic default toast. */
public ?string $notify = null;
/** Audit descriptor — one row written on confirm. */
public string $auditAction = '';
/** The sealed routing input — the ONLY thing that decides what runs/gets audited. */
#[Locked]
public string $token = '';
public ?string $auditTarget = null;
public function mount(
string $token,
string $heading = '',
string $body = '',
string $confirmLabel = '',
bool $danger = false,
string $icon = '',
?string $notify = null,
): void {
$this->token = $token;
$this->body = $body;
$this->danger = $danger;
$this->icon = $icon;
public ?int $serverId = null;
// Localized fallbacks for any copy the opener did not pass.
$this->heading = $heading !== '' ? $heading : __('modals.confirm_action.default_heading');
$this->confirmLabel = $confirmLabel !== '' ? $confirmLabel : __('common.confirm');
$this->notify = $notify ?? __('modals.confirm_action.default_notify');
}
/** Follow-up event re-dispatched to the page after a successful confirm. */
public string $event = '';
/** @var array<string, mixed> */
public array $params = [];
public string $notify = 'Aktion ausgeführt.';
public static function modalMaxWidth(): string
{
return 'md';
}
/** Audit-target line for the view — read from the signed token, not client state. */
#[Computed]
public function targetLabel(): ?string
{
return ConfirmToken::peek($this->token)['auditTarget'] ?? null;
}
public function confirm(): void
{
try {
// Flip the token pending → confirmed (single transition). This IS the
// confirmation: an apply handler may only consume a confirmed token.
$payload = ConfirmToken::confirm($this->token);
} catch (InvalidConfirmToken) {
// Forged / tampered / expired / already-confirmed — never audit or dispatch.
$this->dispatch('notify', message: __('modals.confirm_action.rejected'));
$this->closeModal();
return;
}
if (($payload['auditAction'] ?? '') !== '') {
if ($this->auditAction !== '') {
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $payload['serverId'] ?? null,
'server_id' => $this->serverId,
'actor' => Auth::user()?->name ?? 'system',
'action' => $payload['auditAction'],
'target' => $payload['auditTarget'] ?? null,
'action' => $this->auditAction,
'target' => $this->auditTarget,
'ip' => request()->ip(),
]);
}
// Re-dispatch the sealed event, handing the token on so the apply handler can
// burn it (single-use) and read the sealed params itself.
$this->dispatch($payload['event'], confirmToken: $this->token);
// Empty notify = the handler will report the real outcome itself.
if ($this->notify !== '') {
$this->dispatch('notify', message: $this->notify);
if ($this->event !== '') {
$this->dispatch($this->event, ...$this->params);
}
$this->dispatch('notify', message: $this->notify);
$this->closeModal();
}

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

@ -4,12 +4,9 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
@ -36,12 +33,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';
@ -63,7 +54,7 @@ class CreateServer extends ModalComponent
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
if (! $isIp && ! $isHost) {
$fail(__('modals.create_server.validation_ip_or_host'));
$fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.');
}
}],
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
@ -81,35 +72,28 @@ class CreateServer extends ModalComponent
protected function validationAttributes(): array
{
return [
'name' => __('modals.create_server.attr_name'),
'ip' => __('modals.create_server.attr_ip'),
'sshPort' => __('modals.create_server.attr_ssh_port'),
'username' => __('modals.create_server.attr_user'),
'authType' => __('modals.create_server.attr_auth'),
'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'),
'credentialName' => __('modals.create_server.attr_credential_name'),
'name' => 'Name',
'ip' => 'IP/Host',
'sshPort' => 'SSH-Port',
'username' => 'Benutzer',
'authType' => 'Authentifizierung',
'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort',
'credentialName' => 'Zugangs-Name',
];
}
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
// probe throws -> the whole transaction rolls back, so no half-registered
// server is left behind, and the operator sees the real SSH reason on the
// form. A verified server starts as 'pending' ("Initialisierung") — never
// red — until the first contact promotes it.
// Atomic: a failure creating the credential or audit must not leave a
// server row without its required SSH credential.
$server = DB::transaction(function () use ($data) {
$server = Server::create([
'name' => trim($data['name']),
'ip' => trim($data['ip']),
'ssh_port' => (int) $data['sshPort'],
'status' => 'pending',
'status' => 'offline',
]);
$server->credential()->create([
@ -120,14 +104,6 @@ class CreateServer extends ModalComponent
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
]);
$server->load('credential');
$probe = app(FleetService::class)->testConnection($server);
if (! $probe['ok']) {
throw ValidationException::withMessages([
'secret' => __('modals.create_server.validation_ssh_failed', ['error' => Str::limit((string) $probe['error'], 120)]),
]);
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
@ -141,7 +117,7 @@ class CreateServer extends ModalComponent
});
$this->dispatch('serverCreated');
$this->dispatch('notify', message: __('modals.create_server.notify_added'));
$this->dispatch('notify', message: 'Server hinzugefügt.');
$this->closeModal();
}

View File

@ -1,137 +0,0 @@
<?php
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.
*/
class CreateUser extends ModalComponent
{
public string $name = '';
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';
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
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)],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
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.
$email = mb_strtolower(trim($data['email']));
if (User::where('email', $email)->exists()) {
$this->addError('email', __('validation.unique', ['attribute' => __('accounts.email_label')]));
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);
$user = User::create([
'name' => trim($data['name']),
'email' => $email,
'password' => Hash::make($password),
'must_change_password' => ! $operatorSet,
'role' => $data['role'],
]);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'user.create',
'target' => $user->email,
'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.
$this->createdName = $user->name;
$this->tempPassword = $password;
}
/** "Fertig": reload the list and close. */
public function finish(): void
{
$this->dispatch('usersChanged');
$this->closeModal();
}
public function render()
{
return view('livewire.modals.create-user');
}
}

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,24 +46,21 @@ 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);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
if (trim($this->username) === '' || trim($this->secret) === '') {
$this->error = __('modals.edit_credential.error_required');
$this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.';
return;
}
if (mb_strlen(trim($this->name)) > 120) {
$this->error = __('modals.edit_credential.error_name_too_long');
$this->error = 'Name darf höchstens 120 Zeichen lang sein.';
return;
}
@ -90,7 +84,7 @@ class EditCredential extends ModalComponent
]);
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: __('modals.edit_credential.notify_saved'));
$this->dispatch('notify', message: 'Zugangsdaten gespeichert.');
$this->closeModal();
}

View File

@ -1,92 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Manually ban an IP in a fail2ban jail (R5: a deliberate confirm dialog, not a
* native popup). Fail2banService refuses to ban loopback or Clusev's own connection,
* so this can never lock the operator out.
*/
class Fail2banBan extends ModalComponent
{
public int $serverId;
/** @var array<int, string> */
public array $jails = [];
public string $jail = '';
public string $ip = '';
public ?string $error = null;
/**
* @param array<int, string> $jails
*/
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';
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function save(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$res = $fail2ban->ban($server, $this->jail, trim($this->ip));
} catch (Throwable $e) {
$this->error = $e->getMessage();
return;
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.fail2ban_ban.error_ban_failed');
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'fail2ban.ban',
'target' => trim($this->ip).' · '.$this->jail.' · '.$server->name,
'ip' => request()->ip(),
]);
$this->dispatch('fail2banChanged');
$this->dispatch('notify', message: __('modals.fail2ban_ban.notify_banned', ['ip' => trim($this->ip)]));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.fail2ban-ban');
}
}

View File

@ -1,124 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Scrollable list of fail2ban jails + their currently-banned IPs, lifted out of the
* servers/show panel so a long ban list never stretches the page. mount() only stores
* the serverId so the modal opens INSTANTLY; load() (fired by wire:init, behind a
* skeleton) does the live jail-status read via Fail2banService (the SAME service the
* page uses). unban() calls Fail2banService::unban (the SAME path as Show::unbanIp),
* audits the success, refreshes this modal's list, and dispatches `fail2banChanged`
* so the page panel re-reads too.
*/
class Fail2banBans extends ModalComponent
{
#[Locked]
public int $serverId;
/** Jail status rows (name/currentlyBanned/currentlyFailed/bannedIps) for the list. */
public array $jails = [];
/** True once the live status was read (distinguishes "no bans" from "not loaded"). */
public bool $loaded = false;
public ?string $error = null;
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;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
/** Lazy: the SSH read runs after the modal renders (wire:init), behind a skeleton. */
public function load(Fail2banService $fail2ban): void
{
$this->loadJails($fail2ban);
}
/** Read the live jail/banned-IP status (same Fail2banService::status the page calls). */
private function loadJails(Fail2banService $fail2ban): void
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$status = $fail2ban->status($server);
$this->jails = $status['jails'] ?? [];
$this->error = ! empty($status['readError']) ? __('servers.fail2ban_read_error') : null;
$this->loaded = true;
} catch (Throwable $e) {
$this->error = Str::limit($e->getMessage(), 120);
}
}
/**
* Unban one IP mirrors Show::unbanIp: call Fail2banService::unban, audit the
* success, refresh this list, and dispatch `fail2banChanged` so the page panel
* re-reads its own fail2ban state.
*/
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');
return;
}
try {
$res = $fail2ban->unban($server, $jail, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'fail2ban.unban',
'target' => $ip.' · '.$jail.' · '.$server->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
// Re-read this modal's list, then tell the page panel to re-pull its own state.
$this->loadJails($fail2ban);
$this->dispatch('fail2banChanged');
}
public function render()
{
return view('livewire.modals.fail2ban-bans');
}
}

View File

@ -4,7 +4,7 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use App\Services\MaintenanceService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
@ -33,15 +33,13 @@ class Fail2banConfig extends ModalComponent
/** True only once the current policy was read — guards save() from overwriting with unseen defaults. */
public bool $loaded = false;
public function mount(int $serverId, Fail2banService $fail2ban): void
public function mount(int $serverId, MaintenanceService $maintenance): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
@ -49,7 +47,7 @@ class Fail2banConfig extends ModalComponent
$this->serverName = $server->name;
try {
$current = $fail2ban->readConfig($server);
$current = $maintenance->readFail2ban($server);
$this->bantime = $current['bantime'];
$this->maxretry = $current['maxretry'];
$this->findtime = $current['findtime'];
@ -64,14 +62,12 @@ class Fail2banConfig extends ModalComponent
return 'lg';
}
public function save(Fail2banService $fail2ban): void
public function save(MaintenanceService $maintenance): 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) {
$this->error = __('modals.fail2ban_config.error_read_locked');
$this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.';
return;
}
@ -87,25 +83,24 @@ class Fail2banConfig extends ModalComponent
'maxretry' => ['required', 'integer', 'min:1', 'max:100'],
'findtime' => $duration,
], [
'bantime.regex' => __('modals.fail2ban_config.error_bantime_invalid'),
'findtime.regex' => __('modals.fail2ban_config.error_findtime_invalid'),
'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
], [
'bantime' => __('modals.fail2ban_config.attr_bantime'),
'maxretry' => __('modals.fail2ban_config.attr_maxretry'),
'findtime' => __('modals.fail2ban_config.attr_findtime'),
'bantime' => 'Sperrdauer',
'maxretry' => 'Max. Fehlversuche',
'findtime' => 'Zeitfenster',
]);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
try {
// Tuning lives in its OWN drop-in (separate from the whitelist), so this can
// never touch ignoreip. Values were validated/clamped above.
$result = $fail2ban->writeTuning(
// Values are already validated/clamped to the allowed ranges above.
$result = $maintenance->writeFail2ban(
$server,
(string) $validated['bantime'],
(int) $validated['maxretry'],
@ -118,7 +113,7 @@ class Fail2banConfig extends ModalComponent
}
if (! $result['ok']) {
$this->error = __('modals.fail2ban_config.error_save_failed', ['output' => $result['output']]);
$this->error = 'Speichern fehlgeschlagen. '.$result['output'];
return;
}
@ -133,7 +128,7 @@ class Fail2banConfig extends ModalComponent
]);
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: __('modals.fail2ban_config.notify_saved'));
$this->dispatch('notify', message: 'fail2ban konfiguriert.');
$this->closeModal();
}

View File

@ -5,7 +5,6 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use App\Support\Ssh\Sftp;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
@ -13,16 +12,9 @@ use Throwable;
/**
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
* binary or oversized files, and writes back on save with an AuditEvent.
* Image files are shown as a read-only base64 preview instead of the editor.
*/
class FileEditor extends ModalComponent
{
/** Image preview is capped — larger images are refused (base64 inflates ~33%). */
private const IMAGE_MAX_BYTES = 5_242_880; // 5 MB
/** Extensions rendered as an inline image preview (read-only). */
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
public int $serverId;
public string $path;
@ -39,9 +31,6 @@ class FileEditor extends ModalComponent
public ?string $error = null;
/** base64 `data:` URI for image previews; null for text files. */
public ?string $dataUri = null;
public function mount(int $serverId, string $path, string $name): void
{
$this->serverId = $serverId;
@ -54,55 +43,21 @@ class FileEditor extends ModalComponent
return 'lg';
}
/** True when the opened file is an image (preview, not editable). */
public function isImage(): bool
public function load(FleetService $fleet): void
{
return in_array($this->extension(), self::IMAGE_EXTENSIONS, true);
}
private function extension(): string
{
return strtolower(pathinfo($this->name, PATHINFO_EXTENSION));
}
/** MIME type for the image preview data URI, keyed off the extension. */
private function imageMime(): string
{
return match ($this->extension()) {
'svg' => 'image/svg+xml',
'jpg', 'jpeg' => 'image/jpeg',
'ico' => 'image/x-icon',
default => 'image/'.$this->extension(),
};
}
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');
$this->error = 'Server nicht gefunden.';
$this->loaded = true;
return;
}
try {
if ($this->isImage()) {
$this->loadImage($server, $sftp);
} else {
$r = $fleet->readFile($server, $this->path);
$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'];
}
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -110,49 +65,13 @@ class FileEditor extends ModalComponent
$this->loaded = true;
}
/**
* Read the image bytes over SFTP and expose them as a base64 data URI.
* Refuses files larger than IMAGE_MAX_BYTES (checked before download).
*/
private function loadImage(Server $server, Sftp $sftp): void
{
$sftp->connect($server);
try {
if ($sftp->size($this->path) > self::IMAGE_MAX_BYTES) {
$this->tooBig = true;
return;
}
$bytes = $sftp->get($this->path);
if ($bytes === '') {
$this->error = __('modals.file_editor.image_read_failed');
return;
}
$this->dataUri = 'data:'.$this->imageMime().';base64,'.base64_encode($bytes);
} finally {
$sftp->disconnect();
}
}
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.
if ($this->isImage()) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
@ -175,7 +94,7 @@ class FileEditor extends ModalComponent
]);
$this->dispatch('fileSaved');
$this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name]));
$this->dispatch('notify', message: '„'.$this->name.'" gespeichert.');
$this->closeModal();
}

View File

@ -1,115 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FirewallService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Add a firewall rule. For ufw the operator picks action/protocol/port/source; for
* firewalld (an allow-list default zone) it opens a port. All validation happens in
* FirewallService this modal only collects and forwards. Adding is non-destructive,
* so no R5 confirm (deleting a rule is the guarded path).
*/
class FirewallRule extends ModalComponent
{
public int $serverId;
public string $tool = 'ufw';
public string $action = 'allow';
public string $proto = 'tcp';
public string $port = '';
public string $from = '';
public ?string $error = null;
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';
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function save(FirewallService $firewall): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
$port = trim($this->port) !== '' ? (int) $this->port : null;
try {
$res = $firewall->addRule($server, $this->action, $this->proto, $port, $this->from);
} catch (Throwable $e) {
$this->error = $e->getMessage();
return;
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.firewall_rule.error_add_failed');
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'firewall.rule_add',
'target' => $server->name.' · '.$this->summary($port),
'ip' => request()->ip(),
]);
$this->dispatch('firewallChanged');
$this->dispatch('notify', message: __('modals.firewall_rule.notify_added'));
$this->closeModal();
}
/** Short human summary of the rule for the audit log. */
private function summary(?int $port): string
{
if ($this->tool === 'firewalld') {
return __('modals.firewall_rule.summary_port_opened', [
'port' => (string) $port,
'proto' => $this->proto === 'udp' ? 'udp' : 'tcp',
]);
}
$parts = [$this->action];
if ($port !== null) {
$parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : '');
}
if (trim($this->from) !== '') {
$parts[] = __('modals.firewall_rule.summary_from', ['source' => trim($this->from)]);
}
return implode(' ', $parts);
}
public function render()
{
return view('livewire.modals.firewall-rule');
}
}

View File

@ -41,15 +41,13 @@ 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;
$server = Server::find($serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
@ -70,15 +68,13 @@ class HardeningAction extends ModalComponent
public function apply(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if ($this->error !== null || $this->done) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
@ -95,25 +91,23 @@ 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']) ?: 'Unbekannter Fehler.', 200);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' '.__('modals.hardening_action.audit_failed_suffix')),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: __('modals.hardening_action.notify_applied', ['action' => $this->heading]));
$this->dispatch('notify', message: $this->heading.' angewendet.');
} else {
$this->dispatch('notify', message: __('modals.hardening_action.notify_failed', ['action' => $this->heading]));
$this->dispatch('notify', message: $this->heading.' fehlgeschlagen.');
}
}

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

@ -1,78 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
* a replayed/stale snapshot has nothing to re-render and fires no event captured codes
* cannot be re-revealed by replaying a signed snapshot.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// One-time, server-set flag (a timestamp put on first-factor enrollment). pull() reads
// + forgets, so a refresh of the manage view will no longer reveal the codes. mount()
// does not run on hydrate, so a replayed snapshot never re-triggers this. The flag is
// valid only within [now-600s, now]: a stale flag (enrollment whose modal never opened)
// or a non-timestamp value reveals nothing.
$freshAt = session()->pull('2fa.codes_fresh');
$age = is_int($freshAt) ? now()->timestamp - $freshAt : null;
if ($age !== null && $age >= 0 && $age <= 600) {
$this->dispatchCodes();
}
}
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
Auth::user()->replaceRecoveryCodes();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'two_factor.recovery_regenerate',
'target' => Auth::user()->email,
'ip' => request()->ip(),
]);
// One-time download grant (a timestamp) for the freshly regenerated set, consumed by
// the download route within a short window. Without it a later direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
// Reveal the freshly generated set once, via the transient event (never a property).
$this->dispatchCodes();
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
}
public function render()
{
return view('livewire.modals.recovery-codes');
}
/**
* Deliver the current user's recovery codes to the browser via a one-time event. The
* codes never enter a Livewire property or the snapshot Alpine holds them in JS memory
* only, so a replayed snapshot cannot re-render them.
*/
protected function dispatchCodes(): void
{
$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());
}
}

View File

@ -1,94 +0,0 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Server;
use App\Services\SshKeyProvisioner;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
* Confirm + run the safe "generate key → install → verify → switch credential → disable
* password login" flow (R5). On success the freshly generated private key is revealed ONCE
* (download client-side; it is never re-fetchable). The key is also kept encrypted in the vault.
*/
class SshKeyProvision extends ModalComponent
{
#[Locked]
public int $serverId;
public string $serverName = '';
public bool $done = false;
public bool $ok = false;
public ?string $privateKey = null;
public ?string $publicKey = null;
public ?string $error = null;
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 ?? '';
}
public static function modalMaxWidth(): string
{
return 'lg';
}
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;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$result = $provisioner->enableKeyOnlyAccess($server);
} catch (\Throwable $e) {
$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('credentialChanged');
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
return;
}
$this->done = true;
$this->ok = $result['ok'];
$this->privateKey = $result['privateKey'] ?? null;
$this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok
? __('modals.ssh_key_provision.notify_ok', ['server' => $this->serverName])
: __('modals.ssh_key_provision.notify_failed'));
}
public function render()
{
return view('livewire.modals.ssh-key-provision');
}
}

View File

@ -10,11 +10,10 @@ use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Apply pending package updates across apt/dnf/zypper (resolved per OS). mount()
* reads whether updates are supported on this host + the pending count; the body
* explains in GERMAN what happens never a shell command. „Jetzt aktualisieren"
* runs the upgrade, AUDITs, notifies, and keeps the modal open showing a clean
* German result. Unsupported hosts (e.g. Arch/Alpine) get a German reason instead.
* Apply pending apt package updates (Debian/Ubuntu only). mount() reads whether
* apt exists + the pending count; the body explains in GERMAN what happens never
* a shell command. „Jetzt aktualisieren" runs the upgrade, AUDITs, notifies, and
* keeps the modal open showing a clean German result.
*/
class SystemUpdate extends ModalComponent
{
@ -22,11 +21,7 @@ class SystemUpdate extends ModalComponent
public string $serverName = '';
/** True when Clusev can manage package updates on this host. */
public bool $supported = false;
/** German reason shown when updates are not supported on this OS. */
public ?string $unsupportedReason = null;
public bool $hasApt = false;
/** Pending upgrade count, or null when it could not be determined ("unbekannt"). */
public ?int $pending = null;
@ -35,21 +30,18 @@ class SystemUpdate extends ModalComponent
public bool $ok = false;
/** Trimmed package-manager output, only shown when the upgrade failed. */
/** Trimmed apt output, only shown when the upgrade failed. */
public string $output = '';
public ?string $error = null;
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);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
@ -57,9 +49,8 @@ class SystemUpdate extends ModalComponent
$this->serverName = $server->name;
try {
$this->unsupportedReason = $maintenance->updateSupport($server);
$this->supported = $this->unsupportedReason === null;
$this->pending = $this->supported ? $maintenance->pendingUpdates($server) : null;
$this->hasApt = $maintenance->hasApt($server);
$this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null;
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -72,27 +63,24 @@ 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) {
if ($this->error !== null || $this->done || ! $this->hasApt) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->error = 'Server nicht gefunden.';
return;
}
try {
$result = $maintenance->applyUpgrades($server);
$result = $maintenance->aptUpgrade($server);
} catch (Throwable $e) {
$this->done = true;
$this->ok = false;
$this->output = $e->getMessage();
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
return;
}
@ -107,16 +95,16 @@ class SystemUpdate extends ModalComponent
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.package_upgrade',
'target' => $server->name.($this->ok ? '' : ' '.__('modals.system_update.audit_failed_suffix')),
'action' => 'system.apt_upgrade',
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->pending = 0;
$this->dispatch('notify', message: __('modals.system_update.notify_updated'));
$this->dispatch('notify', message: 'System aktualisiert.');
} else {
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
}
}

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

@ -18,19 +18,7 @@ class ServerSwitcher extends Component
session(['active_server_id' => $server->id]);
$this->redirect($this->targetFor($server, $this->safeReturnUrl()), navigate: true);
}
/**
* Where to land after selecting a server. On a server-details page, switching should
* VIEW the chosen server (so the URL, sidebar and scoped context never diverge);
* everywhere else just reload the current page so the active context updates.
*/
public function targetFor(Server $server, string $current): string
{
return preg_match('#^/servers/[^/]+#', $current) === 1
? route('servers.show', $server)
: $current;
$this->redirect($this->safeReturnUrl(), navigate: true);
}
/**

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,29 +3,19 @@
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\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Server — Clusev')]
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
{
return __('servers.index_title');
}
/** A freshly added server should appear without a manual reload (render() re-queries). */
#[On('serverCreated')]
public function refreshServers(): void
@ -37,11 +27,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,12 +44,10 @@ 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),
'offline' => (int) ($counts['offline'] ?? 0),
])->title($this->title());
]);
}
}

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

@ -4,37 +4,22 @@ namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use App\Services\FirewallService;
use App\Services\FleetService;
use App\Services\HardeningService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use App\Support\Os\OsDetector;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Server-Details — Clusev')]
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
{
return __('servers.show_title');
}
public bool $connected = false;
public bool $ready = false;
@ -47,18 +32,6 @@ class Show extends Component
public array $hardening = [];
/** Firewall state (installed/active/defaults/rules) for the Firewall-Regeln panel. */
public array $firewall = [];
/** fail2ban state (jails/banned IPs/whitelist) for the fail2ban-Status panel. */
public array $fail2ban = [];
/** Input for adding an IP/CIDR to the fail2ban whitelist. */
public string $newIgnoreIp = '';
/** Detected OS tooling (family/package manager/firewall) for the identity panel. */
public array $os = [];
public array $sshKeys = [];
/**
@ -68,13 +41,6 @@ class Show extends Component
*/
public function mount(): void
{
// Viewing a server's details makes it the ACTIVE server, so the sidebar switcher
// (and the scoped sections) reflect the server on screen. Set ONLY at mount — i.e.
// once per deliberate navigation — so a slow in-flight load() or a parallel tab
// can never write a stale value over a newer selection (the single-session active
// server is last-writer-wins; we keep writes to deliberate navigations only).
session(['active_server_id' => $this->server->id]);
// identity + gauges render from the persisted row immediately;
// the live snapshot loads lazily via wire:init -> load().
}
@ -110,42 +76,12 @@ class Show extends Component
$this->interfaces = $snap['interfaces'];
$this->sshKeys = $snap['sshKeys'];
// Detected OS profile (cached) — surfaces the package manager + firewall
// tooling and drives which hardening features are offered.
try {
// fresh probe on each full load; primes the 1h cache the hardening,
// firewall and update reads reuse within this request cycle.
$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) {
$this->os = [];
}
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
// Firewall rules/defaults (separate PRIVILEGED read; empty on failure).
try {
$this->firewall = app(FirewallService::class)->status($this->server);
} catch (Throwable) {
$this->firewall = [];
}
// fail2ban jails/banned IPs/whitelist (separate PRIVILEGED read).
try {
$this->fail2ban = app(Fail2banService::class)->status($this->server);
} catch (Throwable) {
$this->fail2ban = [];
}
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
$this->connected = true;
} catch (Throwable) {
@ -161,10 +97,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 +106,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;
@ -187,45 +117,29 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('servers.confirm_remove_key_heading'),
'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'heading' => 'SSH-Schlüssel entfernen',
'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.",
'confirmLabel' => 'Entfernen',
'danger' => true,
'icon' => 'trash',
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
'token' => ConfirmToken::issue(
'keyRemoved',
['fingerprint' => $fingerprint],
'ssh_key.remove',
"{$comment} · {$this->server->name}",
$this->server->id,
),
'auditAction' => 'ssh_key.remove',
'auditTarget' => "{$comment} · {$this->server->name}",
'serverId' => $this->server->id,
'event' => 'keyRemoved',
'params' => ['fingerprint' => $fingerprint],
'notify' => "Schlüssel „{$comment}“ entfernt.",
],
);
}
/** Applies the confirmed key removal over SSH, then reloads the list. */
#[On('keyRemoved')]
public function removeKey(string $confirmToken, FleetService $fleet): void
public function removeKey(string $fingerprint, 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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$fingerprint = $payload['params']['fingerprint'];
try {
$fleet->removeAuthorizedKey($this->server, $fingerprint);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => $e->getMessage()]));
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.$e->getMessage());
return;
}
@ -268,8 +182,6 @@ class Show extends Component
*/
public function toggleCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -288,7 +200,7 @@ class Show extends Component
]);
$this->server->refresh();
$this->dispatch('notify', message: $disable ? __('servers.notify_access_locked') : __('servers.notify_access_unlocked'));
$this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.');
}
/**
@ -297,8 +209,6 @@ class Show extends Component
*/
public function confirmDeleteCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -307,40 +217,25 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('servers.confirm_delete_credential_heading'),
'body' => __('servers.confirm_delete_credential_body', ['server' => $this->server->name]),
'confirmLabel' => __('common.delete'),
'heading' => 'Zugang löschen',
'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.",
'confirmLabel' => 'Löschen',
'danger' => true,
'icon' => 'trash',
'notify' => __('servers.notify_access_deleted'),
'token' => ConfirmToken::issue(
'credentialDeleted',
[],
'credential.delete',
$this->server->name.' · '.$cred->username,
$this->server->id,
),
'auditAction' => 'credential.delete',
'auditTarget' => $this->server->name.' · '.$cred->username,
'serverId' => $this->server->id,
'event' => 'credentialDeleted',
'params' => [],
'notify' => 'Zugang gelöscht.',
],
);
}
/** Applies the confirmed credential deletion, then refreshes the row. */
#[On('credentialDeleted')]
public function deleteCredential(string $confirmToken): void
public function deleteCredential(): 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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$this->server->credential()->delete();
$this->server->refresh();
}
@ -350,259 +245,15 @@ 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 = [];
}
}
/**
* Delete a firewall rule (R5): opens the confirm modal, which writes the
* AuditEvent and re-dispatches `firewallRuleDelete` (handled below).
*/
public function confirmDeleteRule(int $index): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$rule = $this->firewall['rules'][$index] ?? null;
if ($rule === null) {
return;
}
$spec = $rule['spec'] ?? '';
$label = $rule['label'] ?? ($rule['raw'] ?? $spec);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('servers.confirm_remove_rule_heading'),
'body' => __('servers.confirm_remove_rule_body', ['label' => $label, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
// No premature audit/notify — the handler records the real outcome
// only after the remote command returns (audit reflects success).
'notify' => '',
'token' => ConfirmToken::issue(
'firewallRuleDelete',
['spec' => $spec, 'label' => $label],
serverId: $this->server->id,
),
],
);
}
/** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */
#[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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$spec = $payload['params']['spec'];
$label = $payload['params']['label'];
try {
$res = $firewall->deleteRule($this->server, $spec);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
// The rule was already gone — inform, refresh, but do NOT audit a non-event.
if (! empty($res['notFound'])) {
$this->dispatch('notify', message: __('servers.notify_rule_not_found'));
$this->reloadFirewall($firewall);
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $this->server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'firewall.rule_delete',
'target' => $label.' · '.$this->server->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('servers.notify_firewall_rule_removed'));
$this->reloadFirewall($firewall);
}
/** Re-read the firewall state after an add/delete/default change. */
#[On('firewallChanged')]
public function reloadFirewall(FirewallService $firewall): void
{
try {
$this->firewall = $firewall->status($this->server);
} catch (Throwable) {
// keep the current state on failure
}
}
/** 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) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
$this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name);
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
/** 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;
}
try {
$res = $fail2ban->addIgnoreIp($this->server, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
if (! empty($res['noop'])) {
$this->newIgnoreIp = '';
$this->dispatch('notify', message: $res['output']);
return;
}
$this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name);
$this->newIgnoreIp = '';
$this->dispatch('notify', message: __('servers.notify_ip_whitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
/** 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) {
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
if (! empty($res['noop'])) {
$this->dispatch('notify', message: $res['output']);
return;
}
$this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name);
$this->dispatch('notify', message: __('servers.notify_ip_unwhitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
/** Re-read fail2ban state after an unban/ban/whitelist change. */
#[On('fail2banChanged')]
public function reloadFail2ban(Fail2banService $fail2ban): void
{
try {
$this->fail2ban = $fail2ban->status($this->server);
} catch (Throwable) {
// keep the current state on failure
}
}
/** Write one AuditEvent for a fail2ban action (success path only). */
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $this->server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
$this->ready = false;
$this->load($fleet);
}
public function render()
{
return view('livewire.servers.show')->title($this->title());
return view('livewire.servers.show');
}
}

View File

@ -3,23 +3,20 @@
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\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dienste — Clusev')]
class Index extends Component
{
use WithFleetContext;
/** Cap on retained journal rows so the live poll never grows the payload unbounded. */
private const JOURNAL_MAX = 200;
/** Active server name (from fleet context). */
public string $server = '—';
@ -36,9 +33,6 @@ class Index extends Component
/** @var array<int, array{time:string,unit:string,level:string,text:string}> */
public array $journal = [];
/** journald cursor to resume the live poll from (null until the first read). */
public ?string $journalCursor = null;
public function mount(): void
{
$this->server = $this->activeServer()?->name ?? '—';
@ -54,7 +48,6 @@ class Index extends Component
$data = $fleet->systemd($active);
$this->services = $data['services'];
$this->journal = $data['journal'];
$this->journalCursor = $data['cursor'] ?? null;
$this->connected = true;
} catch (Throwable) {
$this->connected = false;
@ -65,128 +58,61 @@ class Index extends Component
}
/**
* Live journal: poll for entries since the last cursor and APPEND them, so no line
* is dropped between ticks (a true `journalctl -f` stream needs a PTY deferred to
* the web terminal). Caps the retained rows and survives a transient SSH hiccup.
* Service control (R5): opens the wire-elements/modal confirm dialog. The
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
*/
public function pollJournal(FleetService $fleet): void
public function confirm(string $op, int $index): void
{
if (! $this->ready || ! $this->connected) {
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
return;
}
[$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'],
};
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.",
'confirmLabel' => $label,
'danger' => $op === 'stop',
'icon' => $icon,
'auditAction' => $action,
'auditTarget' => "{$name} · {$this->server}",
'event' => 'serviceConfirmed',
'params' => ['op' => $op, 'name' => $name],
'notify' => "{$name}: {$label} ausgeführt.",
],
);
}
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
#[On('serviceConfirmed')]
public function applyService(string $op, string $name, FleetService $fleet): void
{
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return;
}
try {
// Fetch ceiling == display ceiling: never under-fill, never carry more than we show.
$data = $fleet->journalSince($active, $this->journalCursor, self::JOURNAL_MAX);
} catch (Throwable) {
return; // keep the current view; retry on the next tick
}
if ($data['journal'] !== []) {
$this->journal = array_slice(
array_merge($this->journal, $data['journal']),
-self::JOURNAL_MAX
);
}
$this->journalCursor = $data['cursor'];
}
/**
* Service control (R5): opens the wire-elements/modal confirm dialog. The
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
*/
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;
}
[$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => [__('services.confirm_start_heading'), __('services.confirm_start_phrase'), __('services.confirm_start_label'), 'service.start', 'power'],
'stop' => [__('services.confirm_stop_heading'), __('services.confirm_stop_phrase'), __('services.confirm_stop_label'), 'service.stop', 'power'],
default => [__('services.confirm_restart_heading'), __('services.confirm_restart_phrase'), __('services.confirm_restart_label'), 'service.restart', 'rotate'],
};
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => __('services.confirm_body', ['name' => $name, 'phrase' => $phrase, 'server' => $this->server]),
'confirmLabel' => $label,
'danger' => $op === 'stop',
'icon' => $icon,
'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]),
'token' => ConfirmToken::issue(
'serviceConfirmed',
['op' => $op, 'name' => $name],
$action,
"{$name} · {$this->server}",
$this->activeServer()?->id,
),
],
);
}
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
#[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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$op = $payload['params']['op'];
$name = $payload['params']['name'];
$active = $this->activeServer();
if (! $active || ($payload['serverId'] ?? null) !== $active->id || ! $active->credential_exists) {
return; // wrong/absent server (retarget attempt) or no credential — no-op
}
try {
$res = $fleet->serviceAction($active, $op, $name);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('services.action_error', ['name' => $name, 'error' => $e->getMessage()]));
$this->dispatch('notify', message: "{$name}: ".$e->getMessage());
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']
? "{$name}: {$op} ausgeführt."
: "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90));
try {
$this->services = $fleet->systemd($active)['services'];
@ -213,6 +139,6 @@ class Index extends Component
public function render()
{
return view('livewire.services.index')->title(__('services.title'));
return view('livewire.services.index');
}
}

View File

@ -1,267 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
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;
/**
* SMTP configuration card. The operator points the panel at an SMTP server so
* password-reset mails (and future notifications) actually leave the box. Values
* are stored as Setting rows and applied to runtime config by AppServiceProvider::boot().
*
* The SMTP password is the one secret here: it is stored ENCRYPTED at rest
* (Crypt::encryptString, APP_KEY) Setting::put stores plaintext, so we encrypt the
* value ourselves never rendered back to the browser, and never logged/echoed
* (any error from the test-send is shown without the password).
*/
class Email extends Component
{
public string $mail_host = '';
public int $mail_port = 587;
public string $mail_username = '';
/** New password typed in the form. Empty = keep the stored one (never pre-filled). */
public string $mail_password = '';
public string $mail_encryption = 'tls';
public string $mail_from_address = '';
public string $mail_from_name = '';
/** True once a password has been stored, so the field shows a "(set)" placeholder. */
public bool $passwordSet = false;
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', '') ?? '';
$this->mail_encryption = Setting::get('mail_encryption', 'tls') ?? 'tls';
$this->mail_from_address = Setting::get('mail_from_address', '') ?? '';
$this->mail_from_name = Setting::get('mail_from_name', '') ?? '';
$this->passwordSet = (Setting::get('mail_password') ?? '') !== '';
}
protected function rules(): array
{
return [
'mail_host' => ['required', 'string', 'max:255'],
'mail_port' => ['required', 'integer', 'min:1', 'max:65535'],
'mail_username' => ['nullable', 'string', 'max:255'],
'mail_password' => ['nullable', 'string', 'max:255'],
'mail_encryption' => ['required', 'in:none,tls,ssl'],
'mail_from_address' => ['required', 'email', 'max:255'],
'mail_from_name' => ['required', 'string', 'max:255'],
];
}
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);
Setting::put('mail_encryption', $this->mail_encryption);
Setting::put('mail_from_address', $this->mail_from_address);
Setting::put('mail_from_name', $this->mail_from_name);
// Only overwrite the stored password when a new one was typed; otherwise keep
// the existing (encrypted) value untouched. Store encrypted at rest.
if ($this->mail_password !== '') {
Setting::put('mail_password', Crypt::encryptString($this->mail_password));
$this->passwordSet = true;
}
$this->reset('mail_password');
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.configure',
'target' => $this->mail_host,
'ip' => request()->ip(),
]);
$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
* transport error is surfaced as a message with the secret masked out.
*/
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 {
$this->applyMailerConfig();
Mail::raw(__('mail.test_body'), function ($message) use ($to) {
$message->to($to)->subject(__('mail.test_subject'));
});
$this->dispatch('notify', message: __('mail.notify_test_ok', ['email' => $to]));
} catch (Throwable $e) {
$this->dispatch('notify', message: __('mail.notify_test_failed', [
'error' => $this->maskSecret(Str::limit($e->getMessage(), 160)),
]));
}
}
/** Host + from-address present — the minimum to actually send. */
public function configured(): bool
{
return $this->mail_host !== '' && $this->mail_from_address !== '';
}
/**
* Apply the saved SMTP settings to runtime config for the duration of this request,
* so the test-send uses exactly what the operator configured (decrypting the stored
* password). Mirrors AppServiceProvider's runtime override.
*/
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 = '';
}
}
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $this->mail_host,
'mail.mailers.smtp.port' => $this->mail_port,
'mail.mailers.smtp.username' => $this->mail_username ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $this->mail_encryption === 'none' ? null : $this->mail_encryption,
'mail.from.address' => $this->mail_from_address,
'mail.from.name' => $this->mail_from_name,
]);
// Drop any mailer Laravel already resolved this request so the new config takes effect.
Mail::purge('smtp');
}
/** Strip the stored SMTP password out of any string before it is shown/logged. */
private function maskSecret(string $text): string
{
$stored = Setting::get('mail_password');
if ($stored === null || $stored === '') {
return $text;
}
try {
$plain = Crypt::decryptString($stored);
} catch (Throwable) {
return $text;
}
return $plain !== '' ? str_replace($plain, '****', $text) : $text;
}
public function render()
{
return view('livewire.settings.email');
}
}

View File

@ -2,28 +2,104 @@
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Einstellungen — Clusev')]
class Index extends Component
{
#[Url]
public string $tab = 'profile';
public bool $openRecoveryModal = false;
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
if (session('open_recovery_modal')) {
$this->tab = 'security';
$this->openRecoveryModal = true;
}
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: 'Profil gespeichert.');
}
public function updatePassword(): void
{
$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');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: 'Passwort geändert.');
}
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => '2FA deaktivieren',
'body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.',
'confirmLabel' => 'Deaktivieren',
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'event' => 'twoFactorDisabled',
'notify' => '2FA deaktiviert.',
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(): void
{
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.index')->title(__('settings.title'));
return view('livewire.settings.index', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
]);
}
}

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

@ -1,89 +0,0 @@
<?php
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
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
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);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: __('settings.password_changed'));
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.profile');
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class Security extends Component
{
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.disable_2fa_heading'),
'body' => __('settings.disable_2fa_body'),
'confirmLabel' => __('common.disable'),
'danger' => true,
'icon' => 'shield',
'notify' => __('settings.disable_2fa_notify'),
'token' => ConfirmToken::issue(
'twoFactorDisabled',
[],
'two_factor.disable',
Auth::user()?->email,
),
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(string $confirmToken): void
{
try {
ConfirmToken::consume($confirmToken, 'twoFactorDisabled');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
// Remove the TOTP factor only — security keys are managed on their own card. If no
// factor remains afterwards, the backup codes are dropped too.
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
Auth::user()->resetIfNoFactor();
}
public function render()
{
return view('livewire.settings.security', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
'hasTotp' => Auth::user()->hasTotp(),
]);
}
}

View File

@ -1,107 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class Sessions extends Component
{
/**
* Open the R5 confirm for "sign out other devices". The current session is
* kept; only the other rows are deleted on confirm. The modal writes the
* audit row (session.logout_others) and re-dispatches the apply event.
*/
public function confirmLogoutOthers(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('sessions.logout_others'),
'body' => __('sessions.logout_others_body'),
'confirmLabel' => __('sessions.logout_others'),
'danger' => true,
'icon' => 'logout',
'notify' => __('sessions.logout_others_notify'),
'token' => ConfirmToken::issue(
'sessionsLogoutOthers',
[],
'session.logout_others',
Auth::user()?->email,
),
],
);
}
#[On('sessionsLogoutOthers')]
public function logoutOthers(string $confirmToken, SessionService $sessions): void
{
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutOthers');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$sessions->logoutOtherDevices(Auth::user());
// List re-renders on the next round trip; no explicit refresh needed.
}
/**
* Open the R5 confirm for the destructive GLOBAL logout. On confirm every
* session is wiped and every remember_token rotated including this
* operator's, so the apply handler redirects to login.
*/
public function confirmLogoutAll(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('sessions.logout_all'),
'body' => __('sessions.logout_all_body'),
'confirmLabel' => __('sessions.logout_all'),
'danger' => true,
'icon' => 'power',
// Defer the toast: this session is about to be destroyed and the
// user redirected to login, so a toast would never be seen.
'notify' => '',
'token' => ConfirmToken::issue(
'sessionsLogoutAll',
[],
'session.logout_all',
Auth::user()?->email,
),
],
);
}
#[On('sessionsLogoutAll')]
public function logoutAll(string $confirmToken, SessionService $sessions)
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
// Truncates all sessions + rotates every remember_token. This logs the
// operator out too, so push them to login (AuthenticateSession would
// otherwise bounce the very next request).
$sessions->logoutEveryone();
Auth::logout();
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.settings.sessions', [
'sessions' => app(SessionService::class)->forUser(Auth::user()),
]);
}
}

View File

@ -1,320 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
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
* (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
{
/** 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');
}
/**
* Destructive (R5): open the confirm modal for removing an account. Guards
* (self / last account) are enforced here AND again in the apply handler, so
* a stale event can never slip past after the modal is open.
*/
public function confirmRemove(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! $this->canRemove($user)) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.remove_heading'),
'body' => __('accounts.remove_body'),
'confirmLabel' => __('accounts.remove'),
'danger' => true,
'icon' => 'trash',
// Defer the toast: the apply handler reports the real outcome.
'notify' => '',
'token' => ConfirmToken::issue(
'userRemoved',
['userId' => $userId],
auditTarget: $user->email,
),
],
);
}
#[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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$self = Auth::id();
// 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;
}
$user = User::find($userId);
if (! $user || $user->getKey() === $self) {
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
// stolen remember-me cookie can never resurrect the account between the
// session wipe and the row delete.
$sessions->logoutUserEverywhere($user);
$user->delete();
return $email;
});
if ($email === null) {
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'));
}
/**
* Destructive (R5): open the confirm modal for signing another account out
* of every device. Never offered for self (self uses Settings Sessions).
*/
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;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.logout_heading'),
'body' => __('accounts.logout_body'),
'confirmLabel' => __('accounts.logout'),
'danger' => true,
'icon' => 'logout',
'notify' => '',
'token' => ConfirmToken::issue(
'userLoggedOut',
['userId' => $userId],
auditTarget: $user->email,
),
],
);
}
#[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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$user = User::find($userId);
if (! $user || $user->is(Auth::user())) {
return;
}
$sessions->logoutUserEverywhere($user);
$this->audit('user.logout', $user->email);
$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
{
// The list is re-fetched in render(); this handler just forces the round trip.
}
/**
* 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).
*/
private function canRemove(User $user): bool
{
return ! $user->is(Auth::user()) && User::count() > 1;
}
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()
{
return view('livewire.settings.users', [
'users' => User::query()->orderBy('name')->get(),
'currentId' => Auth::id(),
]);
}
}

View File

@ -1,125 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Services\WebauthnService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
class WebauthnKeys extends Component
{
#[Validate('required|string|max:60')]
public string $newName = '';
/** JSON creation options for navigator.credentials.create (the JS posts the result to register). */
public function options(WebauthnService $webauthn): array
{
abort_unless($webauthn->available(), 404);
// Validate the label BEFORE the browser ceremony, so an invalid name never
// leaves an orphaned credential on the authenticator.
$this->validate();
return $webauthn->registrationOptions(Auth::user());
}
public function register(array $attestation, WebauthnService $webauthn): void
{
// WebAuthn must be available (domain + HTTPS). No existing-factor guard — a key may
// be the FIRST/only factor.
abort_unless($webauthn->available(), 404);
$this->validate();
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
$this->reset('newName');
// First factor enrolled and no codes yet → generate + show the recovery modal.
if (! Auth::user()->hasRecoveryCodes()) {
Auth::user()->replaceRecoveryCodes();
// Server-set one-time flag so the opening modal reveals the fresh set once.
session()->put('2fa.codes_fresh', now()->timestamp);
// One-time download grant (a timestamp) — the download route requires + consumes
// it within a short window, so the fresh set can be downloaded once and a later
// direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
$this->dispatch('openModal', component: 'modals.recovery-codes');
}
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'webauthn.register',
'target' => $cred->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('auth.webauthn_added'));
}
/** Destructive (R5): open the confirm modal, which re-dispatches `webauthnKeyRemoved`. */
public function confirmRemove(int $id): void
{
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
if (! $cred) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('auth.webauthn_title'),
'body' => __('auth.webauthn_remove_confirm'),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
'notify' => '',
'token' => ConfirmToken::issue(
'webauthnKeyRemoved',
['id' => $id],
),
],
);
}
#[On('webauthnKeyRemoved')]
public function remove(string $confirmToken, WebauthnService $webauthn): void
{
abort_unless($webauthn->available(), 404);
try {
$payload = ConfirmToken::consume($confirmToken, 'webauthnKeyRemoved');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$id = $payload['params']['id'];
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
if ($cred) {
$name = $cred->name;
$cred->delete();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'webauthn.remove',
'target' => $name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('auth.webauthn_removed'));
Auth::user()->resetIfNoFactor();
}
}
public function render()
{
return view('livewire.settings.webauthn-keys', [
'available' => app(WebauthnService::class)->available(),
'keys' => Auth::user()->webauthnCredentials()->latest()->get(),
]);
}
}

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,263 +3,129 @@
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\Attributes\Title;
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.
* Domain & TLS: persists the panel domain + Let's Encrypt admin email (Settings),
* then renders the matching Caddy site block via DeploymentService for the operator
* to review. The app NEVER reloads Caddy itself (separate container) it only
* stages the config and shows the reload command.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
#[Title('System — Clusev')]
class Index extends Component
{
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
/** Valid user-facing release channels with their German descriptions. */
public const CHANNELS = [
'stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.',
'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
];
/** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */
public string $domain = '';
/** The domain form field (FQDN, or empty to clear the override). */
public string $domainInput = '';
public string $email = '';
/** True after a save — shows the "restart the stack to apply" notice. */
public bool $restartPending = false;
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $tlsMode = 'caddy';
public string $channel = 'stable';
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
// (configured) domain. They differ when a saved change awaits a restart.
$this->domain = (string) ($deployment->domain() ?? '');
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
$this->email = (string) ($deployment->email() ?? '');
$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 = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable';
}
/** @return array<string, array<int, string>> */
private function domainRules(): array
/** Persist domain + email, then (re)render the Caddy site config for review. */
public function saveDomain(DeploymentService $deployment): void
{
// Empty clears the override (revert to install-time APP_DOMAIN / bare IP).
return ['domainInput' => ['nullable', 'string', 'max:253', 'regex:'.DeploymentService::DOMAIN_REGEX]];
}
/** 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'),
$data = $this->validate([
'domain' => ['nullable', 'string', 'max:253', 'regex:/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'],
'email' => ['nullable', 'email', 'max:255', 'required_with:domain'],
], [
'domain.regex' => 'Bitte eine gültige Domain angeben (z. B. panel.example.com).',
'email.required_with' => 'Bei gesetzter Domain ist eine Admin-E-Mail für Let\'s Encrypt Pflicht.',
]);
$new = strtolower(trim($this->domainInput));
$deployment->setDomain($data['domain'] ?? null);
$deployment->setEmail($data['email'] ?? null);
if ($new === (string) ($deployment->configuredDomain() ?? '')) {
return; // no change vs the already-configured (pending) domain
}
$this->domain = (string) ($deployment->domain() ?? '');
$this->email = (string) ($deployment->email() ?? '');
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_domain_heading'),
'body' => $new !== ''
? __('system.change_domain_body', ['domain' => $new])
: __('system.clear_domain_body'),
'confirmLabel' => __('system.change_domain_confirm'),
'danger' => true,
'icon' => 'globe',
'notify' => '', // defer: we surface the restart notice instead of a toast
'token' => ConfirmToken::issue(
'domainChanged',
['domain' => $new],
'system.settings_updated',
'panel_domain='.($new !== '' ? $new : '(cleared)'),
),
],
);
$deployment->writeCaddyConfig();
$this->audit('system.settings_updated', $this->domain !== '' ? $this->domain : 'bare-ip');
$this->dispatch('notify', message: $this->domain !== ''
? 'Domain gespeichert. Caddy-Konfiguration neu erzeugt.'
: 'Bare-IP-Modus gespeichert. Caddy-Konfiguration neu erzeugt.');
}
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): 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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$domain = strtolower(trim($payload['params']['domain']));
// Defensive re-validation (the value round-trips through the modal event).
if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) {
return;
}
$pending = $deployment->setDomain($domain);
// The active (served) domain stays frozen until restart; only the pending changes.
$this->domainInput = (string) ($pending ?? '');
$this->restartPending = $deployment->restartPending();
$this->dispatch('notify', message: __('system.domain_saved_notify'));
}
/**
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
* The container never touches the Docker socket it only requests via a marker file
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
* (a button, not auto-fired on save) so the operator controls the timing.
*/
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;
}
$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
{
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');
return;
}
$key = 'cert-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
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);
}
/** 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) {
if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_tls_heading'),
'body' => $mode === 'external'
? __('system.change_tls_body_external')
: __('system.change_tls_body_caddy'),
'confirmLabel' => __('system.change_tls_confirm'),
'danger' => true,
'icon' => 'shield',
'notify' => '',
'token' => ConfirmToken::issue(
'tlsModeChanged',
['mode' => $mode],
),
'heading' => 'Release-Kanal wechseln',
'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.',
'confirmLabel' => 'Wechseln',
'danger' => false,
'icon' => 'git-branch',
'auditAction' => 'system.settings_updated',
'auditTarget' => 'release_channel='.$channel,
'event' => 'channelChanged',
'params' => ['channel' => $channel],
'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.',
],
);
}
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
#[On('channelChanged')]
public function applyChannel(string $channel): 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) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$mode = $payload['params']['mode'];
if (! in_array($mode, self::TLS_MODES, true)) {
if (! array_key_exists($channel, self::CHANNELS)) {
return;
}
$deployment->setTlsMode($mode);
$this->tlsMode = $mode;
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.settings_updated',
'target' => 'tls_mode='.$mode,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
$this->restartPending = true;
$this->dispatch('notify', message: __('system.tls_saved_notify'));
}
public function render(DeploymentService $deployment)
{
return view('livewire.system.index', [
'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(),
])->title(__('system.title'));
'caddyConfig' => $deployment->renderCaddyConfig(),
'reloadHint' => $deployment->reloadHint(),
'channels' => self::CHANNELS,
]);
}
}

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,25 @@
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\Attributes\Title;
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')]
#[Title('Version — Clusev')]
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 +29,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,133 +39,34 @@ 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';
$this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
$this->updateStatus = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').';
} elseif ($this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
$this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').';
} else {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
$this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$channel.').';
}
}
/**
* 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 +86,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 +110,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 +143,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 +158,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 +215,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'));
}
}

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