Compare commits
421 Commits
v0.4.4
...
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 | |
|
|
92969eb0dc | |
|
|
4bcdafbeff | |
|
|
7d35f3abac | |
|
|
703207c03c | |
|
|
07020a9ea0 | |
|
|
6b5bc6938c | |
|
|
8b0db1b7ef | |
|
|
deea1a802c | |
|
|
81a9e4d641 | |
|
|
997b624b0f | |
|
|
fceb0eef77 | |
|
|
1c3d3d6a01 | |
|
|
9410db1b40 | |
|
|
1e6fdb457e | |
|
|
b1fb0e766a | |
|
|
9215a5b908 | |
|
|
b9b4b333e1 | |
|
|
21c087a11f | |
|
|
1fb6b34fd1 | |
|
|
4f1ea8ef0f | |
|
|
d6d37583d0 | |
|
|
f2cbf60c04 | |
|
|
62158eecdc | |
|
|
e58a08b974 | |
|
|
2520b87982 | |
|
|
db179ad98a | |
|
|
3d86c8db8d | |
|
|
217189598b | |
|
|
9035078ac0 | |
|
|
4a3c572a89 | |
|
|
c54932b532 | |
|
|
02741539ca | |
|
|
fd3834731e | |
|
|
d44f6930bb | |
|
|
cb3360b235 | |
|
|
6290cec6ab | |
|
|
5ab665321f | |
|
|
57a3dd51b6 | |
|
|
bcf27d25ab | |
|
|
b91895e103 | |
|
|
9a6c09e488 | |
|
|
dd891cd11e | |
|
|
bdccd05cd9 | |
|
|
9ee3fb520e | |
|
|
02d74bb147 | |
|
|
7e1d342844 | |
|
|
4eeff4916a | |
|
|
d384a7d918 | |
|
|
10f594c08d | |
|
|
79c25fbf83 | |
|
|
3399a5017a | |
|
|
73eecd03e0 | |
|
|
bb1ad53030 | |
|
|
4ced37ac40 | |
|
|
0cdc6c3a7b | |
|
|
5f6ad27329 | |
|
|
5eb3deff0b | |
|
|
cfa442d084 | |
|
|
883b6f66fe | |
|
|
77fee98452 | |
|
|
8cf8c771c8 | |
|
|
8b1bbf401a | |
|
|
dc885e293b | |
|
|
991e17fcee | |
|
|
923e0d4e1c | |
|
|
bd6ba03c3c | |
|
|
8ef907d429 | |
|
|
99a183bcc7 | |
|
|
19fc6e1f34 | |
|
|
cc8e60af5a | |
|
|
e8843454a6 | |
|
|
588f5aee2f | |
|
|
8d7f2da8d0 | |
|
|
6f8f5dbc2b | |
|
|
b847b80ab6 | |
|
|
3629f18844 | |
|
|
103453877b | |
|
|
4f4b7225f5 | |
|
|
5b1e7e5168 | |
|
|
61df4497d7 | |
|
|
00337e038c | |
|
|
aa43947dcf | |
|
|
55d513f241 | |
|
|
c19a35e43d | |
|
|
6a4ba16480 | |
|
|
a8616809f1 | |
|
|
e9a6a6c083 | |
|
|
82d5644767 | |
|
|
9a01bfedf7 | |
|
|
1273d8290d | |
|
|
44d4046526 | |
|
|
bdec68f661 | |
|
|
3bbd6bf096 | |
|
|
0b1b5357d2 | |
|
|
7ea789c078 | |
|
|
0088122ff1 | |
|
|
a69551a7eb | |
|
|
f4c24d51a6 | |
|
|
6e0ec724ca | |
|
|
784566d06b | |
|
|
5ae0b7d135 | |
|
|
2b04a18023 | |
|
|
44b87d78ba | |
|
|
139c376056 | |
|
|
98a39e1257 | |
|
|
5704d6c0f7 | |
|
|
fe5c190eb0 | |
|
|
7afa50d253 | |
|
|
0cb9b073ce | |
|
|
54ebb49941 | |
|
|
78b1017844 | |
|
|
eef79d1758 | |
|
|
27ebc570f4 | |
|
|
7faa5c7d00 | |
|
|
197e6f7e49 | |
|
|
2ff2cc34c0 | |
|
|
3a67aa99ec | |
|
|
f6a65827fd | |
|
|
abaed12b0b | |
|
|
cb8b906aa8 | |
|
|
764e1e5e53 | |
|
|
b2cb94bb32 | |
|
|
f3e595ed45 | |
|
|
63fb1686a4 | |
|
|
f0a337c8bd | |
|
|
a0ef0e6d9b | |
|
|
ecf5bf5fc5 | |
|
|
75dcfc11d3 | |
|
|
0fcfd31305 | |
|
|
d132efd861 | |
|
|
c5d3de6e0b |
|
|
@ -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
|
||||
|
|
|
|||
47
.env.example
47
.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
|
||||
|
|
@ -26,7 +26,7 @@ DB_PASSWORD=change-me-strong
|
|||
# mariadb container only (compose), not read by Laravel:
|
||||
DB_ROOT_PASSWORD=change-me-strong-root
|
||||
|
||||
# ── Cache / Queue / Session via Redis ────────────────────────────────
|
||||
# ── Cache / Queue via Redis · Session via database ───────────────────
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
|
@ -34,7 +34,8 @@ REDIS_PASSWORD=null
|
|||
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
SESSION_DRIVER=redis
|
||||
# database = sessions are listable/revocable in Settings (Redis stays for cache/queue)
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
|
|
@ -46,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):
|
||||
|
|
@ -64,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.
|
||||
|
|
@ -80,12 +86,45 @@ IMAGE_TAG=latest
|
|||
APP_DOMAIN=
|
||||
APP_SCHEME=http
|
||||
ACME_EMAIL=
|
||||
# Deployed commit + branch, shown on the Version page. install.sh fills these from the host's
|
||||
# .git (the prod image ships none) — leave empty; in dev the page reads .git directly.
|
||||
CLUSEV_BUILD_SHA=
|
||||
CLUSEV_BUILD_BRANCH=
|
||||
# Update source for the in-app version check. install.sh sets this from the clone origin
|
||||
# (git remote get-url origin). Left UNSET (keep this commented), it falls back to the public repo
|
||||
# default in config/clusev.php — an empty value here would instead disable the check, so leave the
|
||||
# key absent rather than blank. Never put a private repo URL in a tracked file — only in the .env.
|
||||
# CLUSEV_REPOSITORY=https://github.com/clusev/clusev
|
||||
# Dev-only release controls (dashboard Release page + host git-bridge + live pipeline). Leave unset on
|
||||
# any non-dev install. Both values below are PRIVATE — never commit them, set only on the dev box:
|
||||
# CLUSEV_RELEASE_CONTROLS=true # enables the /release page + watcher install
|
||||
# GIT_STAGING_ACCESS_TOKEN= # GitHub PAT, READ only (Actions:Read + Contents:Read)
|
||||
# CLUSEV_STAGING_SLUG=owner/private-mirror # the GitHub-private mirror repo (for live CI status)
|
||||
# Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default.
|
||||
APP_TIMEZONE=UTC
|
||||
# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its
|
||||
# X-Forwarded-Proto. Leave as 127.0.0.1/32 (trust nothing) when Caddy terminates TLS itself.
|
||||
TRUSTED_PROXY_CIDR=127.0.0.1/32
|
||||
# Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"):
|
||||
SITE_ADDRESS=:80
|
||||
# Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io/<owner>/clusev@sha256:..).
|
||||
CLUSEV_IMAGE=clusev-app:prod
|
||||
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
|
||||
UPDATE_HMAC_KEY=
|
||||
# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies
|
||||
# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned
|
||||
# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must
|
||||
# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key.
|
||||
# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers
|
||||
# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents
|
||||
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
|
||||
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
|
||||
TERMINAL_SIDECAR_SECRET=
|
||||
# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates
|
||||
# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait).
|
||||
CLUSEV_HONEYPOT_CANARY_APP=
|
||||
CLUSEV_HONEYPOT_CANARY_DB=
|
||||
CLUSEV_HONEYPOT_CANARY_API=
|
||||
|
||||
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
|
||||
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
1065
CHANGELOG.md
1065
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
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.
|
||||
242
README.md
242
README.md
|
|
@ -1,83 +1,223 @@
|
|||
# Clusev
|
||||
<div align="center">
|
||||
|
||||
Self-hosted control panel to administer a **fleet of Linux servers from one dashboard**,
|
||||
agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers
|
||||
with **phpseclib** (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted
|
||||
SSH-credential vault, and a full audit log. Multi-server is free and never paywalled.
|
||||
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
|
||||
|
||||
> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details,
|
||||
> auth + 2FA. UI copy is German; meta-docs are English.
|
||||
<br>
|
||||
|
||||
**Stack:** Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3.
|
||||
Everything runs in Docker. See `CLAUDE.md` for architecture and `rules.md` for the hard conventions.
|
||||
**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>
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## What is Clusev
|
||||
|
||||
The host needs only Docker (no PHP/Composer/Node). The dev `app` container runs php-fpm + nginx +
|
||||
Vite via supervisor; `reverb`, `queue`, `mariadb`, `redis` are their own services.
|
||||
**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.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD
|
||||
docker compose up -d --build # app on :80, Vite HMR on :5173
|
||||
docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate
|
||||
docker compose exec app php artisan migrate --seed
|
||||
```
|
||||
> **Multi-server management is free and never paywalled.** Optional Pro modules (SSO/LDAP, RBAC,
|
||||
> audit export, alerting) are separate — the core stays open.
|
||||
|
||||
Run any tooling inside the container, e.g.
|
||||
`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt).
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## Production install
|
||||
## Features
|
||||
|
||||
One host, only Docker + a sudo user. **Caddy** is the single host-exposed service: auto-TLS when a
|
||||
domain is set, plain HTTP on the bare IP otherwise. `install.sh` is idempotent — it generates
|
||||
secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin
|
||||
with a **one-time random password printed only on the terminal**.
|
||||
| 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 interface is **German by default** (English available). Status is shown the operational way —
|
||||
colour, dots and pills — never emoji.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
<div align="center">
|
||||
<img src="art/clusev-architecture.svg" alt="Operator → Clusev control plane → your fleet" width="100%">
|
||||
</div>
|
||||
|
||||
Laravel is the **control plane**, not a re-implementation of your servers. The browser talks to it
|
||||
over HTTPS and a Reverb WebSocket for live telemetry; it reaches each server over **SSH exec + SFTP**
|
||||
using credentials sealed in an encrypted vault that never leaves the control plane. Everything runs as
|
||||
one Docker stack — `app` (php-fpm + nginx + Vite), `reverb`, `queue`, `scheduler`, `mariadb`,
|
||||
`redis` — on a single VM.
|
||||
|
||||
| Layer | Technology |
|
||||
|:--|:--|
|
||||
| Framework | **Laravel 13** |
|
||||
| UI / interactivity | **Livewire 3** (class-based) |
|
||||
| Styling | **Tailwind v4** (CSS-first, `@theme`) |
|
||||
| Realtime | **Laravel Reverb** + Echo |
|
||||
| Queue / cache | **Redis** |
|
||||
| Database | **MariaDB** |
|
||||
| SSH transport | **phpseclib** (exec + SFTP) |
|
||||
| Reverse proxy / TLS | **Caddy** (automatic Let's Encrypt) |
|
||||
| Packaging | **Docker** + Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://git.bave.dev/boban/clusev.git && cd clusev
|
||||
./install.sh
|
||||
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
|
||||
git clone https://github.com/clusev/clusev.git
|
||||
cd clusev
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment):
|
||||
The installer is **idempotent** (safe to re-run) and, in one pass:
|
||||
|
||||
```
|
||||
Domain (empty = access by IP over HTTP): clusev.example.com
|
||||
Admin e-mail (login + Let's Encrypt): admin@example.com
|
||||
```
|
||||
1. Installs **Docker** (Debian/Ubuntu, from Docker's official repository) if it isn't already present.
|
||||
2. Creates a dedicated **`clusev`** system user — in the `docker` group, owning and running the stack
|
||||
— with a random password.
|
||||
3. Installs over the server's **IP address (plain HTTP)** — no domain is asked for. Prompts only for
|
||||
the **HTTP port** (default 80) and an **admin e-mail** (default **`admin@clusev.local`**), or reads
|
||||
`CLUSEV_ADMIN_EMAIL` / an optional `CLUSEV_DOMAIN` from the environment for an unattended install. A
|
||||
domain with automatic HTTPS is set later in the dashboard — see [Access, domain & TLS](#access-domain--tls).
|
||||
4. Generates all secrets (once — never regenerated on re-run), builds the image, starts the stack,
|
||||
runs the migrations, and creates the first administrator with a **random initial password** printed
|
||||
in the closing summary.
|
||||
5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status.
|
||||
|
||||
The closing banner shows the URL + the one-time admin password. On first login Clusev **forces** a
|
||||
password change and 2FA enrolment before the panel unlocks.
|
||||
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
|
||||
(your e-mail — `admin@clusev.local` if you left it empty — plus a **random initial password**), and
|
||||
the **`clusev` host user** + its own random password. Both passwords are shown **only once** — note
|
||||
them down.
|
||||
|
||||
### One knob: `APP_DOMAIN`
|
||||
### First login
|
||||
|
||||
| `APP_DOMAIN` | proxy | URL | Reverb |
|
||||
|---|---|---|---|
|
||||
| *(empty)* | Caddy serves plain HTTP on `APP_PORT` | `http://<ip>` | `ws://<ip>/app/*` |
|
||||
| `clusev.example.com` | Caddy gets a Let's Encrypt cert, forces HTTPS | `https://…` | `wss://…/app/*` |
|
||||
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).
|
||||
|
||||
`SITE_ADDRESS`, `APP_URL`, `REVERB_*` are **derived** from it by the installer — nothing hardcoded.
|
||||
Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs
|
||||
publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead.
|
||||
---
|
||||
|
||||
### Manual deploy (for operators who don't `curl | bash`)
|
||||
## 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 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
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
|
||||
docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com
|
||||
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
|
||||
```
|
||||
|
||||
> In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified,
|
||||
> 2FA-gated, with backup + rollback) are designed in `docs/install-update-design.md` and land in
|
||||
> v1.x — they are intentionally **not** shipped yet.
|
||||
`sudo clusev update` fast-forwards the repo (it never discards local changes), re-runs the idempotent
|
||||
installer non-interactively, and updates itself if the script changed. Secrets and the configured
|
||||
domain / e-mail are preserved. (The older two-step `git pull && sudo ./install.sh` still works.)
|
||||
|
||||
---
|
||||
|
||||
## Security & 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 +
|
||||
new password) as a fallback.
|
||||
|
||||
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
|
||||
|
||||
```bash
|
||||
clusev reset-admin
|
||||
```
|
||||
|
||||
This clears the second factor so you can set a new password on the next login. The bare-IP
|
||||
`http://<server-ip>` address is also always available if a domain becomes unreachable. (This command
|
||||
is documented in-panel under Settings → Security and is deliberately not shown on the public
|
||||
forgot-password screen.)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,18 +9,27 @@ use Illuminate\Support\Str;
|
|||
/**
|
||||
* Idempotent first-run bootstrap (called by install.sh, phase 7).
|
||||
*
|
||||
* Creates the initial admin with a one-time random password that is printed
|
||||
* exactly once on stdout — never written to .env, a file, or the database in
|
||||
* plaintext. Re-running once an admin exists is a hard no-op (INSTALL_LOCK
|
||||
* idiom): no default credential, no "first-visitor-wins" claim window.
|
||||
* 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 e-mail (login + Let's-Encrypt contact) when none is given. A fixed,
|
||||
* predictable value so a fresh, unattended install always logs in at admin@clusev.local —
|
||||
* not a host-derived address the operator would have to guess. Override with --email /
|
||||
* CLUSEV_ADMIN_EMAIL, or change it later under Settings → Profile.
|
||||
*/
|
||||
private const DEFAULT_EMAIL = 'admin@clusev.local';
|
||||
|
||||
protected $signature = "clusev:install
|
||||
{--email= : Admin-Login + Let's-Encrypt-E-Mail}
|
||||
{--name=Administrator : Anzeigename des Admins}";
|
||||
|
||||
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Einmal-Zufallspasswort an';
|
||||
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit zufälligem Initialpasswort an (Wechsel empfohlen, nicht erzwungen)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -30,8 +39,7 @@ class Install extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost';
|
||||
$email = (string) ($this->option('email') ?: "admin@{$host}");
|
||||
$email = (string) ($this->option('email') ?: self::DEFAULT_EMAIL);
|
||||
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error("Ungueltige Admin-E-Mail: {$email}");
|
||||
|
|
@ -39,22 +47,24 @@ class Install extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$password = Str::password(20);
|
||||
// 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 rotate + 2FA on first login
|
||||
'must_change_password' => true, // nudges a rotation on first login (optional)
|
||||
'role' => 'admin', // the first/root account is a full administrator
|
||||
]);
|
||||
|
||||
// One-time password marker — the ONLY place the plaintext appears.
|
||||
// install.sh greps these two lines for the closing banner; never stored.
|
||||
// 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. Passwort erscheint nur jetzt und wird nicht gespeichert.');
|
||||
$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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Enforce the audit-log retention policy: delete audit_events older than the
|
||||
* configured number of days. The policy lives in the `audit_retention_days`
|
||||
* setting (managed from the Audit page); empty or 0 means "keep forever".
|
||||
*
|
||||
* Scheduled daily in routes/console.php.
|
||||
*/
|
||||
class PruneAudit extends Command
|
||||
{
|
||||
protected $signature = 'clusev:prune-audit';
|
||||
|
||||
protected $description = 'Prune audit_events older than the configured retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) Setting::get('audit_retention_days', '0');
|
||||
|
||||
if ($days <= 0) {
|
||||
$this->info('Aufbewahrung unbegrenzt — nichts zu tun');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$deleted = AuditEvent::where('created_at', '<', now()->subDays($days))->delete();
|
||||
|
||||
$this->info("Aufbewahrung {$days} Tage — {$deleted} Audit-Einträge gelöscht");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Emergency, shell-only recovery: resets the admin password (and optionally 2FA)
|
||||
* when the operator is locked out (lost password + authenticator + backup codes).
|
||||
* Requires server access, so it is a safe last resort.
|
||||
*/
|
||||
class ResetAdmin extends Command
|
||||
{
|
||||
protected $signature = 'clusev:reset-admin
|
||||
{--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
|
||||
{--password= : Neues Passwort (zufällig erzeugt, falls leer)}
|
||||
{--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';
|
||||
|
||||
protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = (string) $this->option('email');
|
||||
$user = $email !== ''
|
||||
? User::where('email', $email)->first()
|
||||
: (User::count() === 1 ? User::first() : null);
|
||||
|
||||
if (! $user) {
|
||||
$this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$password = (string) ($this->option('password') ?: Str::password(20));
|
||||
// Rotate remember_token so any previously issued remember-me cookie is revoked.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'must_change_password' => false,
|
||||
'remember_token' => Str::random(60),
|
||||
// Restore full admin rights — this is a lockout-recovery path, so the operator must
|
||||
// regain the control plane regardless of the account's prior role.
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
if ($this->option('disable-2fa')) {
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
]);
|
||||
// Drop security keys too — a key that prompted the emergency reset must not
|
||||
// silently revive when 2FA is re-enabled.
|
||||
$user->webauthnCredentials()->delete();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
$this->info("Passwort für {$user->email} zurückgesetzt.");
|
||||
if (! $this->option('password')) {
|
||||
$this->warn("Neues Passwort (jetzt notieren): {$password}");
|
||||
}
|
||||
if ($this->option('disable-2fa')) {
|
||||
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 security onboarding before the panel is reachable:
|
||||
* 1) rotate the seeded password, then 2) enrol 2FA.
|
||||
* 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,16 +18,10 @@ class EnsureSecurityOnboarded
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user) {
|
||||
$allowed = $request->routeIs('password.change', 'two-factor.setup', 'logout');
|
||||
|
||||
if ($user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
|
||||
return redirect()->route('password.change');
|
||||
}
|
||||
|
||||
if (! $user->must_change_password && ! $user->hasTwoFactorEnabled() && ! $allowed) {
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
if ($user && $user->must_change_password
|
||||
&& ! $request->session()->get('onboarding.password_skipped')
|
||||
&& ! $request->routeIs('password.change', 'logout')) {
|
||||
return redirect()->route('password.change');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
|||
use App\Services\DeploymentService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
|
|
@ -43,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);
|
||||
}
|
||||
|
||||
|
|
@ -68,10 +70,22 @@ class PanelScheme
|
|||
// Force HTTPS for the active domain — on the default TLS port (443). Build the URL
|
||||
// explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL
|
||||
// (Caddy serves TLS only on 443; https://domain:8080 would be unreachable).
|
||||
if (! $request->isSecure()) {
|
||||
//
|
||||
// In external-proxy mode the upstream terminates TLS and owns HTTP->HTTPS; this Caddy is
|
||||
// HTTP-only, so the app must NOT bounce to https://domain:443 (unreachable). The forwarded
|
||||
// scheme still drives cookie-Secure/HSTS above.
|
||||
if (! $this->deployment->externalTls() && ! $request->isSecure()) {
|
||||
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
|
||||
}
|
||||
|
||||
// Serving the active domain over HTTPS: force generated URLs (assets, routes) to
|
||||
// https://<domain> so they MATCH the page scheme. Critical in external-proxy mode, where
|
||||
// the upstream terminates TLS and the forwarded scheme may not be trusted here — without
|
||||
// this asset() emits http:// URLs that the HTTPS page's CSP ('self') blocks, leaving the
|
||||
// panel with no CSS/JS. The bare-IP recovery path returned earlier and stays on HTTP.
|
||||
URL::forceRootUrl('https://'.$domain);
|
||||
URL::forceScheme('https');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,30 @@ class SetLocale
|
|||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $request->user()?->locale
|
||||
?? $request->session()->get('locale')
|
||||
?? config('app.locale');
|
||||
self::apply($request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve + set the active locale: signed-in user's preference, then the session
|
||||
* choice, then the app default. Safe to call before the session is started — e.g.
|
||||
* from the exception handler for errors thrown before this middleware (CSRF 419,
|
||||
* unmatched-route 404, maintenance 503) — a missing session is simply skipped, so
|
||||
* those custom error pages still honour the user's language where it is known.
|
||||
*/
|
||||
public static function apply(Request $request): void
|
||||
{
|
||||
$locale = config('app.locale');
|
||||
|
||||
if ($request->hasSession()) {
|
||||
$locale = $request->user()?->locale
|
||||
?? $request->session()->get('locale')
|
||||
?? $locale;
|
||||
}
|
||||
|
||||
if (in_array($locale, self::SUPPORTED, true)) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,48 +3,151 @@
|
|||
namespace App\Livewire\Audit;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Models\Setting;
|
||||
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 = '';
|
||||
|
||||
/**
|
||||
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
|
||||
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
|
||||
* Aufbewahrungsdauer in Tagen (Formularfeld). Leer/0 = unbegrenzt; ein
|
||||
* positiver Wert N löscht beim täglichen Prune Einträge älter als N Tage.
|
||||
*/
|
||||
protected function events(): Collection
|
||||
public ?string $retentionDays = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$events = AuditEvent::with('server')->latest()->limit(50)->get();
|
||||
// Empty/0 means "keep forever" — surface that as a blank field, not "0".
|
||||
$stored = (int) Setting::get('audit_retention_days', '0');
|
||||
$this->retentionDays = $stored > 0 ? (string) $stored : null;
|
||||
}
|
||||
|
||||
/** A changed search must restart at page 1 — never land on an out-of-range page. */
|
||||
public function updatedQ(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/** Persist the retention policy, audit the change and notify the operator. */
|
||||
public function saveRetention(): void
|
||||
{
|
||||
// Panel-wide policy mutation (a short retention prunes audit history) — admin only. Viewing
|
||||
// the audit log stays open to everyone; only changing the retention window is gated.
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
$validated = $this->validate([
|
||||
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
|
||||
]);
|
||||
|
||||
$days = $validated['retentionDays'] !== null && $validated['retentionDays'] !== ''
|
||||
? (int) $validated['retentionDays']
|
||||
: 0;
|
||||
|
||||
Setting::put('audit_retention_days', $days > 0 ? (string) $days : null);
|
||||
$this->retentionDays = $days > 0 ? (string) $days : null;
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'audit.retention_set',
|
||||
'target' => 'audit_retention_days='.$days,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('audit.retention_saved_notify'));
|
||||
}
|
||||
|
||||
/** Effective retention in days (0 = unlimited) for the policy summary. */
|
||||
protected function retentionPolicy(): int
|
||||
{
|
||||
return $this->retentionDays !== null && $this->retentionDays !== ''
|
||||
? (int) $this->retentionDays
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit events, newest first, optionally filtered. The filter runs in SQL (so it spans the whole
|
||||
* history, not just one page) and matches actor / action code / target / server name — plus the
|
||||
* READABLE action labels: a term is translated to the action codes whose localized label contains
|
||||
* it, so searching "Endpoint geändert" still finds wg.set-endpoint entries.
|
||||
*/
|
||||
protected function eventsQuery(): Builder
|
||||
{
|
||||
$query = AuditEvent::with('server')->latest();
|
||||
|
||||
$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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Models\WebauthnCredential;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class ForgotPassword extends Component
|
||||
{
|
||||
/** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
|
||||
private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $code = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/** Reveals the secondary inline 2FA-proof form when a mailer is configured. */
|
||||
public bool $showCodeReset = false;
|
||||
|
||||
/** True when a real mailer is configured (then the email-link option is offered too). */
|
||||
public function mailEnabled(): bool
|
||||
{
|
||||
return ! in_array(config('mail.default'), ['log', 'array', null], true);
|
||||
}
|
||||
|
||||
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
|
||||
public function sendResetLink(): void
|
||||
{
|
||||
if (! $this->mailEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate(['email' => ['required', 'email']]);
|
||||
|
||||
// The notification is queued (see User::sendPasswordResetNotification), so this only ever
|
||||
// pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither
|
||||
// latency nor an exception can distinguish a known address from an unknown one.
|
||||
try {
|
||||
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
|
||||
} catch (\Throwable $e) {
|
||||
// The client response stays identical regardless — but surface the failure to ops
|
||||
// (report() → logs), otherwise a broken queue/token store silently "succeeds" for
|
||||
// every address and a broken reset flow goes unnoticed.
|
||||
report($e);
|
||||
}
|
||||
|
||||
// Generic — never reveal whether the email exists.
|
||||
$this->dispatch('notify', message: __('auth.reset_link_sent'));
|
||||
}
|
||||
|
||||
/** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */
|
||||
public function resetPassword()
|
||||
{
|
||||
$this->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'code' => ['required', 'string'],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
]);
|
||||
|
||||
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
|
||||
// path is a second TOTP/backup brute-force surface (email + code, no password), so a
|
||||
// distributed (multi-IP) attempt must be capped per account the same way the challenge
|
||||
// is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open).
|
||||
$email = strtolower($this->email);
|
||||
$key = 'forgot:'.md5($email.'|'.request()->ip());
|
||||
$acctKey = 'forgot-acct:'.md5($email);
|
||||
foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) {
|
||||
if (RateLimiter::tooManyAttempts($k, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::where('email', $this->email)->first();
|
||||
|
||||
// Resolve 2FA status with a *uniform* lookup pattern: always exactly one
|
||||
// webauthn-existence query — for a TOTP user that wouldn't otherwise need it, and for a
|
||||
// missing account — so the presence/absence of that query can't tell accounts apart.
|
||||
$hasWebauthn = $user
|
||||
? $user->hasWebauthnCredentials()
|
||||
: WebauthnCredential::whereKey(0)->exists();
|
||||
$hasTotp = (bool) $user?->hasTotp();
|
||||
$has2fa = $hasTotp || $hasWebauthn;
|
||||
|
||||
if ($user && $has2fa) {
|
||||
// Keep the TOTP HMAC uniform: a webauthn-only account has no TOTP secret, so
|
||||
// verifyTotp() returns without hashing — spend an equivalent throwaway HMAC first so
|
||||
// this branch costs the same as a TOTP account would.
|
||||
if (! $hasTotp) {
|
||||
$this->dummyTotpVerify();
|
||||
}
|
||||
$ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
|
||||
} else {
|
||||
// No account, or one without 2FA: run the same HMAC + locked recovery lookup a real
|
||||
// wrong-code attempt does, so the verification path is not a timing/query oracle that
|
||||
// distinguishes a known address.
|
||||
$this->dummyTotpVerify();
|
||||
$this->dummyRecoveryLookup();
|
||||
$ok = false;
|
||||
}
|
||||
|
||||
if (! $ok) {
|
||||
RateLimiter::hit($key, 60); // 5 / minute (email+IP)
|
||||
RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
|
||||
// Generic message — never reveal whether the email exists.
|
||||
throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
RateLimiter::clear($acctKey);
|
||||
// Rotate remember_token too, so a stolen remember-me cookie can't survive the reset.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
'must_change_password' => false,
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => $user->id,
|
||||
'actor' => $user->name ?? 'system',
|
||||
'action' => 'password.reset',
|
||||
'target' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
session()->flash('status', __('auth.reset_done'));
|
||||
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for User::verifyTotp() — one throwaway Google2FA HMAC against a fixed dummy secret,
|
||||
* so a request with no (or no TOTP) account spends the same crypto as a real wrong-code
|
||||
* attempt. It must NOT do heavier work than the real path (a cost-12 bcrypt here would make
|
||||
* this slower, re-opening the oracle in reverse and amplifying CPU DoS). Result is discarded.
|
||||
*/
|
||||
private function dummyTotpVerify(): void
|
||||
{
|
||||
try {
|
||||
(new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? '');
|
||||
} catch (\Throwable) {
|
||||
// ignore — verifyTotp swallows the same failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for User::useRecoveryCode() — a locked SELECT in a transaction + an in_array scan,
|
||||
* matching the dominant DB cost of the recovery path without touching a real row (id 0 never
|
||||
* exists, so nothing is locked). Exceptions are deliberately NOT caught: a DB failure must
|
||||
* surface here exactly as it would in the real path, otherwise an outage becomes an
|
||||
* error-response oracle (generic for an unknown address, 500 for a real account).
|
||||
*/
|
||||
private function dummyRecoveryLookup(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
$row = User::whereKey(0)->lockForUpdate()->first();
|
||||
in_array($this->code, $row?->recoveryCodes() ?? array_fill(0, 8, ''), true);
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -14,6 +16,14 @@ use Livewire\Component;
|
|||
#[Layout('layouts.auth')]
|
||||
class Login extends Component
|
||||
{
|
||||
/**
|
||||
* A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one
|
||||
* Hash::check against this so the failure branch costs the same bcrypt as a known
|
||||
* account — closing the response-timing oracle that would otherwise reveal which
|
||||
* addresses exist (same class as the password-reset constant-time fix).
|
||||
*/
|
||||
private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u';
|
||||
|
||||
#[Validate('required|email')]
|
||||
public string $email = '';
|
||||
|
||||
|
|
@ -26,22 +36,51 @@ class Login extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
$key = 'login:'.md5(strtolower($this->email).'|'.request()->ip());
|
||||
// Three auto-expiring counters, none of which can permanently lock the control plane
|
||||
// (all decay in minutes; the host CLI `clusev:reset-admin` is always available):
|
||||
// - pair (email+IP): the tight 5/min bucket (unchanged).
|
||||
// - ip : caps one source hammering many accounts AND the bcrypt CPU it burns.
|
||||
// - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the
|
||||
// single admin account is still capped instead of scaling linearly with IPs.
|
||||
$email = strtolower($this->email);
|
||||
$pairKey = 'login:'.md5($email.'|'.request()->ip());
|
||||
$ipKey = 'login-ip:'.md5(request()->ip());
|
||||
$acctKey = 'login-acct:'.md5($email);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) {
|
||||
if (RateLimiter::tooManyAttempts($k, $max)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::where('email', $this->email)->first();
|
||||
|
||||
if (! $user || ! Hash::check($this->password, $user->password)) {
|
||||
RateLimiter::hit($key, 60);
|
||||
// Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend
|
||||
// one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the
|
||||
// faster no-account response would re-open the enumeration timing oracle.
|
||||
$validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH);
|
||||
|
||||
if (! $user || ! $validPassword) {
|
||||
RateLimiter::hit($pairKey, 60); // 5 / minute
|
||||
RateLimiter::hit($ipKey, 60); // 20 / minute
|
||||
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
|
||||
$ip = request()->ip();
|
||||
app(BruteforceGuard::class)->record($ip, 'login');
|
||||
AuditEvent::create([
|
||||
'user_id' => null,
|
||||
'actor' => 'system',
|
||||
'action' => 'auth.login_failed',
|
||||
'target' => $this->email,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
// Only the tight pair bucket is cleared on success; the broader ip/acct counters
|
||||
// decay on their own so one valid login can't reset a flood's budget.
|
||||
RateLimiter::clear($pairKey);
|
||||
|
||||
// 2FA gate: hold the login until the TOTP code is verified.
|
||||
if ($user->hasTwoFactorEnabled()) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ namespace App\Livewire\Auth;
|
|||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -19,21 +21,52 @@ class PasswordChange extends Component
|
|||
|
||||
public function update()
|
||||
{
|
||||
$this->validate([
|
||||
'current' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
|
||||
], attributes: [
|
||||
'current' => __('auth.attr_current_password'),
|
||||
'password' => __('auth.attr_new_password'),
|
||||
]);
|
||||
// Throttle the current-password re-auth so a hijacked session can't brute-force the
|
||||
// password. Keyed on the authenticated user id (never IP) and auto-expiring, so it can
|
||||
// only slow the user's own session briefly — never a cross-user or control-plane lockout.
|
||||
$key = 'reauth:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate([
|
||||
'current' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
], attributes: [
|
||||
'current' => __('auth.attr_current_password'),
|
||||
'password' => __('auth.attr_new_password'),
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('current', $e->errors())) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
Auth::user()->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
'must_change_password' => false,
|
||||
])->save();
|
||||
|
||||
// onboarding continues: the middleware sends an un-enrolled user to 2FA setup.
|
||||
return $this->redirect(route('two-factor.setup'), navigate: true);
|
||||
// 2FA is optional now — go straight to the panel; 2FA is offered in Settings.
|
||||
return $this->redirect(route('dashboard'), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the current password for now — rotation is not forced. Remember the choice for this session
|
||||
* so the prompt does not reappear; `must_change_password` stays true, so the persistent default-
|
||||
* password banner keeps warning until the password is actually changed.
|
||||
*/
|
||||
public function skip()
|
||||
{
|
||||
session()->put('onboarding.password_skipped', true);
|
||||
|
||||
return $this->redirect(route('dashboard'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class ResetPassword extends Component
|
||||
{
|
||||
public string $token = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public function mount(string $token): void
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->email = (string) request()->query('email', '');
|
||||
}
|
||||
|
||||
/** Consume the emailed reset token and set a new password. */
|
||||
public function resetPassword()
|
||||
{
|
||||
$this->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', PasswordRule::min(6)],
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
[
|
||||
'email' => $this->email,
|
||||
'password' => $this->password,
|
||||
'password_confirmation' => $this->password_confirmation,
|
||||
'token' => $this->token,
|
||||
],
|
||||
function ($user) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
'must_change_password' => false,
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => $user->id,
|
||||
'actor' => $user->name ?? 'system',
|
||||
'action' => 'password.reset',
|
||||
'target' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
event(new PasswordReset($user));
|
||||
},
|
||||
);
|
||||
|
||||
if ($status !== Password::PasswordReset) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]);
|
||||
}
|
||||
|
||||
session()->flash('status', __('auth.reset_done'));
|
||||
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.reset-password')->title(__('auth.title_forgot'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 +2,19 @@
|
|||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use App\Services\WebauthnService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class TwoFactorChallenge extends Component
|
||||
{
|
||||
use CompletesTwoFactorChallenge;
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $code = '';
|
||||
|
||||
|
|
@ -22,43 +23,65 @@ class TwoFactorChallenge extends Component
|
|||
if (! session()->has('2fa.user')) {
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
|
||||
// No TOTP and webauthnAvailable() is false (no registered key, or — for a key-only user —
|
||||
// no secure context on http + bare IP) → the backup code is the only path: go to its view.
|
||||
if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
|
||||
return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function verify()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
$user = $this->pendingUser();
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled()) {
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
|
||||
}
|
||||
|
||||
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
|
||||
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
|
||||
// secret would break verifyKey); always allow a one-time backup (recovery) code.
|
||||
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
|
||||
|
||||
if (! $valid) {
|
||||
RateLimiter::hit($key, 60);
|
||||
$this->recordFailedAttempt('2fa');
|
||||
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$this->clearRateLimit();
|
||||
|
||||
$remember = (bool) session('2fa.remember');
|
||||
session()->forget(['2fa.user', '2fa.remember']);
|
||||
return $this->completeLogin($user);
|
||||
}
|
||||
|
||||
Auth::login($user, $remember);
|
||||
session()->regenerate();
|
||||
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
|
||||
public function assertionOptions(WebauthnService $webauthn): array
|
||||
{
|
||||
$user = User::find(session('2fa.user'));
|
||||
abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);
|
||||
|
||||
return $this->redirectIntended(route('dashboard'), navigate: true);
|
||||
return $webauthn->assertionOptions($user);
|
||||
}
|
||||
|
||||
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
|
||||
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
|
||||
{
|
||||
$this->assertNotRateLimited();
|
||||
|
||||
$user = User::find(session('2fa.user'));
|
||||
|
||||
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
|
||||
$this->recordFailedAttempt('2fa');
|
||||
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
|
||||
}
|
||||
|
||||
$this->clearRateLimit();
|
||||
|
||||
return $this->completeLogin($user);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Illuminate\Validation\ValidationException;
|
|||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use PragmaRX\Google2FA\Exceptions\Google2FAException;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
|
|
@ -19,8 +20,8 @@ class TwoFactorSetup extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
if (Auth::user()->hasTwoFactorEnabled()) {
|
||||
return $this->redirect(route('dashboard'), navigate: true);
|
||||
if (Auth::user()->hasTotp()) {
|
||||
return $this->redirect(route('settings'), navigate: true);
|
||||
}
|
||||
|
||||
$this->secret = (new Google2FA)->generateSecretKey();
|
||||
|
|
@ -30,7 +31,13 @@ class TwoFactorSetup extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
|
||||
try {
|
||||
$matches = (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code) ?? '');
|
||||
} catch (Google2FAException $e) {
|
||||
$matches = false;
|
||||
}
|
||||
|
||||
if (! $matches) {
|
||||
throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]);
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +46,21 @@ class TwoFactorSetup extends Component
|
|||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $this->redirect(route('dashboard'), navigate: true);
|
||||
// First factor enrolled and no codes yet → generate the single recovery set and
|
||||
// ask Settings to pop the modal (the app layout hosts wire-elements/modal).
|
||||
if (! Auth::user()->hasRecoveryCodes()) {
|
||||
Auth::user()->replaceRecoveryCodes();
|
||||
session()->flash('open_recovery_modal', true);
|
||||
// put (not flash): must survive the redirect AND the later openModal Livewire
|
||||
// request; the modal pulls it on mount to reveal the freshly generated codes once.
|
||||
session()->put('2fa.codes_fresh', now()->timestamp);
|
||||
// One-time download grant (a timestamp) — the download route requires + consumes
|
||||
// it within a short window, so the fresh set can be downloaded once and a later
|
||||
// direct hit 403s.
|
||||
session()->put('2fa.download_grant', now()->timestamp);
|
||||
}
|
||||
|
||||
return $this->redirect(route('settings'), navigate: true);
|
||||
}
|
||||
|
||||
public function qrCode(): string
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ namespace App\Livewire\Files;
|
|||
|
||||
use App\Livewire\Concerns\WithFleetContext;
|
||||
use App\Services\FleetService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -58,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();
|
||||
}
|
||||
|
||||
|
|
@ -78,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();
|
||||
|
|
@ -98,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) {
|
||||
|
|
@ -107,7 +120,7 @@ class Index extends Component
|
|||
try {
|
||||
$content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name));
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: $e->getMessage());
|
||||
$this->dispatch('notify', message: __('files.download_failed', ['error' => $e->getMessage()]));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -118,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) {
|
||||
|
|
@ -165,11 +182,17 @@ class Index extends Component
|
|||
*/
|
||||
public function confirmDelete(int $index): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
$name = $this->entries[$index]['name'] ?? null;
|
||||
if ($name === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Seal the FULL path + the server id so the token cannot be retargeted to
|
||||
// another directory (client-held $path) or host (session-selected server).
|
||||
$fullPath = rtrim($this->path, '/')."/{$name}";
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
|
|
@ -178,24 +201,41 @@ class Index extends Component
|
|||
'confirmLabel' => __('common.delete'),
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'file.delete',
|
||||
'auditTarget' => rtrim($this->path, '/')."/{$name}",
|
||||
'event' => 'fileConfirmed',
|
||||
'params' => ['name' => $name],
|
||||
'notify' => __('files.delete_notify', ['name' => $name]),
|
||||
'token' => ConfirmToken::issue(
|
||||
'fileConfirmed',
|
||||
['path' => $fullPath],
|
||||
'file.delete',
|
||||
$fullPath,
|
||||
$this->activeServer()?->id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed deletion over SFTP, then reloads the directory. */
|
||||
#[On('fileConfirmed')]
|
||||
public function deleteEntry(string $name, FleetService $fleet): void
|
||||
public function deleteEntry(string $confirmToken, FleetService $fleet): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$active = $this->activeServer();
|
||||
|
||||
if ($active && $active->credential_exists) {
|
||||
if (! $active || ($payload['serverId'] ?? null) !== $active->id) {
|
||||
return; // token sealed for a different server — refuse to retarget
|
||||
}
|
||||
|
||||
// Delete the SEALED path, never a client-recombined one.
|
||||
$path = $payload['params']['path'];
|
||||
|
||||
if ($active->credential_exists) {
|
||||
try {
|
||||
$fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name);
|
||||
$fleet->deleteFile($active, $path);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,26 @@
|
|||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Generic R5 confirmation dialog for destructive / stateful actions.
|
||||
*
|
||||
* The opener passes copy + an audit descriptor + a follow-up event. On confirm
|
||||
* we persist exactly one AuditEvent and re-dispatch the opener's event so the
|
||||
* originating page can apply its own state change. The modal stays
|
||||
* The opener mints a signed, single-use {@see ConfirmToken} that seals the follow-up
|
||||
* event + its params + the audit descriptor, and passes ONLY that token (plus display
|
||||
* copy) to the modal. On confirm we re-verify the token, persist exactly one
|
||||
* AuditEvent from the SEALED descriptor (never from client input), and re-dispatch the
|
||||
* sealed event so the originating page can apply its own state change — handing the
|
||||
* same token on so the apply handler can burn it (single-use). The modal stays
|
||||
* domain-agnostic and is reused for every confirm in the app (R5).
|
||||
*
|
||||
* Routing lives only in the token: the modal exposes no client-mutable event/params/
|
||||
* audit fields, and the token itself is #[Locked] so it cannot be swapped after open.
|
||||
*/
|
||||
class ConfirmAction extends ModalComponent
|
||||
{
|
||||
|
|
@ -26,59 +36,75 @@ class ConfirmAction extends ModalComponent
|
|||
|
||||
public string $icon = '';
|
||||
|
||||
/** Audit descriptor — one row written on confirm. */
|
||||
public string $auditAction = '';
|
||||
|
||||
public ?string $auditTarget = null;
|
||||
|
||||
public ?int $serverId = null;
|
||||
|
||||
/** Follow-up event re-dispatched to the page after a successful confirm. */
|
||||
public string $event = '';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $params = [];
|
||||
|
||||
/** Toast shown on confirm. Pass '' to defer the notification to the handler
|
||||
* (e.g. when the real outcome is only known after a remote command runs);
|
||||
* leave null to fall back to the generic default toast. */
|
||||
public ?string $notify = null;
|
||||
|
||||
/** The sealed routing input — the ONLY thing that decides what runs/gets audited. */
|
||||
#[Locked]
|
||||
public string $token = '';
|
||||
|
||||
public function mount(
|
||||
string $token,
|
||||
string $heading = '',
|
||||
string $body = '',
|
||||
string $confirmLabel = '',
|
||||
bool $danger = false,
|
||||
string $icon = '',
|
||||
?string $notify = null,
|
||||
): void {
|
||||
$this->token = $token;
|
||||
$this->body = $body;
|
||||
$this->danger = $danger;
|
||||
$this->icon = $icon;
|
||||
|
||||
// Localized fallbacks for any copy the opener did not pass.
|
||||
$this->heading = $heading !== '' ? $heading : __('modals.confirm_action.default_heading');
|
||||
$this->confirmLabel = $confirmLabel !== '' ? $confirmLabel : __('common.confirm');
|
||||
$this->notify = $notify ?? __('modals.confirm_action.default_notify');
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'md';
|
||||
}
|
||||
|
||||
/** Apply localized fallbacks for any copy the opener did not pass. */
|
||||
public function boot(): void
|
||||
/** Audit-target line for the view — read from the signed token, not client state. */
|
||||
#[Computed]
|
||||
public function targetLabel(): ?string
|
||||
{
|
||||
if ($this->heading === '') {
|
||||
$this->heading = __('modals.confirm_action.default_heading');
|
||||
}
|
||||
if ($this->confirmLabel === '') {
|
||||
$this->confirmLabel = __('common.confirm');
|
||||
}
|
||||
if ($this->notify === null) {
|
||||
$this->notify = __('modals.confirm_action.default_notify');
|
||||
}
|
||||
return ConfirmToken::peek($this->token)['auditTarget'] ?? null;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
if ($this->auditAction !== '') {
|
||||
try {
|
||||
// Flip the token pending → confirmed (single transition). This IS the
|
||||
// confirmation: an apply handler may only consume a confirmed token.
|
||||
$payload = ConfirmToken::confirm($this->token);
|
||||
} catch (InvalidConfirmToken) {
|
||||
// Forged / tampered / expired / already-confirmed — never audit or dispatch.
|
||||
$this->dispatch('notify', message: __('modals.confirm_action.rejected'));
|
||||
$this->closeModal();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (($payload['auditAction'] ?? '') !== '') {
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $this->serverId,
|
||||
'server_id' => $payload['serverId'] ?? null,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $this->auditAction,
|
||||
'target' => $this->auditTarget,
|
||||
'action' => $payload['auditAction'],
|
||||
'target' => $payload['auditTarget'] ?? null,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->event !== '') {
|
||||
$this->dispatch($this->event, ...$this->params);
|
||||
}
|
||||
// Re-dispatch the sealed event, handing the token on so the apply handler can
|
||||
// burn it (single-use) and read the sealed params itself.
|
||||
$this->dispatch($payload['event'], confirmToken: $this->token);
|
||||
|
||||
// Empty notify = the handler will report the real outcome itself.
|
||||
if ($this->notify !== '') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts
|
||||
* default to `operator` — least privilege). Restricted to admins (`manage-users`). The creator
|
||||
* may set a password directly, or leave it blank — then a strong initial password is generated, the
|
||||
* account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal
|
||||
* (never persisted or logged). An operator-set password is the user's own, so no forced rotation.
|
||||
*/
|
||||
class CreateUser extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/** New account role (admin > operator > viewer). Defaults to operator — least privilege. */
|
||||
public string $role = 'operator';
|
||||
|
||||
/** Optional operator-set password; blank = generate an initial password and reveal it once. */
|
||||
public string $password = '';
|
||||
|
||||
/** Clear-text temp password, held in component state only to reveal it once. */
|
||||
public ?string $tempPassword = null;
|
||||
|
||||
/** Name of the account just created, used in the reveal copy. */
|
||||
public string $createdName = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
// Optional — when set it must meet the same strength as a self-chosen password.
|
||||
'password' => ['nullable', 'string', Password::min(6)],
|
||||
'role' => ['required', Rule::enum(Role::class)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function validationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('accounts.name_label'),
|
||||
'email' => __('accounts.email_label'),
|
||||
'password' => __('accounts.password_label'),
|
||||
'role' => __('accounts.role_label'),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$data = $this->validate();
|
||||
|
||||
// Re-check uniqueness server-side regardless of the live validator state.
|
||||
$email = mb_strtolower(trim($data['email']));
|
||||
if (User::where('email', $email)->exists()) {
|
||||
$this->addError('email', __('validation.unique', ['attribute' => __('accounts.email_label')]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Operator-set password = the user's own login (no forced rotation). Blank = generate a strong
|
||||
// initial password that the user is prompted to rotate, and reveal it once below.
|
||||
$operatorSet = $this->password !== '';
|
||||
$password = $operatorSet ? $this->password : Str::password(16);
|
||||
|
||||
$user = User::create([
|
||||
'name' => trim($data['name']),
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'must_change_password' => ! $operatorSet,
|
||||
'role' => $data['role'],
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'user.create',
|
||||
'target' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
// Operator already knows the password they set → no reveal; just confirm and close.
|
||||
if ($operatorSet) {
|
||||
$this->dispatch('usersChanged');
|
||||
$this->dispatch('notify', message: __('accounts.created_notify', ['name' => $user->name]));
|
||||
$this->closeModal();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reveal the generated initial password ONCE — swaps the form for the reveal panel.
|
||||
$this->createdName = $user->name;
|
||||
$this->tempPassword = $password;
|
||||
}
|
||||
|
||||
/** "Fertig": reload the list and close. */
|
||||
public function finish(): void
|
||||
{
|
||||
$this->dispatch('usersChanged');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.create-user');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\Fail2banService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Scrollable list of fail2ban jails + their currently-banned IPs, lifted out of the
|
||||
* servers/show panel so a long ban list never stretches the page. mount() only stores
|
||||
* the serverId so the modal opens INSTANTLY; load() (fired by wire:init, behind a
|
||||
* skeleton) does the live jail-status read via Fail2banService (the SAME service the
|
||||
* page uses). unban() calls Fail2banService::unban (the SAME path as Show::unbanIp),
|
||||
* audits the success, refreshes this modal's list, and dispatches `fail2banChanged`
|
||||
* so the page panel re-reads too.
|
||||
*/
|
||||
class Fail2banBans extends ModalComponent
|
||||
{
|
||||
#[Locked]
|
||||
public int $serverId;
|
||||
|
||||
/** Jail status rows (name/currentlyBanned/currentlyFailed/bannedIps) for the list. */
|
||||
public array $jails = [];
|
||||
|
||||
/** True once the live status was read (distinguishes "no bans" from "not loaded"). */
|
||||
public bool $loaded = false;
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
public function mount(int $serverId): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
// Store the id ONLY so the modal opens instantly; the SSH read happens in load().
|
||||
$this->serverId = $serverId;
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
/** Lazy: the SSH read runs after the modal renders (wire:init), behind a skeleton. */
|
||||
public function load(Fail2banService $fail2ban): void
|
||||
{
|
||||
$this->loadJails($fail2ban);
|
||||
}
|
||||
|
||||
/** Read the live jail/banned-IP status (same Fail2banService::status the page calls). */
|
||||
private function loadJails(Fail2banService $fail2ban): void
|
||||
{
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $fail2ban->status($server);
|
||||
$this->jails = $status['jails'] ?? [];
|
||||
$this->error = ! empty($status['readError']) ? __('servers.fail2ban_read_error') : null;
|
||||
$this->loaded = true;
|
||||
} catch (Throwable $e) {
|
||||
$this->error = Str::limit($e->getMessage(), 120);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban one IP — mirrors Show::unbanIp: call Fail2banService::unban, audit the
|
||||
* success, refresh this list, and dispatch `fail2banChanged` so the page panel
|
||||
* re-reads its own fail2ban state.
|
||||
*/
|
||||
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $fail2ban->unban($server, $jail, $ip);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $res['ok']) {
|
||||
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'fail2ban.unban',
|
||||
'target' => $ip.' · '.$jail.' · '.$server->name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
|
||||
|
||||
// Re-read this modal's list, then tell the page panel to re-pull its own state.
|
||||
$this->loadJails($fail2ban);
|
||||
$this->dispatch('fail2banChanged');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.fail2ban-bans');
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
|
|||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\FleetService;
|
||||
use App\Support\Ssh\Sftp;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
|
@ -12,9 +13,16 @@ use Throwable;
|
|||
/**
|
||||
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
|
||||
* binary or oversized files, and writes back on save with an AuditEvent.
|
||||
* Image files are shown as a read-only base64 preview instead of the editor.
|
||||
*/
|
||||
class FileEditor extends ModalComponent
|
||||
{
|
||||
/** Image preview is capped — larger images are refused (base64 inflates ~33%). */
|
||||
private const IMAGE_MAX_BYTES = 5_242_880; // 5 MB
|
||||
|
||||
/** Extensions rendered as an inline image preview (read-only). */
|
||||
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
|
||||
|
||||
public int $serverId;
|
||||
|
||||
public string $path;
|
||||
|
|
@ -31,6 +39,9 @@ class FileEditor extends ModalComponent
|
|||
|
||||
public ?string $error = null;
|
||||
|
||||
/** base64 `data:` URI for image previews; null for text files. */
|
||||
public ?string $dataUri = null;
|
||||
|
||||
public function mount(int $serverId, string $path, string $name): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
|
|
@ -43,8 +54,35 @@ class FileEditor extends ModalComponent
|
|||
return 'lg';
|
||||
}
|
||||
|
||||
public function load(FleetService $fleet): void
|
||||
/** True when the opened file is an image (preview, not editable). */
|
||||
public function isImage(): bool
|
||||
{
|
||||
return in_array($this->extension(), self::IMAGE_EXTENSIONS, true);
|
||||
}
|
||||
|
||||
private function extension(): string
|
||||
{
|
||||
return strtolower(pathinfo($this->name, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/** MIME type for the image preview data URI, keyed off the extension. */
|
||||
private function imageMime(): string
|
||||
{
|
||||
return match ($this->extension()) {
|
||||
'svg' => 'image/svg+xml',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'ico' => 'image/x-icon',
|
||||
default => 'image/'.$this->extension(),
|
||||
};
|
||||
}
|
||||
|
||||
public function load(FleetService $fleet, Sftp $sftp): void
|
||||
{
|
||||
// The actual content read (SFTP get / readFile). Gate it directly — not just the
|
||||
// Files::edit entry point — so file bytes the server credential can reach (root →
|
||||
// /etc/shadow, keys, .env) are never disclosed to a read-only viewer.
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
|
|
@ -54,10 +92,17 @@ class FileEditor extends ModalComponent
|
|||
}
|
||||
|
||||
try {
|
||||
$r = $fleet->readFile($server, $this->path);
|
||||
$this->content = $r['content'];
|
||||
$this->binary = $r['binary'];
|
||||
$this->tooBig = $r['tooBig'];
|
||||
if ($this->isImage()) {
|
||||
$this->loadImage($server, $sftp);
|
||||
} else {
|
||||
$r = $fleet->readFile($server, $this->path);
|
||||
$this->binary = $r['binary'];
|
||||
$this->tooBig = $r['tooBig'];
|
||||
// Never bind raw non-UTF-8 bytes to a public property: Livewire JSON-encodes the
|
||||
// snapshot and invalid UTF-8 makes json_encode fail, so the client receives an empty
|
||||
// response ("undefined is not valid JSON"). Binary/oversize files show a notice instead.
|
||||
$this->content = ($r['binary'] || $r['tooBig']) ? '' : $r['content'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -65,10 +110,46 @@ class FileEditor extends ModalComponent
|
|||
$this->loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the image bytes over SFTP and expose them as a base64 data URI.
|
||||
* Refuses files larger than IMAGE_MAX_BYTES (checked before download).
|
||||
*/
|
||||
private function loadImage(Server $server, Sftp $sftp): void
|
||||
{
|
||||
$sftp->connect($server);
|
||||
|
||||
try {
|
||||
if ($sftp->size($this->path) > self::IMAGE_MAX_BYTES) {
|
||||
$this->tooBig = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = $sftp->get($this->path);
|
||||
|
||||
if ($bytes === '') {
|
||||
$this->error = __('modals.file_editor.image_read_failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dataUri = 'data:'.$this->imageMime().';base64,'.base64_encode($bytes);
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function save(FleetService $fleet): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
$this->error = null;
|
||||
|
||||
// Images are a read-only preview — never write them back.
|
||||
if ($this->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
|
||||
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
|
||||
*
|
||||
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
|
||||
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
|
||||
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
|
||||
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
|
||||
* a replayed/stale snapshot has nothing to re-render and fires no event — captured codes
|
||||
* cannot be re-revealed by replaying a signed snapshot.
|
||||
*/
|
||||
class RecoveryCodes extends ModalComponent
|
||||
{
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// One-time, server-set flag (a timestamp put on first-factor enrollment). pull() reads
|
||||
// + forgets, so a refresh of the manage view will no longer reveal the codes. mount()
|
||||
// does not run on hydrate, so a replayed snapshot never re-triggers this. The flag is
|
||||
// valid only within [now-600s, now]: a stale flag (enrollment whose modal never opened)
|
||||
// or a non-timestamp value reveals nothing.
|
||||
$freshAt = session()->pull('2fa.codes_fresh');
|
||||
$age = is_int($freshAt) ? now()->timestamp - $freshAt : null;
|
||||
if ($age !== null && $age >= 0 && $age <= 600) {
|
||||
$this->dispatchCodes();
|
||||
}
|
||||
}
|
||||
|
||||
/** Regenerate the current user's backup codes (invalidates the old set). */
|
||||
public function regenerate(): void
|
||||
{
|
||||
Auth::user()->replaceRecoveryCodes();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name ?? 'system',
|
||||
'action' => 'two_factor.recovery_regenerate',
|
||||
'target' => Auth::user()->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
// One-time download grant (a timestamp) for the freshly regenerated set, consumed by
|
||||
// the download route within a short window. Without it a later direct hit 403s.
|
||||
session()->put('2fa.download_grant', now()->timestamp);
|
||||
|
||||
// Reveal the freshly generated set once, via the transient event (never a property).
|
||||
$this->dispatchCodes();
|
||||
|
||||
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.recovery-codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the current user's recovery codes to the browser via a one-time event. The
|
||||
* codes never enter a Livewire property or the snapshot — Alpine holds them in JS memory
|
||||
* only, so a replayed snapshot cannot re-render them.
|
||||
*/
|
||||
protected function dispatchCodes(): void
|
||||
{
|
||||
$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\SshKeyProvisioner;
|
||||
use Livewire\Attributes\Locked;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirm + run the safe "generate key → install → verify → switch credential → disable
|
||||
* password login" flow (R5). On success the freshly generated private key is revealed ONCE
|
||||
* (download client-side; it is never re-fetchable). The key is also kept encrypted in the vault.
|
||||
*/
|
||||
class SshKeyProvision extends ModalComponent
|
||||
{
|
||||
#[Locked]
|
||||
public int $serverId;
|
||||
|
||||
public string $serverName = '';
|
||||
|
||||
public bool $done = false;
|
||||
|
||||
public bool $ok = false;
|
||||
|
||||
public ?string $privateKey = null;
|
||||
|
||||
public ?string $publicKey = null;
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
public function mount(int $serverId): void
|
||||
{
|
||||
// Rotating to key-only access (installs a key, switches the credential, disables
|
||||
// password login) over SSH is a fleet-admin action.
|
||||
abort_unless(auth()->user()?->can('manage-fleet'), 403);
|
||||
|
||||
$this->serverId = $serverId;
|
||||
$this->serverName = Server::find($serverId)?->name ?? '';
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function run(SshKeyProvisioner $provisioner): void
|
||||
{
|
||||
// Re-gate on run: refuse a demoted user / hand-crafted /livewire/update after mount.
|
||||
abort_unless(auth()->user()?->can('manage-fleet'), 403);
|
||||
|
||||
if ($this->done) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $provisioner->enableKeyOnlyAccess($server);
|
||||
} catch (\Throwable $e) {
|
||||
$this->done = true;
|
||||
$this->ok = false;
|
||||
$this->error = $e->getMessage();
|
||||
// credentialChanged already triggers the FULL page reload on Show (key list +
|
||||
// credential + security state) — no extra hardeningApplied needed.
|
||||
$this->dispatch('credentialChanged');
|
||||
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
$this->privateKey = $result['privateKey'] ?? null;
|
||||
$this->publicKey = $result['publicKey'] ?? null;
|
||||
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
|
||||
|
||||
$this->dispatch('credentialChanged');
|
||||
|
||||
$this->dispatch('notify', message: $this->ok
|
||||
? __('modals.ssh_key_provision.notify_ok', ['server' => $this->serverName])
|
||||
: __('modals.ssh_key_provision.notify_failed'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.ssh-key-provision');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,19 @@ class ServerSwitcher extends Component
|
|||
|
||||
session(['active_server_id' => $server->id]);
|
||||
|
||||
$this->redirect($this->safeReturnUrl(), navigate: true);
|
||||
$this->redirect($this->targetFor($server, $this->safeReturnUrl()), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to land after selecting a server. On a server-details page, switching should
|
||||
* VIEW the chosen server (so the URL, sidebar and scoped context never diverge);
|
||||
* everywhere else just reload the current page so the active context updates.
|
||||
*/
|
||||
public function targetFor(Server $server, string $current): string
|
||||
{
|
||||
return preg_match('#^/servers/[^/]+#', $current) === 1
|
||||
? route('servers.show', $server)
|
||||
: $current;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,14 @@ use App\Services\Fail2banService;
|
|||
use App\Services\FirewallService;
|
||||
use App\Services\FleetService;
|
||||
use App\Services\HardeningService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use App\Support\Os\OsDetector;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -22,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
|
||||
{
|
||||
|
|
@ -61,6 +68,13 @@ class Show extends Component
|
|||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
// Viewing a server's details makes it the ACTIVE server, so the sidebar switcher
|
||||
// (and the scoped sections) reflect the server on screen. Set ONLY at mount — i.e.
|
||||
// once per deliberate navigation — so a slow in-flight load() or a parallel tab
|
||||
// can never write a stale value over a newer selection (the single-session active
|
||||
// server is last-writer-wins; we keep writes to deliberate navigations only).
|
||||
session(['active_server_id' => $this->server->id]);
|
||||
|
||||
// identity + gauges render from the persisted row immediately;
|
||||
// the live snapshot loads lazily via wire:init -> load().
|
||||
}
|
||||
|
|
@ -147,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();
|
||||
}
|
||||
|
||||
|
|
@ -156,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;
|
||||
|
|
@ -172,20 +192,36 @@ class Show extends Component
|
|||
'confirmLabel' => __('common.remove'),
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'ssh_key.remove',
|
||||
'auditTarget' => "{$comment} · {$this->server->name}",
|
||||
'serverId' => $this->server->id,
|
||||
'event' => 'keyRemoved',
|
||||
'params' => ['fingerprint' => $fingerprint],
|
||||
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
|
||||
'token' => ConfirmToken::issue(
|
||||
'keyRemoved',
|
||||
['fingerprint' => $fingerprint],
|
||||
'ssh_key.remove',
|
||||
"{$comment} · {$this->server->name}",
|
||||
$this->server->id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed key removal over SSH, then reloads the list. */
|
||||
#[On('keyRemoved')]
|
||||
public function removeKey(string $fingerprint, FleetService $fleet): void
|
||||
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) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
if (($payload['serverId'] ?? null) !== $this->server->id) {
|
||||
return; // token sealed for a different server — refuse to retarget
|
||||
}
|
||||
$fingerprint = $payload['params']['fingerprint'];
|
||||
|
||||
try {
|
||||
$fleet->removeAuthorizedKey($this->server, $fingerprint);
|
||||
} catch (Throwable $e) {
|
||||
|
|
@ -232,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;
|
||||
|
|
@ -259,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;
|
||||
|
|
@ -272,20 +312,35 @@ class Show extends Component
|
|||
'confirmLabel' => __('common.delete'),
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'credential.delete',
|
||||
'auditTarget' => $this->server->name.' · '.$cred->username,
|
||||
'serverId' => $this->server->id,
|
||||
'event' => 'credentialDeleted',
|
||||
'params' => [],
|
||||
'notify' => __('servers.notify_access_deleted'),
|
||||
'token' => ConfirmToken::issue(
|
||||
'credentialDeleted',
|
||||
[],
|
||||
'credential.delete',
|
||||
$this->server->name.' · '.$cred->username,
|
||||
$this->server->id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed credential deletion, then refreshes the row. */
|
||||
#[On('credentialDeleted')]
|
||||
public function deleteCredential(): void
|
||||
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) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
if (($payload['serverId'] ?? null) !== $this->server->id) {
|
||||
return; // token sealed for a different server — refuse to retarget
|
||||
}
|
||||
|
||||
$this->server->credential()->delete();
|
||||
$this->server->refresh();
|
||||
}
|
||||
|
|
@ -295,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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -308,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;
|
||||
|
|
@ -325,17 +416,35 @@ class Show extends Component
|
|||
'icon' => 'trash',
|
||||
// No premature audit/notify — the handler records the real outcome
|
||||
// only after the remote command returns (audit reflects success).
|
||||
'event' => 'firewallRuleDelete',
|
||||
'params' => ['spec' => $spec, 'label' => $label],
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'firewallRuleDelete',
|
||||
['spec' => $spec, 'label' => $label],
|
||||
serverId: $this->server->id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */
|
||||
#[On('firewallRuleDelete')]
|
||||
public function deleteFirewallRule(string $spec, string $label, FirewallService $firewall): void
|
||||
public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void
|
||||
{
|
||||
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
|
||||
// while the user could manage the network must still be refused after a demotion.
|
||||
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
if (($payload['serverId'] ?? null) !== $this->server->id) {
|
||||
return; // token sealed for a different server — refuse to retarget
|
||||
}
|
||||
$spec = $payload['params']['spec'];
|
||||
$label = $payload['params']['label'];
|
||||
|
||||
try {
|
||||
$res = $firewall->deleteRule($this->server, $spec);
|
||||
} catch (Throwable $e) {
|
||||
|
|
@ -385,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) {
|
||||
|
|
@ -406,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;
|
||||
|
|
@ -439,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,8 +3,10 @@
|
|||
namespace App\Livewire\Services;
|
||||
|
||||
use App\Livewire\Concerns\WithFleetContext;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -15,6 +17,9 @@ class Index extends Component
|
|||
{
|
||||
use WithFleetContext;
|
||||
|
||||
/** Cap on retained journal rows so the live poll never grows the payload unbounded. */
|
||||
private const JOURNAL_MAX = 200;
|
||||
|
||||
/** Active server name (from fleet context). */
|
||||
public string $server = '—';
|
||||
|
||||
|
|
@ -31,6 +36,9 @@ class Index extends Component
|
|||
/** @var array<int, array{time:string,unit:string,level:string,text:string}> */
|
||||
public array $journal = [];
|
||||
|
||||
/** journald cursor to resume the live poll from (null until the first read). */
|
||||
public ?string $journalCursor = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->server = $this->activeServer()?->name ?? '—';
|
||||
|
|
@ -46,6 +54,7 @@ class Index extends Component
|
|||
$data = $fleet->systemd($active);
|
||||
$this->services = $data['services'];
|
||||
$this->journal = $data['journal'];
|
||||
$this->journalCursor = $data['cursor'] ?? null;
|
||||
$this->connected = true;
|
||||
} catch (Throwable) {
|
||||
$this->connected = false;
|
||||
|
|
@ -55,6 +64,40 @@ class Index extends Component
|
|||
$this->ready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live journal: poll for entries since the last cursor and APPEND them, so no line
|
||||
* is dropped between ticks (a true `journalctl -f` stream needs a PTY — deferred to
|
||||
* the web terminal). Caps the retained rows and survives a transient SSH hiccup.
|
||||
*/
|
||||
public function pollJournal(FleetService $fleet): void
|
||||
{
|
||||
if (! $this->ready || ! $this->connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$active = $this->activeServer();
|
||||
|
||||
if (! $active || ! $active->credential_exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch ceiling == display ceiling: never under-fill, never carry more than we show.
|
||||
$data = $fleet->journalSince($active, $this->journalCursor, self::JOURNAL_MAX);
|
||||
} catch (Throwable) {
|
||||
return; // keep the current view; retry on the next tick
|
||||
}
|
||||
|
||||
if ($data['journal'] !== []) {
|
||||
$this->journal = array_slice(
|
||||
array_merge($this->journal, $data['journal']),
|
||||
-self::JOURNAL_MAX
|
||||
);
|
||||
}
|
||||
|
||||
$this->journalCursor = $data['cursor'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service control (R5): opens the wire-elements/modal confirm dialog. The
|
||||
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
|
||||
|
|
@ -62,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;
|
||||
|
|
@ -81,23 +126,36 @@ class Index extends Component
|
|||
'confirmLabel' => $label,
|
||||
'danger' => $op === 'stop',
|
||||
'icon' => $icon,
|
||||
'auditAction' => $action,
|
||||
'auditTarget' => "{$name} · {$this->server}",
|
||||
'event' => 'serviceConfirmed',
|
||||
'params' => ['op' => $op, 'name' => $name],
|
||||
'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]),
|
||||
'token' => ConfirmToken::issue(
|
||||
'serviceConfirmed',
|
||||
['op' => $op, 'name' => $name],
|
||||
$action,
|
||||
"{$name} · {$this->server}",
|
||||
$this->activeServer()?->id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
|
||||
#[On('serviceConfirmed')]
|
||||
public function applyService(string $op, string $name, FleetService $fleet): void
|
||||
public function applyService(string $confirmToken, FleetService $fleet): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('operate'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$op = $payload['params']['op'];
|
||||
$name = $payload['params']['name'];
|
||||
|
||||
$active = $this->activeServer();
|
||||
|
||||
if (! $active || ! $active->credential_exists) {
|
||||
return;
|
||||
if (! $active || ($payload['serverId'] ?? null) !== $active->id || ! $active->credential_exists) {
|
||||
return; // wrong/absent server (retarget attempt) or no credential — no-op
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -108,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'];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* SMTP configuration card. The operator points the panel at an SMTP server so
|
||||
* password-reset mails (and future notifications) actually leave the box. Values
|
||||
* are stored as Setting rows and applied to runtime config by AppServiceProvider::boot().
|
||||
*
|
||||
* The SMTP password is the one secret here: it is stored ENCRYPTED at rest
|
||||
* (Crypt::encryptString, APP_KEY) — Setting::put stores plaintext, so we encrypt the
|
||||
* value ourselves — never rendered back to the browser, and never logged/echoed
|
||||
* (any error from the test-send is shown without the password).
|
||||
*/
|
||||
class Email extends Component
|
||||
{
|
||||
public string $mail_host = '';
|
||||
|
||||
public int $mail_port = 587;
|
||||
|
||||
public string $mail_username = '';
|
||||
|
||||
/** New password typed in the form. Empty = keep the stored one (never pre-filled). */
|
||||
public string $mail_password = '';
|
||||
|
||||
public string $mail_encryption = 'tls';
|
||||
|
||||
public string $mail_from_address = '';
|
||||
|
||||
public string $mail_from_name = '';
|
||||
|
||||
/** True once a password has been stored, so the field shows a "(set)" placeholder. */
|
||||
public bool $passwordSet = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->mail_host = Setting::get('mail_host', '') ?? '';
|
||||
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
|
||||
$this->mail_username = Setting::get('mail_username', '') ?? '';
|
||||
$this->mail_encryption = Setting::get('mail_encryption', 'tls') ?? 'tls';
|
||||
$this->mail_from_address = Setting::get('mail_from_address', '') ?? '';
|
||||
$this->mail_from_name = Setting::get('mail_from_name', '') ?? '';
|
||||
$this->passwordSet = (Setting::get('mail_password') ?? '') !== '';
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'mail_host' => ['required', 'string', 'max:255'],
|
||||
'mail_port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'mail_username' => ['nullable', 'string', 'max:255'],
|
||||
'mail_password' => ['nullable', 'string', 'max:255'],
|
||||
'mail_encryption' => ['required', 'in:none,tls,ssl'],
|
||||
'mail_from_address' => ['required', 'email', 'max:255'],
|
||||
'mail_from_name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
$this->validate();
|
||||
|
||||
// Retarget guard: if the SMTP HOST or USERNAME changes and no new password was entered, DROP
|
||||
// the stored password BEFORE the new host is written — so the old credential can never be
|
||||
// sent to a new (possibly attacker-controlled) SMTP server; re-entry is then required. Same
|
||||
// class as the alert Gotify token guard. uncached() reads the committed state (no stale cache).
|
||||
$targetChanged = $this->mail_host !== (string) Setting::uncached('mail_host', '')
|
||||
|| $this->mail_username !== (string) Setting::uncached('mail_username', '');
|
||||
if ($targetChanged) {
|
||||
// Drop the old password BEFORE any target field is written — regardless of whether a
|
||||
// fresh one was typed — so no concurrent MailSettings::apply() can ever read the new
|
||||
// host paired with the old password. A freshly typed password is re-written below.
|
||||
Setting::forget('mail_password');
|
||||
$this->passwordSet = false;
|
||||
}
|
||||
|
||||
Setting::put('mail_host', $this->mail_host);
|
||||
Setting::put('mail_port', (string) $this->mail_port);
|
||||
Setting::put('mail_username', $this->mail_username);
|
||||
Setting::put('mail_encryption', $this->mail_encryption);
|
||||
Setting::put('mail_from_address', $this->mail_from_address);
|
||||
Setting::put('mail_from_name', $this->mail_from_name);
|
||||
|
||||
// Only overwrite the stored password when a new one was typed; otherwise keep
|
||||
// the existing (encrypted) value untouched. Store encrypted at rest.
|
||||
if ($this->mail_password !== '') {
|
||||
Setting::put('mail_password', Crypt::encryptString($this->mail_password));
|
||||
$this->passwordSet = true;
|
||||
}
|
||||
|
||||
$this->reset('mail_password');
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name,
|
||||
'action' => 'mail.configure',
|
||||
'target' => $this->mail_host,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('mail.notify_saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully clear the SMTP configuration — removes every mail_* setting (incl. the encrypted
|
||||
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
|
||||
*/
|
||||
public function clearConfig(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
|
||||
Setting::forget($key);
|
||||
}
|
||||
|
||||
$this->mail_host = '';
|
||||
$this->mail_port = 587;
|
||||
$this->mail_username = '';
|
||||
$this->mail_password = '';
|
||||
$this->mail_encryption = 'tls';
|
||||
$this->mail_from_address = '';
|
||||
$this->mail_from_name = '';
|
||||
$this->passwordSet = false;
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name,
|
||||
'action' => 'mail.reset',
|
||||
'target' => '—',
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('mail.notify_reset'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
|
||||
* from the saved settings. Never targets anyone else; never leaks the password — any
|
||||
* transport error is surfaced as a message with the secret masked out.
|
||||
*/
|
||||
public function sendTest(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
if (! $this->configured()) {
|
||||
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay.
|
||||
// Per-user + auto-expiring: only ever limits the operator's own test button briefly.
|
||||
$key = 'mail-test:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||
$this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user
|
||||
|
||||
// Retarget guard: never test-send the STORED password against a host/username the operator
|
||||
// just changed in the form but hasn't saved — that could leak the old credential to a new
|
||||
// (possibly hostile) server. Require the password to be re-entered for a changed target.
|
||||
if ($this->mail_password === ''
|
||||
&& ($this->mail_host !== (string) Setting::uncached('mail_host', '')
|
||||
|| $this->mail_username !== (string) Setting::uncached('mail_username', ''))) {
|
||||
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.test_retarget_password_required')]), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$to = Auth::user()->email;
|
||||
|
||||
try {
|
||||
$this->applyMailerConfig();
|
||||
|
||||
Mail::raw(__('mail.test_body'), function ($message) use ($to) {
|
||||
$message->to($to)->subject(__('mail.test_subject'));
|
||||
});
|
||||
|
||||
$this->dispatch('notify', message: __('mail.notify_test_ok', ['email' => $to]));
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: __('mail.notify_test_failed', [
|
||||
'error' => $this->maskSecret(Str::limit($e->getMessage(), 160)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/** Host + from-address present — the minimum to actually send. */
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->mail_host !== '' && $this->mail_from_address !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the saved SMTP settings to runtime config for the duration of this request,
|
||||
* so the test-send uses exactly what the operator configured (decrypting the stored
|
||||
* password). Mirrors AppServiceProvider's runtime override.
|
||||
*/
|
||||
private function applyMailerConfig(): void
|
||||
{
|
||||
// Prefer a FRESHLY typed password (test-before-save); fall back to the stored one only when
|
||||
// the field is blank — and the sendTest() retarget guard has already ensured that fallback
|
||||
// is used only when host+username are unchanged from what the stored password was set for.
|
||||
$password = '';
|
||||
if ($this->mail_password !== '') {
|
||||
$password = $this->mail_password;
|
||||
} else {
|
||||
$stored = Setting::uncached('mail_password');
|
||||
if ($stored !== null && $stored !== '') {
|
||||
try {
|
||||
$password = Crypt::decryptString($stored);
|
||||
} catch (Throwable) {
|
||||
$password = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config([
|
||||
'mail.default' => 'smtp',
|
||||
'mail.mailers.smtp.host' => $this->mail_host,
|
||||
'mail.mailers.smtp.port' => $this->mail_port,
|
||||
'mail.mailers.smtp.username' => $this->mail_username ?: null,
|
||||
'mail.mailers.smtp.password' => $password ?: null,
|
||||
'mail.mailers.smtp.encryption' => $this->mail_encryption === 'none' ? null : $this->mail_encryption,
|
||||
'mail.from.address' => $this->mail_from_address,
|
||||
'mail.from.name' => $this->mail_from_name,
|
||||
]);
|
||||
|
||||
// Drop any mailer Laravel already resolved this request so the new config takes effect.
|
||||
Mail::purge('smtp');
|
||||
}
|
||||
|
||||
/** Strip the stored SMTP password out of any string before it is shown/logged. */
|
||||
private function maskSecret(string $text): string
|
||||
{
|
||||
$stored = Setting::get('mail_password');
|
||||
if ($stored === null || $stored === '') {
|
||||
return $text;
|
||||
}
|
||||
|
||||
try {
|
||||
$plain = Crypt::decryptString($stored);
|
||||
} catch (Throwable) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return $plain !== '' ? str_replace($plain, '****', $text) : $text;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.email');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,102 +2,28 @@
|
|||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $tab = 'profile';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
public bool $openRecoveryModal = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
}
|
||||
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
|
||||
]);
|
||||
|
||||
Auth::user()->update($data);
|
||||
$this->audit('profile.update', $data['email']);
|
||||
$this->dispatch('notify', message: __('settings.profile_saved'));
|
||||
}
|
||||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(10)],
|
||||
]);
|
||||
|
||||
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
$this->audit('password.change', Auth::user()->email);
|
||||
$this->dispatch('notify', message: __('settings.password_changed'));
|
||||
}
|
||||
|
||||
public function confirmDisableTwoFactor(): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('settings.disable_2fa_heading'),
|
||||
'body' => __('settings.disable_2fa_body'),
|
||||
'confirmLabel' => __('common.disable'),
|
||||
'danger' => true,
|
||||
'icon' => 'shield',
|
||||
'auditAction' => 'two_factor.disable',
|
||||
'auditTarget' => Auth::user()?->email,
|
||||
'event' => 'twoFactorDisabled',
|
||||
'notify' => __('settings.disable_2fa_notify'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('twoFactorDisabled')]
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
Auth::user()->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function audit(string $action, string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name,
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
if (session('open_recovery_modal')) {
|
||||
$this->tab = 'security';
|
||||
$this->openRecoveryModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.index', [
|
||||
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
|
||||
])->title(__('settings.title'));
|
||||
return view('livewire.settings.index')->title(__('settings.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
}
|
||||
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
|
||||
]);
|
||||
|
||||
Auth::user()->update($data);
|
||||
$this->audit('profile.update', $data['email']);
|
||||
$this->dispatch('notify', message: __('settings.profile_saved'));
|
||||
}
|
||||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
// Throttle the current-password re-auth (hijacked-session brute-force protection).
|
||||
// User-id keyed + auto-expiring: only ever slows the user's own session briefly.
|
||||
$key = 'reauth:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('current_password', $e->errors())) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
|
||||
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
$this->audit('password.change', Auth::user()->email);
|
||||
$this->dispatch('notify', message: __('settings.password_changed'));
|
||||
}
|
||||
|
||||
private function audit(string $action, string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name,
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.profile');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Security extends Component
|
||||
{
|
||||
public function confirmDisableTwoFactor(): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('settings.disable_2fa_heading'),
|
||||
'body' => __('settings.disable_2fa_body'),
|
||||
'confirmLabel' => __('common.disable'),
|
||||
'danger' => true,
|
||||
'icon' => 'shield',
|
||||
'notify' => __('settings.disable_2fa_notify'),
|
||||
'token' => ConfirmToken::issue(
|
||||
'twoFactorDisabled',
|
||||
[],
|
||||
'two_factor.disable',
|
||||
Auth::user()?->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('twoFactorDisabled')]
|
||||
public function disableTwoFactor(string $confirmToken): void
|
||||
{
|
||||
try {
|
||||
ConfirmToken::consume($confirmToken, 'twoFactorDisabled');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
|
||||
// Remove the TOTP factor only — security keys are managed on their own card. If no
|
||||
// factor remains afterwards, the backup codes are dropped too.
|
||||
Auth::user()->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
|
||||
Auth::user()->resetIfNoFactor();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.security', [
|
||||
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
|
||||
'hasTotp' => Auth::user()->hasTotp(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Services\SessionService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Sessions extends Component
|
||||
{
|
||||
/**
|
||||
* Open the R5 confirm for "sign out other devices". The current session is
|
||||
* kept; only the other rows are deleted on confirm. The modal writes the
|
||||
* audit row (session.logout_others) and re-dispatches the apply event.
|
||||
*/
|
||||
public function confirmLogoutOthers(): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('sessions.logout_others'),
|
||||
'body' => __('sessions.logout_others_body'),
|
||||
'confirmLabel' => __('sessions.logout_others'),
|
||||
'danger' => true,
|
||||
'icon' => 'logout',
|
||||
'notify' => __('sessions.logout_others_notify'),
|
||||
'token' => ConfirmToken::issue(
|
||||
'sessionsLogoutOthers',
|
||||
[],
|
||||
'session.logout_others',
|
||||
Auth::user()?->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('sessionsLogoutOthers')]
|
||||
public function logoutOthers(string $confirmToken, SessionService $sessions): void
|
||||
{
|
||||
try {
|
||||
ConfirmToken::consume($confirmToken, 'sessionsLogoutOthers');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
|
||||
$sessions->logoutOtherDevices(Auth::user());
|
||||
// List re-renders on the next round trip; no explicit refresh needed.
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the R5 confirm for the destructive GLOBAL logout. On confirm every
|
||||
* session is wiped and every remember_token rotated — including this
|
||||
* operator's, so the apply handler redirects to login.
|
||||
*/
|
||||
public function confirmLogoutAll(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('sessions.logout_all'),
|
||||
'body' => __('sessions.logout_all_body'),
|
||||
'confirmLabel' => __('sessions.logout_all'),
|
||||
'danger' => true,
|
||||
'icon' => 'power',
|
||||
// Defer the toast: this session is about to be destroyed and the
|
||||
// user redirected to login, so a toast would never be seen.
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'sessionsLogoutAll',
|
||||
[],
|
||||
'session.logout_all',
|
||||
Auth::user()?->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('sessionsLogoutAll')]
|
||||
public function logoutAll(string $confirmToken, SessionService $sessions)
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
try {
|
||||
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
|
||||
// Truncates all sessions + rotates every remember_token. This logs the
|
||||
// operator out too, so push them to login (AuthenticateSession would
|
||||
// otherwise bounce the very next request).
|
||||
$sessions->logoutEveryone();
|
||||
Auth::logout();
|
||||
|
||||
return $this->redirect(route('login'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.sessions', [
|
||||
'sessions' => app(SessionService::class)->forUser(Auth::user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\SessionService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Multi-user account management. Accounts now carry an RBAC role (admin > operator >
|
||||
* viewer); the mutating actions here are gated behind the `manage-users` ability (admin
|
||||
* only). Destructive actions (remove, force-logout) and role changes go through the R5
|
||||
* confirm modal, which writes one AuditEvent and re-dispatches the apply event back here
|
||||
* (mirrors Settings\Security / Settings\Sessions).
|
||||
*
|
||||
* The last-admin invariant is enforced everywhere: the sole remaining admin can neither
|
||||
* be removed nor demoted, so the panel can never be stranded with zero administrators.
|
||||
*
|
||||
* The current operator is always recorded as the actor on each audit row.
|
||||
*/
|
||||
class Users extends Component
|
||||
{
|
||||
/** Open the CreateUser modal; it dispatches `usersChanged` on success. */
|
||||
public function create(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$this->dispatch('openModal', component: 'modals.create-user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive (R5): open the confirm modal for removing an account. Guards
|
||||
* (self / last account) are enforced here AND again in the apply handler, so
|
||||
* a stale event can never slip past after the modal is open.
|
||||
*/
|
||||
public function confirmRemove(int $userId): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || ! $this->canRemove($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('accounts.remove_heading'),
|
||||
'body' => __('accounts.remove_body'),
|
||||
'confirmLabel' => __('accounts.remove'),
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
// Defer the toast: the apply handler reports the real outcome.
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'userRemoved',
|
||||
['userId' => $userId],
|
||||
auditTarget: $user->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('userRemoved')]
|
||||
public function remove(string $confirmToken, SessionService $sessions): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'userRemoved');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$userId = $payload['params']['userId'];
|
||||
|
||||
$self = Auth::id();
|
||||
|
||||
// Atomic: lock the user rows + recount inside the transaction so two concurrent
|
||||
// deletes can't both pass the "more than one account" check and strand the
|
||||
// install with zero users (a lock-out). The last account is never removable.
|
||||
// Sentinel: null = silent no-op (self / last account / gone); 'last_admin' =
|
||||
// refused because it would strand zero admins → surface the guard toast.
|
||||
$email = DB::transaction(function () use ($userId, $self, $sessions): ?string {
|
||||
if (User::lockForUpdate()->count() <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || $user->getKey() === $self) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Last-admin invariant: removing the sole administrator would leave the panel
|
||||
// with zero admins (no one can manage users again). Lock + recount admins in
|
||||
// the same transaction so concurrent removes can't both slip past the check.
|
||||
if ($user->role === Role::Admin
|
||||
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
|
||||
return 'last_admin';
|
||||
}
|
||||
|
||||
$email = $user->email;
|
||||
|
||||
// Kill every session + rotate the remember_token BEFORE deleting, so a
|
||||
// stolen remember-me cookie can never resurrect the account between the
|
||||
// session wipe and the row delete.
|
||||
$sessions->logoutUserEverywhere($user);
|
||||
$user->delete();
|
||||
|
||||
return $email;
|
||||
});
|
||||
|
||||
if ($email === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($email === 'last_admin') {
|
||||
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->audit('user.delete', $email);
|
||||
$this->dispatch('notify', message: __('accounts.remove_notify'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive (R5): open the confirm modal for signing another account out
|
||||
* of every device. Never offered for self (self uses Settings → Sessions).
|
||||
*/
|
||||
public function confirmLogout(int $userId): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || $user->is(Auth::user())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('accounts.logout_heading'),
|
||||
'body' => __('accounts.logout_body'),
|
||||
'confirmLabel' => __('accounts.logout'),
|
||||
'danger' => true,
|
||||
'icon' => 'logout',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'userLoggedOut',
|
||||
['userId' => $userId],
|
||||
auditTarget: $user->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('userLoggedOut')]
|
||||
public function logout(string $confirmToken, SessionService $sessions): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'userLoggedOut');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$userId = $payload['params']['userId'];
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || $user->is(Auth::user())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sessions->logoutUserEverywhere($user);
|
||||
|
||||
$this->audit('user.logout', $user->email);
|
||||
$this->dispatch('notify', message: __('accounts.logout_notify'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensitive (R5): open the confirm modal for changing another account's role. The
|
||||
* new role must be a valid Role case; the apply handler re-checks the last-admin
|
||||
* invariant so a stale event can never demote the sole admin after the modal opens.
|
||||
*/
|
||||
public function confirmRoleChange(int $userId, string $role): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
$user = User::find($userId);
|
||||
if (! $user || ! in_array($role, array_column(Role::cases(), 'value'), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('accounts.role_change_confirm_heading'),
|
||||
'body' => __('accounts.role_change_confirm_body', [
|
||||
'name' => $user->name,
|
||||
'role' => Role::from($role)->label(),
|
||||
]),
|
||||
'confirmLabel' => __('accounts.role_change_confirm'),
|
||||
'danger' => false,
|
||||
'icon' => 'shield',
|
||||
// Defer the toast: the apply handler reports the real outcome.
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'userRoleChanged',
|
||||
['userId' => $userId, 'role' => $role],
|
||||
auditTarget: $user->email,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('userRoleChanged')]
|
||||
public function applyRoleChange(string $confirmToken): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-users'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'userRoleChanged');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$userId = $payload['params']['userId'];
|
||||
$role = $payload['params']['role'];
|
||||
|
||||
// Atomic: lock the target + recount admins inside the transaction so two concurrent
|
||||
// demotions can't both pass the last-admin check and strand zero administrators.
|
||||
// Sentinel: null = silent no-op (gone / same role); 'last_admin' = refused because
|
||||
// it would demote the sole admin → surface the guard toast.
|
||||
$result = DB::transaction(function () use ($userId, $role): ?string {
|
||||
$user = User::whereKey($userId)->lockForUpdate()->first();
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$newRole = Role::from($role);
|
||||
|
||||
// Last-admin invariant: never demote the sole administrator away from admin.
|
||||
if ($user->role === Role::Admin
|
||||
&& $newRole !== Role::Admin
|
||||
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
|
||||
return 'last_admin';
|
||||
}
|
||||
|
||||
$user->role = $newRole;
|
||||
$user->save();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name,
|
||||
'action' => 'user.role_change',
|
||||
'target' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
'meta' => ['to' => $role],
|
||||
]);
|
||||
|
||||
return $user->email;
|
||||
});
|
||||
|
||||
if ($result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result === 'last_admin') {
|
||||
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('usersChanged');
|
||||
$this->dispatch('notify', message: __('accounts.role_changed_toast'));
|
||||
}
|
||||
|
||||
/** Reload the list after the CreateUser modal adds an account. */
|
||||
#[On('usersChanged')]
|
||||
public function refreshList(): void
|
||||
{
|
||||
// The list is re-fetched in render(); this handler just forces the round trip.
|
||||
}
|
||||
|
||||
/**
|
||||
* A user may be offered the remove control unless it is the current operator or the
|
||||
* last remaining account (no lock-out). This is only the UI gate; the removal itself
|
||||
* is restricted to admins (`manage-users`) and the apply handler additionally refuses
|
||||
* to strand zero administrators (last-admin invariant).
|
||||
*/
|
||||
private function canRemove(User $user): bool
|
||||
{
|
||||
return ! $user->is(Auth::user()) && User::count() > 1;
|
||||
}
|
||||
|
||||
private function audit(string $action, ?string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.users', [
|
||||
'users' => User::query()->orderBy('name')->get(),
|
||||
'currentId' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\WebauthnService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class WebauthnKeys extends Component
|
||||
{
|
||||
#[Validate('required|string|max:60')]
|
||||
public string $newName = '';
|
||||
|
||||
/** JSON creation options for navigator.credentials.create (the JS posts the result to register). */
|
||||
public function options(WebauthnService $webauthn): array
|
||||
{
|
||||
abort_unless($webauthn->available(), 404);
|
||||
// Validate the label BEFORE the browser ceremony, so an invalid name never
|
||||
// leaves an orphaned credential on the authenticator.
|
||||
$this->validate();
|
||||
|
||||
return $webauthn->registrationOptions(Auth::user());
|
||||
}
|
||||
|
||||
public function register(array $attestation, WebauthnService $webauthn): void
|
||||
{
|
||||
// WebAuthn must be available (domain + HTTPS). No existing-factor guard — a key may
|
||||
// be the FIRST/only factor.
|
||||
abort_unless($webauthn->available(), 404);
|
||||
$this->validate();
|
||||
|
||||
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
|
||||
$this->reset('newName');
|
||||
|
||||
// First factor enrolled and no codes yet → generate + show the recovery modal.
|
||||
if (! Auth::user()->hasRecoveryCodes()) {
|
||||
Auth::user()->replaceRecoveryCodes();
|
||||
// Server-set one-time flag so the opening modal reveals the fresh set once.
|
||||
session()->put('2fa.codes_fresh', now()->timestamp);
|
||||
// One-time download grant (a timestamp) — the download route requires + consumes
|
||||
// it within a short window, so the fresh set can be downloaded once and a later
|
||||
// direct hit 403s.
|
||||
session()->put('2fa.download_grant', now()->timestamp);
|
||||
$this->dispatch('openModal', component: 'modals.recovery-codes');
|
||||
}
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name ?? 'system',
|
||||
'action' => 'webauthn.register',
|
||||
'target' => $cred->name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('auth.webauthn_added'));
|
||||
}
|
||||
|
||||
/** Destructive (R5): open the confirm modal, which re-dispatches `webauthnKeyRemoved`. */
|
||||
public function confirmRemove(int $id): void
|
||||
{
|
||||
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
|
||||
if (! $cred) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('auth.webauthn_title'),
|
||||
'body' => __('auth.webauthn_remove_confirm'),
|
||||
'confirmLabel' => __('common.remove'),
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'webauthnKeyRemoved',
|
||||
['id' => $id],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('webauthnKeyRemoved')]
|
||||
public function remove(string $confirmToken, WebauthnService $webauthn): void
|
||||
{
|
||||
abort_unless($webauthn->available(), 404);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'webauthnKeyRemoved');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$id = $payload['params']['id'];
|
||||
|
||||
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
|
||||
if ($cred) {
|
||||
$name = $cred->name;
|
||||
$cred->delete();
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()->name ?? 'system',
|
||||
'action' => 'webauthn.remove',
|
||||
'target' => $name,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('notify', message: __('auth.webauthn_removed'));
|
||||
Auth::user()->resetIfNoFactor();
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.webauthn-keys', [
|
||||
'available' => app(WebauthnService::class)->available(),
|
||||
'keys' => Auth::user()->webauthnCredentials()->latest()->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,29 +2,30 @@
|
|||
|
||||
namespace App\Livewire\System;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\DeploymentService;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* System settings — dashboard-configurable Domain + TLS 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'];
|
||||
|
||||
/** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */
|
||||
public string $domain = '';
|
||||
|
|
@ -35,16 +36,10 @@ class Index extends Component
|
|||
/** True after a save — shows the "restart the stack to apply" notice. */
|
||||
public bool $restartPending = false;
|
||||
|
||||
public string $channel = 'stable';
|
||||
/** True once the operator clicked "restart now" — the host watcher is applying it. */
|
||||
public bool $restartRequested = false;
|
||||
|
||||
/** Channel key => localized description, built per-request for the view. */
|
||||
private function channelDescriptions(): array
|
||||
{
|
||||
return [
|
||||
'stable' => __('system.channel_stable'),
|
||||
'beta' => __('system.channel_beta'),
|
||||
];
|
||||
}
|
||||
public string $tlsMode = 'caddy';
|
||||
|
||||
public function mount(DeploymentService $deployment): void
|
||||
{
|
||||
|
|
@ -53,9 +48,8 @@ class Index extends Component
|
|||
$this->domain = (string) ($deployment->domain() ?? '');
|
||||
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
|
||||
$this->restartPending = $deployment->restartPending();
|
||||
$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->restartRequested = $deployment->restartRequested();
|
||||
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
|
|
@ -68,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'),
|
||||
|
|
@ -89,19 +85,30 @@ class Index extends Component
|
|||
'confirmLabel' => __('system.change_domain_confirm'),
|
||||
'danger' => true,
|
||||
'icon' => 'globe',
|
||||
'auditAction' => 'system.settings_updated',
|
||||
'auditTarget' => 'panel_domain='.($new !== '' ? $new : '(cleared)'),
|
||||
'event' => 'domainChanged',
|
||||
'params' => ['domain' => $new],
|
||||
'notify' => '', // defer: we surface the restart notice instead of a toast
|
||||
'token' => ConfirmToken::issue(
|
||||
'domainChanged',
|
||||
['domain' => $new],
|
||||
'system.settings_updated',
|
||||
'panel_domain='.($new !== '' ? $new : '(cleared)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('domainChanged')]
|
||||
public function applyDomain(string $domain, DeploymentService $deployment): void
|
||||
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
|
||||
// while the user could manage the panel must still be refused after a demotion.
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$domain = strtolower(trim($payload['params']['domain']));
|
||||
|
||||
// Defensive re-validation (the value round-trips through the modal event).
|
||||
if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) {
|
||||
|
|
@ -115,39 +122,133 @@ class Index extends Component
|
|||
$this->dispatch('notify', message: __('system.domain_saved_notify'));
|
||||
}
|
||||
|
||||
/** Release channel changes are audited (confirmation via the shared modal). */
|
||||
public function confirmChannel(string $channel): void
|
||||
/**
|
||||
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
|
||||
* The container never touches the Docker socket — it only requests via a marker file
|
||||
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
|
||||
* (a button, not auto-fired on save) so the operator controls the timing.
|
||||
*/
|
||||
public function restartNow(DeploymentService $deployment): void
|
||||
{
|
||||
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
// Surface a failed sentinel write instead of a "restarting" state that never resolves.
|
||||
if (! $deployment->requestRestart()) {
|
||||
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->restartRequested = true;
|
||||
$this->dispatch('notify', message: __('system.restart_running'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS
|
||||
* pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user
|
||||
* throttled (5 / 10 min, auto-expiring — never a control-plane lockout) so it can't hammer
|
||||
* ACME, and audited. Only meaningful in caddy mode with an active domain.
|
||||
*/
|
||||
public function requestCertificate(DeploymentService $deployment): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
$domain = $deployment->domain();
|
||||
|
||||
if ($domain === null || $deployment->externalTls()) {
|
||||
$this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$key = 'cert-request:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
$result = $deployment->requestCertificate($domain);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'tls.cert_request',
|
||||
'target' => $domain.' → '.$result['status'],
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
[$message, $level] = match ($result['status']) {
|
||||
'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'],
|
||||
'dns_mismatch' => [__('system.cert_dns_mismatch', [
|
||||
'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—',
|
||||
'server' => $result['serverIp'] ?? '?',
|
||||
]), 'error'],
|
||||
default => [__('system.cert_failed'), 'error'],
|
||||
};
|
||||
|
||||
$this->dispatch('notify', message: $message, level: $level);
|
||||
}
|
||||
|
||||
/** TLS-mode changes are audited (confirmation via the shared modal). */
|
||||
public function confirmTlsMode(string $mode): void
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
|
||||
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',
|
||||
'auditAction' => 'system.settings_updated',
|
||||
'auditTarget' => 'release_channel='.$channel,
|
||||
'event' => 'channelChanged',
|
||||
'params' => ['channel' => $channel],
|
||||
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
|
||||
'heading' => __('system.change_tls_heading'),
|
||||
'body' => $mode === 'external'
|
||||
? __('system.change_tls_body_external')
|
||||
: __('system.change_tls_body_caddy'),
|
||||
'confirmLabel' => __('system.change_tls_confirm'),
|
||||
'danger' => true,
|
||||
'icon' => 'shield',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'tlsModeChanged',
|
||||
['mode' => $mode],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('channelChanged')]
|
||||
public function applyChannel(string $channel): void
|
||||
#[On('tlsModeChanged')]
|
||||
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
|
||||
{
|
||||
if (! in_array($channel, self::CHANNELS, true)) {
|
||||
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
|
||||
// while the user could manage the panel must still be refused after a demotion.
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return; // forged / replayed / direct-bypass attempt — no-op
|
||||
}
|
||||
$mode = $payload['params']['mode'];
|
||||
|
||||
if (! in_array($mode, self::TLS_MODES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Setting::put('release_channel', $channel);
|
||||
$this->channel = $channel;
|
||||
$deployment->setTlsMode($mode);
|
||||
$this->tlsMode = $mode;
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'system.settings_updated',
|
||||
'target' => 'tls_mode='.$mode,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->restartPending = true;
|
||||
$this->dispatch('notify', message: __('system.tls_saved_notify'));
|
||||
}
|
||||
|
||||
public function render(DeploymentService $deployment)
|
||||
|
|
@ -156,7 +257,9 @@ 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(),
|
||||
])->title(__('system.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,23 +2,48 @@
|
|||
|
||||
namespace App\Livewire\Versions;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\DeploymentService;
|
||||
use App\Services\ReleaseChecker;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Version & releases page. Everything shown here is real: the installed version
|
||||
* comes from config, the latest published release is the newest git TAG read from
|
||||
* .git at runtime, and the release history is the project's own CHANGELOG.md parsed
|
||||
* BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no
|
||||
* fake release server — the update check honestly compares the installed version
|
||||
* against the newest tag in the configured channel.
|
||||
* Version & releases page. Everything shown here is real: the installed version comes from
|
||||
* config, the latest published release is the newest git TAG in the channel — read from the
|
||||
* project's public Git host over its read-only tag API (the production image ships no .git, so
|
||||
* a local read would always be empty; local .git is only a dev fallback) — and the release
|
||||
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
|
||||
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
|
||||
* the check honestly compares the installed version against the newest tag in the channel.
|
||||
*/
|
||||
#[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
|
||||
* watcher consumes before update.sh runs) — so the "update läuft" state survives BOTH a page
|
||||
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
|
||||
* backstop so a failed update can never wedge the banner. Completion = running version > base.
|
||||
*/
|
||||
private const UPDATE_FLAG = 'clusev:update-in-progress';
|
||||
|
||||
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
|
||||
private const CHANGELOG_PER_PAGE = 8;
|
||||
|
||||
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
|
||||
* Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
|
||||
#[Url(as: 'series')]
|
||||
public string $series = '';
|
||||
|
||||
/** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
|
||||
#[Url(as: 'page')]
|
||||
public int $changelogPage = 1;
|
||||
|
||||
public ?string $lastChecked = null;
|
||||
|
||||
|
|
@ -27,6 +52,101 @@ class Index extends Component
|
|||
/** 'current' | 'update' | null — drives the banner tone. */
|
||||
public ?string $updateState = null;
|
||||
|
||||
/** Set once an update has been requested this session — the stack is rebuilding. */
|
||||
public bool $updateRequested = false;
|
||||
|
||||
/** Running version captured WHEN the update was requested — completion = version moved past it. */
|
||||
public ?string $updateBaseVersion = null;
|
||||
|
||||
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
|
||||
public int $updatePolls = 0;
|
||||
|
||||
/**
|
||||
* On load, in order:
|
||||
* 1. If an update is in flight (Redis marker): either announce it just finished (running
|
||||
* version now past the marked base → success toast + clear) or resume the "läuft" state +
|
||||
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
|
||||
* Redis, surviving both the reload and the app rebuild).
|
||||
* 2. Otherwise surface an available update straight from the cached check (no network), so the
|
||||
* button shows immediately when the badge already knows a newer version — no re-click.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$installed = (string) config('clusev.version');
|
||||
$marker = Cache::get(self::UPDATE_FLAG);
|
||||
|
||||
if (is_array($marker) && isset($marker['base'])) {
|
||||
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
|
||||
// Finished while the operator was away (or reloaded) — announce + clear.
|
||||
Cache::forget(self::UPDATE_FLAG);
|
||||
$this->updateState = 'current';
|
||||
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
|
||||
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
|
||||
|
||||
return;
|
||||
}
|
||||
// Still running — resume the notice + polling.
|
||||
$this->updateRequested = true;
|
||||
$this->updateBaseVersion = (string) $marker['base'];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
|
||||
if ($latest !== null && $this->isNewer($latest, $installed)) {
|
||||
$this->updateState = 'update';
|
||||
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
|
||||
* detected purely from the running version moving PAST the value captured at request time —
|
||||
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
|
||||
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
|
||||
*/
|
||||
public function pollUpdate(): void
|
||||
{
|
||||
if (! $this->updateRequested) {
|
||||
return;
|
||||
}
|
||||
$this->updatePolls++;
|
||||
$current = (string) config('clusev.version');
|
||||
$this->updateBaseVersion ??= $current;
|
||||
|
||||
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
|
||||
Cache::forget(self::UPDATE_FLAG);
|
||||
$this->updateRequested = false;
|
||||
$this->updatePolls = 0;
|
||||
$this->updateState = 'current';
|
||||
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
|
||||
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
|
||||
Cache::forget(self::UPDATE_FLAG);
|
||||
$this->updateRequested = false;
|
||||
$this->updatePolls = 0;
|
||||
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-check on page load (wire:init) so an available update + button appear without the
|
||||
* operator clicking "check" first. No-op while an update is running, so it never clobbers the
|
||||
* "läuft"/poll state. Async (wire:init), so it never blocks the initial render.
|
||||
*/
|
||||
public function autoCheck(): void
|
||||
{
|
||||
if ($this->updateRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->checkUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Honest update check: compare the installed version against the newest release
|
||||
* tag visible in the channel. No external server, no stars, no CVE feed.
|
||||
|
|
@ -37,7 +157,8 @@ class Index extends Component
|
|||
|
||||
$channel = $this->channel();
|
||||
$installed = (string) config('clusev.version');
|
||||
$latest = $this->latestTag();
|
||||
// Force a fresh remote lookup — this is the explicit "check for updates" action.
|
||||
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
||||
|
||||
if ($latest === null) {
|
||||
$this->updateState = 'current';
|
||||
|
|
@ -51,20 +172,118 @@ class Index extends Component
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-triggered update: write the update sentinel a host watcher reacts to by running
|
||||
* update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
|
||||
* installed one is actually published in the channel (never fires blindly), is per-user
|
||||
* throttled (auto-expiring — never a control-plane lockout) and audited. The container never
|
||||
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
||||
* say so and do not await a result.
|
||||
*/
|
||||
public function requestUpdate(DeploymentService $deployment): mixed
|
||||
{
|
||||
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||
|
||||
$channel = $this->channel();
|
||||
$installed = (string) config('clusev.version');
|
||||
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
||||
|
||||
// Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
|
||||
$this->lastChecked = now()->format('H:i');
|
||||
if ($latest === null || ! $this->isNewer($latest, $installed)) {
|
||||
$this->updateState = 'current';
|
||||
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
||||
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
||||
|
||||
return null;
|
||||
}
|
||||
$this->updateState = 'update';
|
||||
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
||||
|
||||
$key = 'update-request:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return null;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
// Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by
|
||||
// the app user), surface that instead of a "running" state that would never resolve.
|
||||
if (! $deployment->requestUpdate()) {
|
||||
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
|
||||
|
||||
return null;
|
||||
}
|
||||
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
|
||||
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
|
||||
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
|
||||
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'deploy.update_request',
|
||||
'target' => $installed.' → '.$latest.' ('.$channel.')',
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->updateRequested = true;
|
||||
$this->updateBaseVersion = $installed; // completion = the running version moves past this
|
||||
$this->updatePolls = 0;
|
||||
$this->dispatch('notify', message: __('versions.update_started'));
|
||||
|
||||
// Redirect to the self-contained progress page BEFORE the container goes down.
|
||||
// navigate:false forces a full browser navigation (the page must be Livewire-independent
|
||||
// to survive the teardown — see resources/views/update-progress.blade.php).
|
||||
$prev = url()->previous();
|
||||
$parsedPrev = parse_url($prev, PHP_URL_PATH);
|
||||
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
|
||||
? $parsedPrev
|
||||
: route('dashboard', absolute: false);
|
||||
|
||||
// Pass the pre-update version: the progress page finishes only when the running version
|
||||
// moves past it (the old container answers /up throughout the build — see the page).
|
||||
return $this->redirect(
|
||||
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
|
||||
navigate: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||
private function channel(): string
|
||||
{
|
||||
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
|
||||
|
||||
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
|
||||
return app(ReleaseChecker::class)->channel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest release tag from .git, without needing the git binary. Reads loose
|
||||
* refs under refs/tags plus packed-refs, normalises a leading "v", and sorts
|
||||
* by semantic version. Returns null when nothing is tagged yet.
|
||||
* Newest release tag for the channel. Prefers the public Git host's tag API (the
|
||||
* production image ships NO .git, so a local read would always be empty there), and
|
||||
* falls back to local .git tags for dev. The remote result is cached so a page render
|
||||
* never makes a network call — only the explicit "check for updates" action does, by
|
||||
* passing $forceRemote. Returns null when nothing usable is found anywhere.
|
||||
*/
|
||||
private function latestTag(): ?string
|
||||
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
|
||||
{
|
||||
$checker = app(ReleaseChecker::class);
|
||||
|
||||
if ($forceRemote) {
|
||||
$remote = $checker->refresh($channel);
|
||||
if ($remote !== null) {
|
||||
return $remote;
|
||||
}
|
||||
// Remote unreachable — fall through to whatever we last cached / can read locally.
|
||||
}
|
||||
|
||||
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest release tag from local .git, without needing the git binary. Reads loose refs
|
||||
* under refs/tags plus packed-refs. Empty in the production image (no .git) — that is why
|
||||
* the remote API is the primary source above.
|
||||
*/
|
||||
private function localLatestTag(string $channel): ?string
|
||||
{
|
||||
$root = base_path('.git');
|
||||
$tags = [];
|
||||
|
|
@ -84,22 +303,7 @@ class Index extends Component
|
|||
}
|
||||
}
|
||||
|
||||
// Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v".
|
||||
$versions = [];
|
||||
foreach (array_unique($tags) as $tag) {
|
||||
$v = ltrim($tag, 'vV');
|
||||
if (preg_match('/^\d+\.\d+\.\d+/', $v)) {
|
||||
$versions[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
if ($versions === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
|
||||
|
||||
return $versions[0];
|
||||
return app(ReleaseChecker::class)->newestVersion($tags, $channel);
|
||||
}
|
||||
|
||||
/** Is the given tag a newer semantic version than the installed one? */
|
||||
|
|
@ -108,9 +312,19 @@ class Index extends Component
|
|||
return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
|
||||
}
|
||||
|
||||
/** Resolve the current commit from .git without needing the git binary. */
|
||||
/**
|
||||
* Resolve the deployed commit + branch. In production this comes from config (install.sh
|
||||
* baked it into .env from the host's .git — the prod image ships none); in dev it falls back
|
||||
* to a live .git read. Branch defaults to the channel only when nothing is known.
|
||||
*/
|
||||
private function build(): array
|
||||
{
|
||||
$sha = config('clusev.build.sha');
|
||||
$branch = config('clusev.build.branch');
|
||||
if ($sha || $branch) {
|
||||
return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
|
||||
}
|
||||
|
||||
$root = base_path('.git');
|
||||
$head = @file_get_contents($root.'/HEAD');
|
||||
|
||||
|
|
@ -141,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
|
||||
|
|
@ -156,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) {
|
||||
|
|
@ -213,19 +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();
|
||||
$latestTag = $this->latestTag();
|
||||
$seriesList = $this->groupBySeries($releases);
|
||||
|
||||
// Active series: keep the operator's choice while it still exists, else the newest.
|
||||
$keys = array_column($seriesList, 'key');
|
||||
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
|
||||
$activeSeries = null;
|
||||
foreach ($seriesList as $s) {
|
||||
if ($s['key'] === $activeKey) {
|
||||
$activeSeries = $s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate the active series' releases so a long history never becomes an endless scroll.
|
||||
$activeReleases = $activeSeries['releases'] ?? [];
|
||||
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
|
||||
$page = min(max(1, $this->changelogPage), $totalPages);
|
||||
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
|
||||
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
|
||||
|
||||
// Mark the series that holds the running version (a small "installed" cue in the rail).
|
||||
$installed = ltrim((string) config('clusev.version'), 'vV');
|
||||
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? 'v'.$m[1].'.'.$m[2] : null;
|
||||
|
||||
// Passive (no network): cached remote result if a check has run, else local .git.
|
||||
$latestTag = $this->resolveLatestTag($this->channel());
|
||||
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
|
||||
// update, before the next check). Never present yourself as behind a release you've
|
||||
// already passed — hide it until a fresh check resolves the true latest.
|
||||
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
|
||||
$latestTag = null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue