Commit Graph

193 Commits (142b531d45708aa301eb8a1e3f9372f44988ccfe)

Author SHA1 Message Date
boban 142b531d45 feat(auth): make password rotation optional + correct the 2FA copy
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>
2026-06-25 19:47:17 +02:00
boban 83626352e2 feat(terminal): Clusev-host SSH login tile + server search; drop the inline hint
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>
2026-06-25 19:46:55 +02:00
boban 07fe404ce9 feat(terminal): web terminal — per-server SSH + Clusev host PTY (xterm.js + node sidecar)
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>
2026-06-25 03:21:10 +02:00
boban 8dfc11fd5d fix(versions): keep the changelog series stable on reload (0.10 -> 0.1 bug)
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>
2026-06-25 02:15:41 +02:00
boban b2c6de64da refactor(metrics): interactive history chart — smooth, area, tooltip, instant ranges
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>
2026-06-25 01:51:24 +02:00
boban 6227505528 feat(metrics): persistent resource-history graph on the Server-Details page
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>
2026-06-25 01:31:27 +02:00
boban 940c4c80fa fix(hardening): fail2ban — pin the sshd jail to the systemd backend
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>
2026-06-25 01:14:06 +02:00
boban d56bdadd14 feat(ssh-keys): name an added SSH key so it is identifiable
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>
2026-06-25 00:53:49 +02:00
boban 7ec2c065bb fix(hardening): guard against root-login self-lockout
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>
2026-06-25 00:41:50 +02:00
boban ab728efc6f fix(versions): dev box lists BOTH its source and the public repo
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>
2026-06-24 23:51:28 +02:00
boban 8e77f31f00 fix(versions): non-dev installs only link the public repo on the Projekt card
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>
2026-06-24 23:41:18 +02:00
boban 05c34b81ed fix(update): make the update-progress screen survive a browser refresh
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>
2026-06-24 23:05:54 +02:00
boban 1eb4c4fef7 fix(release): cache public-tags reads + tighten yank tag validation (review)
- 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>
2026-06-23 06:01:36 +02:00
boban 67a69f3189 feat(release): publish + yank UI, strings, audit labels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:28:49 +02:00
boban ea33797236 feat(release): deploy-to-public, promote-to-stable, yank actions (R5 + audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:26:14 +02:00
boban 7c5b4cf222 feat(release): PromotionService — dispatch promote/yank workflows + read public tags
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:08:11 +02:00
boban 44d0072c1a refactor(release): drop the test-server step — pipeline is build readiness only
'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>
2026-06-23 02:46:17 +02:00
boban f82555469a style: pint --test fixes (CI pint step now reachable after the test fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:29:59 +02:00
boban fca75bc0a3 fix(release): neutralise private slug in tests + render step descriptions (review)
- 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>
2026-06-23 02:13:26 +02:00
boban 0ac819e4ad feat(release): wire the live pipeline + test-server setting into the page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:49:01 +02:00
boban 070c6646f3 feat(release): PipelineStatus — live tag/mirror/CI/test-server status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:43:00 +02:00
boban c696950f25 feat(release): localized strings + audit labels for staging releases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:56:09 +02:00
boban 4b68b8dc04 feat(release): flag-gated /release page + Deploy-to-Staging via host bridge
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>
2026-06-22 23:39:47 +02:00
boban 54b41591b9 feat(release): ReleaseBridge — write staging request, read host result
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:14:34 +02:00
boban 05c98a7b9e feat(release): ReleasePlanner — proposed beta targets from the current version
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:07:41 +02:00
boban 437221788d fix(versions): discriminate release caches by repository + assert the UA value
- 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>
2026-06-22 22:15:36 +02:00
boban 586fac7132 feat(versions): support GitHub host in the update check
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>
2026-06-22 21:41:52 +02:00
boban 359aa86aea fix(files): binary file content no longer breaks the Livewire snapshot (v0.9.54)
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>
2026-06-22 05:49:05 +02:00
boban 4b2aee3941 fix(versions): show what's-new (v-prefixed ref) + sidebar update badge (v0.9.51)
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>
2026-06-22 00:19:27 +02:00
boban c6597be59e feat(audit): paginate the log + DB-wide search (v0.9.50)
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>
2026-06-21 23:58:27 +02:00
boban 6d9dcb9529 feat(versions): show the available release's changelog before updating (v0.9.49)
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>
2026-06-21 23:48:37 +02:00
boban e154f7d92f feat(audit): readable action labels + failures surfaced (v0.9.48)
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>
2026-06-21 23:41:48 +02:00
boban 60e1edc0d5 feat(wg): configurable DNS + Server/Peers tabs + faster offline (v0.9.45)
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>
2026-06-21 22:48:46 +02:00
boban 920dfb02bf feat(wg): compact QR + meaningful download filename (v0.9.44)
- 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>
2026-06-21 22:25:41 +02:00
boban 9c0d390365 refactor(wireguard): remove dead code + DRY the WG surface (no behavior change)
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>
2026-06-21 16:33:41 +02:00
boban 2b245c3d3a feat(wireguard): SSH lock + peer config download (v0.9.40)
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>
2026-06-21 09:25:19 +02:00
boban 94425f93e8 fix(wg): remove SSH hint from setup form + time out a non-responding host (no endless spinner)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:38:43 +02:00
boban 8e05cf62ec feat(wg): set WireGuard up from the dashboard (setup form + bridge action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 08:09:35 +02:00
boban 858a00955a fix(wg): single audit per confirm action (no descriptor double-audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:00:26 +02:00
boban 7ccefa5890 feat(wg): gate toggle + endpoint/port/subnet settings from the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:53:29 +02:00
boban ee8f2bac91 feat(wg): add/remove peers from the dashboard (show-once config + QR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:36:26 +02:00
boban ed5506e558 feat(wg): WgBridge — app side of the write-bridge (validate + request + result) 2026-06-21 00:32:02 +02:00
boban 0c9aa12aab feat(wg): traffic-history SVG chart + window selector on /wireguard 2026-06-21 00:07:18 +02:00
boban 49f70c0c9f fix(wg): WgTraffic up = throughput delta (not raw tx) — consistent with down
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:04:07 +02:00
boban 130e1aeaf6 feat(wg): WgTraffic — bucketed down/up throughput series 2026-06-21 00:02:35 +02:00
boban 9e6f3775b7 feat(wg): clusev:wg-sample command (sample peers + prune) + schedule 2026-06-21 00:00:06 +02:00
boban ae4937654d feat(wg): wg_traffic_samples table + model 2026-06-20 23:59:11 +02:00
boban e640a96ca0 feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:35:58 +02:00
boban 9b835e9dbe feat(wg): WgStatus reader + auth-gated /wg-status.json route 2026-06-20 23:32:44 +02:00
boban c9556bffbf feat(wg): WireGuard help topic (registration + DE/EN content + test) 2026-06-20 22:46:04 +02:00