Rotation is optional now, so a well-known default ('clusev') would be a standing takeover
credential on a fleet-root console. The installer now creates the first admin with a random
Str::password(16), printed once on the CLUSEV_ADMIN_PASSWORD= line that install.sh already
surfaces in the closing banner. must_change_password still nudges a rotation, but even if the
operator keeps it, the secret is no longer guessable.
Verified: the printed password authenticates + differs per install; noop-when-admin-exists is
intact; Codex review APPROVED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes from the security audit (the brute-force/rate-limiting angle matters more now that the
password policy is min(6)/no-complexity, so IP-keyed throttles must not be spoofable):
- bootstrap/app.php: trustProxies(at: '*') → trust only PRIVATE ranges in prod. Caddy reaches
app:80 from the docker bridge (private IP) so it stays trusted, but a public/off-network source
can no longer forge X-Forwarded-For to spoof request()->ip() and bypass the login/2FA/forgot
throttles + the brute-force ban.
- TerminalSession + HostCredential: replace $guarded=[] with an explicit $fillable allowlist; add
$hidden=[secret,passphrase] to HostCredential so it never serializes its credential fields.
- nginx /terminal/ws: access_log off — the single-use session token rode the query string into the
access log.
Verified: full suite 467 pass; all panel pages 200 with zero console errors; server terminal still
connects + runs commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator decision: password strength is the user's own responsibility. Removed the
mixedCase + numbers requirement from every password rule (Password::min(6) is all that
remains), so a plain 6-char password ("abcdef") is accepted. Dropped the
"upper/lowercase + a digit" clause from the hints (de + en).
Verified: a 6-char simple password validates; a 5-char one still rejects (length); full
suite 467 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator decision — 12 was too long; 6 is acceptable and password strength is the user's
own responsibility. Changed Password::min(12) → min(6) in every place that validates a
password (password change, forgot/reset, settings profile, create-user) and updated the
visible hints (de + en). The upper/lowercase + digit complexity is unchanged — only the
minimum length was flagged.
Verified: a 6-char complex password ("Abc123") now validates; too-short and complexity-less
passwords still reject; the password page shows "min. 6"; full suite 467 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Add-account modal only ever generated a one-time password — there was no way to set
one. Add an optional password field (with a show/hide toggle):
- blank → previous behaviour: a strong one-time password is generated, the account is
flagged must_change_password, and the clear text is revealed once.
- filled → that becomes the user's own login (validated min 12 / mixed case / digit);
must_change_password stays false (no forced rotation) and nothing is revealed.
Browser-verified: the modal shows the password field + hint; both paths covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- sidebar: the "2FA on/off" text badge next to the user name was visually noisy. Replace it
with a single status shield — a cyan shield-check when 2FA is on, a muted struck-through
shield when off; the label moves to the hover/aria title so it stays reachable.
- terminal: the server-target search now appears once there is more than one server (was 5+),
so it is there as soon as filtering helps; added a clear (×) button + an accent focus ring.
Browser-verified: sidebar shows just the shield (no text); search filters the rail (homelab →
only homelab-01) with a working clear button; both badge states render correctly. 465 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal is for the LOCAL machine Clusev runs on. Letting the operator
point it at an arbitrary host/server makes no sense (that is what fleet servers are for)
and the pre-filled host.docker.internal read as a confusing mandatory magic value.
- the host is now FIXED to host.docker.internal server-side (HostShell::HOST), not a form
field; the modal asks only for the login — user + password|key, and the port.
- the rail tile sub-label shows just the login user (the host is implied/local).
Browser-verified: modal has no HOST/IP field (user/port/auth/password only); saving shows
the user on the tile; opening the host reaches the real host sshd (a wrong password yields
an SSH auth error, proving the connection), zero console errors. 16 terminal tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seeded-password rotation was forced before the panel was reachable. It is now a
nudge, not a wall: the prompt shows once per session and can be skipped, and a standing
warning banner keeps reminding while the default password is still in use.
- EnsureSecurityOnboarded redirects to the password page only when must_change_password
AND the session has not set onboarding.password_skipped (set by PasswordChange::skip,
which lands on the dashboard WITHOUT rotating — so the flag/banner persist).
- app layout shows a default-password warning banner while must_change_password is true.
- the metrics broadcast channel no longer gates on securityOnboarded() — a deliberately
un-rotated (but authenticated) operator would otherwise lose realtime metrics;
/broadcasting/auth still runs in the web group (guest = 403), so it stays auth-gated.
- 2FA was already optional (never enforced) — only the login copy was wrong: "2FA
required/enforced" → "recommended/optional" (de + en).
Browser-verified (R12): login → skippable prompt → dashboard banner → free navigation
(no re-redirect); login copy correct; zero console errors. Full suite 465 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no
sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet
server — so it shows the real root@<host> prompt with full rights.
- HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets
the operator set host/port/user + password|key. The clear-text secret is never echoed
back; on edit a blank secret keeps the stored one, and switching auth method requires a
fresh secret (no password silently reused as a key).
- open('host') mints a kind=host token only when a login is configured, else opens the
setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted,
internal-net only) for kind=host, or 404 when unconfigured.
- compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host
sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly).
- rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line.
Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as
root runs commands, zero console errors; search filters; resolve returns the host spec.
15 terminal tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dedicated /terminal page with a target rail: one terminal per fleet server (SSH)
plus one for the Clusev host itself. php-fpm cannot hold an interactive PTY, so a
small Node sidecar (ws + ssh2 + node-pty) bridges xterm.js to the PTY.
Flow: the Livewire page mints a single-use, 60s TerminalSession token and dispatches
it to an Alpine xterm island; the browser opens a same-origin WS (/terminal/ws,
proxied by nginx in dev / Caddy in prod to terminal:3000); the sidecar burns the
token at an internal resolve endpoint (shared-secret header, private network only)
for a connection spec, then opens an SSH PTY (decrypted credential, never sent to the
browser) or a local host PTY. Real shell = native Tab/Shift-Tab completion.
Security:
- token burned ATOMICALLY (single conditional UPDATE) — no double-open race
- resolve endpoint guarded by hash_equals shared secret, exempt from CSRF + PanelScheme
host enforcement (private net only), returns decrypted creds over the internal net
- sidecar enforces same-origin on the WS upgrade (hostname match, port-agnostic)
- single-use 60s tokens, swept daily so the table can't grow unbounded
Verified in-browser (R12): host + server terminals connect, accept keyboard input,
run commands, Tab-complete; cross-origin WS rejected; zero console errors. 9 feature
tests cover minting, the resolve spec, single-use burn, expiry, and locked creds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The series rail synced ?series=<key> via Livewire #[Url] with a bare numeric key like
"0.10". Livewire type-juggles that to 0.1 (the trailing zero is lost), so reloading
while viewing the 0.10 series silently switched to 0.1 and rewrote the URL. Prefix the
series key with a non-numeric "v" (?series=v0.10) so it round-trips as a string; the
rail label still shows the dotted "0.10". Surfaced now that a 0.10 series exists.
Browser-verified: selecting 0.10 -> ?series=v0.10, and it survives a reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the Server-Details history graph from a static Livewire-rendered SVG into a
self-contained Alpine island fed by a JSON endpoint, addressing the feedback:
- Smooth Catmull-Rom curves instead of angular segments.
- Continuous line with an area gradient fill; the line breaks ONLY on a real outage
(delta-t > 2.5x the bucket), not on every empty bucket — no more dropouts.
- Hover crosshair + tooltip (time + CPU/MEM/DISK at the point).
- Instant client-side range switching (1h/24h/7d/30d) via
GET /servers/{server}/history.json — no Livewire round-trip.
- X-axis labels moved below the plot (no longer overlapping the 0 gridline).
MetricHistory::series() now returns only non-empty buckets plus from/now/bucket. The
chart wrapper is wire:ignore so the parent's 10s poll never clobbers the Alpine state;
the island self-refreshes every 60s. The old Livewire range logic is removed.
Codex review: clean (auth-gated route, range clamped, numeric-only JSON, guarded hover
bindings, R3/R4-compliant). Browser-verified: smooth curves, area fill, working tooltip
+ crosshair, instant range switch, zero console errors. 446 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A CPU/RAM/Disk history graph over selectable ranges (1h/24h/7d/30d) on the server
detail page, backed by a persisted time series (the live 15s view stays cache-only).
- metric_samples table (server_id cascade, cpu/mem/disk %, load, sampled_at indexed).
- clusev:sample-metrics (scheduled every minute, mirrors clusev:wg-sample): persists
one row per server from the cached live reading, prunes beyond --retention (30d).
- MetricHistory::series() buckets + averages samples for a window; empty buckets stay
null so the chart shows gaps, not fabricated values.
- Servers/Show: range selector + a gap-aware SVG line chart (theme-token strokes,
R3-compliant, mirrors the dashboard chart). historyRange is clamped via setRange()
and the updatedHistoryRange() hook (it's a public, client-settable property).
Codex review: fixed a right-edge bucket off-by-one, clamped percentages on write, and
guarded the public range property against direct client values. Browser-verified: the
chart renders with data, range buttons, legend and gaps, zero console errors. 447
tests green, pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified live against a real Debian 12: the hardening actions work end-to-end
(root + password self-lockout guards refuse correctly, auto-updates and ufw enable,
disabling password auth persists and blocks password logins). One gap surfaced: on
a minimal image without rsyslog the distro-default fail2ban sshd jail (logpath
/var/log/auth.log) fails to start — "Have not found any log file". Pin the sshd jail
to `backend = systemd` (reads the journal) via a jail.d drop-in on enable:
log-source-agnostic and valid on every supported (systemd) target. fail2ban then
comes up active with the sshd jail reading the journal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "add SSH key" modal had no name field — keys appeared only by comment +
fingerprint, so additional keys weren't tellable apart. Add a name field; the name
becomes the key's OpenSSH comment (what the server list shows), for both generated
and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at
64 chars) so a crafted name cannot inject a second authorized_keys line — proven by
test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disabling SSH root login (PermitRootLogin no) severs Clusev's OWN access when it
connects AS root — afterwards neither key nor password reaches root. The
password-auth toggle already guards against self-lockout; the root toggle did not.
Refuse to disable root login while the active credential's username is root, with a
clear message: add a non-root sudo user + key and switch Clusev to it first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the Projekt-card change: the dev/release-control box must list its
actual update source (e.g. private Gitea) AND the public repo — the previous commit
showed only the source on dev. Replaced the single project link with projectLinks():
the public repo is always listed (validated slug, canonical fallback); the dev box
prepends its source when it is a real URL (a garbage/hostless source is dropped, never
an empty link). Staging/stable still list the public repo only — no private leak.
Codex review: no leak path on non-dev; hardened the dev source against a hostless URL.
Verified: 433 tests green (dev-both / non-dev-only / malformed-slug / garbage-source),
pint clean, /versions shows both links on the dev box with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions "Projekt" card linked config('clusev.repository') — the per-install
update source, which on the dev box is the private Gitea. A staging/stable install
must never surface a private source. Now only the dev/release-control box shows its
actual source; every other install links the public open-core repo only, built from
the public slug with a canonical fallback (a malformed or full-URL slug can never
produce a private/garbage link). The update SOURCE (ReleaseChecker) is unchanged —
this governs only the displayed link.
Codex review: hardened the slug to reject non-owner/repo values; the release_controls
gate is the dev-box invariant (it also gates the dev-only /release page, and a staging
box's CLUSEV_REPOSITORY is the public repo anyway).
Verified: 432 tests green (incl. dev / non-dev / malformed-slug cases), pint clean,
/versions loads 200 with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The self-update progress screen reset its elapsed timer to 0 and dropped the
phase checklist back to step one on every browser refresh — and the long image
build made it look like the update had restarted. Root causes: the timer and
phase state lived only in page JS (Date.now() at page load), and a
30s-before-page-load staleness guard rejected the real phase feed whenever a
stage had run longer than 30s (which the image build always does).
- DeploymentService: requestUpdate() now also writes a best-effort
run/update-started.json {at,from}. New updateStartedAt()/updatePhase() expose
the server-side start + current macro-stage; updatePhase() requires a positive
timestamp so a corrupt or stale 'done' can never drive a premature finish.
clearUpdateRequest() also removes the start marker.
- routes: /update-status.json delegates to updatePhase(); /update-progress
passes startedAt/initialPhase/hasFeed. A stage is trusted only when tied to
THIS update's start (startedAt) and written at/after it — a leftover stage from
a previous run can no longer resume or trigger a premature completion.
- update-progress.blade.php: the elapsed timer is anchored to the server-side
start (counts the real elapsed across a refresh); the checklist resumes at the
live stage; the live feed is authoritative (corrects any time-heuristic
overshoot) and the heuristic stays off once a real stage is known.
Verified: 28 UpdateProgressTest cases + full suite (426) green, pint clean, and a
headless browser load+reload mid-update keeps the timer counting and the phase in
place with zero console errors. Codex review: two premature-completion edge cases
found and fixed (timestamp validation + start-anchored freshness).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PromotionService::publicTags() is called on every Release render incl. the 5s
page poll; the anonymous GitHub tag API is rate-limited (60/h per IP). Cache it
120s so the panel never gets rate-limited to empty. (Test flushes cache in setUp.)
- yank.yml: replace the loose shell-glob tag guard with an anchored grep regex so
a garbage/space/metachar tag dispatched straight from the GitHub UI is rejected
before reaching git push --delete (the panel already validates tag ∈ publicTags).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'Staging' is not a single server: any beta-channel server pulls a release itself,
so a per-server 'deployed' step in the dev dashboard was arbitrary + contradicted
that model. The pipeline now tracks only the global build facts of a beta —
Tag (Gitea) → Mirror (GitHub-private) → CI. Removes PipelineStatus::testStep, the
test-server Setting/input/saveTestServer, and the now-orphaned lang keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PipelineStatusTest: the private mirror slug 'boksbc/clusev-staging' was hardcoded
in this tracked test (tests/ ships to the public repo) — exactly the leak the
design forbids. Neutralise to an example slug 'acme/staging'. Add the missing
HTTP-error degrade tests (GitHub 5xx, GitHub throwing, test-server unreachable →
'unknown', never throws) — the core robustness guarantee was untested.
- Live rail: render the per-step description (the built $sub array was dead code)
and colour the live state by status (online/warning/offline).
- saveTestServer: refresh() the pipeline cache so a changed test-server URL is
reflected immediately, not after the 15s TTL.
- .env.example: document the dev-only release/pipeline keys (commented, no values).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ReleaseChecker: mix a short hash of clusev.repository into the tag + changelog
cache keys so a cached value can never be reused across a host/repo change (the
promotion pipeline re-points CLUSEV_REPOSITORY between the private mirror and the
public repo).
- Strengthen the GitHub User-Agent test to assert the value (Clusev-Panel), not
mere presence — Guzzle always sets some UA, so the old check was a tautology.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a
GitHub-hosted public repo would never resolve a release. Centralise the repo-URL
parsing and branch by host: GitHub uses api.github.com for tags and
raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires);
Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so
all host logic lives in one place.
Make the update-check tests hermetic: they now set clusev.repository themselves
(neutral host) instead of depending on the ambient .env — so they pass in CI with
an empty .env.example — and add GitHub-host coverage for tags + changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a binary / non-UTF-8 file in the file manager threw "undefined is not
valid JSON". The editor bound the raw file bytes to its public $content
property; Livewire JSON-encodes the component snapshot, and invalid UTF-8 makes
PHP's json_encode return false, so the client received an empty response and
JSON.parse("undefined") failed (the trace in mergeNewSnapshot/deepClone).
Fixed at both layers: FleetService::readFile blanks the content when it detects
binary/non-UTF-8 (returns the binary flag instead), and FileEditor::load only
binds content for non-binary, non-oversize files. Binary/oversize files show
their existing notice; the snapshot is always valid UTF-8.
Tests: load blanks binary content (keeps the snapshot valid) and keeps valid
text content. 374 pass, Pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes the operator asked for:
- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
test masked it with a wildcard fake; tightened with Http::assertSent.
- The sidebar now shows a "1" badge on the Version item when an update is
available, so an update is visible on every page, not only the Versions page.
Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
(no network in the sidebar); a scheduled clusev:check-update (every 30 min,
anonymous/read-only) keeps the cache warm so the badge is accurate even before
the operator opens the Versions page.
Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.
Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit log loaded the latest 50 entries and filtered them in memory, so it
couldn't grow and search only spanned those 50. Now it is paginated (25/page)
with the page deep-linked in the URL via Livewire WithPagination (?page=), and
the search runs in SQL across the whole history — matching actor / action code /
target / server name AND the readable labels (a term is translated to the action
codes whose localized label contains it, so "Endpoint geändert" still finds
wg.set-endpoint). Pagination control mirrors the changelog's windowed pager.
Tests: 25/page with page 2 overflow, search resets to page 1, search filters in
SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint
clean, /audit loads 200 with no console errors, audit lang parity 62/62.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator
couldn't see what an available update contained until after applying it. Now,
when behind a published release, a "What's new in vX.Y.Z" panel shows the
changelog of the available version(s) — fetched from the remote CHANGELOG.md at
the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with
the shared changelog parser and filtered to versions newer than installed.
Degrades gracefully: a failed fetch yields an empty preview and never blocks the
update button. Refactored releases() to a reusable parseChangelog().
Tests: preview lists only newer versions + renders their entries; no preview
when current. 362 pass, Pint clean, /versions loads 200 with no console errors,
versions lang parity 51/51.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>