Compare commits

..

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

381 changed files with 17289 additions and 19711 deletions

View File

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

View File

@ -2,7 +2,7 @@ APP_NAME=Clusev
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://10.10.90.136
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
@ -47,7 +47,7 @@ REVERB_APP_ID=changeme
REVERB_APP_KEY=changeme
REVERB_APP_SECRET=changeme
# client-facing (browser connects to the host-exposed port):
REVERB_HOST=localhost
REVERB_HOST=10.10.90.136
REVERB_PORT=8080
REVERB_SCHEME=http
# server bind (inside the container):
@ -65,11 +65,6 @@ FILESYSTEM_DISK=local
# ── Docker Compose knobs (host ports / uid / image) ──────────────────
APP_PORT=80
VITE_PORT=5173
# Dev HMR only: the address the BROWSER uses to reach this box's Vite dev server (the dev VM's LAN IP
# or hostname, e.g. 192.0.2.10). Needed when developing against a REMOTE box — otherwise the CSS/JS
# load from the wrong origin. Leave unset for purely local dev; Vite then infers it from the request
# host. Never used in prod (assets are built). Keep the real address in the untracked .env only.
# VITE_HMR_HOST=
REVERB_HOST_PORT=8080
DB_PORT=3306
# Containers run as this host user (must own the bind-mount). nexxo=1002 on this VM.
@ -90,16 +85,6 @@ ACME_EMAIL=
# .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
@ -111,20 +96,6 @@ SITE_ADDRESS=:80
CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_KEY=
# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies
# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned
# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must
# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key.
# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers
# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
TERMINAL_SIDECAR_SECRET=
# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates
# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait).
CLUSEV_HONEYPOT_CANARY_APP=
CLUSEV_HONEYPOT_CANARY_DB=
CLUSEV_HONEYPOT_CANARY_API=
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,

32
.gitattributes vendored
View File

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

View File

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

View File

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

View File

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

View File

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

35
.gitignore vendored
View File

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

View File

@ -7,116 +7,13 @@ Alle nennenswerten Änderungen an Clusev. Format nach
**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand
reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer
eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich
getaggte Releases (Kanal `stable`) — niemals Entwicklungs-Builds.
**Was hier steht:** nur, was Nutzer der Installation merken — knapp, ein Eintrag
pro Änderung, gruppiert nach _Hinzugefügt / Geändert / Behoben / Sicherheit_. Rein
interne Arbeit (Wartungs-Tooling, CI, Refactorings, Tests) gehört nicht hierher,
sondern in die git-Historie.
getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Builds.
## [Unreleased]
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.11.0] - 2026-07-05
### Geändert
- **Ein einziger stabiler Release-Kanal.** Nutzer erhalten ausschließlich finalisierte `vX.Y.Z`-Releases;
die interne Release-Steuerung schneidet jetzt Release Candidates statt Vorab-Kanälen.
## [0.11.0-rc1] - 2026-06-26
### Hinzugefügt
- **Web-Terminal.** Vollwertiges SSH-Terminal direkt im Browser — eines pro Server und eines für den
Clusev-Host selbst. Echtes PTY mit Tab-Autovervollständigung; ein eigener Reiter im Menü.
- **Geführte Einführungs-Tour.** Beim ersten Login zeigt ein Spotlight-Rundgang die wichtigsten
Bereiche. Überspringbar und jederzeit in den Einstellungen erneut startbar.
- **Server-Suche im Terminal.** Ab mehreren Servern lässt sich die Zielliste durchsuchen.
- **Eigenes Passwort beim Anlegen eines Kontos.** Beim Hinzufügen eines Admins kann direkt ein
Passwort vergeben werden (leer = sicheres Einmal-Passwort, das einmalig angezeigt wird).
### Geändert
- **Passwortwechsel ist optional.** Der erste Login erzwingt kein neues Passwort mehr — ein
Warnbanner erinnert daran, sperrt aber nicht aus. 2FA war und bleibt optional (nur die
Login-Texte waren irreführend und wurden korrigiert).
- **Passwort-Mindestlänge auf 6 Zeichen** gesenkt, ohne Komplexitätszwang — die Passwortstärke
liegt in der Verantwortung des Nutzers.
- **Zufälliges Erst-Passwort.** Der Installer erzeugt ein zufälliges Einmal-Passwort statt des
festen `clusev` und zeigt es nur einmal an.
- **Installer fragt nicht mehr nach der Domain.** Die Installation läuft über IP/HTTP; die Domain
(mit automatischem HTTPS) wird später im Dashboard unter System gesetzt.
- **2FA-Status in der Seitenleiste** als dezentes Schild-Symbol statt Text-Badge.
### Sicherheit
- **Host-Key-Pinning** für die Terminal-SSH-Verbindungen — eine geänderte Server-Kennung (möglicher
MITM) bricht die Verbindung ab, bevor Zugangsdaten übertragen werden.
- **Proxy-Vertrauen gehärtet:** nur noch private Netze werden vertraut, damit ein gefälschter
`X-Forwarded-For` die IP-basierten Brute-Force-Limits nicht mehr umgehen kann.
## [0.10.0-rc1] - 2026-06-25
### Hinzugefügt
- **Verlaufs-Graf auf der Server-Detailseite.** CPU, RAM und Disk über Zeit — Zeitraum wählbar
(1 h / 24 h / 7 d / 30 d), mit Hover-Tooltip; der gewählte Zeitraum bleibt in der URL erhalten.
- **SSH-Schlüssel benennen.** Beim Hinzufügen eines Schlüssels lässt sich ein Name vergeben, sodass
Schlüssel in der Liste zuordenbar sind.
### Behoben
- **Aussperrschutz beim SSH-Root-Login.** Solange Clusev sich als `root` verbindet, lässt sich
„SSH-Root-Login deaktivieren" nicht mehr ausführen — das hätte den eigenen Zugang gekappt.
- **fail2ban startet zuverlässig.** Die `sshd`-Jail liest jetzt das systemd-Journal statt
`/var/log/auth.log` — so läuft fail2ban auch auf minimalen Images ohne rsyslog an.
## [0.9.68] - 2026-06-23
### Intern
- **Release-Tooling im Dev-Dashboard + CI-Pipeline.** Release Candidate schneiden, Live-Pipeline-Status,
Veröffentlichen / Promote-to-Stable / Yank — für Nutzer der Installation nicht sichtbar.
Details in der git-Historie. (Sammelt die internen Versionen 0.9.590.9.68.)
## [0.9.58] - 2026-06-22
### Hinzugefügt
- **Update-Check unterstützt jetzt GitHub.** Public-Nutzer sehen Updates aus dem öffentlichen
GitHub-Repo (Tags + Changelog); interne Dev/Staging-Stufen bleiben privat.
### Geändert
- **Öffentliche Projekt-Adresse ist jetzt `github.com/clusev/clusev`.** README, Quelltext- und
Konsolen-Branding sowie die Standard-Update-Quelle zeigen aufs öffentliche Repo.
## [0.9.57] - 2026-06-22
### Hinzugefügt
- **Stabiler Release-Kanal `stable`.** Nutzer beziehen ausschließlich stabile Releases (`vX.Y.Z`) —
niemals Entwicklungs-Builds.
## [0.9.56] - 2026-06-22
### Hinzugefügt
- **Branding im Quelltext + in der Browser-Konsole.** Wer den Seitenquelltext ansieht, findet jetzt
einen freundlichen Clusev-Hinweis (wer wir sind, Projektlink) als HTML-Kommentar. Öffnet jemand die
Browser-Konsole, erscheint ein gestyltes „CLUSEV"-Branding mit Projektlink — **und eine
Self-XSS-Sicherheitswarnung**: bei einem Server-Control-Panel ist „Füg das mal in deine Konsole ein"
ein realer Social-Engineering-Angriff, daher der Hinweis genau dort. Erscheint auf jeder Seite (auch
vor dem Login).
### Behoben
- **Datei-Manager: Dateiname im Tablet-Modus vom Rechte-Block überdeckt.** Die Tabellenspalten
(Rechte/Größe/Geändert) + Aktions-Buttons quetschten die Namensspalte auf nahezu null. Die Liste
rendert jetzt **unterhalb großer Bildschirme als gestapelte Karte** (Name in voller Breite, Meta-Zeile
und Aktionen darunter); die Tabellenansicht erscheint erst auf breiten Displays. Die mobile Ansicht
wurde dabei mit aufgeräumt.
- **Zeiger-Cursor auf Buttons fehlte.** Tailwind v4 stellt Buttons standardmäßig auf den
Standard-Cursor; auf anklickbaren Elementen (z. B. Ordnernamen, alle Buttons) erscheint jetzt wieder
der Hand-Zeiger.
### Behoben
- **Datei-Manager: „undefined is not valid JSON" beim Öffnen mancher Dateien.** Öffnete man eine
**Binär-/Nicht-UTF-8-Datei**, landeten deren Rohbytes in einer Livewire-Eigenschaft. Livewire
JSON-kodiert seinen Zustand, und ungültiges UTF-8 lässt `json_encode` scheitern → der Client bekam
eine leere Antwort und warf den Fehler. Binär-/zu-große Dateien werden jetzt nicht mehr als Inhalt
gebunden (es erscheint der Binär-Hinweis), sodass der Zustand immer gültiges JSON bleibt — sowohl
im Editor als auch in der zugrunde liegenden Datei-Lese-Funktion abgesichert.
## [0.9.53] - 2026-06-22
### Behoben
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
@ -219,14 +116,14 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
damit das Scannen am Handy zuverlässig bleibt.
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
`<endpoint-host>-<peer>.conf` (z. B. `203.0.113.10-laptop.conf`) statt `clusev-wireguard.conf`.
`<endpoint-host>-<peer>.conf` (z. B. `10.10.90.165-laptop.conf`) statt `clusev-wireguard.conf`.
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
### Behoben
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein
Host ohne Port eingegeben (z. B. `203.0.113.10` statt `203.0.113.10:51820`), landete dieser
Host ohne Port eingegeben (z. B. `10.10.90.165` statt `10.10.90.165:51820`), landete dieser
Wert unverändert in den Peer-Configs — WireGuard verlangt aber zwingend `host:port`, sodass die
WireGuard-App den (QR- oder Datei-)Import als „keine gültige WireGuard-Konfiguration" ablehnte.
Der Listen-Port wird jetzt automatisch ergänzt, wenn der Endpoint keinen Port enthält (sowohl bei
@ -731,7 +628,7 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet ausschließlich `vX.Y.Z` an.
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet nur `vX.Y.Z`, `beta` auch Vorab-Tags.
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
## [0.9.6] - 2026-06-19
@ -1301,10 +1198,10 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force-
Drossel, Session-Cookie-Härtung.
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
pro Version, Kanal `stable` (kein nutzer-seitiges `dev`).
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
[Unreleased]: https://github.com/clusev/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/clusev/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/clusev/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/clusev/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/clusev/clusev/releases/tag/v0.1.0
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://git.bave.dev/boban/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0

266
CLAUDE.md Normal file
View File

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

View File

@ -1,12 +1,13 @@
<div align="center">
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
<img src="docs/assets/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
<br>
**One dashboard for your whole fleet. Agentless over SSH. Self-hosted, security-first, open core.**
[![License](https://img.shields.io/badge/license-AGPL--3.0-FF6B2C?style=flat-square&labelColor=0F1318)](#license)
[![Version](https://img.shields.io/badge/release-v0.9.40-FF6B2C?style=flat-square&labelColor=0F1318)](https://git.bave.dev/boban/clusev)
[![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)
@ -46,7 +47,6 @@ reimplements their daemons; it orchestrates the real ones.
| **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. |
@ -61,7 +61,7 @@ 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%">
<img src="docs/assets/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
@ -88,7 +88,7 @@ one Docker stack — `app` (php-fpm + nginx + Vite), `reverb`, `queue`, `schedul
```bash
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
git clone https://github.com/clusev/clusev.git
git clone https://git.bave.dev/boban/clusev.git
cd clusev
sudo ./install.sh
```
@ -98,67 +98,34 @@ 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).
3. Asks for a **domain** (leave empty for IP access) and an **admin e-mail** (default
**`admin@clusev.local`**), or reads `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` from the environment for
an unattended install. With a domain it checks whether DNS already points here: if so a certificate
is obtained automatically; if not, it warns you and lets you take the domain anyway (HTTP until DNS
is correct) or continue on the IP.
4. Generates all secrets (once — never regenerated on re-run), builds the image, starts the stack,
runs the migrations, and creates the first administrator with a **random initial password** printed
in the closing summary.
runs the migrations, and creates the first administrator with the **default password `clusev`**
(you are forced to change it on first login).
5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status.
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
(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.
(your e-mail — `admin@clusev.local` if you left it empty — plus the default password **`clusev`**),
and the **`clusev` host user** + its random password (shown **only once** — note it 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).
Log in with your admin e-mail and the password **`clusev`** — the panel then **forces you to set a new
password immediately**. Do this right away: `clusev` is a known default, so the account is only safe
once changed. 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.
---
## Requirements
**The Clusev panel host** — the one VM that runs the dashboard:
- **OS:** Debian 12/13 or Ubuntu 22.04/24.04, with **root** access.
- **CPU / RAM:** 1 vCPU and **1 GB RAM** minimum (**2 GB recommended**) — the whole Docker stack
(app, MariaDB, Redis, Reverb, queue) runs here.
- **Disk:** ~**5 GB** free minimum (**10 GB+** recommended — image and database grow over time).
- **Network:** a **public IP**; optionally a domain whose DNS points at the server (for automatic
HTTPS — Let's Encrypt needs ports **80/443** reachable).
- **Tools:** only **git** to fetch the repo — the installer sets up Docker and everything else.
**The servers you manage** need **nothing installed** — just **SSH** reachable from the panel and a
standard Linux `/proc`. Full control (service management + hardening) additionally needs **systemd**
and one of `apt` / `dnf` / `zypper`. See [**Supported operating systems**](#supported-operating-systems).
---
## Supported operating systems
Clusev is **agentless** — it manages whatever it can reach over SSH. How much works depends on the
target's init system and package manager:
| OS family | Metrics | systemd services | Hardening<br>(SSH · fail2ban · firewall · auto-updates) |
|:--|:-:|:-:|:-:|
| **Debian · Ubuntu** (and derivatives) | ✅ | ✅ | ✅ |
| **RHEL · Fedora · Rocky · AlmaLinux · CentOS Stream · Amazon Linux** | ✅ | ✅ | ✅ |
| **openSUSE · SLES** | ✅ | ✅ | ✅ |
| **Arch** | ✅ | ✅ | ⚠️ not yet (`pacman` not wired) |
| **Alpine** (OpenRC) | ✅ | ❌ no systemd | ❌ |
| **Other / unidentified Linux** | ✅ if `/proc` present | ❌ | ❌ |
**Metrics** (CPU, memory, disk, load) read from `/proc` and work on essentially any Linux. **Service
control** needs **systemd**. **Hardening** uses the host's firewall (`ufw` / `firewalld`) and package
manager (`apt` / `dnf` / `zypper`); where those aren't present, the action is shown as unavailable
rather than failing silently.
- A **Debian or Ubuntu** server (a small VM is enough) with **root** access.
- A **public IP**. Optionally a domain whose DNS points at the server (for automatic HTTPS).
- Only **git** to fetch the repo — the installer sets up Docker and everything else.
---
@ -166,8 +133,7 @@ rather than failing silently.
- **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
- **With a domain:** set it during install or later under **System → Domain &amp; TLS**. The panel
obtains and renews a Let's Encrypt certificate automatically and serves HTTPS — just point DNS at the
server. (Let's Encrypt needs publicly reachable ports 80/443.)
- **Behind your own reverse proxy** (one that already terminates TLS): switch _TLS-Terminierung_ to
@ -183,12 +149,13 @@ terminal needed; a small, scoped host service performs the restart).
## Updating
```bash
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
cd clusev
sudo ./update.sh # pulls the latest code, then rebuilds, restarts and migrates
```
`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.)
`update.sh` 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.)
---
@ -201,7 +168,8 @@ new password) as a fallback.
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
```bash
clusev reset-admin
cd clusev
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
```
This clears the second factor so you can set a new password on the next login. The bare-IP
@ -218,6 +186,6 @@ RBAC, audit export, alerting) are separate.
<div align="center">
**Project home — [github.com/clusev/clusev](https://github.com/clusev/clusev)**
**Project home — [git.bave.dev/boban/clusev](https://git.bave.dev/boban/clusev)**
</div>

View File

@ -4,19 +4,22 @@ namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Idempotent first-run bootstrap (called by install.sh, phase 7).
*
* Creates the initial admin with a 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 the standard default password (operator decision: a
* known, memorable default so login is never blocked by a missed one-time secret).
* `must_change_password` is set, so the onboarding gate FORCES a new password on the
* first login the default is only valid until then. Re-running once an admin exists is
* a hard no-op. NOTE: a known default credential is only acceptable because of the forced
* rotation; the operator must change it immediately (banner / MOTD / README say so).
*/
class Install extends Command
{
/** Standard default admin password — forced to change on first login (must_change_password). */
private const DEFAULT_PASSWORD = 'clusev';
/**
* 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
@ -29,7 +32,7 @@ class Install extends Command
{--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 Standard-Passwort an (Zwangswechsel beim ersten Login)';
public function handle(): int
{
@ -47,24 +50,22 @@ class Install extends Command
return self::FAILURE;
}
// Random, non-guessable initial password (printed once below). Satisfies any policy.
$password = Str::password(16);
$password = self::DEFAULT_PASSWORD;
$user = User::create([
'name' => (string) ($this->option('name') ?: 'Administrator'),
'email' => $email,
'password' => $password, // 'hashed' cast hashes on save
'must_change_password' => true, // nudges a rotation on first login (optional)
'role' => 'admin', // the first/root account is a full administrator
'must_change_password' => true, // forces a new password 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.
// install.sh greps these two lines for the closing banner. The password is the
// standard default and must be changed on first login (must_change_password).
$this->newLine();
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
$this->line('CLUSEV_ADMIN_PASSWORD='.$password);
$this->newLine();
$this->info('Admin angelegt. Generiertes Passwort jetzt notieren — es wird nur einmal angezeigt.');
$this->info('Admin angelegt. Standard-Passwort — beim ersten Login sofort aendern.');
return self::SUCCESS;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,9 +44,8 @@ class PanelScheme
}
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
// a plain-HTTP call with an internal Host; the health check + the terminal sidecar's
// resolve call likewise come over the private network with a service-name Host).
if ($request->is('_caddy/*', 'up', '_internal/*')) {
// a plain-HTTP call with an internal Host; the health check likewise).
if ($request->is('_caddy/*', 'up')) {
return $next($request);
}

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

@ -44,10 +44,6 @@ class Index extends Component
/** 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'],
]);

View File

@ -69,7 +69,7 @@ class ForgotPassword extends Component
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(6)],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
]);
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset

View File

@ -34,7 +34,7 @@ class PasswordChange extends Component
try {
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
], attributes: [
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
@ -57,18 +57,6 @@ class PasswordChange extends Component
return $this->redirect(route('dashboard'), navigate: true);
}
/**
* Keep the current password for now rotation is not forced. Remember the choice for this session
* so the prompt does not reappear; `must_change_password` stays true, so the persistent default-
* password banner keeps warning until the password is actually changed.
*/
public function skip()
{
session()->put('onboarding.password_skipped', true);
return $this->redirect(route('dashboard'), navigate: true);
}
public function render()
{
return view('livewire.auth.password-change')->title(__('auth.title_password_change'));

View File

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

View File

@ -1,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,6 @@ class LoginProtection extends Component
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();
@ -38,7 +37,6 @@ class LoginProtection extends Component
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'],
@ -74,7 +72,6 @@ class LoginProtection extends Component
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)) {
@ -85,7 +82,6 @@ class LoginProtection extends Component
public function confirmUnban(string $ip): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -103,7 +99,6 @@ class LoginProtection extends Component
#[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) {
@ -116,7 +111,6 @@ class LoginProtection extends Component
public function confirmUnbanAll(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -134,7 +128,6 @@ class LoginProtection extends Component
#[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) {
@ -163,8 +156,6 @@ class LoginProtection extends Component
'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

@ -54,7 +54,7 @@ class Profile extends Component
try {
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
]);
} catch (ValidationException $e) {
if (array_key_exists('current_password', $e->errors())) {

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
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;
@ -13,17 +14,23 @@ use Livewire\Attributes\On;
use Livewire\Component;
/**
* System settings dashboard-configurable Domain + TLS.
* System settings dashboard-configurable Domain + TLS and the release channel.
*
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
* mid-session that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
@ -39,8 +46,19 @@ class Index extends Component
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
@ -49,6 +67,9 @@ class Index extends Component
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
@ -62,8 +83,6 @@ class Index extends Component
/** Validate, then confirm the domain change via the shared modal (audited there). */
public function confirmDomain(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate($this->domainRules(), [
'domainInput.regex' => __('system.domain_invalid'),
'domainInput.max' => __('system.domain_invalid'),
@ -99,10 +118,6 @@ class Index extends Component
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
} catch (InvalidConfirmToken) {
@ -130,8 +145,6 @@ class Index extends Component
*/
public function restartNow(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
// Surface a failed sentinel write instead of a "restarting" state that never resolves.
if (! $deployment->requestRestart()) {
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
@ -150,8 +163,6 @@ class Index extends Component
*/
public function requestCertificate(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$domain = $deployment->domain();
if ($domain === null || $deployment->externalTls()) {
@ -190,11 +201,53 @@ class Index extends Component
$this->dispatch('notify', message: $message, level: $level);
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
'token' => ConfirmToken::issue(
'channelChanged',
['channel' => $channel],
'system.settings_updated',
'release_channel='.$channel,
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $confirmToken): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$channel = $payload['params']['channel'];
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
return;
}
@ -221,10 +274,6 @@ class Index extends Component
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
} catch (InvalidConfirmToken) {
@ -257,6 +306,7 @@ class Index extends Component
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'isOverridden' => $deployment->domainIsOverridden(),
'channels' => $this->channelDescriptions(),
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
'certStatus' => $deployment->certStatus(),

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

@ -7,6 +7,7 @@ use App\Services\DeploymentService;
use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
@ -182,8 +183,6 @@ class Index extends Component
*/
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);
@ -384,10 +383,7 @@ class Index extends Component
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];
$key = $m[1].'.'.$m[2];
} else {
$key = 'other';
}
@ -402,7 +398,7 @@ class Index extends Component
'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)
default => $key,
},
'unreleased' => $key === 'unreleased',
'count' => count($rels),
@ -420,7 +416,7 @@ class Index extends Component
return $ra <=> $rb;
}
return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
return $ra === 1 ? version_compare($b['key'].'.0', $a['key'].'.0') : 0;
});
return $series;
@ -553,7 +549,7 @@ class Index extends Component
}
// 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'));
$content = $this->fetchRemoteChangelog('v'.ltrim($latestTag, 'vV'));
if ($content === null) {
return [];
}
@ -569,43 +565,37 @@ class Index extends Component
}
/**
* 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}>
* Fetch CHANGELOG.md from the public Git host at a given ref (tag). Anonymous + read-only;
* cached briefly so the page render never repeats the network call. Null on any failure.
*/
private function projectLinks(): array
private function fetchRemoteChangelog(string $ref): ?string
{
$slug = trim((string) config('clusev.public_slug'), '/');
if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
$slug = 'clusev/clusev';
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
$urls = ['https://github.com/'.$slug];
[, $base, $owner, $name] = $m;
// 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);
$key = 'clusev:remote-changelog:'.$ref;
$cached = Cache::get($key);
if (is_string($cached)) {
return $cached;
}
try {
$res = Http::timeout(5)->get("{$base}/api/v1/repos/{$owner}/{$name}/raw/CHANGELOG.md", ['ref' => $ref]);
if (! $res->successful()) {
return null;
}
}
$body = $res->body();
Cache::put($key, $body, now()->addMinutes(10)); // only successes are cached
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);
return $body;
} catch (\Throwable $e) {
report($e);
return null;
}
}
public function render()
@ -633,7 +623,7 @@ class Index extends Component
// 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;
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? $m[1].'.'.$m[2] : null;
// Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel());
@ -647,7 +637,7 @@ class Index extends Component
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,

View File

@ -3,14 +3,12 @@
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;
@ -73,17 +71,6 @@ class Index extends Component
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);
@ -96,8 +83,6 @@ class Index extends Component
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'),
@ -115,8 +100,6 @@ class Index extends Component
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}$/'],
@ -161,10 +144,6 @@ class Index extends Component
#[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) {
@ -181,8 +160,6 @@ class Index extends Component
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;
}
@ -193,8 +170,6 @@ class Index extends Component
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'),
@ -211,8 +186,6 @@ class Index extends Component
/** 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'),
@ -238,8 +211,6 @@ class Index extends Component
#[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) {
@ -251,8 +222,6 @@ class Index extends Component
public function runGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
@ -279,8 +248,6 @@ class Index extends Component
#[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) {
@ -292,8 +259,6 @@ class Index extends Component
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
@ -323,8 +288,6 @@ class Index extends Component
#[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) {
@ -356,8 +319,6 @@ class Index extends Component
#[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) {
@ -421,7 +382,7 @@ class Index extends Component
/**
* 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.)
* name e.g. "10.10.90.165-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
*/
public function downloadConfig(): ?StreamedResponse
{
@ -509,21 +470,9 @@ class Index extends Component
{
$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,
'traffic' => $traffic->series($window),
'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'];
@ -36,32 +32,10 @@ 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',
'fail2ban.ban', 'wg.action-failed',
];
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
namespace App\Models;
use App\Enums\Role;
use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@ -16,7 +15,7 @@ use Illuminate\Support\Str;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
@ -32,8 +31,6 @@ class User extends Authenticatable
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
'must_change_password' => 'boolean',
'onboarding_tour_completed_at' => 'datetime',
'role' => Role::class,
];
}
@ -146,18 +143,6 @@ class User extends Authenticatable
return ! $this->must_change_password;
}
/** True when the account is a full administrator. */
public function isAdmin(): bool
{
return $this->role === Role::Admin;
}
/** True when the account's role is at least $role in the admin>operator>viewer hierarchy. */
public function roleAtLeast(Role $role): bool
{
return $this->role?->atLeast($role) ?? false;
}
/**
* When neither factor remains (no TOTP, no keys), 2FA is fully off so drop the backup
* codes (nothing left to recover into). Call after removing any factor.

View File

@ -2,20 +2,18 @@
namespace App\Providers;
use App\Enums\Role;
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\User;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\MailSettings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
@ -54,14 +52,6 @@ class AppServiceProvider extends ServiceProvider
EnsureSecurityOnboarded::class,
]);
// RBAC abilities. admin has every dangerous control-plane ability; operator can run
// day-to-day operations; viewer is read-only. Gates are the single source consumed by route
// `can:` middleware, component mount() abort_unless, per-action guards and @can in Blade.
foreach (['manage-panel', 'manage-users', 'manage-network', 'manage-fleet'] as $ability) {
Gate::define($ability, fn (User $u) => $u->isAdmin());
}
Gate::define('operate', fn (User $u) => $u->roleAtLeast(Role::Operator));
// Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint
// (/livewire/update carries every component request, including the auth actions). The
// per-action limiters in the components are the precise gate; this is the blunt cap so a
@ -87,12 +77,50 @@ class AppServiceProvider extends ServiceProvider
config(['app.url' => 'https://'.$domain]);
}
// Operator SMTP settings → runtime mail config. Shared helper (MailSettings::apply) because
// THREE process shapes need it fresh: web requests (here, per request), the queue worker
// (Queue::before, per job — boot() runs only once in that long-lived process), and the
// long-lived metrics poller (AlertNotifier applies it right before queueing, since the
// default MAILER NAME is baked into a mailable at queue time).
MailSettings::apply();
Queue::before(fn () => MailSettings::apply());
$this->applyMailSettings();
}
/**
* Apply the operator's SMTP settings (Settings\Email) to runtime config so
* password-reset mails and notifications actually send. boot() runs every request,
* so a saved change takes effect on the next request.
*
* Only switches to the `smtp` mailer when host + from-address are configured;
* otherwise the safe install default (`log`) is left untouched, so nothing breaks
* while unconfigured. The stored SMTP password is encrypted at rest (APP_KEY) and
* decrypted here. Wrapped in try/catch like DeploymentService the settings table
* may not exist yet during install/migrate, and that must never break boot.
*/
private function applyMailSettings(): void
{
try {
$host = Setting::get('mail_host');
$from = Setting::get('mail_from_address');
if ($host === null || $host === '' || $from === null || $from === '') {
return; // unconfigured -> keep the safe default mailer
}
$encryption = Setting::get('mail_encryption', 'tls');
$storedPassword = Setting::get('mail_password');
$password = null;
if ($storedPassword !== null && $storedPassword !== '') {
$password = Crypt::decryptString($storedPassword);
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $host,
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
'mail.from.address' => $from,
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
]);
} catch (Throwable) {
// Settings table not migrated yet (install/migrate) or an undecryptable value —
// leave the default mailer untouched. Never break boot.
}
}
}

View File

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

View File

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

View File

@ -118,69 +118,6 @@ class BruteforceGuard
]);
}
/**
* Ban an IP on the FIRST hit no maxretry threshold, no RateLimiter window. Used by the
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
* throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
* trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
* disabled brute-force protection must not silently disable honeypot bans). Returns true when a
* ban was written, false when it no-op'd (invalid / exempt IP) the caller stays a cheap no-op
* for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
*/
public function banNow(?string $ip, string $reason): bool
{
if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
return false;
}
$canonical = $this->canonical($ip);
$now = now();
BannedIp::upsert(
[[
'ip' => $canonical,
'banned_until' => $now->copy()->addMinutes($this->bantime()),
'reason' => $reason,
'attempts' => $this->maxretry(),
'created_at' => $now,
'updated_at' => $now,
]],
['ip'],
['banned_until', 'reason', 'attempts', 'updated_at'],
);
Cache::forget('bruteforce:banned:'.$canonical);
// Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
// looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
// once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
// includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
// honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
// rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
// which is minutes of human time later, well past this window (so it is not tied to unban()).
$auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason;
if (Cache::add($auditKey, 1, 60)) {
try {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason],
]);
} catch (\Throwable $e) {
// The Cache::add above atomically CLAIMED the dedup slot before the write. If the
// write throws (a transient DB blip), release the slot so the 60s window is not
// burned with no audit row — the next attempt re-claims and re-audits this ban.
Cache::forget($auditKey);
throw $e;
}
}
return true;
}
/**
* Whether $ip has an active ban. Does NOT check enabled() the BlockBannedIp middleware
* gates on enabled() before calling this, so disabling the feature stops enforcement while

View File

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

View File

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

View File

@ -52,20 +52,6 @@ class DeploymentService
*/
private const UPDATE_FILE = 'app/restart-signal/update.request';
/**
* Update start marker written (best-effort) when an update is requested, on the same
* bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase
* checklist are anchored to the SERVER-side start, not to page-load time a browser refresh
* mid-update no longer resets the timer to zero nor drops the checklist back to phase one.
*/
private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json';
/**
* Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses
* (fetch build restart migrate done). Same bind-mounted directory. Read-only token.
*/
private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json';
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
@ -223,58 +209,6 @@ class DeploymentService
return storage_path(self::UPDATE_FILE);
}
/** Absolute path of the update-start marker on the shared (bind-mounted) volume. */
public function updateStartedPath(): string
{
return storage_path(self::UPDATE_STARTED_FILE);
}
/**
* Epoch seconds when the current update was requested, or null if there is no readable marker.
* The update-progress page anchors its elapsed timer and live phase feed to this so both
* survive a browser refresh instead of resetting on every page load. Never throws.
*/
public function updateStartedAt(): ?int
{
$path = $this->updateStartedPath();
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
$at = is_array($data) ? ($data['at'] ?? null) : null;
return is_int($at) && $at > 0 ? $at : null;
}
/**
* The current update macro-stage written by the host updater, or null when absent/unrecognised.
* Only ever returns a whitelisted stage token (never arbitrary file content). Shape:
* ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => <epoch>]. Never throws.
*
* @return array{stage: string, at: int}|null
*/
public function updatePhase(): ?array
{
$path = storage_path(self::UPDATE_PHASE_FILE);
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done', 'error'], true)) {
return null;
}
// Require a real timestamp: the progress page's freshness logic ties a stage to THIS update
// via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0`
// fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a
// premature completion redirect. Treat it as "no stage" and let the page fall back.
$at = $data['at'] ?? null;
if (! is_int($at) || $at <= 0) {
return null;
}
return ['stage' => (string) $data['stage'], 'at' => $at];
}
/**
* Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped
* clusev-update.path unit) reacts by running update.sh git pull + idempotent re-install
@ -290,31 +224,9 @@ class DeploymentService
@mkdir($dir, 0775, true);
}
// Sign the marker so the host updater (watch.sh) can reject a forged/limited write to the
// shared ./run dir before running a root update: line 1 = timestamp, line 2 = its HMAC.
// Falls back to the bare timestamp when no key is configured (dev / pre-HMAC installs), which
// the host accepts too.
$ts = now()->toIso8601String();
$key = (string) config('clusev.update_hmac_key');
$body = $key !== ''
? $ts.PHP_EOL.hash_hmac('sha256', $ts, $key).PHP_EOL
: $ts.PHP_EOL;
// Returns false when the (bind-mounted) sentinel dir is not writable by the app user —
// the caller surfaces that instead of showing a "running" state that never resolves.
$written = @file_put_contents($path, $body) !== false;
if ($written) {
// Best-effort start marker (a failure here must never fail the update): lets the
// progress page render a refresh-proof elapsed time and accept the live phase feed
// regardless of how long the current stage has been running.
@file_put_contents($this->updateStartedPath(), json_encode([
'at' => now()->timestamp,
'from' => (string) config('clusev.version'),
]).PHP_EOL);
}
return $written;
return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
}
/** True while an update request is pending (the host watcher clears it once handled). */
@ -323,14 +235,13 @@ class DeploymentService
return is_file($this->updateSignalPath());
}
/** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes
* the sentinel normally). */
/** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */
public function clearUpdateRequest(): void
{
foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) {
if (is_file($path)) {
@unlink($path);
}
$path = $this->updateSignalPath();
if (is_file($path)) {
@unlink($path);
}
}

View File

@ -1,169 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\DockerNotInstalled;
use App\Models\Server;
use InvalidArgumentException;
use RuntimeException;
/**
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
* through FleetService::runPrivileged (base64 transport shell-injection-safe); every container
* id/name is validated before interpolation so a crafted name can neither inject a command nor a
* leading-dash argument. Mirrors the FleetService::serviceAction discipline.
*/
class DockerService
{
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'];
/**
* A non-interactive SSH exec (and sudo's secure_path) often has a MINIMAL PATH (~/usr/bin:/bin),
* so `docker` installed in /usr/local/bin (get.docker.com), /snap/bin (snap), or /usr/sbin isn't
* found "sh: docker: not found" even though it IS installed. Prepend the common bin dirs to
* every docker call so the binary is located regardless of the login shell's PATH. Single-quoted
* so `${PATH:+:$PATH}` reaches the remote shell literally (not interpolated by PHP).
*/
private const PATH_PREFIX = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin${PATH:+:$PATH}; ';
public function __construct(private FleetService $fleet) {}
public function available(Server $server): bool
{
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'command -v docker >/dev/null 2>&1 && echo yes || echo no');
return trim($res['output']) === 'yes';
}
/**
* @return array<int, array{id:string,name:string,image:string,state:string,status:string,ports:string}>
*/
public function containers(Server $server): array
{
// Tab-separated columns (NOT `{{json .}}`): works on much older Docker AND avoids a JSON
// key-casing dependency. `.State` was added late, so we derive state from `.Status` text —
// this is the whole reason a modern-only `{{json .}}` could return nothing on an older host.
$fmt = '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}';
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'");
if (! $res['ok']) {
$err = $this->firstLine($res['output']);
// Distinguish "docker binary absent" (a native server, no runtime) from a real fault
// right here, so callers get the honest "not installed" state from THIS single `docker
// ps` call instead of paying a second SSH round-trip on a separate available() probe.
if (preg_match('/docker:\s.*not found/i', $err)) {
throw new DockerNotInstalled($err);
}
// Surface the REAL reason (daemon down / permission / rootless socket) rather than a
// misleading empty list — the component shows this instead of "no containers".
throw new RuntimeException($err ?: 'docker ps failed');
}
$out = [];
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$p = explode("\t", $line);
$status = trim($p[3] ?? '');
$name = trim($p[1] ?? '');
$out[] = [
'id' => trim($p[0] ?? ''),
'name' => $name,
'display' => self::displayName($name),
'image' => trim($p[2] ?? ''),
'state' => $this->stateFromStatus($status),
'status' => $status,
'ports' => trim($p[4] ?? ''),
];
}
return $out;
}
/**
* Display-only container name: strip the compose replica suffix ("clusev-app-1" -> "clusev-app").
* NEVER use for docker commands actions/logs keep the real name/id.
*/
public static function displayName(string $name): string
{
return preg_replace('/-\d+$/', '', $name) ?: $name;
}
/** Derive a coarse state from docker's Status text (portable across versions that lack .State). */
private function stateFromStatus(string $status): string
{
$s = strtolower($status);
return match (true) {
str_contains($s, '(paused)') => 'paused',
str_starts_with($s, 'up') => 'running',
str_starts_with($s, 'created') => 'created',
str_starts_with($s, 'restarting') => 'restarting',
default => 'exited', // Exited / Dead / Removal
};
}
private function firstLine(string $out): string
{
return trim(preg_split('/\r?\n/', trim($out))[0] ?? '');
}
public function containerAction(Server $server, string $id, string $op): array
{
if (! in_array($op, self::ACTIONS, true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->assertValidRef($id);
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker {$op} {$id}");
return ['ok' => $res['ok'], 'output' => trim($res['output'])];
}
public function logs(Server $server, string $id, int $lines = 200): string
{
$this->assertValidRef($id);
$lines = max(1, min(2000, $lines));
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat
// the response or, via invalid UTF-8, break Livewire's JSON snapshot. head -c bounds it, and
// mb_scrub drops any invalid byte sequence before it reaches a Livewire public property.
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker logs --tail {$lines} {$id} 2>&1 | head -c 262144");
return mb_scrub(trim($res['output']), 'UTF-8');
}
/**
* @return array<int, array{name:string,status:string}>
*/
public function composeStacks(Server $server): array
{
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'docker compose ls --format json 2>/dev/null');
if (! $res['ok']) {
return [];
}
$rows = json_decode(trim($res['output']), true);
if (! is_array($rows)) {
return [];
}
return array_map(fn ($r) => [
'name' => (string) ($r['Name'] ?? ''),
'status' => (string) ($r['Status'] ?? ''),
], array_values(array_filter($rows, 'is_array')));
}
/**
* A container id or name hex id or a docker name. MUST start alphanumeric (blocks a leading
* dash = argument injection) and contain only [\w.-] (no shell metacharacters).
*/
private function assertValidRef(string $ref): void
{
// \A ... \z (not ^...$): \z anchors the ABSOLUTE end so a trailing newline can't sneak past.
if (! preg_match('/\A[a-zA-Z0-9][\w.-]*\z/', $ref)) {
throw new InvalidArgumentException('Ungueltige Container-Referenz.');
}
}
}

View File

@ -398,12 +398,7 @@ class Fail2banService
private function validJail(string $jail): bool
{
// Must START with an alphanumeric/underscore: a leading '-' would be parsed by
// fail2ban-client as an OPTION (e.g. "-h", "--help") rather than a jail argument,
// and a leading '.' has no legitimate jail use. Internal '.-_' stay allowed so real
// names like "nginx-http-auth" pass. (Shell metacharacters are already inert via the
// base64 transport in runPrivileged; this closes the argument-injection confusion.)
return (bool) preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]*$/', $jail);
return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail);
}
private function validIpOrCidr(string $v): bool

View File

@ -561,9 +561,7 @@ class FleetService
$content = $size > $maxBytes ? '' : (string) $sftp->get($path);
$binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8'));
// Never hand back raw non-UTF-8 bytes: bound to a Livewire public property they break the
// JSON snapshot ("undefined is not valid JSON"). The caller renders a binary notice instead.
return ['content' => $binary ? '' : $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
return ['content' => $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
} finally {
$sftp->disconnect();
}
@ -606,47 +604,18 @@ class FleetService
if (! in_array($op, ['start', 'stop', 'restart'], true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
// Unit MUST start alphanumeric (blocks a leading-dash value that systemctl would parse as an
// option, e.g. `-H host` / `--signal=`) — mirrors the DockerService::ref() discipline. Every
// systemctl call below ALSO passes the unit after `--` (end-of-options) and escapeshellarg'd,
// so a hostile unit can never become a flag even if the pattern were ever loosened.
if (! preg_match('/\A[a-zA-Z0-9][\w@.:-]*\z/', $unit)) {
if (! preg_match('/^[\w@.:-]+$/', $unit)) {
throw new InvalidArgumentException('Ungueltige Unit.');
}
$safe = escapeshellarg($unit);
$ssh = $this->client($server);
try {
[$out, $code] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} -- {$safe} 2>&1"
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1"
);
$out = trim($out);
$ok = $code === 0;
// A `start`/`restart` can EXIT 0 (systemd accepted the request) yet the unit dies on
// launch → is-active=failed a moment later (e.g. monit crashing on a bad config). Verify
// the real post-action state; if it never came up, report failure WITH the reason (last
// journal lines) so the operator sees *why* — not a false "gestartet". (stop = the unit
// going inactive IS success, so skip the check there.)
if ($ok && $op !== 'stop') {
[$state] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl is-active -- {$safe} 2>&1"
);
$state = trim($state);
if ($state !== 'active') {
$ok = false;
[$why] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl status --no-pager --lines=12 -- {$safe} 2>&1 || true"
);
$detail = trim($why);
$out = trim(($out !== '' ? $out."\n" : '')
.$unit.': '.$state.' ('.$op.')'
.($detail !== '' ? "\n".$detail : ''));
}
}
return ['ok' => $ok, 'output' => $out];
return ['ok' => $code === 0, 'output' => trim($out)];
} finally {
$ssh->disconnect();
}
@ -749,7 +718,7 @@ class FleetService
{
$ips = [];
foreach ($this->lines($addr) as $line) {
// "2: ens18 inet 203.0.113.11/24 brd ..."
// "2: ens18 inet 10.10.90.162/24 brd ..."
if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) {
$ips[$m[1]][] = $m[2];
}

View File

@ -206,13 +206,6 @@ class HardeningService
return ['ok' => $res['ok'], 'output' => $res['output']];
}
// Lock-out guard: disabling root login (PermitRootLogin no) severs Clusev's OWN access when
// it connects AS root — no key or password can reach root afterwards. Refuse until Clusev is
// switched to a non-root credential (a sudo user with a key), then root login can be disabled.
if ($action === 'ssh_root' && ! $enable && $server->credential?->username === 'root') {
return ['ok' => false, 'output' => __('backend.ssh_root_self_lockout')];
}
// Lock-out guard: never disable password auth without a usable key path.
if ($action === 'ssh_password' && ! $enable) {
if ($server->credential?->auth_type === 'password') {
@ -238,15 +231,8 @@ class HardeningService
return match ($action) {
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
// Pin the sshd jail to the SYSTEMD backend (reads the journal) instead of the distro
// default /var/log/auth.log — which is absent on minimal/container images without
// rsyslog, where fail2ban would otherwise fail to start. All supported targets are
// systemd, and `apt/dnf install fail2ban` pulls the systemd binding via Recommends.
'fail2ban' => $enable
? $pm->ensureInstalledScript('fail2ban')
.' && mkdir -p /etc/fail2ban/jail.d'
.' && printf \'[sshd]\nbackend = systemd\nenabled = true\n\' > /etc/fail2ban/jail.d/00-clusev-sshd.local'
.' && systemctl enable --now fail2ban'
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
: 'systemctl disable --now fail2ban',
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),

View File

@ -1,66 +0,0 @@
<?php
namespace App\Services;
use App\Models\HealthCheck;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* Runs an uptime probe NATIVELY (Laravel HTTP client / a PHP TCP stream) no SSH, no shell, so the
* target is never interpolated into a command. An HTTP probe reports only up/down + status code +
* latency (never the response body), so it can't exfiltrate an internal endpoint's content. Redirects
* are disabled so a 30x can't bounce the probe to an unseen target.
*/
class HealthService
{
/**
* @return array{ok:bool, latency:?int, detail:string}
*/
public function probe(HealthCheck $check, int $timeout = 5): array
{
return $check->type === 'tcp'
? $this->tcp((string) $check->target, (int) $check->port, $timeout)
: $this->http((string) $check->target, $timeout);
}
private function http(string $url, int $timeout): array
{
$start = microtime(true);
try {
$res = Http::timeout($timeout)->withoutRedirecting()->get($url);
$latency = (int) round((microtime(true) - $start) * 1000);
$status = $res->status();
// 2xx/3xx = up; a 4xx/5xx means the endpoint answered but is unhealthy.
return ['ok' => $status >= 200 && $status < 400, 'latency' => $latency, 'detail' => 'HTTP '.$status];
} catch (Throwable $e) {
return ['ok' => false, 'latency' => null, 'detail' => $this->reason($e->getMessage())];
}
}
private function tcp(string $host, int $port, int $timeout): array
{
$start = microtime(true);
$errno = 0;
$errstr = '';
$sock = @stream_socket_client('tcp://'.$host.':'.$port, $errno, $errstr, $timeout);
if (! $sock) {
return ['ok' => false, 'latency' => null, 'detail' => $errstr !== '' ? $errstr : 'connection failed'];
}
fclose($sock);
$latency = (int) round((microtime(true) - $start) * 1000);
return ['ok' => true, 'latency' => $latency, 'detail' => 'TCP '.$port.' open'];
}
/** Keep the failure reason short + on one line for the compact status row. */
private function reason(string $msg): string
{
$msg = trim(preg_split('/\R/', $msg)[0] ?? $msg);
return mb_strlen($msg) > 80 ? mb_substr($msg, 0, 80).'…' : $msg;
}
}

View File

@ -47,36 +47,6 @@ class MaintenanceService
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
}
/**
* Pending update count AND its security subset in one round-trip. Either value is NULL when the
* query couldn't run (unsupported manager / errored) so the caller shows "unbekannt" rather than
* a misleading 0. Security is best-effort per family; it is always pending.
*
* @return array{pending: ?int, security: ?int}
*/
public function updateCounts(Server $server): array
{
$os = $this->detector->detect($server);
if (! $os->managesPackages()) {
return ['pending' => null, 'security' => null];
}
$res = $this->fleet->runPlain($server, PackageManager::for($os)->countsScript());
if (! str_contains($res['output'], 'CLUSEV_OK')) {
return ['pending' => null, 'security' => null];
}
$pending = preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
$security = preg_match('/CLUSEV_SECURITY=(\d+)/', $res['output'], $s) ? (int) $s[1] : null;
// Security can never exceed total pending (guards a noisy grep).
if ($pending !== null && $security !== null) {
$security = min($security, $pending);
}
return ['pending' => $pending, 'security' => $security];
}
/**
* Refresh the index and apply all upgrades as root, using the host's package
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400

View File

@ -1,69 +0,0 @@
<?php
namespace App\Services;
use App\Models\MetricSample;
use App\Models\Server;
/**
* Turns persisted per-minute metric samples into a downsampled cpu/mem/disk (% average) + load
* series for the Server-Details history chart. Bucketing is done in PHP (DB-agnostic, like the
* WireGuard traffic series) each returned point is the AVERAGE of the samples in a bucket, and
* ONLY non-empty buckets are returned (the chart connects consecutive points and the client breaks
* the line only on a real time gap, so normal sampling renders a continuous curve, not dropouts).
*/
class MetricHistory
{
/**
* @return array{window:int, from:int, now:int, bucket:int,
* points:array<int,array{t:int,cpu:int,mem:int,disk:int,load:float}>}
*/
public function series(Server $server, int $windowSeconds): array
{
$windowSeconds = max(300, $windowSeconds);
// Bucket width is at least the 1/min sample interval, so a bucket holds ~one sample and the
// line stays continuous instead of zig-zagging between half-empty buckets.
$width = max(60, (int) floor($windowSeconds / 120));
$buckets = (int) ceil($windowSeconds / $width);
$now = time();
$from = $now - $windowSeconds;
$rows = MetricSample::query()
->where('server_id', $server->id)
->where('sampled_at', '>=', now()->subSeconds($windowSeconds))
->orderBy('sampled_at')
->get(['cpu', 'mem', 'disk', 'load', 'sampled_at']);
$sum = [];
foreach ($rows as $r) {
$idx = (int) floor(($r->sampled_at->getTimestamp() - $from) / $width);
if ($idx < 0) {
continue;
}
if ($idx >= $buckets) {
$idx = $buckets - 1;
}
$sum[$idx] ??= ['cpu' => 0, 'mem' => 0, 'disk' => 0, 'load' => 0.0, 'n' => 0];
$sum[$idx]['cpu'] += (int) $r->cpu;
$sum[$idx]['mem'] += (int) $r->mem;
$sum[$idx]['disk'] += (int) $r->disk;
$sum[$idx]['load'] += (float) $r->load;
$sum[$idx]['n']++;
}
ksort($sum);
$points = [];
foreach ($sum as $i => $b) {
$n = $b['n'];
$points[] = [
't' => (int) round($from + ($i + 0.5) * $width),
'cpu' => (int) round($b['cpu'] / $n),
'mem' => (int) round($b['mem'] / $n),
'disk' => (int) round($b['disk'] / $n),
'load' => round($b['load'] / $n, 2),
];
}
return ['window' => $windowSeconds, 'from' => $from, 'now' => $now, 'bucket' => $width, 'points' => $points];
}
}

View File

@ -1,130 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Live status of the build pipeline for the currently-cut release candidate, for the dev Release page: tag (Gitea),
* mirror (GitHub-private), CI (GitHub Actions). These are global build facts of one release candidate there is no
* "deployed" step because any server tracking the candidate pulls a release itself (no single staging target).
* Each step is cached briefly; any failure degrades to 'unknown' (never throws). The read-only GitHub
* token and the private staging slug come from config (env) and are never rendered or put in a URL.
*/
class PipelineStatus
{
private const TTL_SECONDS = 15;
/**
* @return array{tag:string, steps:array<int,array{key:string,state:string,detail:?string,url:?string}>, ciRunning:bool}|null
*/
public function forTrackedRc(): ?array
{
$version = (string) config('clusev.version');
if (! str_contains($version, '-rc')) {
return null;
}
$tag = 'v'.ltrim($version, 'vV');
$mirror = $this->mirrorStep($tag);
$ci = $this->ciStep($tag);
$steps = [
['key' => 'tag', 'state' => 'done', 'detail' => $tag, 'url' => null],
$mirror,
$ci,
];
$ciRunning = $mirror['state'] === 'pending'
|| in_array($ci['state'], ['running', 'pending'], true);
return ['tag' => $tag, 'steps' => $steps, 'ciRunning' => $ciRunning];
}
/** Forget the cached step results so the next read re-fetches (driven by the page poll). */
public function refresh(): void
{
$version = (string) config('clusev.version');
if (! str_contains($version, '-rc')) {
return;
}
$tag = 'v'.ltrim($version, 'vV');
foreach (['mirror', 'ci'] as $step) {
Cache::forget("clusev:pipeline:{$tag}:{$step}");
}
}
private function github(): PendingRequest
{
return Http::timeout(5)
->withToken((string) config('clusev.github_token'))
->acceptJson()
->withHeaders(['User-Agent' => 'Clusev-Panel']);
}
/** @return array{key:string,state:string,detail:?string,url:?string} */
private function mirrorStep(string $tag): array
{
return Cache::remember("clusev:pipeline:{$tag}:mirror", self::TTL_SECONDS, function () use ($tag) {
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
try {
$res = $this->github()->get("https://api.github.com/repos/{$slug}/git/refs/tags/{$tag}");
$state = $res->status() === 404 ? 'pending' : ($res->successful() ? 'done' : 'unknown');
return ['key' => 'mirror', 'state' => $state, 'detail' => null, 'url' => null];
} catch (\Throwable $e) {
report($e);
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
});
}
/** @return array{key:string,state:string,detail:?string,url:?string} */
private function ciStep(string $tag): array
{
return Cache::remember("clusev:pipeline:{$tag}:ci", self::TTL_SECONDS, function () use ($tag) {
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
try {
$res = $this->github()->get("https://api.github.com/repos/{$slug}/actions/runs", ['per_page' => 30, 'event' => 'push']);
if (! $res->successful()) {
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
$run = null;
foreach ((array) ($res->json('workflow_runs') ?? []) as $r) {
if (is_array($r) && ($r['head_branch'] ?? null) === $tag) {
$run = $r; // workflow_runs are newest-first
break;
}
}
if ($run === null) {
return ['key' => 'ci', 'state' => 'pending', 'detail' => null, 'url' => null];
}
$status = (string) ($run['status'] ?? '');
$conclusion = (string) ($run['conclusion'] ?? '');
$url = is_string($run['html_url'] ?? null) ? $run['html_url'] : null;
$state = match (true) {
in_array($status, ['queued', 'in_progress', 'waiting', 'requested', 'pending'], true) => 'running',
$status === 'completed' => $conclusion === 'success' ? 'done' : 'failed',
default => 'unknown',
};
return ['key' => 'ci', 'state' => $state, 'detail' => null, 'url' => $url];
} catch (\Throwable $e) {
report($e);
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
});
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Services;
use App\Models\Server;
/**
* Turns a server's hardening state into a single security-posture SCORE. Reuses
* HardeningService::state() (the CIS-relevant gates: SSH root/password, fail2ban, firewall) rather
* than re-probing. `neutral` items (auto-updates = an operator preference, not a gate) and
* OS-unsupported items don't count toward the score. The remediation for a failing check is the
* existing hardening toggle on the server-details page.
*/
class PostureService
{
public function __construct(private HardeningService $hardening) {}
/**
* @return array{score:int, rating:string, passed:int, applicable:int,
* checks:array<int, array{key:string,label:string,detail:string,secure:bool,supported:bool,neutral:bool,counts:bool}>}
*/
public function score(Server $server): array
{
$items = $this->hardening->state($server); // throws if the host can't be read
$checks = [];
$passed = 0;
$applicable = 0;
foreach ($items as $it) {
// A check counts toward the score only when the OS supports it AND it's a real security
// gate (not the neutral auto-updates preference).
$counts = $it['supported'] && ! $it['neutral'];
if ($counts) {
$applicable++;
if ($it['secure']) {
$passed++;
}
}
$checks[] = [
'key' => $it['key'],
'label' => $it['label'],
'detail' => $it['detail'],
'secure' => (bool) $it['secure'],
'supported' => (bool) $it['supported'],
'neutral' => (bool) $it['neutral'],
'counts' => $counts,
];
}
$score = $applicable > 0 ? (int) round($passed / $applicable * 100) : 100;
return [
'score' => $score,
'rating' => $this->rating($score),
'passed' => $passed,
'applicable' => $applicable,
'checks' => $checks,
];
}
/** strong ≥90 · fair ≥60 · weak below — mapped to the status triad in the UI. */
private function rating(int $score): string
{
return $score >= 90 ? 'strong' : ($score >= 60 ? 'fair' : 'weak');
}
}

View File

@ -1,84 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Triggers the public-promotion / yank GitHub Actions workflows on the private staging repo via the
* GitHub API (workflow_dispatch), and reads the public repo's tags. The trigger token
* (config clusev.github_token, needs Actions:Write) ONLY dispatches the workflows do the public
* push/delete with their own PUBLIC_REPO_TOKEN. Reads of the public repo's tags are anonymous (it is
* public). Every call degrades to false / [] (never throws); the token is only sent in the header.
*/
class PromotionService
{
public function deployPublic(string $rcTag): bool
{
return $this->dispatch('promote-public.yml', ['tag' => $rcTag]);
}
public function yank(string $tag): bool
{
return $this->dispatch('yank.yml', ['tag' => $tag]);
}
/**
* The public repo's tag names, newest first (anonymous read; the repo is public). [] on failure.
*
* @return array<int,string>
*/
public function publicTags(int $limit = 10): array
{
$slug = (string) config('clusev.public_slug');
if ($slug === '') {
return [];
}
// Cached: render() (incl. the 5s page poll) calls this every render, and the anonymous GitHub
// tag API is rate-limited (60/h per IP). 120s keeps it well under the limit; a yank reflects
// once the workflow has actually deleted the tag (~minutes) anyway, so staleness is harmless.
return Cache::remember("clusev:public-tags:{$slug}:{$limit}", 120, function () use ($slug, $limit) {
try {
$res = Http::timeout(5)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
->get("https://api.github.com/repos/{$slug}/tags", ['per_page' => $limit]);
if (! $res->successful()) {
return [];
}
return array_values(array_filter(
array_map(static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '', (array) $res->json()),
static fn (string $n): bool => $n !== '',
));
} catch (\Throwable $e) {
report($e);
return [];
}
});
}
/** @param array<string,string> $inputs */
private function dispatch(string $workflowFile, array $inputs): bool
{
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return false;
}
try {
$res = Http::timeout(5)->withToken($token)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
->post("https://api.github.com/repos/{$slug}/actions/workflows/{$workflowFile}/dispatches", [
'ref' => (string) config('clusev.release_branch'),
'inputs' => $inputs,
]);
return $res->successful(); // 204 No Content on success
} catch (\Throwable $e) {
report($e);
return false;
}
}
}

View File

@ -1,64 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* The app side of the release bridge. requestStaging() writes a strictly-validated request to
* run/release-request.json (a host watcher runs it); result() reads the host-written result for an
* id this app issued. Validation here is the first gate the host re-validates every value.
* Reuses the same bind-mounted signal dir as the WireGuard/restart bridges.
*/
class ReleaseBridge
{
private function dir(): string
{
return storage_path('app/restart-signal');
}
/**
* Write a staging-release request for a bare X.Y.Z target; returns the request id, or null when
* the signal dir is not writable. Throws on a non-semver target (defence-in-depth).
*/
public function requestStaging(string $target): ?string
{
if (preg_match('/^\d+\.\d+\.\d+$/', $target) !== 1) {
throw new \InvalidArgumentException('invalid target');
}
$id = Str::random(32);
$payload = ['id' => $id, 'action' => 'stage', 'target' => $target, 'at' => time()];
@mkdir($this->dir(), 0775, true);
$ok = @file_put_contents($this->dir().'/release-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));
return $ok === false ? null : $id;
}
/**
* Read the host result for an id THIS app issued. Null until the result exists. Never throws.
*
* @return array{ok:bool, tag:?string, message:string}|null
*/
public function result(string $id): ?array
{
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
return null;
}
$file = $this->dir()."/release-result-{$id}.json";
if (! is_file($file)) {
return null;
}
$data = json_decode((string) @file_get_contents($file), true);
if (! is_array($data) || ($data['id'] ?? null) !== $id) {
return null;
}
return [
'ok' => (bool) ($data['ok'] ?? false),
'tag' => isset($data['tag']) && is_string($data['tag']) ? $data['tag'] : null,
'message' => (string) ($data['message'] ?? ''),
];
}
}

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