Compare commits
290 Commits
v0.9.18
...
feat/v1-fo
| Author | SHA1 | Date |
|---|---|---|
|
|
265136fc42 | |
|
|
182cb0fedd | |
|
|
4248c83c42 | |
|
|
b7e44ca3c2 | |
|
|
40383261bd | |
|
|
d348e77ad7 | |
|
|
da53b6f7c7 | |
|
|
5024675c94 | |
|
|
f9a08a98d2 | |
|
|
6dbe2303a4 | |
|
|
b0c46f2b18 | |
|
|
4d349eebb9 | |
|
|
b4c309bd54 | |
|
|
096ba3ca98 | |
|
|
4c67b278bd | |
|
|
fd6eba5c29 | |
|
|
3fae5293e2 | |
|
|
c253e79c0c | |
|
|
7e6c01d5fa | |
|
|
4ab255832d | |
|
|
7535ee2d63 | |
|
|
65b92bc65c | |
|
|
3cab72bf46 | |
|
|
79862ab014 | |
|
|
31852fc04c | |
|
|
5e4f29216d | |
|
|
35b7c15598 | |
|
|
0472b3531a | |
|
|
40d8dfc96d | |
|
|
aa854916ce | |
|
|
bf4366dfc9 | |
|
|
930f78efbd | |
|
|
0943d56201 | |
|
|
46924135e6 | |
|
|
6927987a0d | |
|
|
bc2f2b527f | |
|
|
c914790a46 | |
|
|
f804e25a66 | |
|
|
e6f6e7f8a4 | |
|
|
c63af35194 | |
|
|
473fa8c0d0 | |
|
|
6c70d885f7 | |
|
|
1b3e058de6 | |
|
|
72de7b9a22 | |
|
|
e602320c9d | |
|
|
e58d1a4b07 | |
|
|
8b34ed8ef5 | |
|
|
4f9699d470 | |
|
|
c8e1e07a43 | |
|
|
61b8419106 | |
|
|
b529201186 | |
|
|
25ded63d87 | |
|
|
27b35e1f72 | |
|
|
55c3975d53 | |
|
|
3c25f097fa | |
|
|
a6176341e2 | |
|
|
d7156ddc96 | |
|
|
8a814337ff | |
|
|
4af1633ca9 | |
|
|
ace3af5849 | |
|
|
a3cc563883 | |
|
|
383f2fe74a | |
|
|
10486672a5 | |
|
|
fff2d017be | |
|
|
4e4023f12d | |
|
|
176b132da9 | |
|
|
54518c1f30 | |
|
|
e02f4d036a | |
|
|
72b82df7a1 | |
|
|
6348d269a5 | |
|
|
d08a8a3866 | |
|
|
657e659d76 | |
|
|
f3c5fee839 | |
|
|
9f784ce97a | |
|
|
0fbf379ae9 | |
|
|
2a1ad6595b | |
|
|
dc26dccaa3 | |
|
|
cc4cc3db4c | |
|
|
c3788f5851 | |
|
|
fd4f6ceb0f | |
|
|
e38409bf31 | |
|
|
f3d2a9592e | |
|
|
6f43b480b1 | |
|
|
d08670270e | |
|
|
00a9d8b9a3 | |
|
|
2b1e98c5d4 | |
|
|
e1a886671b | |
|
|
0ec9f3664a | |
|
|
142b531d45 | |
|
|
83626352e2 | |
|
|
707b031bd2 | |
|
|
e3b5aa5902 | |
|
|
05042c3582 | |
|
|
07fe404ce9 | |
|
|
8dfc11fd5d | |
|
|
d8da46151e | |
|
|
1697a5d7c2 | |
|
|
fbd9c7e257 | |
|
|
b2c6de64da | |
|
|
6227505528 | |
|
|
940c4c80fa | |
|
|
d56bdadd14 | |
|
|
7ec2c065bb | |
|
|
cf70317534 | |
|
|
ab728efc6f | |
|
|
8e77f31f00 | |
|
|
05c34b81ed | |
|
|
e39a5939f2 | |
|
|
8a7d91bd00 | |
|
|
1eb4c4fef7 | |
|
|
67a69f3189 | |
|
|
ea33797236 | |
|
|
84f84cbb03 | |
|
|
7c5b4cf222 | |
|
|
d3b2ad2f23 | |
|
|
5c620bc0c7 | |
|
|
44d0072c1a | |
|
|
f26aa66cf5 | |
|
|
9b2d71e1f8 | |
|
|
f82555469a | |
|
|
770bfec97f | |
|
|
25271717c2 | |
|
|
bdee34ef8f | |
|
|
fca75bc0a3 | |
|
|
787e48d8fd | |
|
|
0ac819e4ad | |
|
|
070c6646f3 | |
|
|
06e9fcbad7 | |
|
|
665c9f93bd | |
|
|
8acc6ab6ba | |
|
|
d80e44086a | |
|
|
788d38fab5 | |
|
|
164d17e707 | |
|
|
6a284caa44 | |
|
|
25516ad323 | |
|
|
d502756b66 | |
|
|
baecb0c6d5 | |
|
|
c696950f25 | |
|
|
76c1d46d12 | |
|
|
4b68b8dc04 | |
|
|
58a94d1e9a | |
|
|
d4f973044b | |
|
|
54b41591b9 | |
|
|
05c98a7b9e | |
|
|
39ccf0d539 | |
|
|
366d237cb8 | |
|
|
628c9fdbb0 | |
|
|
51b8572d98 | |
|
|
437221788d | |
|
|
a94efb0b0d | |
|
|
b00e7a49fd | |
|
|
ccabc1cfa6 | |
|
|
a92eb8182b | |
|
|
586fac7132 | |
|
|
44313d5ac5 | |
|
|
8e9499780e | |
|
|
4aa55b7124 | |
|
|
e2dc9b17a4 | |
|
|
c4f90b0830 | |
|
|
79bd08b0d8 | |
|
|
0dd7564ab0 | |
|
|
c6e759a3b4 | |
|
|
ee111bfa9a | |
|
|
a1386b0533 | |
|
|
06b5600641 | |
|
|
30034a564b | |
|
|
70da2718fb | |
|
|
e2b4a968c9 | |
|
|
4ca2ed96d5 | |
|
|
8914f5f746 | |
|
|
359aa86aea | |
|
|
7a6d20cbed | |
|
|
91be170e78 | |
|
|
4b2aee3941 | |
|
|
c6597be59e | |
|
|
6d9dcb9529 | |
|
|
e154f7d92f | |
|
|
c89b28e1ef | |
|
|
645d7dd4a4 | |
|
|
60e1edc0d5 | |
|
|
920dfb02bf | |
|
|
dc6d2fae59 | |
|
|
9bb9f2561b | |
|
|
7794e98997 | |
|
|
20aec565ed | |
|
|
9c0d390365 | |
|
|
2b245c3d3a | |
|
|
94425f93e8 | |
|
|
a064e8eeb8 | |
|
|
8e05cf62ec | |
|
|
01778bde80 | |
|
|
6f9091d3fa | |
|
|
2d12978616 | |
|
|
858a00955a | |
|
|
7ccefa5890 | |
|
|
7ff02c4e68 | |
|
|
95a5811544 | |
|
|
e006de4962 | |
|
|
b1387bd654 | |
|
|
ee8f2bac91 | |
|
|
ed5506e558 | |
|
|
a6bb8b7da4 | |
|
|
695b33cf37 | |
|
|
f91d8ed834 | |
|
|
037d285148 | |
|
|
0c9aa12aab | |
|
|
e1fb24203d | |
|
|
49f70c0c9f | |
|
|
130e1aeaf6 | |
|
|
9e6f3775b7 | |
|
|
ae4937654d | |
|
|
eff836a1fb | |
|
|
16b854ae50 | |
|
|
96349808e2 | |
|
|
e640a96ca0 | |
|
|
9b835e9dbe | |
|
|
9aabd7fce5 | |
|
|
3d73489717 | |
|
|
c164c0aec3 | |
|
|
dfdb0ce732 | |
|
|
19fe1b8915 | |
|
|
bc82cdaff5 | |
|
|
287f962745 | |
|
|
0fa11bd0ea | |
|
|
fa7e1c428e | |
|
|
c9556bffbf | |
|
|
0342d4c6e0 | |
|
|
51c9702dcf | |
|
|
1e9834fc0d | |
|
|
8caa78b2b3 | |
|
|
dd964a2e8b | |
|
|
f154ee8aaf | |
|
|
084c21a869 | |
|
|
552417fd4b | |
|
|
05150bf89f | |
|
|
37cb449af5 | |
|
|
9bb349dd9c | |
|
|
a1b37751a0 | |
|
|
542ad1a4d8 | |
|
|
df5d9e2112 | |
|
|
d2f70900ff | |
|
|
ffc17966ef | |
|
|
001f44d959 | |
|
|
f0210c302b | |
|
|
77d50d44e5 | |
|
|
6c81ea7fd1 | |
|
|
cabad0e770 | |
|
|
7b4215d055 | |
|
|
96f86e2761 | |
|
|
01867dba09 | |
|
|
968d8c48c9 | |
|
|
2936af82bd | |
|
|
7645241a7a | |
|
|
df32275fd9 | |
|
|
49d5c0aed3 | |
|
|
ce98fbdba0 | |
|
|
1f7b06c576 | |
|
|
9756f9d463 | |
|
|
fe8c25c824 | |
|
|
d58073086c | |
|
|
4ea4050c80 | |
|
|
7c1a32518d | |
|
|
8a8232a355 | |
|
|
67ce2de538 | |
|
|
4e32f76d1b | |
|
|
e8bbe21b78 | |
|
|
0765293123 | |
|
|
1fef9e61fe | |
|
|
f581529164 | |
|
|
8ec3b0b69a | |
|
|
0f5d14a252 | |
|
|
b619b320c2 | |
|
|
5967b7b7e5 | |
|
|
8d2b7087a9 | |
|
|
437a22592b | |
|
|
580c72a59d | |
|
|
503f4d7fdf | |
|
|
83bd42f16a | |
|
|
1d9dbdfc9b | |
|
|
d1fb93ffe3 | |
|
|
d60f97413f | |
|
|
d0275db308 | |
|
|
28c28df868 | |
|
|
ca693219bf | |
|
|
60295a0aea | |
|
|
a709ef772c | |
|
|
c020af4c09 | |
|
|
5ebee9b4a5 | |
|
|
5abf984df2 | |
|
|
a61feb1bef |
|
|
@ -8,7 +8,6 @@
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.gitea
|
||||
|
||||
# Deps / build output (reinstalled or rebuilt in the image)
|
||||
vendor
|
||||
|
|
@ -24,7 +23,10 @@ storage/framework/sessions/*
|
|||
storage/framework/views/*
|
||||
database/*.sqlite
|
||||
|
||||
# Dev tooling / tests / docs (not needed at runtime)
|
||||
# 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).
|
||||
tests
|
||||
phpunit.xml
|
||||
.phpunit.cache
|
||||
|
|
@ -34,6 +36,24 @@ 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
|
||||
|
|
|
|||
33
.env.example
33
.env.example
|
|
@ -2,7 +2,7 @@ APP_NAME=Clusev
|
|||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://10.10.90.136
|
||||
APP_URL=http://localhost
|
||||
|
||||
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=10.10.90.136
|
||||
REVERB_HOST=localhost
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
# server bind (inside the container):
|
||||
|
|
@ -65,6 +65,11 @@ 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.
|
||||
|
|
@ -85,6 +90,16 @@ 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
|
||||
|
|
@ -96,6 +111,20 @@ 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,
|
||||
|
|
|
|||
|
|
@ -7,5 +7,35 @@
|
|||
*.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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
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}"
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
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"
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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}"
|
||||
|
|
@ -14,9 +14,13 @@
|
|||
.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
|
||||
|
|
@ -34,3 +38,32 @@ 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/
|
||||
|
|
|
|||
580
CHANGELOG.md
580
CHANGELOG.md
|
|
@ -7,12 +7,576 @@ 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`, optional `beta`) — niemals Entwicklungs-Builds.
|
||||
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.
|
||||
|
||||
## [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.59–0.9.68.)
|
||||
|
||||
## [0.9.58] - 2026-06-22
|
||||
|
||||
### Hinzugefügt
|
||||
- **Update-Check unterstützt jetzt GitHub.** Public-Nutzer sehen Updates aus dem öffentlichen
|
||||
GitHub-Repo (Tags + Changelog); interne Dev/Staging-Stufen bleiben privat.
|
||||
|
||||
### Geändert
|
||||
- **Öffentliche Projekt-Adresse ist jetzt `github.com/clusev/clusev`.** README, Quelltext- und
|
||||
Konsolen-Branding sowie die Standard-Update-Quelle zeigen aufs öffentliche Repo.
|
||||
|
||||
## [0.9.57] - 2026-06-22
|
||||
|
||||
### Hinzugefügt
|
||||
- **Stabiler Release-Kanal `stable`.** Nutzer beziehen ausschließlich stabile Releases (`vX.Y.Z`) —
|
||||
niemals Entwicklungs-Builds.
|
||||
|
||||
## [0.9.56] - 2026-06-22
|
||||
|
||||
### Hinzugefügt
|
||||
- **Branding im Quelltext + in der Browser-Konsole.** Wer den Seitenquelltext ansieht, findet jetzt
|
||||
einen freundlichen Clusev-Hinweis (wer wir sind, Projektlink) als HTML-Kommentar. Öffnet jemand die
|
||||
Browser-Konsole, erscheint ein gestyltes „CLUSEV"-Branding mit Projektlink — **und eine
|
||||
Self-XSS-Sicherheitswarnung**: bei einem Server-Control-Panel ist „Füg das mal in deine Konsole ein"
|
||||
ein realer Social-Engineering-Angriff, daher der Hinweis genau dort. Erscheint auf jeder Seite (auch
|
||||
vor dem Login).
|
||||
|
||||
### Behoben
|
||||
- **Datei-Manager: Dateiname im Tablet-Modus vom Rechte-Block überdeckt.** Die Tabellenspalten
|
||||
(Rechte/Größe/Geändert) + Aktions-Buttons quetschten die Namensspalte auf nahezu null. Die Liste
|
||||
rendert jetzt **unterhalb großer Bildschirme als gestapelte Karte** (Name in voller Breite, Meta-Zeile
|
||||
und Aktionen darunter); die Tabellenansicht erscheint erst auf breiten Displays. Die mobile Ansicht
|
||||
wurde dabei mit aufgeräumt.
|
||||
- **Zeiger-Cursor auf Buttons fehlte.** Tailwind v4 stellt Buttons standardmäßig auf den
|
||||
Standard-Cursor; auf anklickbaren Elementen (z. B. Ordnernamen, alle Buttons) erscheint jetzt wieder
|
||||
der Hand-Zeiger.
|
||||
|
||||
### Behoben
|
||||
- **Datei-Manager: „undefined is not valid JSON" beim Öffnen mancher Dateien.** Öffnete man eine
|
||||
**Binär-/Nicht-UTF-8-Datei**, landeten deren Rohbytes in einer Livewire-Eigenschaft. Livewire
|
||||
JSON-kodiert seinen Zustand, und ungültiges UTF-8 lässt `json_encode` scheitern → der Client bekam
|
||||
eine leere Antwort und warf den Fehler. Binär-/zu-große Dateien werden jetzt nicht mehr als Inhalt
|
||||
gebunden (es erscheint der Binär-Hinweis), sodass der Zustand immer gültiges JSON bleibt — sowohl
|
||||
im Editor als auch in der zugrunde liegenden Datei-Lese-Funktion abgesichert.
|
||||
|
||||
### Behoben
|
||||
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
|
||||
ohne stabilen `wire:key` ordnete Livewires DOM-Abgleich die Zeilen beim Neu-Rendern nach Position
|
||||
zu und verlor dabei den Alpine-Zustand der Buttons (Modal-Auslöser) — die Konsole warf „pending is
|
||||
not defined" und Aktionen klemmten. Alle betroffenen Listen haben jetzt einen stabilen `wire:key`:
|
||||
Datei-Manager (Einträge + Pfad), Dienste, Release-Kanal-Auswahl, Benutzer, Sicherheitsschlüssel,
|
||||
IP-Sperren sowie die Server-Detailseite (Härtung, Firewall-Regeln, SSH-Schlüssel, fail2ban-Ausnahmen).
|
||||
|
||||
### Behoben
|
||||
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard
|
||||
wurden noch als `wg.set-endpoint` / `deploy.update_request` angezeigt (die /audit-Seite war bereits
|
||||
lesbar). Jetzt zeigt auch das Dashboard die verständlichen Bezeichnungen, und fehlgeschlagene /
|
||||
sicherheitsrelevante Ereignisse erscheinen rot mit Warn-Symbol — konsistent mit der Audit-Seite.
|
||||
|
||||
### Behoben
|
||||
- **„Was ist neu" wurde nicht angezeigt.** Der Vorschau-Block lud die `CHANGELOG.md` am Tag `0.9.50`,
|
||||
die Release-Tags heißen aber `v0.9.50` — der Abruf lief ins Leere (404) und der Block blieb leer.
|
||||
Jetzt wird die richtige `v`-präfixierte Tag-Referenz verwendet; die Änderungen der verfügbaren
|
||||
Version erscheinen wieder vor dem Update.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Update-Hinweis in der Seitenleiste.** Ist eine neue Version verfügbar, zeigt der Menüpunkt
|
||||
**Version** jetzt ein kleines **„1"-Abzeichen** — man sieht ein Update also auf jeder Seite, nicht
|
||||
erst auf der Version-Seite. Der Status wird von einer geplanten Prüfung (alle 30 Min, anonym und
|
||||
nur lesend gegen den Git-Host) im Cache warm gehalten; die Seitenleiste macht selbst keinen Netzabruf.
|
||||
|
||||
### Geändert
|
||||
- Die Release-Prüfung (Version-Seite, Seitenleisten-Abzeichen, geplante Prüfung) nutzt jetzt einen
|
||||
gemeinsamen `ReleaseChecker`-Dienst (vorher in der Version-Seite verkapselt).
|
||||
|
||||
## [0.9.50] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **Audit-Log mit Seiten-Navigation.** Das Log war auf die letzten 50 Einträge begrenzt; jetzt ist es
|
||||
paginiert (25 pro Seite), die aktuelle Seite steht in der URL (`?page=`) und ist verlinkbar.
|
||||
|
||||
### Geändert
|
||||
- **Audit-Suche durchsucht den gesamten Verlauf.** Die Filterung läuft jetzt in der Datenbank über
|
||||
alle Einträge (nicht nur die geladene Seite) — über Akteur, Aktion, Ziel, Server **und** die
|
||||
lesbaren Bezeichnungen (z. B. findet „Endpoint geändert" die `wg.set-endpoint`-Einträge).
|
||||
|
||||
### Hinzugefügt
|
||||
- **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite
|
||||
jetzt ein „Das ist neu in vX.Y.Z"-Feld mit dem Änderungsprotokoll der **verfügbaren** Version(en) —
|
||||
geladen aus der `CHANGELOG.md` des neuesten Release-Tags. So sieht man **vor** dem Aktualisieren,
|
||||
was sich ändert, und muss das Update nicht erst durchführen. (Anonym + nur lesend, kurz gecacht;
|
||||
bei fehlender Verbindung bleibt der Update-Knopf unverändert nutzbar.)
|
||||
|
||||
### Geändert
|
||||
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
|
||||
das Audit-Log jetzt verständliche Beschreibungen („Administrator · WireGuard-Endpoint geändert").
|
||||
Alle Aktionen sind übersetzt (DE/EN); unbekannte/dynamische Codes werden sauber aufbereitet. Die
|
||||
Suche findet auch die lesbaren Bezeichnungen.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Fehler im Audit-Log sichtbar.** Fehlgeschlagene oder sicherheitsrelevante Ereignisse (fehlgeschlagene
|
||||
Anmeldung, IP-Sperre, fehlgeschlagene 2FA …) werden jetzt **rot mit Warn-Symbol** hervorgehoben.
|
||||
Zusätzlich landen **fehlgeschlagene WireGuard-Aktionen** mit der Host-Fehlermeldung im Audit-Log
|
||||
(statt nur als kurzer Hinweis), sodass man später nachlesen kann, was schiefging.
|
||||
|
||||
### Geändert
|
||||
- **Endpoint-Feld ist jetzt „nur Host".** Es wirkte, als müsse man den Port zweimal eintragen (einmal
|
||||
als Listen-Port, einmal im Endpoint). Tatsächlich genügt der Host: Label heißt jetzt „Öffentlicher
|
||||
Endpoint (Host)", der Platzhalter zeigt nur den Host (ohne `:Port`), und der Hinweis erklärt, dass
|
||||
der Port automatisch vom Listen-Port kommt. Ein abweichender Port ist nur bei Portweiterleitung
|
||||
nötig (`host:port`). Funktion war seit 0.9.43 schon so — jetzt ist es auch in der Oberfläche klar.
|
||||
|
||||
### Behoben
|
||||
- **Traffic-Kopfzeile zeigte 0 B, obwohl der Peer Traffic hatte.** „Empfangen/Gesendet" oben speiste
|
||||
sich aus dem Sample-Durchsatz (0, solange wenig Traffic floss), während die Peer-Zeile den
|
||||
Live-Zähler zeigte — inkonsistent. Die Kopfzeile zeigt jetzt die **Live-Summe der Peer-Zähler**
|
||||
(passt damit immer zur Peer-Zeile). Das Diagramm darunter bleibt der zeitfenster-bezogene Durchsatz
|
||||
(füllt sich über den minütlichen Sampler, sobald spürbarer Traffic fließt).
|
||||
|
||||
### Geändert
|
||||
- **Klarstellung QR-Import am Handy.** Der Hinweis sagt jetzt eindeutig: die WireGuard-App fragt beim
|
||||
QR-Scan **immer** nach einem Tunnel-Namen (App-Vorgabe, nicht abschaltbar). Für einen automatischen
|
||||
Namen die `.conf` **herunterladen** und importieren (Dateiname = Tunnel-Name).
|
||||
|
||||
### Hinzugefügt
|
||||
- **DNS individuell einstellbar.** Der DNS-Server in den Client-Configs war fest auf `1.1.1.1`. Jetzt
|
||||
in der Server-Konfiguration frei wählbar (z. B. dein eigenes Gateway `10.0.0.1`, mehrere
|
||||
kommagetrennt) — gilt für künftige Peer-Configs, host-seitig validiert (nur IPv4). Auch per CLI:
|
||||
`clusev wg set-dns <ip[,ip]>`. Bestehende Tunnel bekommen den Wert beim ersten Setzen ergänzt.
|
||||
- **WireGuard-Seite in Tabs aufgeteilt.** Zwei unabhängige Reiter: **Peers** (Liste, Anlegen,
|
||||
Entfernen, Traffic) und **Server-Konfiguration** (Endpoint, DNS, Port, Subnetz, Gate, SSH-Sperre +
|
||||
Server-Identität). Der aktive Tab ist deep-linkbar (`?tab=server`). So lassen sich Server-Einstellungen
|
||||
getrennt von der Peer-Verwaltung ändern.
|
||||
|
||||
### Geändert
|
||||
- **Offline-Erkennung schneller.** Das Handshake-Fenster für „online" wurde von 180 s auf 150 s
|
||||
gesenkt — ein abgeschalteter Peer wird ~2,5 statt ~3 min nach dem Trennen offline (WireGuard ist
|
||||
verbindungslos, schneller geht es ohne Flackern aktiver Peers nicht).
|
||||
- **Endpoint-Hinweis klargestellt:** der Port ist optional (fehlt er, wird der Listen-Port ergänzt) —
|
||||
man muss ihn nicht doppelt zum Listen-Port eintragen.
|
||||
|
||||
### Geändert
|
||||
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
|
||||
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
|
||||
damit das Scannen am Handy zuverlässig bleibt.
|
||||
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
|
||||
`<endpoint-host>-<peer>.conf` (z. B. `203.0.113.10-laptop.conf`) statt `clusev-wireguard.conf`.
|
||||
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
|
||||
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
|
||||
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
|
||||
|
||||
### Behoben
|
||||
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein
|
||||
Host ohne Port eingegeben (z. B. `203.0.113.10` statt `203.0.113.10:51820`), landete dieser
|
||||
Wert unverändert in den Peer-Configs — WireGuard verlangt aber zwingend `host:port`, sodass die
|
||||
WireGuard-App den (QR- oder Datei-)Import als „keine gültige WireGuard-Konfiguration" ablehnte.
|
||||
Der Listen-Port wird jetzt automatisch ergänzt, wenn der Endpoint keinen Port enthält (sowohl bei
|
||||
der Einrichtung als auch beim späteren Ändern). Bestehende Konfigurationen: einmal den Endpoint
|
||||
unter Einstellungen neu speichern (oder mit Port angeben), dann einen neuen Peer anlegen.
|
||||
|
||||
### Behoben
|
||||
- **Peer-QR-Code ließ sich am Handy nicht scannen („keine gültige WireGuard-Konfiguration").** Der
|
||||
QR einer WireGuard-Client-Config ist dicht (~57 Module); er wurde im Einmal-Dialog mit nur 176 px
|
||||
angezeigt (~2,7 px pro Modul) und kantengeglättet — zu klein/unscharf, sodass die Handy-Kamera ihn
|
||||
fehlerhaft las. Der QR wird jetzt deutlich größer (bis ~300 px) auf weißem Rahmen mit Ruhezone und
|
||||
pixelgenau (`shape-rendering: crispEdges`) gerendert. Alternativ bleibt der **Herunterladen**-Knopf
|
||||
(Config als `.conf` am PC importieren).
|
||||
|
||||
### Behoben
|
||||
- **WireGuard-Aktionen aus dem Dashboard liefen auf dem Host ins Leere („Keine Antwort vom Host").**
|
||||
Der Host-Dienst, der die Dashboard-Anfragen verarbeitet (`clusev-wg.sh serve-request`), brach unter
|
||||
`set -euo pipefail` ab, **bevor** er eine Antwort schrieb — sobald ein Feld leer oder gar nicht
|
||||
vorhanden war: ein leeres Endpoint-Feld bei der Einrichtung („auto"), oder Aktionen ohne
|
||||
bestimmte Felder (Gate ein/aus, Peer anlegen/entfernen). Dadurch wartete die Oberfläche ~30 s und
|
||||
meldete „Keine Antwort vom Host", obwohl der Watcher korrekt lief. Zwei Ursachen behoben:
|
||||
(1) die Feld-Extraktion per `grep` bricht bei fehlendem Treffer nicht mehr den ganzen Handler ab;
|
||||
(2) jede Aktion läuft jetzt in einer Subshell, sodass ein interner Abbruch (`die` bei noch nicht
|
||||
eingerichtetem Tunnel) immer eine saubere Fehlerantwort liefert statt eines Timeouts. Damit
|
||||
funktionieren Einrichtung, Peer-Verwaltung, Gate und SSH-Sperre aus dem Dashboard auf einem echten
|
||||
Host. Neuer Smoke-Test (`docker/wg/serve-request.test.sh`) sichert das ab.
|
||||
|
||||
## [0.9.40] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **Peer-Konfiguration herunterladen.** Die Einmal-Anzeige eines neuen Clients hat neben dem
|
||||
QR-Code jetzt einen **Herunterladen**-Knopf: Die Konfiguration lässt sich als
|
||||
`clusev-wireguard.conf` speichern und am PC in die WireGuard-App importieren — alternativ zum
|
||||
Scannen des QR-Codes am Handy. Ein Hinweis erklärt beide Wege direkt in der Anzeige.
|
||||
- **SSH-Sperre aus dem Dashboard (Port 22).** In den WireGuard-Einstellungen lässt sich SSH
|
||||
optional **zusätzlich** auf den Tunnel sperren — danach ist Port 22 nur noch über WireGuard bzw.
|
||||
die Server-Konsole erreichbar. Die Aktion ist mit einer deutlichen **Aussperr-Warnung** versehen
|
||||
(Inline-Hinweis **und** Bestätigungsdialog): Wer seinen WG-Zugang verliert, kommt per SSH nicht
|
||||
mehr auf den Host — Empfehlung, vorher einen zweiten WG-Peer als Backup-Zugang anzulegen. Auf das
|
||||
Dashboard kommt man auch ohne SSH weiter über den Tunnel. Bestehende SSH-Sitzungen bleiben
|
||||
verbunden; ein nicht laufender Tunnel verhindert das Aktivieren. Neue CLI-Notausgänge:
|
||||
`clusev wg ssh-lock` / `clusev wg ssh-unlock`, und `clusev wg down` (Server-Konsole) hebt alle
|
||||
Sperren auf. Die Sperre übersteht Neustarts nur bei aktivem Tunnel — kommt wg0 nicht hoch, bleibt
|
||||
SSH offen (kein Aussperren durch einen kaputten Boot).
|
||||
|
||||
## [0.9.39] - 2026-06-21
|
||||
|
||||
### Geändert
|
||||
- **WireGuard-Einrichtung: SSH-Hinweis von der Seite entfernt.** Das `sudo clusev wg setup` steht
|
||||
jetzt nur noch in der Hilfe — die Einrichtung läuft über das Dashboard-Formular.
|
||||
|
||||
### Behoben
|
||||
- **Kein ewiges „Wird angewendet …" mehr.** Antwortet der Host-Dienst nicht (z. B. weil der Stack
|
||||
noch nicht auf eine Version mit dem Host-Watcher aktualisiert wurde), bricht die Dashboard-Aktion
|
||||
nach ~30 s mit einer klaren Meldung ab, statt unbegrenzt zu warten. (Hinweis: Peer-Verwaltung +
|
||||
Einrichtung aus dem Dashboard benötigen den per `sudo clusev update` installierten Host-Watcher —
|
||||
auf einem reinen Dev-Stack ohne diesen läuft die Brücke nicht.)
|
||||
|
||||
## [0.9.38] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Erst-Einrichtung aus dem Dashboard.** Der „nicht eingerichtet"-Zustand auf
|
||||
`/wireguard` ist jetzt ein Einrichtungs-Formular (Subnetz / Port / Endpoint / erster Client):
|
||||
WireGuard lässt sich **vollständig aus der UI** aufsetzen, ohne SSH. Der Host prüft das Subnetz
|
||||
host-seitig auf Kollisionen, erzeugt die Schlüssel, startet den Tunnel und gibt die Konfiguration
|
||||
des ersten Clients als Einmal-Anzeige (Text + QR) zurück. Das Gate bleibt dabei **aus** (kein
|
||||
Aussperren). Damit ist WireGuard zu 100 % aus dem Dashboard steuerbar — Live-Status, Traffic,
|
||||
Peers, Einstellungen, Gate **und** Erst-Einrichtung; `sudo clusev wg setup` bleibt als Alternative.
|
||||
|
||||
### Behoben
|
||||
- Korrigiert, dass die Erst-Einrichtung in 0.9.37 fälschlich nur über die CLI möglich war.
|
||||
|
||||
## [0.9.37] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Server-Einstellungen + Gate aus dem Dashboard (SP2, Phase 4 — SP2 abgeschlossen).** Auf
|
||||
`/wireguard` lassen sich jetzt das **Gate ein-/ausschalten** (mit Bestätigung — aus = Panel wieder
|
||||
öffentlich, ein = nur über den Tunnel; SSH-Notausgang bleibt) sowie der **öffentliche Endpoint**,
|
||||
der **Listen-Port** und das **Subnetz** ändern. Port- und Subnetz-Änderungen sind destruktiv
|
||||
(Tunnel-Neustart bzw. Re-Adressierung — Clients/Peers müssen neu) und erfordern eine harte
|
||||
Bestätigung; das Subnetz wird host-seitig auf Kollisionen geprüft. Alle Mutationen laufen über die
|
||||
abgesicherte Schreib-Brücke (Whitelist + erneute host-seitige Validierung jedes Werts), sind
|
||||
auditiert und ratenbegrenzt. Damit ist WireGuard vollständig aus dem Dashboard verwaltbar:
|
||||
Live-Status, Traffic-Verlauf, Peer-Verwaltung und Server-Einstellungen.
|
||||
|
||||
## [0.9.36] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Peer-Verwaltung aus dem Dashboard (SP2, Phase 3).** Auf `/wireguard` lassen sich Clients
|
||||
jetzt **anlegen** (Name eingeben → die Client-Konfiguration + QR-Code erscheinen **einmalig** in
|
||||
einem Dialog und werden nie gespeichert) und **entfernen** (mit Bestätigung). Umgesetzt über eine
|
||||
neue, abgesicherte **Schreib-Brücke**: die App schreibt eine validierte Anfrage nach
|
||||
`run/wg-request.json`, ein Host-Watcher (`clusev-wg.sh serve-request`) prüft Aktion + Argumente
|
||||
**erneut host-seitig** (Whitelist, strikte Muster — Container-Eingaben sind Daten, nie Code),
|
||||
führt den festen Befehl als root aus und schreibt ein Ergebnis (0600, app-eigen, nach 60 s gelöscht).
|
||||
Jede Aktion ist auditiert + pro Benutzer ratenbegrenzt.
|
||||
|
||||
### Sicherheit
|
||||
- Ergebnis-/QR-Dateien (enthalten einen frischen privaten Schlüssel) werden via `umask 077` von Beginn
|
||||
an mit `0600` erzeugt — kein kurzes world-readable-Fenster (durch ein adversariales Security-Review
|
||||
gefunden + behoben).
|
||||
|
||||
## [0.9.35] - 2026-06-21
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Traffic-Verlauf (SP2, Phase 2).** Die `/wireguard`-Seite zeigt jetzt einen
|
||||
Durchsatz-Graphen (Empfangen/Gesendet) über ein wählbares Zeitfenster (1 Std / 24 Std / 7 Tage).
|
||||
Ein periodischer Befehl (`clusev:wg-sample`, minütlich) speichert pro Peer Traffic-Stichproben in
|
||||
der Datenbank; der Graph aggregiert daraus den Durchsatz (Deltas, Zähler-Resets sauber abgefangen)
|
||||
und hält 7 Tage Historie vor. Server-seitig gerendert (SVG, kein JS-Chart) — passt zum bestehenden
|
||||
Dashboard-Chart-Stil.
|
||||
- **Scheduler-Dienst.** Der Prod-Stack führt jetzt den Laravel-Scheduler aus (`schedule:work`) —
|
||||
Voraussetzung für die Traffic-Stichproben und der Auslöser für die bereits vorhandene tägliche
|
||||
Audit-Log-Bereinigung (`clusev:prune-audit`), die zuvor mangels Scheduler nie lief.
|
||||
|
||||
## [0.9.34] - 2026-06-20
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Dashboard — Live-Status (SP2, Phase 1).** Neue Seite `/wireguard` (Reiter „WireGuard"
|
||||
in der Flotte) zeigt den Tunnel live: verbundene Peers (Name, online/offline, letzter Handshake,
|
||||
Endpoint, ↓/↑-Traffic), Gate-Zustand (an/aus + SSH-Notausgang-Hinweis) und Server-Info (Subnetz,
|
||||
Server-IP, Port, Endpoint, Dienst). Gespeist von einem periodischen Host-Collector
|
||||
(`clusev wg collect` via systemd-Timer → `run/wg-status.json`), alle 5 s aktualisiert; ein veralteter
|
||||
Status (Collector offline) wird als solcher angezeigt. Read-only — Peer-Verwaltung + Einstellungen
|
||||
aus der UI folgen in den nächsten Phasen. Erscheint, sobald WireGuard auf dem Host via
|
||||
`sudo clusev wg setup` eingerichtet ist.
|
||||
|
||||
## [0.9.33] - 2026-06-20
|
||||
|
||||
### Hinzugefügt
|
||||
- **WireGuard-Zugang (SP1) — Panel hinter einem Tunnel.** Optionaler Netzwerk-Schutz *zusätzlich* zu
|
||||
2FA und Anmeldeschutz: Der Server wird zum WireGuard-Server, und die Panel-Ports 80/443 lassen sich
|
||||
auf das WG-Subnetz beschränken — komplett über die Host-CLI
|
||||
`clusev wg setup|up|down|status|add-peer|remove-peer` (per SSH). Standardmäßig **aus**. Sicherheit:
|
||||
SSH (22) und der WG-Port sind von der Sperre nie betroffen, `clusev wg down` (per SSH) ist der
|
||||
Notausgang, und ein nach Neustart fehlgeschlagenes `wg0` lässt das Panel offen statt auszusperren.
|
||||
Die Sperre ist eine isolierte iptables-Chain (`CLUSEV-WG-GATE`) an `DOCKER-USER`; eine systemd-Unit
|
||||
wendet sie nach Boot/Docker-Neustart wieder an — nur wenn Tunnel **und** Marker aktiv sind.
|
||||
`install.sh` installiert `wireguard-tools`+`qrencode` (inert, bis der Operator `clusev wg setup`
|
||||
ausführt). Neuer Hilfe-Eintrag „WireGuard-Zugang" (DE+EN).
|
||||
*Rein host-seitig (WireGuard/Firewall laufen außerhalb des Containers). Die Shell-Skripte sind
|
||||
shellcheck-sauber und mehrfach reviewt, aber nicht durch die PHPUnit-Suite testbar — die Laufzeit
|
||||
(setup/up/down/Gate) ist per Runbook auf einer VM zu verifizieren (`docs/superpowers/runbooks/`).*
|
||||
|
||||
## [0.9.32] - 2026-06-20
|
||||
|
||||
### Hinzugefügt
|
||||
- **Serie & Seite als URL-Parameter.** Die gewählte Versionsreihe und die Seite des Änderungs
|
||||
protokolls stehen jetzt in der Adresse (`?series=0.8&page=2`) — teilbar und per Reload/Zurück-Taste
|
||||
erhalten.
|
||||
- **Echter Update-Fortschritt statt Zeit-Schätzung.** Der Host-Updater meldet seine aktuelle Phase
|
||||
über eine Datei (`run/update-phase.json`); die Update-Seite liest sie und schaltet die Schritte
|
||||
**abrufen → Image bauen → Dienste neu starten → migrieren** der Reihe nach echt weiter, statt alle
|
||||
vier am Ende auf einmal grün springen zu lassen. Abschluss erfolgt über das reale „done"-Signal des
|
||||
Updaters (Fallback: Versions-Wechsel). Greift — wie der 502-Fix — erst beim Update, das von einer
|
||||
bereits mit 0.9.32 laufenden Instanz gestartet wird (die während eines Updates angezeigte Seite ist
|
||||
stets noch die der alten Version); ohne Feed bleibt die bisherige Zeit-Heuristik als Fallback.
|
||||
|
||||
### Geändert
|
||||
- Update-Phasen in realer Reihenfolge (Dienste-Neustart vor Datenbank-Migration).
|
||||
|
||||
## [0.9.31] - 2026-06-20
|
||||
|
||||
### Geändert
|
||||
- **Versions-Verlauf als Serien-Browser statt langer Liste.** Releases sind jetzt nach
|
||||
Hauptversion (major.minor, z. B. „0.9") gruppiert: eine Serien-Navigation (ab großen Bildschirmen
|
||||
eine seitliche Leiste, darunter horizontale Pills) wählt die Reihe, rechts erscheinen deren
|
||||
Releases als Accordion (das neueste offen) mit **Seitenblätterung** — kein endloses Scrollen über
|
||||
alle Versionen mehr. Die installierte Reihe ist markiert, jede Zeile zeigt die Änderungs-Kategorien
|
||||
als farbige Punkte auf einen Blick.
|
||||
|
||||
### Behoben
|
||||
- **Aufklapp-Pfeil & „Weiter"-Knopf sichtbar.** Das `chevron-right`-Icon fehlte im Icon-Satz, sodass
|
||||
der Accordion-Pfeil jeder Zeile und der „Weiter"-Knopf der Seitenblätterung als leeres SVG
|
||||
gerendert wurden (unsichtbare Bedienelemente). Icon ergänzt.
|
||||
- **Aufgeklappter Eintrag bleibt offen.** Ein selbst aufgeklapptes Release wird nicht mehr durch
|
||||
einen Hintergrund-Re-Render (Update-Prüfung) wieder zugeklappt (`wire:ignore.self`).
|
||||
- **Touch-Zielgrößen.** Serien-Pills und Seiten-Knöpfe erfüllen auf kleinen Bildschirmen jetzt die
|
||||
44-px-Touch-Mindestgröße.
|
||||
|
||||
## [0.9.30] - 2026-06-20
|
||||
|
||||
### Behoben
|
||||
- **Update löst keinen 502 mehr aus.** Die Fortschritts-Seite erkannte „fertig" bislang an zwei
|
||||
aufeinanderfolgenden `200` auf der Health-Route `/up` — doch der **alte** Container antwortet dort
|
||||
während des minutenlangen Neuaufbaus weiter mit `200`, sodass zu früh zurückgeleitet wurde,
|
||||
mitten in den Stack-Neustart hinein → 502/Weißbild. Die Seite pollt jetzt einen schlanken
|
||||
Versions-Endpoint (`/version.json`) und gilt erst dann als fertig, wenn die **laufende Version
|
||||
tatsächlich über die Ausgangsversion hinaus** gewechselt ist (bzw. nach einem beobachteten
|
||||
down→up-Zyklus bei gleicher Version). Erst dann erfolgt der automatische Rücksprung.
|
||||
|
||||
## [0.9.29] - 2026-06-20
|
||||
|
||||
### Geändert
|
||||
- **Versions-Verlauf als Accordion.** Das Änderungsprotokoll zeigt pro Eintrag nur noch
|
||||
Versionsnummer + Datum; ältere Versionen sind eingeklappt und öffnen per Klick, die neueste ist
|
||||
immer offen — bei vielen Releases deutlich übersichtlicher.
|
||||
- **„Update verfügbar"-Karte responsive entstaucht** — auf schmalen Bildschirmen stapeln Version,
|
||||
Badges, Aktion (Button volle Breite) und Prüfzeitpunkt sauber untereinander statt gequetscht.
|
||||
- **Professionellere Phasen-Beschriftung** auf der Update-Fortschritts-Seite („Update wird
|
||||
abgerufen / Image wird erstellt / Datenbank wird migriert / Dienste werden neu gestartet").
|
||||
|
||||
### Hinzugefügt
|
||||
- **Direkter Server-Link im Dashboard** — Name und IP der aktiven Maschine verlinken jetzt direkt
|
||||
auf deren Detailseite (vorher: erst „Server", dann auswählen).
|
||||
|
||||
## [0.9.28] - 2026-06-20
|
||||
|
||||
### Behoben
|
||||
- **Update bricht nicht mehr mit 502 / Weißbild ab.** Beim Auslösen eines Updates erscheint jetzt
|
||||
eine eigenständige Fortschritts-Seite (`/update-progress`, ohne Livewire), die den Neuaufbau des
|
||||
Stacks im Browser übersteht: sie pollt die Health-Route, zeigt die Phasen
|
||||
(Holen/Bauen/Migrieren/Neustart) + verstrichene Zeit und leitet nach der Wiederherstellung
|
||||
automatisch dorthin zurück, wo man war. (Open-Redirect-sicher: das Rücksprung-Ziel wird auf einen
|
||||
gleich-origin Relativpfad beschränkt.)
|
||||
- **Checkbox im Reiter „Anmeldeschutz"** nutzt jetzt das Design-System (Signal-Orange + sichtbarer
|
||||
Haken über eine wiederverwendbare `x-checkbox`-Komponente; ebenso „Angemeldet bleiben" im Login)
|
||||
statt der blauen Browser-Standard-Checkbox.
|
||||
- **2FA-Status-Badge vereinheitlicht.** Identity-Header, Benutzer-Liste und Sidebar zeigen den
|
||||
„2FA aktiv/aus"-Status jetzt im selben Stil (eine `x-two-factor-badge`-Komponente) statt zuvor
|
||||
drei verschiedener Darstellungen.
|
||||
|
||||
### Hinweis
|
||||
- **Security-Key (WebAuthn/YubiKey) erfordert eine Domain mit HTTPS.** Bei Bare-IP-Zugriff ohne
|
||||
konfigurierte Domain ist ein Security-Key nicht nutzbar (eine IP kann keine WebAuthn-rpId sein);
|
||||
ein Schlüssel-only-Konto wird dann korrekt auf den Backup-Code geleitet. Für den
|
||||
Security-Key-Button das Panel unter einer Domain über HTTPS betreiben.
|
||||
|
||||
## [0.9.27] - 2026-06-20
|
||||
|
||||
### Hinzugefügt
|
||||
- **Anmeldeschutz — Brute-Force-Sperre für das Panel-Login selbst.** Nach zu vielen fehlgeschlagenen
|
||||
Anmelde- oder 2FA-Versuchen wird die Quell-IP zeitlich gesperrt (Standard: 10 Versuche in 10 Min →
|
||||
60 Min Sperre). Durchgesetzt auf HTTP-Ebene für **nicht angemeldete** Anfragen — eingeloggte
|
||||
Operatoren werden nie geblockt und können ihre eigene IP weiterhin im Panel entsperren. (Eigenständig
|
||||
von der Flotten-fail2ban-Verwaltung, die entfernte Server härtet.)
|
||||
- **Neuer Einstellungs-Reiter „Anmeldeschutz"** (Einstellungen → Anmeldeschutz): An/Aus, max. Versuche,
|
||||
Zeitfenster, Bann-Dauer, Whitelist (IP/CIDR), Liste aktiver Sperren mit Entsperren / Alle entsperren,
|
||||
plus „Meine IP zur Whitelist hinzufügen".
|
||||
- **Host-Befehl `clusev unban <ip>` (`--all`)** als Notfall-Ausweg, falls man sich aussperrt — analog
|
||||
zu `clusev reset-admin`.
|
||||
- Fehlgeschlagene Logins und 2FA-Versuche sowie Sperren/Entsperrungen landen jetzt im **Audit-Log**
|
||||
(`auth.login_failed`, `auth.2fa_failed`, `auth.ip_banned`, `auth.ip_unbanned`).
|
||||
|
||||
### Sicherheit
|
||||
- Loopback und private Netze sind standardmäßig ausgenommen (gegen Selbst-Aussperrung). Der
|
||||
IP-Vergleich erfolgt kanonisch (inet_pton; IPv4-mapped-IPv6 wird auf die IPv4-Form zusammengefasst),
|
||||
damit eine Sperre nicht über zwei Schreibweisen derselben Adresse umgangen werden kann. Sperren sind
|
||||
immer zeitlich begrenzt — es gibt keinen permanenten Lockout.
|
||||
|
||||
## [0.9.26] - 2026-06-20
|
||||
|
||||
### Hinzugefügt
|
||||
- **Eigene Backup-Code-Seite im Zwei-Faktor-Login** (`/two-factor-challenge/backup`). Über den Button
|
||||
„Backup-Code verwenden" auf dem Bestätigungs-Screen erreichbar; trennt den seltenen
|
||||
Wiederherstellungs-Fall sauber vom Alltags-Login.
|
||||
|
||||
### Geändert
|
||||
- **Der 2FA-Bestätigungs-Screen zeigt nur noch die primäre Methode** (Authenticator-Code *oder*
|
||||
Security-Key) — der Backup-Code ist nicht mehr inline neben der Eingabe, sondern auf der eigenen Seite.
|
||||
- **Security-Key-only ohne Secure-Context** (bare-IP/HTTP, wo WebAuthn nicht läuft): der Login leitet
|
||||
direkt zur Backup-Code-Seite weiter, statt einen Screen ohne nutzbare Methode zu zeigen.
|
||||
- Gemeinsame Challenge-Logik (Pending-User, Brute-Force-Zähler, Login-Abschluss) in einen Trait
|
||||
ausgelagert; beide Ansichten teilen denselben Topf — das Backup-Code-Rate-Limit (5/60 s pro
|
||||
Nutzer+IP, 20/900 s pro Nutzer) gilt unverändert auch auf der neuen Seite.
|
||||
|
||||
## [0.9.25] - 2026-06-19
|
||||
|
||||
### Hinzugefügt
|
||||
- **Host-Befehl `clusev`** (vom Installer nach `/usr/local/bin` gelegt): kurze Wrapper für
|
||||
`update`, `reset-admin`, `restart`, `logs`, `ps`, `migrate`, `artisan`, `version`. Statt langer
|
||||
`docker compose`-Aufrufe mit der Prod-Compose-Datei genügt z. B. `sudo clusev update` oder
|
||||
`clusev reset-admin`. `clusev help` zeigt die Übersicht.
|
||||
- **Hilfe-Thema „Befehle / CLI" (DE/EN):** dokumentiert jeden `clusev`-Befehl ausführlich und zeigt,
|
||||
was im Hintergrund läuft.
|
||||
|
||||
### Geändert
|
||||
- **Hilfe-Tabs stehen in der URL** (`#[Url]` am Thema) — Reload/Lesezeichen/Teilen behalten das aktive
|
||||
Tab, wie bei den Einstellungen.
|
||||
- Alle Operator-Flächen (Update-Hinweis unter „Version & Releases", Hilfe-Wiederherstellung/-Updates/
|
||||
-Domain-TLS, Reset-Hinweise in Einstellungen/System, Sentinel-Fehlermeldungen, MOTD) zeigen jetzt die
|
||||
kurzen `clusev …`-Befehle; der lange Compose-Dateiname taucht in der Oberfläche nicht mehr auf.
|
||||
|
||||
## [0.9.24] - 2026-06-19
|
||||
|
||||
### Hinzugefügt
|
||||
- **In-Panel-Hilfe-Seite (`/help`, Nav „Hilfe", `g h`).** Linke Themen-Navigation wie die
|
||||
Einstellungen, zweisprachig (DE/EN, folgt dem Umschalter). Themen: Überblick, Domain/TLS &
|
||||
Reverse-Proxy, Sicherheit & 2FA, Updates & Versionen, Server & SSH, Sitzungen & Benutzer, E-Mail,
|
||||
Audit-Log, Konto-Wiederherstellung. Enthält u. a. eine **generische Schritt-für-Schritt-Anleitung
|
||||
für den Betrieb hinter einem externen Reverse-Proxy** (was im Proxy eintragen: HTTP-Ziel,
|
||||
`X-Forwarded-Proto: https`, WebSocket, `TRUSTED_PROXY_CIDR`) und den **2FA-Zugangspfad-Hinweis**
|
||||
(Security-Key nur über die HTTPS-Domain; über Bare-IP/HTTP Backup-Code nötig; TOTP überall).
|
||||
|
||||
### Geändert
|
||||
- TLS-Modus-Hinweis nennt keinen Produktnamen mehr — „ein vorgelagerter Proxy Manager terminiert TLS".
|
||||
|
||||
## [0.9.23] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
- **Konsolen-Warnung „resource was preloaded but not used".** Vite legte zu jeder Seite einen
|
||||
`<link rel="preload">` für CSS/JS zusätzlich zum normalen Stylesheet/Script an. Bei nur einem
|
||||
CSS- und einem JS-Bundle bringt der Preload nichts (die Tags stehen ohnehin direkt daneben), löste
|
||||
aber Chromes „preloaded but not used"-Warnung aus — verstärkt durch DevTools „Cache deaktivieren",
|
||||
das die CSS doppelt lädt. Die Preload-Hints werden jetzt weggelassen (`Vite::usePreloadTagAttributes(false)`);
|
||||
Stylesheet und Script laden unverändert.
|
||||
|
||||
## [0.9.22] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
- **Passwortmanager (Bitwarden) kaperte die Security-Key-Registrierung.** Trotz `cross-platform`
|
||||
fing Bitwardens Browser-Erweiterung `navigator.credentials.create` ab und bot „Passkey speichern"
|
||||
an. Die Optionen enthalten jetzt zusätzlich den **WebAuthn-L3-Hint `hints: ['security-key']`** (bei
|
||||
Registrierung und Login), den moderne Browser/Manager respektieren und daher beiseite treten — der
|
||||
YubiKey wird direkt abgefragt. (Ältere Bitwarden-Versionen, die den Hint nicht kennen, kapern
|
||||
evtl. weiter — dann in Bitwarden „Nach Passkeys fragen" für die Seite/global deaktivieren.)
|
||||
|
||||
## [0.9.21] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
- **Security-Key-Registrierung zielte nicht auf einen Hardware-Schlüssel** → der Browser bot das
|
||||
Passkey-Speichern (z. B. Bitwarden) an statt den YubiKey abzufragen. Die Registrierung setzt jetzt
|
||||
`authenticatorSelection`: **cross-platform** (externer/roaming Schlüssel statt Plattform-/
|
||||
Passwortmanager-Passkey), **residentKey discouraged** (nicht-auffindbarer Zweitfaktor — genau das,
|
||||
was die Passkey-Manager-UI vermeidet) und **userVerification discouraged** (nur Anwesenheit = ein
|
||||
Tippen, kein PIN/Biometrie). Login fragt den Schlüssel ebenfalls mit „discouraged" ab → **einstecken,
|
||||
einmal tippen, fertig**.
|
||||
|
||||
## [0.9.20] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
- **Security-Keys (WebAuthn/YubiKey) waren hinter einem externen TLS-Proxy gesperrt.** Die
|
||||
Verfügbarkeit hing an `request()->isSecure()` — das im Externer-Proxy-Modus `false` ist (der
|
||||
weitergereichte HTTPS-Scheme wird ohne `TRUSTED_PROXY_CIDR` nicht vertraut), obwohl das Panel
|
||||
über die Domain per HTTPS läuft. Security-Keys werden jetzt freigeschaltet, sobald das Panel über
|
||||
die **aktive Domain** erreicht wird (immer HTTPS über die Vordertür), unabhängig vom hier
|
||||
erkannten Request-Scheme. Der Bare-IP-Recovery-Pfad (kein Secure-Context) bleibt korrekt gesperrt.
|
||||
Ein Kern-Sicherheitsfeature hängt damit nicht mehr an einer optionalen Proxy-Einstellung.
|
||||
|
||||
## [0.9.19] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
- **Caddyfile-Änderungen wurden bei einem Update nicht übernommen.** Die Caddy-Config war als
|
||||
**einzelne Datei** gemountet; ein `git pull` ersetzt die Datei (neue Inode), aber der laufende
|
||||
Container hängt weiter an der alten — und `docker compose up -d` baut Caddy bei reiner
|
||||
Inhaltsänderung nicht neu. Dadurch kamen Caddy-Fixes (z. B. der WebSocket-Fix aus 0.9.18) ohne
|
||||
manuelles Eingreifen nie an. Jetzt wird das **Verzeichnis** `docker/caddy` gemountet (spiegelt die
|
||||
Datei live) und `install.sh` baut den Caddy-Container bei jedem Deploy gezielt neu
|
||||
(`up -d --force-recreate caddy`), sodass Caddyfile-Änderungen zuverlässig wirksam werden.
|
||||
|
||||
## [0.9.18] - 2026-06-19
|
||||
|
||||
### Behoben
|
||||
|
|
@ -167,7 +731,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 nur `vX.Y.Z`, `beta` auch Vorab-Tags.
|
||||
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet ausschließlich `vX.Y.Z` an.
|
||||
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
|
||||
|
||||
## [0.9.6] - 2026-06-19
|
||||
|
|
@ -737,10 +1301,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, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
|
||||
pro Version, Kanal `stable` (kein nutzer-seitiges `dev`).
|
||||
|
||||
[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
|
||||
[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
|
||||
|
|
|
|||
266
CLAUDE.md
266
CLAUDE.md
|
|
@ -1,266 +0,0 @@
|
|||
# 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`.** R1–R11 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.
|
||||
224
README.md
224
README.md
|
|
@ -1,96 +1,198 @@
|
|||
# Clusev
|
||||
<div align="center">
|
||||
|
||||
**Self-hosted control panel for a fleet of Linux servers — one dashboard, agentless over SSH.**
|
||||
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
|
||||
|
||||
Clusev is the control plane: a modern, security-first web UI that administers your servers by talking
|
||||
to them directly over SSH (exec + SFTP via phpseclib). It installs nothing on the target machines and
|
||||
never reimplements their daemons. Multi-server management is free and never paywalled.
|
||||
<br>
|
||||
|
||||
**One dashboard for your whole fleet. Agentless over SSH. Self-hosted, security-first, open core.**
|
||||
|
||||
[](#license)
|
||||
[](https://laravel.com)
|
||||
[](https://livewire.laravel.com)
|
||||
[](#quick-start)
|
||||
[](#requirements)
|
||||
|
||||
[**Features**](#features) · [**Architecture**](#architecture) · [**Quick start**](#quick-start) · [**Domain & TLS**](#access-domain--tls) · [**Security**](#security--recovery) · [**License**](#license)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What is Clusev
|
||||
|
||||
**Clusev** is the control plane for a fleet of Linux servers — a modern, security-first web UI that
|
||||
administers your machines by talking to them **directly over SSH** (exec + SFTP via
|
||||
[phpseclib](https://phpseclib.com)). It installs **nothing** on the target servers and never
|
||||
reimplements their daemons; it orchestrates the real ones.
|
||||
|
||||
> **Multi-server management is free and never paywalled.** Optional Pro modules (SSO/LDAP, RBAC,
|
||||
> audit export, alerting) are separate — the core stays open.
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | | |
|
||||
|:--|:--|:--|
|
||||
| **Agentless** | **Security-first** | **Self-hosted** |
|
||||
| Pure SSH + SFTP. No daemon, no package, no push-agent on your servers. | 2FA, encrypted credential vault, tamper-evident audit log, per-device sessions. | One Docker stack on one VM. Your servers, your data, your keys. |
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard & live metrics** — CPU, memory, load and disk per server, streamed in real time.
|
||||
- **systemd services** — list, start/stop/restart, live journal tail.
|
||||
- **SFTP file manager** — browse, edit text files, preview images.
|
||||
- **Server details & hardening** — resource gauges, volumes, interfaces, SSH keys; UFW/firewalld
|
||||
rules and fail2ban status with one-click controls; and a one-click "generate an SSH key and disable
|
||||
password login, safely" flow.
|
||||
- **Security** — optional, pluggable 2FA (TOTP and/or hardware security keys, or off), one-time
|
||||
backup codes, an encrypted SSH-credential vault, and a complete, tamper-evident audit log.
|
||||
- **Multiple administrators** — add further admin accounts; every action is attributed in the audit
|
||||
log; view and revoke active sessions (per device, per user, or globally).
|
||||
- **Domain, TLS & e-mail** — run on a bare IP over HTTP, set a domain for automatic HTTPS, or put your
|
||||
own reverse proxy in front. Configure SMTP in-panel for password-reset e-mails.
|
||||
| Capability | What you get |
|
||||
|:--|:--|
|
||||
| **Dashboard & 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 & hardening** | Resource gauges, volumes, interfaces and SSH keys; UFW / firewalld rules and fail2ban status with one-click controls; a guided _"generate an SSH key and disable password login, safely"_ flow. |
|
||||
| **Security** | Pluggable 2FA (TOTP and/or hardware keys, or off), one-time backup codes, an encrypted SSH-credential vault, and a complete tamper-evident audit log. |
|
||||
| **Multiple administrators** | Add further admin accounts — every action is attributed in the audit log; view and revoke active sessions per device, per user, or globally. |
|
||||
| **Domain, TLS & e-mail** | Run on a bare IP over HTTP, set a domain for automatic Let's Encrypt HTTPS, or sit behind your own reverse proxy. In-panel SMTP for password-reset mail. |
|
||||
| **WireGuard (built-in)** | Stand up a WireGuard tunnel from the dashboard, manage peers and live status, and optionally gate the panel — and even SSH — to the tunnel only. |
|
||||
|
||||
The panel is built on Laravel 13, Livewire 3, Tailwind v4, Laravel Reverb (realtime), Redis, MariaDB
|
||||
and phpseclib — all running in Docker. The interface is in German (English available).
|
||||
The interface is **German by default** (English available). Status is shown the operational way —
|
||||
colour, dots and pills — never emoji.
|
||||
|
||||
## Requirements
|
||||
---
|
||||
|
||||
- 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 (one line below) — the installer sets up Docker and everything else.
|
||||
## Architecture
|
||||
|
||||
## Install
|
||||
<div align="center">
|
||||
<img src="art/clusev-architecture.svg" alt="Operator → Clusev control plane → your fleet" width="100%">
|
||||
</div>
|
||||
|
||||
Laravel is the **control plane**, not a re-implementation of your servers. The browser talks to it
|
||||
over HTTPS and a Reverb WebSocket for live telemetry; it reaches each server over **SSH exec + SFTP**
|
||||
using credentials sealed in an encrypted vault that never leaves the control plane. Everything runs as
|
||||
one Docker stack — `app` (php-fpm + nginx + Vite), `reverb`, `queue`, `scheduler`, `mariadb`,
|
||||
`redis` — on a single VM.
|
||||
|
||||
| Layer | Technology |
|
||||
|:--|:--|
|
||||
| Framework | **Laravel 13** |
|
||||
| UI / interactivity | **Livewire 3** (class-based) |
|
||||
| Styling | **Tailwind v4** (CSS-first, `@theme`) |
|
||||
| Realtime | **Laravel Reverb** + Echo |
|
||||
| Queue / cache | **Redis** |
|
||||
| Database | **MariaDB** |
|
||||
| SSH transport | **phpseclib** (exec + SFTP) |
|
||||
| Reverse proxy / TLS | **Caddy** (automatic Let's Encrypt) |
|
||||
| Packaging | **Docker** + Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
|
||||
git clone https://git.bave.dev/boban/clusev.git
|
||||
git clone https://github.com/clusev/clusev.git
|
||||
cd clusev
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The installer is idempotent (safe to re-run) and, in one pass:
|
||||
The installer is **idempotent** (safe to re-run) and, in one pass:
|
||||
|
||||
1. Installs **Docker** (Debian/Ubuntu, from Docker's official repository) if it isn't already present.
|
||||
2. Creates a dedicated **`clusev`** system user — in the `docker` group, owning and running the stack —
|
||||
with a random password.
|
||||
3. Asks for a **domain** (leave empty for IP access) and an **admin e-mail** (leave empty for the
|
||||
default **`admin@clusev.local`**), or reads `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` from the
|
||||
environment for an unattended install. If you give a domain it
|
||||
checks whether DNS already points here: if so a certificate is obtained automatically; if not, it
|
||||
warns you and lets you take the domain anyway (HTTP until DNS is correct) or continue on the IP.
|
||||
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 & TLS](#access-domain--tls).
|
||||
4. Generates all secrets (once — never regenerated on re-run), builds the image, starts the stack,
|
||||
runs the migrations, and creates the first administrator with the **default password `clusev`**
|
||||
(you are forced to change it on first login).
|
||||
runs the migrations, and creates the first administrator with a **random initial password** printed
|
||||
in the closing summary.
|
||||
5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status.
|
||||
|
||||
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
|
||||
(your e-mail — `admin@clusev.local` if you left it empty — plus the default password **`clusev`**),
|
||||
and the **`clusev` host user** + its random password (shown **only once** — note it down).
|
||||
(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.
|
||||
|
||||
Log in with your admin e-mail (default **`admin@clusev.local`**) 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 you've changed it. 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.
|
||||
### First login
|
||||
|
||||
## Access, domain & TLS
|
||||
Log in with your admin e-mail and the **initial password from the installer's summary**. Setting your
|
||||
own password is **recommended** — a banner keeps reminding you until you do — but no longer forced, so a
|
||||
missed reminder can never lock you out of your own panel. You can change the login e-mail later under
|
||||
**Settings → Profile**. 2FA is **optional but recommended** — enable TOTP and/or a security key from
|
||||
**Settings → Security** whenever you like. A short **guided tour** highlights the key areas on first
|
||||
login (replay it any time from Settings).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**The Clusev panel host** — the one VM that runs the dashboard:
|
||||
|
||||
- **OS:** Debian 12/13 or Ubuntu 22.04/24.04, with **root** access.
|
||||
- **CPU / RAM:** 1 vCPU and **1 GB RAM** minimum (**2 GB recommended**) — the whole Docker stack
|
||||
(app, MariaDB, Redis, Reverb, queue) runs here.
|
||||
- **Disk:** ~**5 GB** free minimum (**10 GB+** recommended — image and database grow over time).
|
||||
- **Network:** a **public IP**; optionally a domain whose DNS points at the server (for automatic
|
||||
HTTPS — Let's Encrypt needs ports **80/443** reachable).
|
||||
- **Tools:** only **git** to fetch the repo — the installer sets up Docker and everything else.
|
||||
|
||||
**The servers you manage** need **nothing installed** — just **SSH** reachable from the panel and a
|
||||
standard Linux `/proc`. Full control (service management + hardening) additionally needs **systemd**
|
||||
and one of `apt` / `dnf` / `zypper`. See [**Supported operating systems**](#supported-operating-systems).
|
||||
|
||||
---
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Clusev is **agentless** — it manages whatever it can reach over SSH. How much works depends on the
|
||||
target's init system and package manager:
|
||||
|
||||
| OS family | Metrics | systemd services | Hardening<br>(SSH · fail2ban · firewall · auto-updates) |
|
||||
|:--|:-:|:-:|:-:|
|
||||
| **Debian · Ubuntu** (and derivatives) | ✅ | ✅ | ✅ |
|
||||
| **RHEL · Fedora · Rocky · AlmaLinux · CentOS Stream · Amazon Linux** | ✅ | ✅ | ✅ |
|
||||
| **openSUSE · SLES** | ✅ | ✅ | ✅ |
|
||||
| **Arch** | ✅ | ✅ | ⚠️ not yet (`pacman` not wired) |
|
||||
| **Alpine** (OpenRC) | ✅ | ❌ no systemd | ❌ |
|
||||
| **Other / unidentified Linux** | ✅ if `/proc` present | ❌ | ❌ |
|
||||
|
||||
**Metrics** (CPU, memory, disk, load) read from `/proc` and work on essentially any Linux. **Service
|
||||
control** needs **systemd**. **Hardening** uses the host's firewall (`ufw` / `firewalld`) and package
|
||||
manager (`apt` / `dnf` / `zypper`); where those aren't present, the action is shown as unavailable
|
||||
rather than failing silently.
|
||||
|
||||
---
|
||||
|
||||
## Access, domain & TLS
|
||||
|
||||
- **Bare IP (no domain):** served over plain HTTP at `http://<server-ip>`. This address always stays
|
||||
reachable as a recovery path, even after a domain is configured.
|
||||
- **With a domain:** set it during install or later under **System → Domain & TLS**. The panel obtains
|
||||
and renews a Let's Encrypt certificate automatically and serves HTTPS — just point DNS at the server.
|
||||
(Let's Encrypt needs publicly reachable ports 80/443.)
|
||||
- **Behind your own reverse proxy** (one that already terminates TLS): switch *TLS-Terminierung* to
|
||||
**Externer Reverse-Proxy** in System → Domain & TLS. The panel then serves HTTP only and trusts the
|
||||
proxy's forwarded scheme — set `TRUSTED_PROXY_CIDR` to the proxy's address and firewall the HTTP
|
||||
- **With a domain:** set it in the dashboard under **System → Domain & TLS** at any time (or preset
|
||||
`CLUSEV_DOMAIN` at install for an unattended setup). The panel
|
||||
obtains and renews a Let's Encrypt certificate automatically and serves HTTPS — just point DNS at the
|
||||
server. (Let's Encrypt needs publicly reachable ports 80/443.)
|
||||
- **Behind your own reverse proxy** (one that already terminates TLS): switch _TLS-Terminierung_ to
|
||||
**Externer Reverse-Proxy** in System → Domain & TLS. The panel then serves HTTP only and trusts
|
||||
the proxy's forwarded scheme — set `TRUSTED_PROXY_CIDR` to the proxy's address and firewall the HTTP
|
||||
port so only the proxy can reach it.
|
||||
|
||||
Domain/TLS changes apply on a stack restart — use the **"Jetzt neu starten"** button in System (no
|
||||
terminal needed; a small, scoped host service performs the restart).
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd clusev
|
||||
sudo ./update.sh # pulls the latest code, then rebuilds, restarts and migrates
|
||||
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
|
||||
```
|
||||
|
||||
`update.sh` fast-forwards the repo (it never discards local changes), re-runs the idempotent
|
||||
`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.)
|
||||
|
||||
## Account recovery
|
||||
---
|
||||
|
||||
## Security & recovery
|
||||
|
||||
The **forgot-password** screen offers self-service recovery: an **e-mail reset link** (valid 15
|
||||
minutes) when SMTP is configured, or an inline **2FA-proof reset** (e-mail + TOTP or a backup code +
|
||||
|
|
@ -99,8 +201,7 @@ new password) as a fallback.
|
|||
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
|
||||
|
||||
```bash
|
||||
cd clusev
|
||||
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
|
||||
clusev reset-admin
|
||||
```
|
||||
|
||||
This clears the second factor so you can set a new password on the next login. The bare-IP
|
||||
|
|
@ -108,8 +209,15 @@ This clears the second factor so you can set a new password on the next login. T
|
|||
is documented in-panel under Settings → Security and is deliberately not shown on the public
|
||||
forgot-password screen.)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules
|
||||
(SSO/LDAP, RBAC, audit export, alerting) are separate. Project home:
|
||||
<https://git.bave.dev/boban/clusev>.
|
||||
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules (SSO/LDAP,
|
||||
RBAC, audit export, alerting) are separate.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Project home — [github.com/clusev/clusev](https://github.com/clusev/clusev)**
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ReleaseChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Refresh the cached latest-release tag so the global sidebar update badge stays accurate without
|
||||
* the operator first opening the Versions page. Anonymous + read-only against the public Git host.
|
||||
*/
|
||||
class CheckUpdate extends Command
|
||||
{
|
||||
protected $signature = 'clusev:check-update';
|
||||
|
||||
protected $description = 'Refresh the cached latest-release tag (keeps the sidebar update badge warm).';
|
||||
|
||||
public function handle(ReleaseChecker $checker): int
|
||||
{
|
||||
$tag = $checker->refresh();
|
||||
$this->info($tag !== null ? "latest release: v{$tag}" : 'remote unreachable — keeping the previous cache');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,22 +4,19 @@ 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 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).
|
||||
* 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.
|
||||
*/
|
||||
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 —
|
||||
|
|
@ -32,7 +29,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 Standard-Passwort an (Zwangswechsel beim ersten Login)';
|
||||
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit zufälligem Initialpasswort an (Wechsel empfohlen, nicht erzwungen)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -50,22 +47,24 @@ class Install extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$password = self::DEFAULT_PASSWORD;
|
||||
// Random, non-guessable initial password (printed once below). Satisfies any policy.
|
||||
$password = Str::password(16);
|
||||
|
||||
$user = User::create([
|
||||
'name' => (string) ($this->option('name') ?: 'Administrator'),
|
||||
'email' => $email,
|
||||
'password' => $password, // 'hashed' cast hashes on save
|
||||
'must_change_password' => true, // forces a new password on first login
|
||||
'must_change_password' => true, // nudges a rotation on first login (optional)
|
||||
'role' => 'admin', // the first/root account is a full administrator
|
||||
]);
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
$this->newLine();
|
||||
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
|
||||
$this->line('CLUSEV_ADMIN_PASSWORD='.$password);
|
||||
$this->newLine();
|
||||
$this->info('Admin angelegt. Standard-Passwort — beim ersten Login sofort aendern.');
|
||||
$this->info('Admin angelegt. Generiertes Passwort jetzt notieren — es wird nur einmal angezeigt.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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;
|
||||
|
|
@ -28,7 +29,7 @@ class PollMetrics extends Command
|
|||
|
||||
protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast';
|
||||
|
||||
public function handle(FleetService $fleet, CredentialVault $vault): int
|
||||
public function handle(FleetService $fleet, CredentialVault $vault, AlertEvaluator $alerts): int
|
||||
{
|
||||
$interval = max(2, (int) $this->option('interval'));
|
||||
|
||||
|
|
@ -36,10 +37,22 @@ class PollMetrics extends Command
|
|||
$clients = [];
|
||||
|
||||
do {
|
||||
$servers = Server::has('credential')->get();
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
$this->warn('Keine Server mit Credential — nichts zu pollen.');
|
||||
$this->warn('Keine Server mit aktivem Credential — nichts zu pollen.');
|
||||
}
|
||||
|
||||
foreach ($servers as $server) {
|
||||
|
|
@ -47,8 +60,9 @@ class PollMetrics extends Command
|
|||
$clients[$server->id] ??= (new SshClient($vault, timeout: 12))->connect($server);
|
||||
|
||||
$m = $fleet->readMetrics($clients[$server->id]);
|
||||
$fleet->applyMetrics($server, $m);
|
||||
$fleet->applyMetrics($server, $m); // forceFills the fresh status onto $server
|
||||
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) {
|
||||
|
|
@ -58,6 +72,7 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -75,4 +90,19 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ 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')) {
|
||||
|
|
@ -57,7 +60,7 @@ class ResetAdmin extends Command
|
|||
|
||||
$this->info("Passwort für {$user->email} zurückgesetzt.");
|
||||
if (! $this->option('password')) {
|
||||
$this->warn("Einmal-Passwort (jetzt notieren): {$password}");
|
||||
$this->warn("Neues Passwort (jetzt notieren): {$password}");
|
||||
}
|
||||
if ($this->option('disable-2fa')) {
|
||||
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\BruteforceGuard;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UnbanIp extends Command
|
||||
{
|
||||
protected $signature = 'clusev:unban {ip? : Zu entsperrende IP-Adresse} {--all : Alle aktiven Bänne entfernen}';
|
||||
|
||||
protected $description = 'Entfernt App-Level-IP-Bänne des Anmeldeschutzes per Shell (Aussperr-Rettung)';
|
||||
|
||||
public function handle(BruteforceGuard $guard): int
|
||||
{
|
||||
if ($this->option('all')) {
|
||||
$count = $guard->unbanAll();
|
||||
$this->info("{$count} Bann/Bänne entfernt.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$ip = (string) $this->argument('ip');
|
||||
if ($ip === '') {
|
||||
$this->error('Bitte eine IP angeben oder --all verwenden.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$guard->unban($ip);
|
||||
$this->info("Bann für {$ip} entfernt (falls vorhanden).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WgTrafficSample;
|
||||
use App\Services\WgStatus;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WgSample extends Command
|
||||
{
|
||||
protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';
|
||||
|
||||
protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';
|
||||
|
||||
public function handle(WgStatus $wg): int
|
||||
{
|
||||
$status = $wg->read();
|
||||
|
||||
if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) {
|
||||
$now = now();
|
||||
$rows = [];
|
||||
foreach ($status['peers'] ?? [] as $peer) {
|
||||
$rows[] = [
|
||||
'peer_pubkey' => (string) ($peer['pubkey'] ?? ''),
|
||||
'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null,
|
||||
'rx' => (int) ($peer['rx'] ?? 0),
|
||||
'tx' => (int) ($peer['tx'] ?? 0),
|
||||
'sampled_at' => $now,
|
||||
];
|
||||
}
|
||||
if ($rows !== []) {
|
||||
WgTrafficSample::insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
$retention = max(1, (int) $this->option('retention'));
|
||||
WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?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 {}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
<?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 ‹ Blog — 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="/">← 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\BruteforceGuard;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Hard-blocks GUEST requests from a banned IP (403). Authenticated requests pass, so an
|
||||
* operator already logged in is never cut off mid-session and can unban their own IP from
|
||||
* the Anmeldeschutz tab. Appended to the web group so Auth::guest() resolves (the session
|
||||
* middleware has run). request()->ip() is correct via the global trustProxies (prod).
|
||||
*/
|
||||
class BlockBannedIp
|
||||
{
|
||||
public function __construct(private BruteforceGuard $guard) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) {
|
||||
return response()->view('errors.blocked', [], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ use Illuminate\Http\Request;
|
|||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* After login, force the seeded-password rotation before the panel is reachable.
|
||||
* 2FA is optional (offered in Settings), so it is NOT forced here.
|
||||
* 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.
|
||||
*/
|
||||
class EnsureSecurityOnboarded
|
||||
{
|
||||
|
|
@ -16,7 +18,9 @@ class EnsureSecurityOnboarded
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
|
||||
if ($user && $user->must_change_password
|
||||
&& ! $request->session()->get('onboarding.password_skipped')
|
||||
&& ! $request->routeIs('password.change', 'logout')) {
|
||||
return redirect()->route('password.change');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ 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 likewise).
|
||||
if ($request->is('_caddy/*', 'up')) {
|
||||
// a plain-HTTP call with an internal Host; the health check + the terminal sidecar's
|
||||
// resolve call likewise come over the private network with a service-name Host).
|
||||
if ($request->is('_caddy/*', 'up', '_internal/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,21 @@ namespace App\Livewire\Audit;
|
|||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** Audit entries per page — keeps a long history navigable (page is deep-linked via ?page=). */
|
||||
private const PER_PAGE = 25;
|
||||
|
||||
/** Freitext-Filter über Akteur / Aktion / Ziel / Server. */
|
||||
public string $q = '';
|
||||
|
||||
|
|
@ -28,9 +35,19 @@ class Index extends Component
|
|||
$this->retentionDays = $stored > 0 ? (string) $stored : null;
|
||||
}
|
||||
|
||||
/** A changed search must restart at page 1 — never land on an out-of-range page. */
|
||||
public function updatedQ(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/** Persist the retention policy, audit the change and notify the operator. */
|
||||
public function saveRetention(): void
|
||||
{
|
||||
// Panel-wide policy mutation (a short retention prunes audit history) — admin only. Viewing
|
||||
// the audit log stays open to everyone; only changing the retention window is gated.
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
$validated = $this->validate([
|
||||
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
|
||||
]);
|
||||
|
|
@ -62,37 +79,74 @@ class Index extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
|
||||
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
|
||||
* Audit events, newest first, optionally filtered. The filter runs in SQL (so it spans the whole
|
||||
* history, not just one page) and matches actor / action code / target / server name — plus the
|
||||
* READABLE action labels: a term is translated to the action codes whose localized label contains
|
||||
* it, so searching "Endpoint geändert" still finds wg.set-endpoint entries.
|
||||
*/
|
||||
protected function events(): Collection
|
||||
protected function eventsQuery(): Builder
|
||||
{
|
||||
$events = AuditEvent::with('server')->latest()->limit(50)->get();
|
||||
$query = AuditEvent::with('server')->latest();
|
||||
|
||||
$needle = trim($this->q);
|
||||
|
||||
if ($needle === '') {
|
||||
return $events;
|
||||
return $query;
|
||||
}
|
||||
|
||||
$needle = mb_strtolower($needle);
|
||||
$lower = mb_strtolower($needle);
|
||||
$like = '%'.$lower.'%';
|
||||
$codes = array_keys(array_filter(
|
||||
(array) trans('audit.actions'),
|
||||
fn ($label): bool => is_string($label) && str_contains(mb_strtolower($label), $lower),
|
||||
));
|
||||
|
||||
return $events->filter(function (AuditEvent $e) use ($needle): bool {
|
||||
$haystack = mb_strtolower(implode(' ', array_filter([
|
||||
$e->actor,
|
||||
$e->action,
|
||||
$e->target,
|
||||
$e->server?->name,
|
||||
])));
|
||||
return $query->where(function (Builder $w) use ($like, $codes): void {
|
||||
$w->whereRaw('LOWER(actor) LIKE ?', [$like])
|
||||
->orWhereRaw('LOWER(action) LIKE ?', [$like])
|
||||
->orWhereRaw('LOWER(target) LIKE ?', [$like])
|
||||
->orWhereHas('server', fn (Builder $s) => $s->whereRaw('LOWER(name) LIKE ?', [$like]));
|
||||
if ($codes !== []) {
|
||||
$w->orWhereIn('action', $codes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return str_contains($haystack, $needle);
|
||||
})->values();
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
/** @var LengthAwarePaginator $events */
|
||||
$events = $this->eventsQuery()->paginate(self::PER_PAGE);
|
||||
|
||||
return view('livewire.audit.index', [
|
||||
'events' => $this->events(),
|
||||
'events' => $events,
|
||||
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
|
||||
'retentionPolicy' => $this->retentionPolicy(),
|
||||
])->title(__('audit.title'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class ForgotPassword extends Component
|
|||
$this->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'code' => ['required', 'string'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
]);
|
||||
|
||||
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\BruteforceGuard;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
|
@ -64,6 +66,15 @@ class Login extends Component
|
|||
RateLimiter::hit($pairKey, 60); // 5 / minute
|
||||
RateLimiter::hit($ipKey, 60); // 20 / minute
|
||||
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
|
||||
$ip = request()->ip();
|
||||
app(BruteforceGuard::class)->record($ip, 'login');
|
||||
AuditEvent::create([
|
||||
'user_id' => null,
|
||||
'actor' => 'system',
|
||||
'action' => 'auth.login_failed',
|
||||
'target' => $this->email,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class PasswordChange extends Component
|
|||
try {
|
||||
$this->validate([
|
||||
'current' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
], attributes: [
|
||||
'current' => __('auth.attr_current_password'),
|
||||
'password' => __('auth.attr_new_password'),
|
||||
|
|
@ -57,6 +57,18 @@ 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'));
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class ResetPassword extends Component
|
|||
{
|
||||
$this->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()],
|
||||
'password' => ['required', 'confirmed', PasswordRule::min(6)],
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class TwoFactorBackup extends Component
|
||||
{
|
||||
use CompletesTwoFactorChallenge;
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $code = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! session()->has('2fa.user')) {
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function verify()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
$user = $this->pendingUser();
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled()) {
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
|
||||
}
|
||||
|
||||
// Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
|
||||
if (! $user->useRecoveryCode($this->code)) {
|
||||
$this->recordFailedAttempt('2fa');
|
||||
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
|
||||
}
|
||||
|
||||
$this->clearRateLimit();
|
||||
|
||||
return $this->completeLogin($user);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.two-factor-backup')->title(__('auth.title_backup'));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,9 @@
|
|||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
|
||||
use App\Models\User;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
|
|
@ -14,77 +13,21 @@ use Livewire\Component;
|
|||
#[Layout('layouts.auth')]
|
||||
class TwoFactorChallenge extends Component
|
||||
{
|
||||
use CompletesTwoFactorChallenge;
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $code = '';
|
||||
|
||||
private ?User $pendingUser = null;
|
||||
|
||||
private bool $pendingResolved = false;
|
||||
|
||||
/** The pending 2FA user, resolved once per request from the session. */
|
||||
private function pendingUser(): ?User
|
||||
{
|
||||
if (! $this->pendingResolved) {
|
||||
$this->pendingUser = User::find(session('2fa.user'));
|
||||
$this->pendingResolved = true;
|
||||
}
|
||||
|
||||
return $this->pendingUser;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! session()->has('2fa.user')) {
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
|
||||
public function getPendingHasTotpProperty(): bool
|
||||
{
|
||||
return (bool) $this->pendingUser()?->hasTotp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
|
||||
* (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
|
||||
* backstop, so a distributed (multi-IP) attempt to brute-force the 6-digit TOTP is still
|
||||
* capped. Both decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
|
||||
*
|
||||
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
|
||||
*/
|
||||
private function rateLimitBuckets(): array
|
||||
{
|
||||
$uid = (string) session('2fa.user');
|
||||
|
||||
return [
|
||||
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
|
||||
'two-factor-acct:'.md5($uid) => [20, 900],
|
||||
];
|
||||
}
|
||||
|
||||
private function assertNotRateLimited(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [$max]) {
|
||||
if (RateLimiter::tooManyAttempts($key, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function hitRateLimit(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
|
||||
RateLimiter::hit($key, $decay);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearRateLimit(): void
|
||||
{
|
||||
foreach (array_keys($this->rateLimitBuckets()) as $key) {
|
||||
RateLimiter::clear($key);
|
||||
// No TOTP and webauthnAvailable() is false (no registered key, or — for a key-only user —
|
||||
// no secure context on http + bare IP) → the backup code is the only path: go to its view.
|
||||
if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
|
||||
return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +37,7 @@ class TwoFactorChallenge extends Component
|
|||
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
$user = $this->pendingUser();
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled()) {
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
|
@ -106,29 +49,13 @@ class TwoFactorChallenge extends Component
|
|||
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
|
||||
|
||||
if (! $valid) {
|
||||
$this->hitRateLimit();
|
||||
$this->recordFailedAttempt('2fa');
|
||||
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
|
||||
}
|
||||
|
||||
$this->clearRateLimit();
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
||||
Auth::login($user, $remember);
|
||||
session()->regenerate();
|
||||
|
||||
return $this->redirectIntended(route('dashboard'), navigate: true);
|
||||
}
|
||||
|
||||
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
|
||||
public function webauthnAvailable(): bool
|
||||
{
|
||||
$user = $this->pendingUser();
|
||||
|
||||
return $user !== null
|
||||
&& app(WebauthnService::class)->available()
|
||||
&& $user->hasWebauthnCredentials();
|
||||
return $this->completeLogin($user);
|
||||
}
|
||||
|
||||
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
|
||||
|
|
@ -148,19 +75,13 @@ class TwoFactorChallenge extends Component
|
|||
$user = User::find(session('2fa.user'));
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
|
||||
$this->hitRateLimit();
|
||||
$this->recordFailedAttempt('2fa');
|
||||
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
|
||||
}
|
||||
|
||||
$this->clearRateLimit();
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
||||
Auth::login($user, $remember);
|
||||
session()->regenerate();
|
||||
|
||||
return $this->redirectIntended(route('dashboard'), navigate: true);
|
||||
return $this->completeLogin($user);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\BruteforceGuard;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
|
||||
* pending-user resolver, the brute-force buckets (ONE shared counter set across both
|
||||
* views), and the login success tail. Centralising it guarantees both views hit the
|
||||
* same rate limit and complete login identically.
|
||||
*
|
||||
* @mixin Component
|
||||
*/
|
||||
trait CompletesTwoFactorChallenge
|
||||
{
|
||||
private ?User $pendingUser = null;
|
||||
|
||||
private bool $pendingResolved = false;
|
||||
|
||||
/** The pending 2FA user, resolved once per request from the session. */
|
||||
protected function pendingUser(): ?User
|
||||
{
|
||||
if (! $this->pendingResolved) {
|
||||
$this->pendingUser = User::find(session('2fa.user'));
|
||||
$this->pendingResolved = true;
|
||||
}
|
||||
|
||||
return $this->pendingUser;
|
||||
}
|
||||
|
||||
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
|
||||
public function getPendingHasTotpProperty(): bool
|
||||
{
|
||||
return (bool) $this->pendingUser()?->hasTotp();
|
||||
}
|
||||
|
||||
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
|
||||
public function webauthnAvailable(): bool
|
||||
{
|
||||
$user = $this->pendingUser();
|
||||
|
||||
return $user !== null
|
||||
&& app(WebauthnService::class)->available()
|
||||
&& $user->hasWebauthnCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
|
||||
* (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
|
||||
* backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
|
||||
* decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
|
||||
*
|
||||
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
|
||||
*/
|
||||
protected function rateLimitBuckets(): array
|
||||
{
|
||||
$uid = (string) session('2fa.user');
|
||||
|
||||
return [
|
||||
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
|
||||
'two-factor-acct:'.md5($uid) => [20, 900],
|
||||
];
|
||||
}
|
||||
|
||||
protected function assertNotRateLimited(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [$max]) {
|
||||
if (RateLimiter::tooManyAttempts($key, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function hitRateLimit(): void
|
||||
{
|
||||
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
|
||||
RateLimiter::hit($key, $decay);
|
||||
}
|
||||
}
|
||||
|
||||
protected function clearRateLimit(): void
|
||||
{
|
||||
foreach (array_keys($this->rateLimitBuckets()) as $key) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */
|
||||
protected function recordFailedAttempt(string $reason): void
|
||||
{
|
||||
$this->hitRateLimit();
|
||||
$ip = request()->ip();
|
||||
app(BruteforceGuard::class)->record($ip, $reason);
|
||||
AuditEvent::create([
|
||||
'user_id' => null,
|
||||
'actor' => 'system',
|
||||
'action' => 'auth.2fa_failed',
|
||||
'target' => (string) session('2fa.user'),
|
||||
'ip' => $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Log the verified pending user in and finish the challenge (shared success tail). */
|
||||
protected function completeLogin(User $user): mixed
|
||||
{
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
|
||||
Auth::login($user, $remember);
|
||||
session()->regenerate();
|
||||
|
||||
return $this->redirectIntended(route('dashboard'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,9 +12,17 @@ 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 Server::withExists('credential')->orderBy('name')->get();
|
||||
return $this->fleetMemo ??= Server::withExists('credential')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function activeServer(): ?Server
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,11 @@ class Index extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
$this->path = rtrim($this->path, '/').'/'.$name;
|
||||
// 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->load();
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +84,8 @@ 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();
|
||||
|
|
@ -100,6 +106,11 @@ class Index extends Component
|
|||
/** Stream a remote file to the browser as a download (SFTP get). */
|
||||
public function download(int $index, FleetService $fleet)
|
||||
{
|
||||
// Reading a file's BYTES over the server credential (often root) discloses secrets a
|
||||
// viewer must not reach (/etc/shadow, keys, .env). Browsing the LISTING stays open;
|
||||
// pulling content requires operate, like every other file operation.
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
$name = $this->entries[$index]['name'] ?? null;
|
||||
$active = $this->activeServer();
|
||||
if ($name === null || ! $active || ! $active->credential_exists) {
|
||||
|
|
@ -120,6 +131,10 @@ 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) {
|
||||
|
|
@ -167,6 +182,8 @@ 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;
|
||||
|
|
@ -200,6 +217,8 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Help;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* In-panel help/documentation. Pure display: a left topic nav (like Settings) and a per-locale
|
||||
* content partial. No persistence, no external calls. Topic keys are also the content-partial
|
||||
* filenames at resources/views/livewire/help/content/{de,en}/<topic>.blade.php.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
/** Help topics in display order. */
|
||||
private const TOPICS = [
|
||||
'overview', 'domain-tls', 'security', 'updates', 'commands',
|
||||
'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery',
|
||||
];
|
||||
|
||||
#[Url]
|
||||
public string $topic = 'overview';
|
||||
|
||||
// mount() handles the test/parameter-injection path (Livewire::test($class, ['topic' => …])).
|
||||
// Browser deep links populate $topic via the #[Url] attribute above and bypass mount on
|
||||
// hydration. Both paths are validated by the render() clamp below.
|
||||
public function mount(?string $topic = null): void
|
||||
{
|
||||
if ($topic !== null) {
|
||||
$this->topic = $topic; // clamped to a known topic in render()
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Authoritative guard: any unknown topic (deep link, tampered property) → overview, so
|
||||
// the view never @includes a missing content partial.
|
||||
if (! in_array($this->topic, self::TOPICS, true)) {
|
||||
$this->topic = 'overview';
|
||||
}
|
||||
|
||||
$labels = [
|
||||
'overview' => __('help.topic_overview'),
|
||||
'domain-tls' => __('help.topic_domain_tls'),
|
||||
'security' => __('help.topic_security'),
|
||||
'updates' => __('help.topic_updates'),
|
||||
'commands' => __('help.topic_commands'),
|
||||
'servers' => __('help.topic_servers'),
|
||||
'sessions' => __('help.topic_sessions'),
|
||||
'email' => __('help.topic_email'),
|
||||
'audit' => __('help.topic_audit'),
|
||||
'wireguard' => __('help.topic_wireguard'),
|
||||
'recovery' => __('help.topic_recovery'),
|
||||
];
|
||||
|
||||
$topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS);
|
||||
|
||||
return view('livewire.help.index', ['topics' => $topics])->title(__('help.title'));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ class AddSshKey extends ModalComponent
|
|||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $keyName = '';
|
||||
|
||||
public string $publicKey = '';
|
||||
|
||||
public ?string $generatedPrivate = null;
|
||||
|
|
@ -26,6 +28,9 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -40,12 +45,39 @@ class AddSshKey extends ModalComponent
|
|||
{
|
||||
$this->error = null;
|
||||
$key = EC::createKey('ed25519');
|
||||
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']);
|
||||
// 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->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);
|
||||
|
|
@ -55,8 +87,11 @@ class AddSshKey extends ModalComponent
|
|||
return;
|
||||
}
|
||||
|
||||
$name = $this->keyComment();
|
||||
|
||||
try {
|
||||
$fleet->addAuthorizedKey($server, $this->publicKey);
|
||||
// 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));
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
|
||||
|
|
@ -68,7 +103,7 @@ class AddSshKey extends ModalComponent
|
|||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'ssh_key.add',
|
||||
'target' => $server->name,
|
||||
'target' => $name !== '' ? $server->name.' · '.$name : $server->name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,12 @@ 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';
|
||||
|
|
@ -87,6 +93,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,20 +2,22 @@
|
|||
|
||||
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 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.
|
||||
* Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts
|
||||
* default to `operator` — least privilege). Restricted to admins (`manage-users`). The creator
|
||||
* may set a password directly, or leave it blank — then a strong initial password is generated, the
|
||||
* account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal
|
||||
* (never persisted or logged). An operator-set password is the user's own, so no forced rotation.
|
||||
*/
|
||||
class CreateUser extends ModalComponent
|
||||
{
|
||||
|
|
@ -23,12 +25,23 @@ 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';
|
||||
|
|
@ -42,6 +55,9 @@ 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)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -53,11 +69,15 @@ 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.
|
||||
|
|
@ -68,13 +88,17 @@ class CreateUser extends ModalComponent
|
|||
return;
|
||||
}
|
||||
|
||||
$temp = Str::password(16);
|
||||
// Operator-set password = the user's own login (no forced rotation). Blank = generate a strong
|
||||
// initial password that the user is prompted to rotate, and reveal it once below.
|
||||
$operatorSet = $this->password !== '';
|
||||
$password = $operatorSet ? $this->password : Str::password(16);
|
||||
|
||||
$user = User::create([
|
||||
'name' => trim($data['name']),
|
||||
'email' => $email,
|
||||
'password' => Hash::make($temp),
|
||||
'must_change_password' => true,
|
||||
'password' => Hash::make($password),
|
||||
'must_change_password' => ! $operatorSet,
|
||||
'role' => $data['role'],
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
|
|
@ -85,9 +109,18 @@ class CreateUser extends ModalComponent
|
|||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
// Reveal the temp password ONCE — swaps the form for the reveal panel.
|
||||
// Operator already knows the password they set → no reveal; just confirm and close.
|
||||
if ($operatorSet) {
|
||||
$this->dispatch('usersChanged');
|
||||
$this->dispatch('notify', message: __('accounts.created_notify', ['name' => $user->name]));
|
||||
$this->closeModal();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reveal the generated initial password ONCE — swaps the form for the reveal panel.
|
||||
$this->createdName = $user->name;
|
||||
$this->tempPassword = $temp;
|
||||
$this->tempPassword = $password;
|
||||
}
|
||||
|
||||
/** "Fertig": reload the list and close. */
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ 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) {
|
||||
|
|
@ -46,6 +49,9 @@ 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);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ 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';
|
||||
|
|
@ -44,6 +46,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ 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;
|
||||
}
|
||||
|
|
@ -77,6 +79,8 @@ 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');
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ 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);
|
||||
|
|
@ -64,6 +66,8 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ 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');
|
||||
|
|
@ -91,9 +96,12 @@ 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();
|
||||
|
|
@ -133,6 +141,8 @@ 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.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ 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';
|
||||
}
|
||||
|
|
@ -44,6 +46,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ 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;
|
||||
|
|
@ -68,6 +70,8 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
public function apply(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
if ($this->error !== null || $this->done) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -91,8 +95,10 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,10 @@ 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 ?? '';
|
||||
}
|
||||
|
|
@ -42,6 +46,9 @@ 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;
|
||||
}
|
||||
|
|
@ -59,7 +66,8 @@ class SshKeyProvision extends ModalComponent
|
|||
$this->done = true;
|
||||
$this->ok = false;
|
||||
$this->error = $e->getMessage();
|
||||
$this->dispatch('hardeningApplied');
|
||||
// credentialChanged already triggers the FULL page reload on Show (key list +
|
||||
// credential + security state) — no extra hardeningApplied needed.
|
||||
$this->dispatch('credentialChanged');
|
||||
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
|
||||
|
||||
|
|
@ -72,7 +80,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ 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);
|
||||
|
|
@ -69,6 +72,9 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<?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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
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')]
|
||||
|
|
@ -14,6 +16,10 @@ 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
|
||||
{
|
||||
|
|
@ -31,7 +37,11 @@ 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}%")
|
||||
|
|
@ -48,6 +58,8 @@ 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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ 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;
|
||||
|
||||
|
|
@ -24,6 +25,10 @@ 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
|
||||
{
|
||||
|
|
@ -156,6 +161,10 @@ 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();
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +174,8 @@ 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;
|
||||
|
|
@ -197,6 +208,10 @@ 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) {
|
||||
|
|
@ -253,6 +268,8 @@ class Show extends Component
|
|||
*/
|
||||
public function toggleCredential(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-fleet'), 403);
|
||||
|
||||
$cred = $this->server->credential;
|
||||
if (! $cred) {
|
||||
return;
|
||||
|
|
@ -280,6 +297,8 @@ class Show extends Component
|
|||
*/
|
||||
public function confirmDeleteCredential(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-fleet'), 403);
|
||||
|
||||
$cred = $this->server->credential;
|
||||
if (! $cred) {
|
||||
return;
|
||||
|
|
@ -309,6 +328,10 @@ 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) {
|
||||
|
|
@ -327,11 +350,45 @@ class Show extends Component
|
|||
* checklist + firewall row reflect the new state.
|
||||
*/
|
||||
#[On('hardeningApplied')]
|
||||
public function reloadSnapshot(FleetService $fleet): void
|
||||
public function reloadSnapshot(): 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();
|
||||
$this->ready = false;
|
||||
$this->load($fleet);
|
||||
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -340,6 +397,8 @@ 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;
|
||||
|
|
@ -371,6 +430,10 @@ 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) {
|
||||
|
|
@ -431,6 +494,8 @@ 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) {
|
||||
|
|
@ -452,6 +517,8 @@ 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;
|
||||
|
|
@ -485,6 +552,8 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -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,6 +105,8 @@ 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;
|
||||
|
|
@ -140,6 +142,8 @@ 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) {
|
||||
|
|
@ -162,9 +166,27 @@ class Index extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
$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)]));
|
||||
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');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->services = $fleet->systemd($active)['services'];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ 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', '') ?? '';
|
||||
|
|
@ -68,8 +69,23 @@ 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);
|
||||
|
|
@ -97,6 +113,38 @@ 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
|
||||
|
|
@ -104,6 +152,8 @@ 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')]));
|
||||
|
||||
|
|
@ -120,6 +170,17 @@ 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 {
|
||||
|
|
@ -150,13 +211,20 @@ 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 = '';
|
||||
$stored = Setting::get('mail_password');
|
||||
if ($stored !== null && $stored !== '') {
|
||||
try {
|
||||
$password = Crypt::decryptString($stored);
|
||||
} catch (Throwable) {
|
||||
$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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\BannedIp;
|
||||
use App\Models\Setting;
|
||||
use App\Rules\ValidIpOrCidr;
|
||||
use App\Services\BruteforceGuard;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class LoginProtection extends Component
|
||||
{
|
||||
public bool $enabled = true;
|
||||
|
||||
public int $maxretry = 10;
|
||||
|
||||
public int $findtime = 10;
|
||||
|
||||
public int $bantime = 60;
|
||||
|
||||
public string $whitelist = '';
|
||||
|
||||
public function mount(BruteforceGuard $guard): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->enabled = $guard->enabled();
|
||||
$this->maxretry = $guard->maxretry();
|
||||
$this->findtime = $guard->findtime();
|
||||
$this->bantime = $guard->bantime();
|
||||
$this->whitelist = implode("\n", $guard->whitelist());
|
||||
}
|
||||
|
||||
public function save(BruteforceGuard $guard): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->validate([
|
||||
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
|
||||
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||
'bantime' => ['required', 'integer', 'min:1', 'max:43200'],
|
||||
'whitelist' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
|
||||
foreach ($lines as $line) {
|
||||
if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) {
|
||||
$this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line]));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0');
|
||||
Setting::put('bruteforce_maxretry', (string) $this->maxretry);
|
||||
Setting::put('bruteforce_findtime', (string) $this->findtime);
|
||||
Setting::put('bruteforce_bantime', (string) $this->bantime);
|
||||
Setting::put('bruteforce_whitelist', implode("\n", $lines));
|
||||
|
||||
// Widening the whitelist immediately clears any now-exempt active bans.
|
||||
foreach (BannedIp::active()->get() as $ban) {
|
||||
if ($guard->isExempt($ban->ip)) {
|
||||
$guard->unban($ban->ip);
|
||||
}
|
||||
}
|
||||
|
||||
$this->audit('auth.protection_updated', null);
|
||||
$this->dispatch('notify', message: __('settings.lp_saved'));
|
||||
}
|
||||
|
||||
public function whitelistMyIp(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$ip = (string) request()->ip();
|
||||
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
|
||||
if (! in_array($ip, $lines, true)) {
|
||||
$lines[] = $ip;
|
||||
}
|
||||
$this->whitelist = implode("\n", $lines);
|
||||
}
|
||||
|
||||
public function confirmUnban(string $ip): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('settings.lp_unban_heading'),
|
||||
'body' => __('settings.lp_unban_body', ['ip' => $ip]),
|
||||
'confirmLabel' => __('settings.lp_unban'),
|
||||
'danger' => false,
|
||||
'icon' => 'shield',
|
||||
'notify' => __('settings.lp_unban_notify'),
|
||||
'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('banCleared')]
|
||||
public function unban(string $confirmToken, BruteforceGuard $guard): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
$ip = (string) $payload['params']['ip'];
|
||||
$guard->unban($ip);
|
||||
$this->audit('auth.ip_unbanned', $ip);
|
||||
}
|
||||
|
||||
public function confirmUnbanAll(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('settings.lp_unban_all_heading'),
|
||||
'body' => __('settings.lp_unban_all_body'),
|
||||
'confirmLabel' => __('settings.lp_unban_all'),
|
||||
'danger' => true,
|
||||
'icon' => 'shield',
|
||||
'notify' => __('settings.lp_unban_all_notify'),
|
||||
'token' => ConfirmToken::issue('bansClearedAll'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('bansClearedAll')]
|
||||
public function unbanAll(string $confirmToken, BruteforceGuard $guard): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
try {
|
||||
ConfirmToken::consume($confirmToken, 'bansClearedAll');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$guard->unbanAll();
|
||||
$this->audit('auth.ip_unbanned', 'all');
|
||||
}
|
||||
|
||||
private function audit(string $action, ?string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(BruteforceGuard $guard)
|
||||
{
|
||||
$ip = (string) request()->ip();
|
||||
|
||||
return view('livewire.settings.login-protection', [
|
||||
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(),
|
||||
'currentIp' => $ip,
|
||||
'currentIpExempt' => $guard->isExempt($ip),
|
||||
// Read-only status of the env-driven honeypot deception layer (no toggle here).
|
||||
'honeypotEnabled' => (bool) config('clusev.honeypot.enabled'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ class Profile extends Component
|
|||
try {
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('current_password', $e->errors())) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class Sessions extends Component
|
|||
*/
|
||||
public function confirmLogoutAll(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
|
|
@ -81,6 +82,7 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\SessionService;
|
||||
|
|
@ -13,11 +14,15 @@ use Livewire\Attributes\On;
|
|||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Multi-user account management. Accounts now carry an RBAC role (admin > operator >
|
||||
* viewer); the mutating actions here are gated behind the `manage-users` ability (admin
|
||||
* only). Destructive actions (remove, force-logout) and role changes go through the R5
|
||||
* confirm modal, which writes one AuditEvent and re-dispatches the apply event back here
|
||||
* (mirrors Settings\Security / Settings\Sessions).
|
||||
*
|
||||
* The last-admin invariant is enforced everywhere: the sole remaining admin can neither
|
||||
* be removed nor demoted, so the panel can never be stranded with zero administrators.
|
||||
*
|
||||
* The current operator is always recorded as the actor on each audit row.
|
||||
*/
|
||||
class Users extends Component
|
||||
|
|
@ -25,6 +30,8 @@ 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');
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +42,8 @@ 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;
|
||||
|
|
@ -62,6 +71,8 @@ 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) {
|
||||
|
|
@ -74,6 +85,8 @@ 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;
|
||||
|
|
@ -84,6 +97,14 @@ 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
|
||||
|
|
@ -99,6 +120,12 @@ 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'));
|
||||
}
|
||||
|
|
@ -109,6 +136,8 @@ 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;
|
||||
|
|
@ -135,6 +164,8 @@ 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) {
|
||||
|
|
@ -153,6 +184,103 @@ 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
|
||||
|
|
@ -161,9 +289,10 @@ class Users extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A user may be offered the remove control unless it is the current operator or the
|
||||
* last remaining account (no lock-out). This is only the UI gate; the removal itself
|
||||
* is restricted to admins (`manage-users`) and the apply handler additionally refuses
|
||||
* to strand zero administrators (last-admin invariant).
|
||||
*/
|
||||
private function canRemove(User $user): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
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;
|
||||
|
|
@ -14,23 +13,17 @@ use Livewire\Attributes\On;
|
|||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* System settings — dashboard-configurable Domain + TLS and the release channel.
|
||||
* System settings — dashboard-configurable Domain + TLS.
|
||||
*
|
||||
* 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'];
|
||||
|
||||
|
|
@ -46,19 +39,8 @@ 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
|
||||
|
|
@ -67,9 +49,6 @@ 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';
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +62,8 @@ 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'),
|
||||
|
|
@ -118,6 +99,10 @@ 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) {
|
||||
|
|
@ -145,6 +130,8 @@ 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');
|
||||
|
|
@ -163,6 +150,8 @@ 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()) {
|
||||
|
|
@ -201,53 +190,11 @@ 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;
|
||||
}
|
||||
|
|
@ -274,6 +221,10 @@ 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) {
|
||||
|
|
@ -306,7 +257,6 @@ 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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,13 @@
|
|||
namespace App\Livewire\Versions;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
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;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
|
|
@ -24,9 +24,6 @@ use Livewire\Component;
|
|||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
/** Only these channels are ever offered to users — there is no 'dev' channel. */
|
||||
private const CHANNELS = ['stable', 'beta'];
|
||||
|
||||
/**
|
||||
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
|
||||
* when the update was requested. Persisted in Redis — NOT the sentinel file (which the host
|
||||
|
|
@ -36,6 +33,18 @@ class Index extends Component
|
|||
*/
|
||||
private const UPDATE_FLAG = 'clusev:update-in-progress';
|
||||
|
||||
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
|
||||
private const CHANGELOG_PER_PAGE = 8;
|
||||
|
||||
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
|
||||
* Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
|
||||
#[Url(as: 'series')]
|
||||
public string $series = '';
|
||||
|
||||
/** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
|
||||
#[Url(as: 'page')]
|
||||
public int $changelogPage = 1;
|
||||
|
||||
public ?string $lastChecked = null;
|
||||
|
||||
public ?string $updateStatus = null;
|
||||
|
|
@ -171,8 +180,10 @@ class Index extends Component
|
|||
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
||||
* say so and do not await a result.
|
||||
*/
|
||||
public function requestUpdate(DeploymentService $deployment): void
|
||||
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);
|
||||
|
|
@ -184,7 +195,7 @@ class Index extends Component
|
|||
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
||||
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$this->updateState = 'update';
|
||||
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
||||
|
|
@ -193,7 +204,7 @@ class Index extends Component
|
|||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
|
|
@ -202,7 +213,7 @@ class Index extends Component
|
|||
if (! $deployment->requestUpdate()) {
|
||||
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
|
||||
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
|
||||
|
|
@ -221,14 +232,28 @@ class Index extends Component
|
|||
$this->updateBaseVersion = $installed; // completion = the running version moves past this
|
||||
$this->updatePolls = 0;
|
||||
$this->dispatch('notify', message: __('versions.update_started'));
|
||||
|
||||
// Redirect to the self-contained progress page BEFORE the container goes down.
|
||||
// navigate:false forces a full browser navigation (the page must be Livewire-independent
|
||||
// to survive the teardown — see resources/views/update-progress.blade.php).
|
||||
$prev = url()->previous();
|
||||
$parsedPrev = parse_url($prev, PHP_URL_PATH);
|
||||
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
|
||||
? $parsedPrev
|
||||
: route('dashboard', absolute: false);
|
||||
|
||||
// Pass the pre-update version: the progress page finishes only when the running version
|
||||
// moves past it (the old container answers /up throughout the build — see the page).
|
||||
return $this->redirect(
|
||||
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
|
||||
navigate: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||
private function channel(): string
|
||||
{
|
||||
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
|
||||
|
||||
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
||||
return app(ReleaseChecker::class)->channel();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -240,58 +265,17 @@ class Index extends Component
|
|||
*/
|
||||
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
|
||||
{
|
||||
$key = 'clusev:latest-release:'.$channel;
|
||||
$checker = app(ReleaseChecker::class);
|
||||
|
||||
if ($forceRemote) {
|
||||
$remote = $this->fetchRemoteLatestTag($channel);
|
||||
$remote = $checker->refresh($channel);
|
||||
if ($remote !== null) {
|
||||
Cache::put($key, $remote, now()->addMinutes(30));
|
||||
|
||||
return $remote;
|
||||
}
|
||||
// Remote unreachable — fall through to whatever we last cached / can read locally.
|
||||
}
|
||||
|
||||
$cached = Cache::get($key);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
return $this->localLatestTag($channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/tags).
|
||||
* Anonymous + read-only (the repo is public) — no token ever leaves the panel. Returns
|
||||
* null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully.
|
||||
*/
|
||||
private function fetchRemoteLatestTag(string $channel): ?string
|
||||
{
|
||||
$repo = (string) config('clusev.repository');
|
||||
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
|
||||
return null;
|
||||
}
|
||||
[, $base, $owner, $name] = $m;
|
||||
|
||||
try {
|
||||
$res = Http::timeout(5)->acceptJson()
|
||||
->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
|
||||
|
||||
if (! $res->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$names = array_map(
|
||||
static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
|
||||
(array) $res->json(),
|
||||
);
|
||||
|
||||
return $this->newestVersion($names, $channel);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -319,37 +303,7 @@ class Index extends Component
|
|||
}
|
||||
}
|
||||
|
||||
return $this->newestVersion($tags, $channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the newest semantic version from a list of tag names, scoped to the channel:
|
||||
* 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-…), so a
|
||||
* beta user is still offered a newer stable. Leading "v" is normalised off the result.
|
||||
*
|
||||
* @param array<int, string> $tagNames
|
||||
*/
|
||||
private function newestVersion(array $tagNames, string $channel): ?string
|
||||
{
|
||||
$pattern = $channel === 'beta'
|
||||
? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
|
||||
: '/^v?\d+\.\d+\.\d+$/';
|
||||
|
||||
$versions = [];
|
||||
foreach (array_unique($tagNames) as $tag) {
|
||||
$tag = trim((string) $tag);
|
||||
if ($tag !== '' && preg_match($pattern, $tag)) {
|
||||
$versions[] = ltrim($tag, 'vV');
|
||||
}
|
||||
}
|
||||
|
||||
if ($versions === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
|
||||
|
||||
return $versions[0];
|
||||
return app(ReleaseChecker::class)->newestVersion($tags, $channel);
|
||||
}
|
||||
|
||||
/** Is the given tag a newer semantic version than the installed one? */
|
||||
|
|
@ -401,6 +355,106 @@ class Index extends Component
|
|||
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
|
||||
}
|
||||
|
||||
/** Switch the visible major.minor series and jump back to its first page. */
|
||||
public function selectSeries(string $series): void
|
||||
{
|
||||
$this->series = $series;
|
||||
$this->changelogPage = 1;
|
||||
}
|
||||
|
||||
/** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
|
||||
public function gotoChangelogPage(int $page): void
|
||||
{
|
||||
$this->changelogPage = max(1, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
|
||||
* keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
|
||||
* if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
|
||||
* show a compact series index + paginated detail instead of one endless list.
|
||||
*
|
||||
* @param array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}> $releases
|
||||
* @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
|
||||
*/
|
||||
private function groupBySeries(array $releases): array
|
||||
{
|
||||
$buckets = [];
|
||||
foreach ($releases as $rel) {
|
||||
if ($rel['unreleased']) {
|
||||
$key = 'unreleased';
|
||||
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
|
||||
// Prefix the series key with a non-numeric 'v' so the ?series= URL value is never a
|
||||
// bare decimal — Livewire's #[Url] otherwise type-juggles "0.10" → 0.1 (the trailing
|
||||
// zero is lost), silently switching the selected series on reload. The label strips it.
|
||||
$key = 'v'.$m[1].'.'.$m[2];
|
||||
} else {
|
||||
$key = 'other';
|
||||
}
|
||||
$buckets[$key][] = $rel;
|
||||
}
|
||||
|
||||
$series = [];
|
||||
foreach ($buckets as $key => $rels) {
|
||||
$dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
|
||||
$series[] = [
|
||||
'key' => $key,
|
||||
'label' => match ($key) {
|
||||
'unreleased' => __('versions.unreleased'),
|
||||
'other' => __('versions.series_other'),
|
||||
default => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
|
||||
},
|
||||
'unreleased' => $key === 'unreleased',
|
||||
'count' => count($rels),
|
||||
'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
|
||||
'releases' => $rels,
|
||||
];
|
||||
}
|
||||
|
||||
// Unreleased first, then series by version descending, 'other' last.
|
||||
usort($series, static function (array $a, array $b): int {
|
||||
$rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
|
||||
$ra = $rank($a);
|
||||
$rb = $rank($b);
|
||||
if ($ra !== $rb) {
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
|
||||
return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
|
||||
});
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pagination window — the full range up to 7 pages, otherwise first + last plus a
|
||||
* 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
|
||||
*
|
||||
* @return array<int, int|string>
|
||||
*/
|
||||
private function pageWindow(int $current, int $total): array
|
||||
{
|
||||
if ($total <= 7) {
|
||||
return range(1, $total);
|
||||
}
|
||||
|
||||
$pages = [1];
|
||||
$from = max(2, $current - 1);
|
||||
$to = min($total - 1, $current + 1);
|
||||
if ($from > 2) {
|
||||
$pages[] = '…';
|
||||
}
|
||||
for ($i = $from; $i <= $to; $i++) {
|
||||
$pages[] = $i;
|
||||
}
|
||||
if ($to < $total - 1) {
|
||||
$pages[] = '…';
|
||||
}
|
||||
$pages[] = $total;
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
|
||||
* version, optional date, and the changes grouped by Keep-a-Changelog
|
||||
|
|
@ -416,11 +470,22 @@ class Index extends Component
|
|||
return [];
|
||||
}
|
||||
|
||||
return $this->parseChangelog((string) file_get_contents($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Keep-a-Changelog document into release nodes (shared by the local history and the
|
||||
* remote "what's new" preview).
|
||||
*
|
||||
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
||||
*/
|
||||
private function parseChangelog(string $content): array
|
||||
{
|
||||
$releases = [];
|
||||
$current = null;
|
||||
$group = null;
|
||||
|
||||
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
|
||||
foreach (preg_split('/\R/', $content) as $line) {
|
||||
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
|
||||
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
|
||||
if ($current !== null) {
|
||||
|
|
@ -473,26 +538,132 @@ class Index extends Component
|
|||
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
|
||||
}
|
||||
|
||||
/**
|
||||
* The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the
|
||||
* latest tag, parsed and filtered to released versions newer than the installed one — so the
|
||||
* "what's new" can be read BEFORE applying the update. Empty when there is no update or the
|
||||
* fetch fails (degrade gracefully; the update button is unaffected).
|
||||
*
|
||||
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
|
||||
*/
|
||||
private function availableChangelog(string $installed, ?string $latestTag): array
|
||||
{
|
||||
if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
|
||||
$content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV'));
|
||||
if ($content === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$inst = ltrim($installed, 'vV');
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->parseChangelog($content),
|
||||
static fn (array $r): bool => ! $r['unreleased']
|
||||
&& preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1
|
||||
&& version_compare(ltrim($r['version'], 'vV'), $inst, '>'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The
|
||||
* dev/release-control box ALSO lists its actual update source first (which may be the private
|
||||
* upstream) — staging and stable never do, so a non-dev server can never expose a private source
|
||||
* regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still
|
||||
* config('clusev.repository'); this only governs the displayed links.)
|
||||
*
|
||||
* release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the
|
||||
* host release bridge, so a staging/stable box never has it on — and even if it did, that box's
|
||||
* CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug
|
||||
* ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public
|
||||
* repo — never to a private host.
|
||||
*
|
||||
* @return list<array{url: string, host: string, path: string}>
|
||||
*/
|
||||
private function projectLinks(): array
|
||||
{
|
||||
$slug = trim((string) config('clusev.public_slug'), '/');
|
||||
if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
|
||||
$slug = 'clusev/clusev';
|
||||
}
|
||||
$urls = ['https://github.com/'.$slug];
|
||||
|
||||
// Dev box only: ALSO surface the actual update source (e.g. the private upstream), shown first.
|
||||
// Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather
|
||||
// than rendered as an empty link — the public repo still shows.
|
||||
if (config('clusev.release_controls')) {
|
||||
$source = (string) config('clusev.repository');
|
||||
if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) {
|
||||
array_unshift($urls, $source);
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(static fn (string $u): array => [
|
||||
'url' => $u,
|
||||
'host' => (string) parse_url($u, PHP_URL_HOST),
|
||||
'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'),
|
||||
], $urls);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$releases = $this->releases();
|
||||
$seriesList = $this->groupBySeries($releases);
|
||||
|
||||
// Active series: keep the operator's choice while it still exists, else the newest.
|
||||
$keys = array_column($seriesList, 'key');
|
||||
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
|
||||
$activeSeries = null;
|
||||
foreach ($seriesList as $s) {
|
||||
if ($s['key'] === $activeKey) {
|
||||
$activeSeries = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate the active series' releases so a long history never becomes an endless scroll.
|
||||
$activeReleases = $activeSeries['releases'] ?? [];
|
||||
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
|
||||
$page = min(max(1, $this->changelogPage), $totalPages);
|
||||
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
|
||||
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
|
||||
|
||||
// Mark the series that holds the running version (a small "installed" cue in the rail).
|
||||
$installed = ltrim((string) config('clusev.version'), 'vV');
|
||||
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? 'v'.$m[1].'.'.$m[2] : null;
|
||||
|
||||
// Passive (no network): cached remote result if a check has run, else local .git.
|
||||
$latestTag = $this->resolveLatestTag($this->channel());
|
||||
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
|
||||
// update, before the next check). Never present yourself as behind a release you've
|
||||
// already passed — hide it until a fresh check resolves the true latest.
|
||||
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '<')) {
|
||||
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
|
||||
$latestTag = null;
|
||||
}
|
||||
|
||||
return view('livewire.versions.index', [
|
||||
'version' => config('clusev.version'),
|
||||
'channel' => $this->channel(),
|
||||
'repository' => config('clusev.repository'),
|
||||
'repositories' => $this->projectLinks(),
|
||||
'license' => config('clusev.license'),
|
||||
'build' => $this->build(),
|
||||
'releases' => $releases,
|
||||
'seriesList' => $seriesList,
|
||||
'activeKey' => $activeKey,
|
||||
'activeSeries' => $activeSeries,
|
||||
'pagedReleases' => $pagedReleases,
|
||||
'changelogPage' => $page,
|
||||
'totalPages' => $totalPages,
|
||||
'pageWindow' => $this->pageWindow($page, $totalPages),
|
||||
'currentSeries' => $currentSeries,
|
||||
'totalReleases' => count($releases),
|
||||
'latestTag' => $latestTag,
|
||||
// What the operator would gain by updating — shown BEFORE applying (only when behind).
|
||||
'availableUpdates' => $this->updateState === 'update'
|
||||
? $this->availableChangelog((string) config('clusev.version'), $latestTag)
|
||||
: [],
|
||||
])->title(__('versions.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Wireguard;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\WgTrafficSample;
|
||||
use App\Services\WgBridge;
|
||||
use App\Services\WgStatus;
|
||||
use App\Services\WgTraffic;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
|
||||
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public const WINDOWS = [3600, 86400, 604800];
|
||||
|
||||
/** Peer-name charset, shared by every app-side validation site (the host re-validates too). */
|
||||
private const PEER_NAME_RE = '/^[A-Za-z0-9._-]{1,64}$/';
|
||||
|
||||
public int $window = self::WINDOWS[0];
|
||||
|
||||
/** Active tab when configured: 'peers' (list + traffic) or 'server' (settings). Deep-linkable. */
|
||||
#[Url]
|
||||
public string $tab = 'peers';
|
||||
|
||||
public string $newPeer = '';
|
||||
|
||||
public string $newEndpoint = '';
|
||||
|
||||
public string $newPort = '';
|
||||
|
||||
public string $newSubnet = '';
|
||||
|
||||
public string $newDns = '';
|
||||
|
||||
// first-time setup form
|
||||
public string $setupSubnet = '10.99.0.0/24';
|
||||
|
||||
public string $setupPort = '51820';
|
||||
|
||||
public string $setupEndpoint = '';
|
||||
|
||||
public string $setupPeer = 'client-1';
|
||||
|
||||
/** id of an in-flight write-request we are polling for. */
|
||||
public ?string $pendingId = null;
|
||||
|
||||
public ?string $pendingAction = null;
|
||||
|
||||
/** Unix time the current request started polling — used to time out a non-responding host. */
|
||||
public ?int $pendingSince = null;
|
||||
|
||||
/** Show-once add-peer result (config text + QR svg). Never persisted. */
|
||||
public ?string $resultConfig = null;
|
||||
|
||||
public ?string $resultQr = null;
|
||||
|
||||
/** Peer name of the in-flight request, carried to the result so the download gets a meaningful filename. */
|
||||
public ?string $pendingName = null;
|
||||
|
||||
public ?string $resultName = null;
|
||||
|
||||
/**
|
||||
* Page guard (RBAC): WireGuard is a manage-network capability. The route carries a
|
||||
* `can:manage-network` middleware for the initial GET, but a Livewire component update
|
||||
* (POST /livewire/update) is NOT re-run through the route middleware — so this mount()
|
||||
* check is the real per-request guard for the page itself.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
}
|
||||
|
||||
public function setWindow(int $seconds): void
|
||||
{
|
||||
$this->window = $this->clampWindow($seconds);
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->tab = in_array($tab, ['peers', 'server'], true) ? $tab : 'peers';
|
||||
}
|
||||
|
||||
public function addPeer(WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
|
||||
'newPeer.regex' => __('wireguard.peer_name_invalid'),
|
||||
'newPeer.required' => __('wireguard.peer_name_invalid'),
|
||||
]);
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$name = $this->newPeer;
|
||||
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
|
||||
$this->pendingAction = 'add-peer';
|
||||
$this->pendingName = $name;
|
||||
$this->newPeer = '';
|
||||
$this->audit('wg.add-peer', $name);
|
||||
}
|
||||
|
||||
public function setupWg(WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
$this->validate([
|
||||
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
|
||||
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
|
||||
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
|
||||
'setupPeer' => ['required', 'regex:'.self::PEER_NAME_RE],
|
||||
], [
|
||||
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
|
||||
'setupPort.regex' => __('wireguard.port_invalid'),
|
||||
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
||||
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
|
||||
]);
|
||||
if (! $this->portInRange($this->setupPort)) {
|
||||
$this->addError('setupPort', __('wireguard.port_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('setup', [
|
||||
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
|
||||
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
|
||||
]);
|
||||
$this->pendingAction = 'setup';
|
||||
$this->pendingName = $this->setupPeer;
|
||||
$this->audit('wg.setup', $this->setupSubnet);
|
||||
}
|
||||
|
||||
/** Opens the wire-elements/modal confirm dialog for peer removal. */
|
||||
public function confirmRemovePeer(string $name): void
|
||||
{
|
||||
if (preg_match(self::PEER_NAME_RE, $name) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->openConfirm('wgPeerRemoved', ['name' => $name],
|
||||
__('wireguard.remove_confirm_title'), __('wireguard.remove_confirm_body'),
|
||||
__('wireguard.remove'), danger: true, icon: 'trash');
|
||||
}
|
||||
|
||||
/** Apply handler — called after the ConfirmAction modal confirms the removal. */
|
||||
#[On('wgPeerRemoved')]
|
||||
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
// Re-gate on consume: a token issued while the user still had rights must be
|
||||
// refused if the role was demoted before it was confirmed (tokens are uid-bound).
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $payload['params']['name'] ?? '';
|
||||
if ($name === '' || preg_match(self::PEER_NAME_RE, $name) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removePeer($bridge, $name);
|
||||
}
|
||||
|
||||
public function removePeer(WgBridge $bridge, string $name): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
|
||||
$this->pendingAction = 'remove-peer';
|
||||
$this->audit('wg.remove-peer', $name);
|
||||
}
|
||||
|
||||
public function setEndpoint(WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
|
||||
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
||||
'newEndpoint.required' => __('wireguard.endpoint_invalid'),
|
||||
]);
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]);
|
||||
$this->pendingAction = 'set-endpoint';
|
||||
$this->audit('wg.set-endpoint', $this->newEndpoint);
|
||||
$this->newEndpoint = '';
|
||||
}
|
||||
|
||||
/** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */
|
||||
public function setDns(WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
$this->validate(['newDns' => ['required', 'regex:/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/']], [
|
||||
'newDns.regex' => __('wireguard.dns_invalid'),
|
||||
'newDns.required' => __('wireguard.dns_invalid'),
|
||||
]);
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('set-dns', ['dns' => $this->newDns]);
|
||||
$this->pendingAction = 'set-dns';
|
||||
$this->audit('wg.set-dns', $this->newDns);
|
||||
$this->newDns = '';
|
||||
}
|
||||
|
||||
public function confirmGate(bool $on): void
|
||||
{
|
||||
$this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'],
|
||||
$on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
|
||||
$on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
|
||||
$on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'),
|
||||
danger: ! $on, icon: 'shield');
|
||||
}
|
||||
|
||||
#[On('wgGateToggle')]
|
||||
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runGate($this->onFlag($payload), $bridge);
|
||||
}
|
||||
|
||||
public function runGate(bool $on, ?WgBridge $bridge = null): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$bridge ??= app(WgBridge::class);
|
||||
$this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []);
|
||||
$this->pendingAction = $on ? 'gate-up' : 'gate-down';
|
||||
$this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH lock (port 22). Turning it ON is the most dangerous action on this page: a user with no
|
||||
* surviving WG access locks themselves out of SSH entirely (only the server console / `clusev wg
|
||||
* down` recovers it). The confirm body spells that out and recommends a backup peer first.
|
||||
*/
|
||||
public function confirmSshGate(bool $on): void
|
||||
{
|
||||
$this->openConfirm('wgSshGate', ['on' => $on ? '1' : '0'],
|
||||
$on ? __('wireguard.ssh_lock_on_title') : __('wireguard.ssh_lock_off_title'),
|
||||
$on ? __('wireguard.ssh_lock_on_body') : __('wireguard.ssh_lock_off_body'),
|
||||
$on ? __('wireguard.ssh_lock_turn_on') : __('wireguard.ssh_lock_turn_off'),
|
||||
danger: $on, icon: 'alert');
|
||||
}
|
||||
|
||||
#[On('wgSshGate')]
|
||||
public function applySshGate(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runSshGate($this->onFlag($payload), $bridge);
|
||||
}
|
||||
|
||||
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$bridge ??= app(WgBridge::class);
|
||||
$this->pendingId = $bridge->request($on ? 'gate-ssh-on' : 'gate-ssh-off', []);
|
||||
$this->pendingAction = $on ? 'gate-ssh-on' : 'gate-ssh-off';
|
||||
$this->audit($on ? 'wg.ssh-lock' : 'wg.ssh-unlock', $on ? 'on' : 'off');
|
||||
}
|
||||
|
||||
public function confirmSetPort(): void
|
||||
{
|
||||
$this->validate(
|
||||
['newPort' => ['required', 'regex:/^\d{1,5}$/']],
|
||||
['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')],
|
||||
);
|
||||
if (! $this->portInRange($this->newPort)) {
|
||||
$this->addError('newPort', __('wireguard.port_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->openConfirm('wgSetPort', ['port' => $this->newPort],
|
||||
__('wireguard.port_confirm_title'), __('wireguard.port_confirm_body'),
|
||||
__('wireguard.apply'), danger: true, icon: 'alert');
|
||||
}
|
||||
|
||||
#[On('wgSetPort')]
|
||||
public function applySetPort(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$port = (string) ($payload['params']['port'] ?? '');
|
||||
$this->pendingId = $bridge->request('set-port', ['port' => $port]);
|
||||
$this->pendingAction = 'set-port';
|
||||
$this->audit('wg.set-port', $port);
|
||||
$this->newPort = '';
|
||||
}
|
||||
|
||||
public function confirmSetSubnet(): void
|
||||
{
|
||||
$this->validate(
|
||||
['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']],
|
||||
['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')],
|
||||
);
|
||||
|
||||
$this->openConfirm('wgSetSubnet', ['subnet' => $this->newSubnet],
|
||||
__('wireguard.subnet_confirm_title'), __('wireguard.subnet_confirm_body'),
|
||||
__('wireguard.apply'), danger: true, icon: 'alert');
|
||||
}
|
||||
|
||||
#[On('wgSetSubnet')]
|
||||
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$subnet = (string) ($payload['params']['subnet'] ?? '');
|
||||
$this->pendingId = $bridge->request('set-subnet', ['subnet' => $subnet]);
|
||||
$this->pendingAction = 'set-subnet';
|
||||
$this->audit('wg.set-subnet', $subnet);
|
||||
$this->newSubnet = '';
|
||||
}
|
||||
|
||||
public function pollResult(WgBridge $bridge): void
|
||||
{
|
||||
if ($this->pendingId === null) {
|
||||
return;
|
||||
}
|
||||
$this->pendingSince ??= time(); // first poll after a request — start the clock
|
||||
$res = $bridge->result($this->pendingId);
|
||||
if ($res === null) {
|
||||
// No host response after a while → the host watcher (systemd) likely isn't running yet.
|
||||
// Stop spinning forever and tell the operator instead of leaving "Wird angewendet …".
|
||||
if (time() - $this->pendingSince > 30) {
|
||||
$this->pendingId = null;
|
||||
$this->pendingAction = null;
|
||||
$this->pendingSince = null;
|
||||
$this->dispatch('notify', message: __('wireguard.no_host_response'), level: 'error');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$this->pendingId = null;
|
||||
$this->pendingSince = null;
|
||||
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
|
||||
$this->resultConfig = $res['config'];
|
||||
$this->resultQr = $res['qr'];
|
||||
$this->resultName = $this->pendingName;
|
||||
} elseif (! $res['ok']) {
|
||||
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
|
||||
// Persist the failure so it's readable later in the audit log, not just a transient toast.
|
||||
$this->audit('wg.action-failed', trim(($this->pendingAction ?? '').': '.$msg));
|
||||
$this->dispatch('notify', message: $msg, level: 'error');
|
||||
} else {
|
||||
$this->dispatch('notify', message: __('wireguard.action_done'));
|
||||
}
|
||||
$this->pendingAction = null;
|
||||
$this->pendingName = null;
|
||||
}
|
||||
|
||||
public function dismissResult(): void
|
||||
{
|
||||
$this->resultConfig = null;
|
||||
$this->resultQr = null;
|
||||
$this->resultName = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the show-once client config as a .conf file. The filename becomes the tunnel name when
|
||||
* imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer
|
||||
* name — e.g. "203.0.113.10-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
|
||||
*/
|
||||
public function downloadConfig(): ?StreamedResponse
|
||||
{
|
||||
if ($this->resultConfig === null) {
|
||||
return null;
|
||||
}
|
||||
$cfg = $this->resultConfig;
|
||||
|
||||
$host = preg_match('/^Endpoint\s*=\s*([^:\s]+)/m', $cfg, $m) === 1 ? $m[1] : 'clusev';
|
||||
$parts = array_filter([$host, (string) $this->resultName]);
|
||||
$base = preg_replace('/[^A-Za-z0-9._-]+/', '-', implode('-', $parts));
|
||||
$base = trim((string) $base, '-.') ?: 'clusev-wireguard';
|
||||
|
||||
return response()->streamDownload(function () use ($cfg) {
|
||||
echo $cfg;
|
||||
}, $base.'.conf', ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/** Clamp a traffic window to one of the allowed values, falling back to the shortest. */
|
||||
private function clampWindow(int $seconds): int
|
||||
{
|
||||
return in_array($seconds, self::WINDOWS, true) ? $seconds : self::WINDOWS[0];
|
||||
}
|
||||
|
||||
/** True when a numeric-string port is within the valid UDP range. */
|
||||
private function portInRange(string $port): bool
|
||||
{
|
||||
return (int) $port >= 1 && (int) $port <= 65535;
|
||||
}
|
||||
|
||||
/** Decode the sealed on/off flag a gate-toggle confirm token carries. */
|
||||
private function onFlag(array $payload): bool
|
||||
{
|
||||
return ($payload['params']['on'] ?? '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared ConfirmAction modal (R5) for one WG action. The issued token carries NO audit
|
||||
* descriptor on purpose — each apply handler audits exactly once itself, so auditing here too
|
||||
* would double-count. Heading/body/label/danger/icon/params are the only per-action differences.
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => $heading,
|
||||
'body' => $body,
|
||||
'confirmLabel' => $confirmLabel,
|
||||
'danger' => $danger,
|
||||
'icon' => $icon,
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue($event, $params),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function throttle(): bool
|
||||
{
|
||||
$key = 'wg-request:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
|
||||
|
||||
return false;
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function audit(string $action, string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(WgStatus $wg, WgTraffic $traffic)
|
||||
{
|
||||
$window = $this->clampWindow($this->window);
|
||||
|
||||
// Traffic samples land at most once per minute (clusev:wg-sample), but the page polls
|
||||
// every 5s (wire:poll) — so re-bucketing the whole WgTrafficSample history on every poll
|
||||
// is ~11/12 wasted work. Cache the aggregate, keyed on the window AND a data fingerprint
|
||||
// (MAX(id) busts on any insert, COUNT busts on a prune) so new/changed data invalidates it
|
||||
// immediately — MAX(sampled_at) alone could collide for two sample sets in the same second.
|
||||
$fp = WgTrafficSample::query()->selectRaw('COALESCE(MAX(id), 0) AS mid, COUNT(*) AS cnt')->first();
|
||||
$series = Cache::remember(
|
||||
"wg:traffic:series:{$window}:{$fp->mid}:{$fp->cnt}",
|
||||
60,
|
||||
fn () => $traffic->series($window),
|
||||
);
|
||||
|
||||
return view('livewire.wireguard.index', [
|
||||
'status' => $wg->read(),
|
||||
'traffic' => $series,
|
||||
'windows' => self::WINDOWS,
|
||||
])->title(__('wireguard.title'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,14 @@ 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'];
|
||||
|
|
@ -31,4 +35,57 @@ class AuditEvent extends Model
|
|||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake-login (honeypot) attempts in the last 24h for the sidebar Threats badge. Cached (the
|
||||
* sidebar renders on every page) but busted the instant one is recorded (forgetThreatLoginCount),
|
||||
* so the badge stays in step with the Threats page instead of lagging up to the TTL.
|
||||
*/
|
||||
public static function threatLoginCount24h(): int
|
||||
{
|
||||
return (int) Cache::remember(
|
||||
self::THREAT_COUNT_CACHE,
|
||||
60,
|
||||
fn () => static::where('action', 'security.honeypot_login')->where('created_at', '>', now()->subDay())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
public static function forgetThreatLoginCount(): void
|
||||
{
|
||||
Cache::forget(self::THREAT_COUNT_CACHE);
|
||||
}
|
||||
|
||||
/** Actions that record a failure or security event — surfaced with a warning style. */
|
||||
private const ERROR_ACTIONS = [
|
||||
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
|
||||
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
|
||||
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
|
||||
'security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used',
|
||||
'service.start.failed', 'service.stop.failed', 'service.restart.failed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable, localized label for the raw action code (e.g. "wg.set-endpoint" ->
|
||||
* "WireGuard-Endpoint geändert"). Falls back to a tidied form for unmapped/dynamic codes
|
||||
* (e.g. the dynamic "harden.*.on|off") so nothing ever shows a bare machine code awkwardly.
|
||||
*/
|
||||
public function getActionLabelAttribute(): string
|
||||
{
|
||||
$labels = (array) trans('audit.actions');
|
||||
|
||||
return $labels[$this->action] ?? $this->prettyAction();
|
||||
}
|
||||
|
||||
/** True when the entry represents a failure / security alert (drives the warning styling). */
|
||||
public function getIsErrorAttribute(): bool
|
||||
{
|
||||
return in_array($this->action, self::ERROR_ACTIONS, true);
|
||||
}
|
||||
|
||||
private function prettyAction(): string
|
||||
{
|
||||
$s = str_replace(['_', '-'], ' ', str_replace('.', ' · ', (string) $this->action));
|
||||
|
||||
return Str::ucfirst(trim($s));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BannedIp extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = ['banned_until' => 'datetime'];
|
||||
|
||||
/** Non-expired bans only. */
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('banned_until', '>', now());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -30,4 +32,26 @@ 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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,9 +26,27 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Notifications\QueuedResetPassword;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
|
|
@ -15,7 +16,7 @@ use Illuminate\Support\Str;
|
|||
use PragmaRX\Google2FA\Exceptions\Google2FAException;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
|
||||
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
|
||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
|
@ -31,6 +32,8 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +146,18 @@ 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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WgTrafficSample extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'rx' => 'integer',
|
||||
'tx' => 'integer',
|
||||
'sampled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -2,17 +2,20 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\DeploymentService;
|
||||
use App\Support\MailSettings;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Throwable;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -36,6 +39,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(DeploymentService $deployment): void
|
||||
{
|
||||
// Drop Vite's <link rel="preload"> hints: with only one CSS + one JS entry the preload
|
||||
// sits right next to the matching stylesheet/script tags, so it adds no real value — but
|
||||
// it triggers Chrome's "preloaded but not used" console warning (especially with DevTools
|
||||
// "Disable cache", which double-fetches the CSS so the preloaded copy is never consumed).
|
||||
Vite::usePreloadTagAttributes(false);
|
||||
|
||||
// Re-apply the onboarding gate to EVERY Livewire component update, not just the
|
||||
// initial page GET. Livewire only re-runs route middleware that is registered as
|
||||
// persistent; without this, a component mounted while onboarded could keep running
|
||||
|
|
@ -45,6 +54,14 @@ 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
|
||||
|
|
@ -70,50 +87,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
config(['app.url' => 'https://'.$domain]);
|
||||
}
|
||||
|
||||
$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.
|
||||
}
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidIpOrCidr implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$v = is_string($value) ? trim($value) : '';
|
||||
|
||||
if (filter_var($v, FILTER_VALIDATE_IP) !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! str_contains($v, '/')) {
|
||||
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$ip, $mask] = explode('/', $v, 2);
|
||||
$max = str_contains($ip, ':') ? 128 : 32;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false || ! ctype_digit($mask) || (int) $mask < 0 || (int) $mask > $max) {
|
||||
$fail(__('settings.lp_whitelist_invalid', ['value' => $v]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?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.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
<?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) ?: [])));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\BannedIp;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
/**
|
||||
* Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
|
||||
* the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
|
||||
* not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
|
||||
*/
|
||||
class BruteforceGuard
|
||||
{
|
||||
/** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
|
||||
private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.0/104'];
|
||||
|
||||
/** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
|
||||
private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";
|
||||
|
||||
/** Ban-check cache TTL (seconds). Stale window on natural expiry; keep <= bantime()/2. */
|
||||
private const CHECK_TTL = 30;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Setting::get('bruteforce_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
public function maxretry(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
|
||||
}
|
||||
|
||||
/** findtime in MINUTES. */
|
||||
public function findtime(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_findtime', '10'));
|
||||
}
|
||||
|
||||
/** bantime in MINUTES. */
|
||||
public function bantime(): int
|
||||
{
|
||||
return max(1, (int) Setting::get('bruteforce_bantime', '60'));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function whitelist(): array
|
||||
{
|
||||
$raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);
|
||||
|
||||
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
|
||||
}
|
||||
|
||||
public function isExempt(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return true; // unknown/malformed IP → never count or ban
|
||||
}
|
||||
$ip = $this->canonical($ip); // fold IPv4-mapped IPv6 so exemption matches the canonical form
|
||||
|
||||
foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) {
|
||||
if ($this->matchesCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function record(?string $ip, string $reason): void
|
||||
{
|
||||
if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$canonical = $this->canonical($ip);
|
||||
$key = 'bruteforce:'.md5($canonical);
|
||||
$hits = RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds; hit() returns the count
|
||||
|
||||
if ($hits < $this->maxretry()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ban + audit at most once per IP per window even if two requests cross the threshold
|
||||
// concurrently (the upsert is idempotent; this also de-dupes the audit row).
|
||||
if (! Cache::add('bruteforce:banning:'.$canonical, true, 60)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomic upsert keyed on the unique ip index. Eloquent upsert needs explicit timestamps
|
||||
// for the bulk path; a single $now keeps created_at/updated_at identical.
|
||||
$now = now();
|
||||
BannedIp::upsert(
|
||||
[[
|
||||
'ip' => $canonical,
|
||||
'banned_until' => $now->copy()->addMinutes($this->bantime()),
|
||||
'reason' => $reason,
|
||||
'attempts' => $hits,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]],
|
||||
['ip'],
|
||||
['banned_until', 'reason', 'attempts', 'updated_at'],
|
||||
);
|
||||
RateLimiter::clear($key);
|
||||
Cache::forget('bruteforce:banned:'.$canonical);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => null,
|
||||
'actor' => 'system',
|
||||
'action' => 'auth.ip_banned',
|
||||
'target' => $canonical,
|
||||
'ip' => $canonical,
|
||||
'meta' => ['reason' => $reason, 'attempts' => $hits],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the
|
||||
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
|
||||
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
|
||||
* throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
|
||||
* trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
|
||||
* disabled brute-force protection must not silently disable honeypot bans). Returns true when a
|
||||
* ban was written, false when it no-op'd (invalid / exempt IP) — the caller stays a cheap no-op
|
||||
* for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
|
||||
*/
|
||||
public function banNow(?string $ip, string $reason): bool
|
||||
{
|
||||
if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$canonical = $this->canonical($ip);
|
||||
|
||||
$now = now();
|
||||
BannedIp::upsert(
|
||||
[[
|
||||
'ip' => $canonical,
|
||||
'banned_until' => $now->copy()->addMinutes($this->bantime()),
|
||||
'reason' => $reason,
|
||||
'attempts' => $this->maxretry(),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]],
|
||||
['ip'],
|
||||
['banned_until', 'reason', 'attempts', 'updated_at'],
|
||||
);
|
||||
Cache::forget('bruteforce:banned:'.$canonical);
|
||||
|
||||
// Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
|
||||
// looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
|
||||
// once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
|
||||
// includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
|
||||
// honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
|
||||
// rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
|
||||
// which is minutes of human time later, well past this window (so it is not tied to unban()).
|
||||
$auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason;
|
||||
if (Cache::add($auditKey, 1, 60)) {
|
||||
try {
|
||||
AuditEvent::create([
|
||||
'user_id' => null,
|
||||
'actor' => 'system',
|
||||
'action' => 'auth.ip_banned',
|
||||
'target' => $canonical,
|
||||
'ip' => $canonical,
|
||||
'meta' => ['reason' => $reason],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// The Cache::add above atomically CLAIMED the dedup slot before the write. If the
|
||||
// write throws (a transient DB blip), release the slot so the 60s window is not
|
||||
// burned with no audit row — the next attempt re-claims and re-audits this ban.
|
||||
Cache::forget($auditKey);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $ip has an active ban. Does NOT check enabled() — the BlockBannedIp middleware
|
||||
* gates on enabled() before calling this, so disabling the feature stops enforcement while
|
||||
* existing rows persist and resume if it is re-enabled (spec §7).
|
||||
*/
|
||||
public function isBanned(string $ip): bool
|
||||
{
|
||||
if ($this->isExempt($ip)) {
|
||||
return false; // exempt short-circuits — whitelisting unblocks immediately
|
||||
}
|
||||
|
||||
$canonical = $this->canonical($ip);
|
||||
|
||||
return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query()
|
||||
->where('ip', $canonical)
|
||||
->where('banned_until', '>', now())
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function unban(string $ip): void
|
||||
{
|
||||
$canonical = $this->canonical($ip);
|
||||
BannedIp::query()->where('ip', $canonical)->delete();
|
||||
Cache::forget('bruteforce:banned:'.$canonical);
|
||||
}
|
||||
|
||||
/** Delete every ban (active or expired) and bust each cache key. Returns the count removed. */
|
||||
public function unbanAll(): int
|
||||
{
|
||||
$ips = BannedIp::query()->pluck('ip');
|
||||
foreach ($ips as $ip) {
|
||||
Cache::forget('bruteforce:banned:'.$this->canonical($ip));
|
||||
}
|
||||
BannedIp::query()->delete();
|
||||
|
||||
return $ips->count();
|
||||
}
|
||||
|
||||
/** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */
|
||||
public function matchesCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
if (! str_contains($cidr, '/')) {
|
||||
$a = @inet_pton($ip);
|
||||
$b = @inet_pton($cidr);
|
||||
|
||||
return $a !== false && $b !== false && $a === $b;
|
||||
}
|
||||
|
||||
[$subnet, $maskStr] = explode('/', $cidr, 2);
|
||||
$pip = @inet_pton($ip);
|
||||
$psub = @inet_pton($subnet);
|
||||
if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) {
|
||||
return false; // v4-vs-v6 mismatch or junk mask
|
||||
}
|
||||
|
||||
$mask = (int) $maskStr;
|
||||
if ($mask > strlen($pip) * 8) {
|
||||
return false; // mask wider than the address family (e.g. IPv4 /33)
|
||||
}
|
||||
$bytes = intdiv($mask, 8);
|
||||
$rem = $mask % 8;
|
||||
if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) {
|
||||
return false;
|
||||
}
|
||||
if ($rem === 0) {
|
||||
return true;
|
||||
}
|
||||
$b = 0xFF & (0xFF << (8 - $rem));
|
||||
|
||||
return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b);
|
||||
}
|
||||
|
||||
private function canonical(string $ip): string
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false) {
|
||||
return $ip;
|
||||
}
|
||||
// Fold an IPv4-mapped IPv6 (::ffff:a.b.c.d) down to plain IPv4 so the same client can't
|
||||
// dodge a ban by switching between the two forms (one ban row + one cache key per IP).
|
||||
if (strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) {
|
||||
$packed = substr($packed, 12);
|
||||
}
|
||||
|
||||
return (string) inet_ntop($packed);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Runs one (arbitrary, user-supplied) command across a set of servers and collects a per-server
|
||||
* result. The command is INTENTIONALLY arbitrary — it runs via FleetService::runPlain (the
|
||||
* credential's login user, base64 transport, no extra sudo) exactly as the web terminal would.
|
||||
* One server failing (connect/exec) never aborts the batch. Remote output is byte-capped +
|
||||
* mb_scrub'd so it can safely land in a Livewire property.
|
||||
*/
|
||||
class CommandRunner
|
||||
{
|
||||
private const MAX_BYTES = 262144; // 256 KiB per server
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Server> $servers
|
||||
* @return array<int, array{server:string, ok:bool, output:string}>
|
||||
*/
|
||||
public function run(string $command, Collection $servers, int $timeout = 60): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$res = $this->fleet->runPlain($server, $command, $timeout);
|
||||
$results[] = ['server' => $server->name, 'ok' => (bool) $res['ok'], 'output' => $this->clean($res['output'])];
|
||||
} catch (Throwable $e) {
|
||||
$results[] = ['server' => $server->name, 'ok' => false, 'output' => $this->clean($e->getMessage())];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function clean(string $output): string
|
||||
{
|
||||
return mb_scrub(mb_strcut($output, 0, self::MAX_BYTES), 'UTF-8');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue