The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.
Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.
Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server config showed a "Port" and an "Endpoint" that repeated the same
:51820, so it looked like the port had to be entered twice. It never did (since
0.9.43 a bare host gets the listen port appended) — but the UI didn't say so.
Now the field is labelled "Public endpoint (host)", the placeholder shows only
the host (port stripped via Str::before), and the hint explains the port is
taken from the listen port automatically; a different port is only needed for
port-forwarding (host:port). Display/copy only — no behaviour change.
Pint clean, /wireguard server tab loads 200 with no console errors, lang
parity 89/89, 354 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The Traffic panel header ("Empfangen/Gesendet") drew from the sampled
throughput, which is 0 until the minutely sampler accrues deltas — so a peer
with live traffic (e.g. 244 B) showed "0 B" in the header. It now sums the
live per-peer rx/tx counters, so the header always matches the peer rows. The
chart below stays windowed throughput (fills from the sampler).
- import_hint reworded to state plainly that the WireGuard phone app ALWAYS
prompts for a tunnel name on a QR scan (app behaviour, not changeable); the
.conf download is the path to an automatic name (filename = tunnel name).
Tests: header sums live peer counters. 354 pass, Pint clean, /wireguard 200 with
no console errors, lang parity 89/89.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three things the operator asked for on the WireGuard page:
- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
active peers re-handshaking well within it, so no flapping; a disconnect now
shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
(auto-appended), no need to repeat the listen port.
Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- QR: 0.9.42 over-enlarged it; bring the show-once peer QR back to a balanced
~240px (still crispEdges on a white quiet-zone frame) so it scans on a phone
without dominating the modal.
- Download filename now becomes a useful tunnel name on import: build it from
the endpoint host + peer name (e.g. "10.10.90.165-laptop.conf") instead of the
generic "clusev-wireguard.conf" — WireGuard apps name a tunnel after the file.
A QR scan can't carry a name (the app prompts for one); the import hint now
explains both paths.
Tests: download-filename cases (host+peer, fallback); 349 pass, Pint clean,
/wireguard loads 200, lang parity 83/83.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If the operator entered an endpoint without a port (e.g. "10.10.90.165"
instead of "10.10.90.165:51820"), that value was written verbatim into every
peer config. WireGuard requires the endpoint as host:port, so the WireGuard app
rejected the QR/file import as "keine gültige WireGuard-Konfiguration" — the
real cause of the invalid-config errors (the QR-size fix in 0.9.42 was secondary).
Normalise host-side: append the listen port when the endpoint has no colon, in
both _write_server_conf (setup — covers the dashboard and interactive paths) and
_set_endpoint (later changes). Verified: bare host -> host:port, host:port left
as-is, DNS host -> host:port; write-bridge smoke test still passes; shellcheck
clean. setup_endpoint_hint (de/en) now documents the host:port format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified dead-code + duplication cleanup over the WireGuard feature surface
(found via an audit workflow, every item adversarially re-verified by grep):
Dead code removed:
- lang keys not_configured_title / not_configured_body (both locales) — orphaned
when the unconfigured empty state became the in-page setup form (setup_title /
setup_intro). 0 references anywhere. de/en parity kept (83/83).
- WgTraffic::series() 'buckets' return key — written, never read by blade/route/test.
- blade $fmtB byte-formatter — byte-identical duplicate of the top-level $fmtBytes
already in scope; collapsed onto $fmtBytes.
DRY / readability (behavior-preserving):
- Index: PEER_NAME_RE constant (was the same regex literal at 5 sites), portInRange()
+ clampWindow() helpers (window default now self::WINDOWS[0]), openConfirm() helper
(5 confirm-modal openers shared one dispatch), onFlag() helper (2 apply handlers).
- WgBridge: validPort() helper (port check was duplicated verbatim).
- clusev-wg.sh: _write_server_conf() shared by both setup paths (the byte-identical
server bring-up was copy-pasted); single CIDR_RE constant for the 3 subnet checks;
fixed a stale header comment that still claimed SSH is "NEVER matched" (the v0.9.40
ssh-lock can match 22); documented the defence-in-depth re-validation in _set_*.
- WgStatus: documented server.pubkey is part of the /wg-status.json contract (kept).
No DB/migration changes (peer_name write-only column left intact — needs a
coordinated migration). 348 tests pass, shellcheck clean, Pint clean, R12 verified
in both the configured + unconfigured states (200, no console errors, no leaked
keys, traffic formatter renders 1.5 MB / 200 KB correctly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two dashboard additions to the WireGuard page:
- Peer config download: the show-once new-client view gets a "Download"
button next to the QR code (streams clusev-wireguard.conf) plus an
inline hint explaining QR-on-phone vs download-on-PC import.
- SSH lock (port 22): an optional toggle in WireGuard settings that walls
off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the
host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo +
wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over
the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot
unit's ConditionPathExists=wg0 and an in-function self-guard keep a
broken tunnel from ever applying the DROP; ssh_gate_apply re-validates
the subnet shape and fails open on a corrupt wg.env. Strong lock-out
warning shown inline AND in the confirm modal (recommend a backup peer).
New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears
both gates. Panel gate and SSH gate are independent toggles.
Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host
whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector
now reports gate.ssh. Adversarial lock-out review: all 8 failure modes
refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.
Also fixes, surfaced by an adversarial review of the change:
- icon: add the missing 'chevron-right' Lucide path — the accordion disclosure
caret and the pagination "Next" button were rendering an empty <svg>.
- blade: wire:ignore.self on each <details> so a user's open/closed toggle is not
reset by an unrelated re-render (autoCheck / check-updates / in-update poll); a
series/page change still re-defaults to first-open via a fresh wire:key.
- blade: 44px touch targets (series pills + pagination) below lg (R7).
- i18n: localised 'Other' series label; pluralised series_count (no "1 Releases").
- Index: render() is the single clamp authority for changelogPage.
Index.php: groupBySeries() + pageWindow() + selectSeries/gotoChangelogPage; render
builds the series list, resolves the active series, paginates. New DE+EN keys.
VersionsChangelogTest covers grouping order, default series, paging, clamp, the
installed-series cue, and the chevron-right icon regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec for a bilingual /help page (left topic nav like Settings) covering all
settings, a generic reverse-proxy setup guide, and the 2FA access-path note
(security key only over the HTTPS domain; backup code on the bare-IP/HTTP path;
TOTP works everywhere). Also generalize the external-TLS hint copy (no product
name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- reverbClient() now returns wss://<domain>:443 when the active domain is being
served (the HTTPS front door), instead of deriving wss://host:80 from a request
that looks like plain HTTP:80 behind an external TLS proxy. Bare-IP recovery
stays plain ws.
- Caddy no longer redirects the /app/* /apps/* WS paths to https when the upstream
already terminated TLS (X-Forwarded-Proto: https) — that redirect broke the
WebSocket in external-proxy mode.
- The 'TLS active' status card no longer claims Let's Encrypt issuance in
external-proxy mode; it states TLS is provided by the upstream proxy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the update button (0.9.15): requestRestart() now returns bool and
restartNow() shows an error toast (pointing at sudo ./update.sh) when the
sentinel can't be written, instead of a 'restarting' state that never resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prod image baked the app user at uid 1002, but install.sh chowns the
bind-mounted ./run to the clusev host user. On a host where clusev != 1002 the
app process could not write the restart/update sentinel — the dashboard button
showed 'läuft' while nothing happened on the server. Pass APP_UID/APP_GID =
HOST_UID/HOST_GID into the prod build so the in-image app user matches clusev
and ./run is writable. Also surface a failed sentinel write as an error toast
instead of a stuck 'running' state (requestUpdate now returns bool).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Update progress now resolves: a wire:poll detects the rebuilt container
reporting a newer version than at request time, flips the card back to
'current' and fires a success toast (survives the restart downtime; times out
after ~5min instead of spinning; resumes after a reload while the sentinel
exists). Fixes the 'Update läuft' state that never ended.
- An available update shows on page load straight from the cached check (no
re-click, no network) instead of only after pressing 'check'.
- Displayed times follow the host timezone instead of UTC: app.timezone is now
env-driven and install.sh bakes APP_TIMEZONE from the host (DB stays UTC).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a host-watcher-backed update button on Version & Releases. The container
(no Docker socket) writes an update sentinel; a new root-run systemd unit
(clusev-update.path/.service) runs watch.sh update -> update.sh (git pull +
idempotent install). The sentinel is consumed before running so a persistent
failure can't loop the path unit.
The button only appears once a check finds a newer release, is per-user
throttled (3/10min, auto-expiring) and audited (deploy.update_request); the UI
warns the dashboard is briefly down during the rebuild. install.sh now installs
both the restart and update host units (update unit runs as root for
docker/systemd/apt).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For the common case where a domain was configured before its DNS pointed here, the
operator can now request the Let's Encrypt cert from System -> Domain & TLS instead of
waiting for the first HTTPS visitor:
- DeploymentService::requestCertificate() does a DNS pre-check (domainResolvesHere via
dns_get_record vs serverPublicIp) and, only when DNS is NOT a clear mismatch, triggers
Caddy's on-demand issuance with an internal handshake (probeCertificate:
`curl --connect-to <domain>:443:caddy:443` — the dial target is pinned to the caddy
service, the domain only sets SNI/Host, so no SSRF). A clear mismatch returns early and
never calls ACME (Let's Encrypt rate-limit protection). The outcome is persisted to the
`tls_cert_status` Setting so the page shows a status without an ACME-triggering probe on
every load.
- System\Index::requestCertificate() is per-user throttled (cert-request:<id>, 5/10min,
auto-expiring -> never a control-plane lockout) and audited (tls.cert_request). The
status line + button render only in caddy mode with an active domain; external mode
shows a note; bare IP hides it.
Automatic on-demand still works on the first HTTPS handshake regardless — this only adds
explicit control + a status. Tests cover the no-handshake-on-mismatch guard, the
issued/failed/not-applicable states, and the per-user throttle. Full suite 192 green;
Codex clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial audit of the auth surface confirmed several exploitable gaps. Every fix
auto-expires and never permanently locks the control plane (bare-IP recovery host and
the clusev:reset-admin CLI stay open).
Login:
- per-IP (20/min) + per-account (30/15min) buckets on top of the existing email+IP 5/min,
so a distributed multi-IP brute-force of the single admin account is capped instead of
scaling linearly with IPs. ip/acct counters decay on their own (a valid login doesn't
reset a flood's budget).
- constant-time: the unknown-email branch now runs one bcrypt against a fixed DUMMY_HASH
(evaluated before the `! $user ||` short-circuit), closing the account-enumeration
timing oracle.
2FA:
- TOTP replay protection — verifyTotp uses verifyKeyNewer with a persisted last-used step
(two_factor_last_used_step migration), so each 30s code is single-use. Passes 0 (never
null) as the old step, since null makes verifyKeyNewer return bare `true` not the step.
- challenge + 2FA-proof reset gain an IP-independent per-account backstop, capping the
distributed TOTP/backup brute-force surface.
Defense-in-depth / DoS:
- global throttle on /livewire/update (180/min per user-id-or-IP) via setUpdateRoute,
keeping 'web' + the persistent EnsureSecurityOnboarded gate intact.
- reauth throttle (5/min per user) on password change/profile; SMTP test-send capped
(3/10min per user) so the panel can't relay spam.
- SshClient gets a short, separate connect timeout (~5s) so a dead/tarpit host can't hold
a worker across several sequential 15s reads (the observed 8.3s /livewire/update DoS).
- profile password policy raised to 12 chars + mixed case + numbers (was 10).
Tests: BruteForceHardeningTest covers TOTP replay, per-email + per-IP login caps,
identical unknown/known rejection, and reauth throttling. Full suite 187 green; Codex
clean; verified in a real browser (login/polls/Livewire updates unaffected by the throttle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.
Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.
CHANGELOG also backfills the prior reset-timing residual fix (2520b87).
Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hardening features completed in parallel (each with its own spec + tests),
sharing call sites (TwoFactorSetup, WebauthnKeys, routes) so committed together:
- ConfirmAction token hardening: destructive confirm flows now carry a server-issued,
single-use, signed token (App\Support\Confirm\ConfirmToken) instead of trusting a
client-mutable event/params/auditTarget. Every #[On] apply handler consumes + validates
the token (forged/replayed/direct-bypass calls no-op). Server-scoped per action; closes
the codebase-wide confirm-bypass + audit-forgery vector across Security, Sessions, Users,
WebauthnKeys, Servers\Show, Services, System, Files.
- Backup-code airtight reveal: codes are revealed via a transient channel, not a persisted
Livewire property, so a captured/replayed snapshot can't re-render them; the recovery
download is grant-gated.
Full suite green (162).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backup (recovery) codes must be viewable ONLY at the moment they are
generated — at first-factor enrollment or on regenerate — and NEVER
re-viewable afterward from "Backup-Codes verwalten".
- Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a
one-time session flag (2fa.codes_fresh); regenerate() sets it. render()
emits codes ONLY when revealed, so a client cannot tamper a flag to
re-view stored codes.
- View: revealed state shows codes grid + download + warning + close;
hidden state shows a security notice + Neu erzeugen + close (no codes,
no download).
- TwoFactorSetup::confirm() and WebauthnKeys::register() put
2fa.codes_fresh on first-factor code generation (put, survives the
redirect + later openModal request).
- Add recovery_hidden_notice (DE/EN parity).
- Tests: manage view hides codes; fresh flag reveals once; regenerate
reveals the new set; first-factor paths set the flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'Eingebautes TLS' (detail moves to the description line); placeholder is just the
example host (the 'leer = IP' hint is already in the help text).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The System page was just constrained to max-w-3xl — narrower, not designed.
Rework it into an intentional composition at max-w-5xl:
- Critical alerts (restart-required + bare-IP) are now full-width banners
above the working area, not buried in stacked inset blocks.
- A 2-column lg grid separates "do" from "know": left/main panel holds the
actionable form (domain input + Speichern, TLS-mode selector); right/side
panel is the status & recovery stack (access address, TLS state, SSH reset).
- Release-Kanal stays a full-width band below. Everything stacks to one
column on mobile.
- Header HTTPS/Plaintext pill made more prominent; domain Save promoted to
the primary action.
All wire bindings (confirmDomain, confirmTlsMode, restartNow, restartPending/
restartRequested) and every __() key preserved; two new section-label keys
(section_config, section_status) added with DE/EN parity. Tactical-Terminal
tokens only, no inline styles, existing component kit reused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
System page capped to a readable width; Speichern gets a proper save glyph (added
save/eye/eye-off/user-plus to the icon set); operator-facing 'Caddy' softened to
neutral wording; added a 'fully locked out -> SSH + clusev:reset-admin' hint. Settings
email field to type=text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>