Commit Graph

431 Commits (a6176341e290c2c981c0606fb483d355b1c953d0)

Author SHA1 Message Date
boban a6176341e2 fix(security): keep internal IPs out of shipped code and docs/ out of the public image
Audit leak-hygiene findings:
- The operator's real internal dev-VM/WireGuard IPs (10.10.90.136/.162/.165) were shipped in
  vite.config.js (hmr.host), lang hint/placeholder strings, a code comment and the CHANGELOG.
  vite HMR host is now env-driven (VITE_HMR_HOST, dev .env only); all other occurrences are replaced
  with RFC5737 documentation addresses (203.0.113.x). Tests updated in lockstep.
- docs/ is export-ignored from the git tree and named in promote.sh's refuse-to-publish list, but was
  never .dockerignore'd, so `COPY . .` baked the private docs (with private host/URL refs) into the
  public image, which the git-tree leak-guard never inspects. Added docs/ (and art/) to .dockerignore.
- /update.log (new repo-root update transcript) gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:25:10 +02:00
boban d7156ddc96 fix(security): harden the update path — signed sentinel, symlink-safe root writes, .env 0600
Security audit (12-angle adversarial workflow) findings on the container→host boundary:
- .env was created world-readable (cp of a 644 .env.example, never chmod'd) exposing the APP_KEY
  that decrypts the whole fleet's SSH vault. Now chown clusev + chmod 0600 before any secret write.
- The root updater followed attacker-planted symlinks in the container-writable ./run (update-phase.json,
  update.log) → arbitrary root file write. Status writes now go via a temp + rename(2) (never follow a
  link); the update transcript moved to the repo root (not the ./run bind mount).
- The generated UPDATE_HMAC_KEY was dead. The app now HMAC-signs the update-request marker
  (config/clusev.php update_hmac_key; DeploymentService) and watch.sh verifies it before running a
  root update, so a stray/limited write to ./run can't drive one (a full container compromise holds
  the key, so this is a bar-raise + integrity check; the standing guarantee is the marker only ever
  re-installs the trusted remote's code, never attacker code).
- force_kv/set_kv wrote unescaped values into a sed replacement — a & | or newline in an operator's
  domain/email/port could corrupt .env or inject a sed command. Values are now CR/LF-stripped and
  sed-escaped, and the HTTP port is validated numeric/in-range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:25:10 +02:00
boban 8a814337ff fix(install): validate release-image digests as ghcr.io/<owner>/<name>@sha256:<64-hex>
Codex R15 flagged that _clusev_ref_ok accepted malformed digest refs (short/non-hex,
which trigger a doomed pull instead of a build fallback) and refs carrying an embedded
newline in ANY segment (force_kv would then split .env, injecting an attacker-chosen key).
Anchor the check to an exact 64-hex sha256 digest and two ghcr.io path segments that each
start alphanumeric and otherwise contain only [A-Za-z0-9._-] — so no newline, "=",
whitespace or slash can appear anywhere in the ref. Malformed or tampered refs now fall
back to a local build and can never reach force_kv. Tests: +6 cases (short, non-hex,
path-traversal, ".." name, newline in digest, newline in owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:45 +02:00
boban 4af1633ca9 fix(install): harden release-image resolver — safe build fallback, ghcr digest allowlist, lint + CI coverage
Review found the build fallback could inherit a stale digest pin (compose build aborts), a slash-shaped/non-ghcr lock ref caused a doomed pull, env mode never persisted the pin (host restart used a nonexistent default tag), and the resolver test was unlinted/unrun in CI. Resolver now always leaves a coherent pin, validates ghcr.io/<owner>/<name>@sha256 refs only, exports the mode flag (drops the file-level shellcheck disable), and CI lints + runs the script unit tests.
2026-07-03 21:27:30 +02:00
boban ace3af5849 feat(install): pull promoted image on public installs via release-images.lock 2026-07-03 21:03:03 +02:00
boban a3cc563883 feat(install): resolve prod image from release-images.lock (pull vs build) 2026-07-03 21:01:26 +02:00
boban 383f2fe74a feat(release): remove the no-op channel selector + dead beta→stable promotion (single stable channel) 2026-07-03 20:43:15 +02:00
boban 10486672a5 ci: build+push prod images, gated auto-promote to public
Backbone sub-project #1, CI half (verified with actionlint; the runtime proof is
the first rc/stable tag push — these jobs cannot run locally).

- ci-staging.yml:
  - new `images` job (needs: test): buildx builds clusev-app (Dockerfile) +
    clusev-terminal (docker/terminal/Dockerfile) → private GHCR, tagged by
    version; then a framework smoke-boot (`php artisan route:list`) so a class
    stripped by export-ignore that is still reachable on boot fails before promote.
  - `deploy-staging` widened to any tag; pulls the CI-built private image
    (CLUSEV_IMAGE/CLUSEV_TERMINAL_IMAGE + CLUSEV_PULL=1) instead of building.
  - new gated `promote` job (needs: [test, images], if success + stable vX.Y.Z,
    no -rc): copy the exact image digests to the public GHCR, run promote.sh
    (leak-guard + export-ignore tree), write release-images.lock, push code+tag.
- promote-public.yml: repurposed as a guarded manual fallback mirroring the auto
  promote (image copy + lock + guard). Dropped the "beta channel" naming.
- promote-stable.yml: removed (no beta→stable step in a single-channel model).

Deploy inputs are GitHub vars/secrets (GHCR_OWNER, PUBLIC_GHCR_OWNER,
PUBLIC_REPO_SLUG, STAGING_*, GHCR_TOKEN, PUBLIC_REPO_TOKEN, PROMOTE_FORBIDDEN) —
never hardcoded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:34:00 +02:00
boban fff2d017be build: export-ignore the promote.sh test scripts too (they invoke the ignored script) 2026-07-03 20:06:14 +02:00
boban 4e4023f12d feat(release): collapse to a single stable channel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:00:50 +02:00
boban 176b132da9 chore(release): remove every 'gitea' reference from the public tree (comments, test fake-host, redundant ignore)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:20 +02:00
boban 54518c1f30 feat(release): string leak-guard in promote.sh + shell test 2026-07-03 19:49:31 +02:00
boban e02f4d036a build: export-ignore the full dev-release bridge + its tests from the public tree
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:47:01 +02:00
boban 72b82df7a1 fix(update): surface update failures instead of spinning forever
A dashboard-triggered update that failed on the host left the /update-progress
page spinning to a vague 10-minute timeout — no error was ever shown, because
nothing wrote a failure marker for the page to read. Fix: make failures visible
and prevent the credential-prompt hang class.

- docker/restart-sentinel/watch.sh: run update.sh under `timeout -k 30 1800` with
  its output tee'd to run/update.log; on ANY non-zero exit (update.sh or its
  exec'd install.sh) — and on the early compose-missing / updater-missing paths —
  write {"stage":"error"} to run/update-phase.json via write_update_error().
- update.sh: export GIT_TERMINAL_PROMPT=0 + GIT_HTTP_LOW_SPEED_* and wrap the pull
  in `timeout 300`, so a private-repo credential miss fails fast instead of
  hanging on a non-interactive prompt.
- DeploymentService::updatePhase(): whitelist the 'error' stage.
- update-progress.blade.php: add an error state (#js-status-error) + showError();
  the status-feed poll now stops and shows it on stage=error, marking the active
  phase red — instead of looping.
- lang/{en,de}/update.php: error_heading / error_hint / back_button.
- UpdateProgressTest: feed surfaces stage=error; page renders the error branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:58:32 +02:00
boban 6348d269a5 docs: fix stale first-login copy (random initial password, rotation optional)
The in-panel help "First login" section still claimed you log in with the
literal default password `clusev` and that "the panel forces a password change
immediately". Both are false since this cycle: the installer generates a RANDOM
password shown once in the closing summary, and EnsureSecurityOnboarded only
nudges (skippable), never forces.

- help/overview.blade.php (en+de): rewritten — sign in with the initial
  password from the installer summary; setting your own is recommended (a
  banner reminds) but not forced.
- lang/{en,de}/auth.php: default-password banner → "Initial password still in
  use" / "Initialpasswort noch aktiv".
- ResetAdmin.php: reset output "Einmal-Passwort" → "Neues Passwort".
- EnsureSecurityOnboarded.php: comment wording aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:50:35 +02:00
boban d08a8a3866 docs: rename the generated password "one-time" → "initial" (rotation is optional)
Password rotation is a nudge, not a wall (Install sets must_change_password=true
but EnsureSecurityOnboarded only prompts and is skippable), so calling the
generated credential a "one-time password" wrongly implied forced/single-use
rotation. Renamed the user-facing term everywhere:

- README + lang/{en,de}/accounts.php: "one-time password" / "Einmal-Passwort"
  → "initial password" / "Initialpasswort"; temp_intro "must change on first
  sign-in" → "prompted to change" / "sollte … geändert werden".
- Install.php $description: "Standard-Passwort (Zwangswechsel …)" → "zufälligem
  Initialpasswort (Wechsel empfohlen, nicht erzwungen)".
- Install.php / CreateUser.php comments aligned.

Kept "one-time backup codes" (2FA codes are single-use) and "shown only once"
(the summary is displayed once).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:43:09 +02:00
boban 657e659d76 docs: use the short `clusev` host-CLI aliases in the README
Operator-facing docs must show the short host commands, not the long
docker-compose invocation (same convention already enforced for the in-panel
help/versions/MOTD surfaces):

- Updating: `sudo ./update.sh` -> `sudo clusev update`.
- Recovery: `docker compose -f docker-compose.prod.yml exec app php artisan
  clusev:reset-admin` -> `clusev reset-admin`.

Add README.md to CommandShortcutsTest's DISPLAY_SURFACES and
UPDATE_COMMAND_SURFACES so the README is regression-guarded too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:26:42 +02:00
boban f3c5fee839 feat(shell): point Help entry at the online docs (docs.clusev.com)
The sidebar "Help" item, the command-palette entry and the `g h` shortcut now
open the public documentation site in a new tab instead of the in-panel help
page — no duplicated docs to keep in sync.

- config/clusev.php: add `docs_url` (env CLUSEV_DOCS_URL, default
  https://docs.clusev.com) so the docs host is configurable per install.
- sidebar + command-palette: Help href -> config('clusev.docs_url'),
  target=_blank rel=noopener.
- app.js: navigate() opens absolute http(s) URLs via
  window.open(_, '_blank', 'noopener'); go() resolves leader keys absent from
  CMDK_GO via the (config-driven) nav list, so `g h` follows the docs URL.

The in-panel /help route/component is kept (unlinked) as an offline fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:12:29 +02:00
boban 9f784ce97a chore: release 0.11.0-beta1
Web terminal (per-server + Clusev-host SSH), guided onboarding tour, terminal server search,
operator-set passwords; optional password rotation, 6-char minimum, random install password,
domain-free installer; host-key pinning + proxy-trust hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:26:34 +02:00
boban 0fbf379ae9 docs(readme): match the installer + auth changes (no domain prompt, random install password, web terminal)
- Quick start: the installer no longer asks for a domain — it installs over IP/HTTP and prompts only
  for the HTTP port + admin email; a domain (auto-HTTPS) is set later in the dashboard.
- The first admin now gets a RANDOM one-time password (shown once in the summary), not the literal
  'clusev'; rotation is recommended (a warning banner nudges) but no longer forced.
- add the web terminal to Features (per-server + Clusev-host SSH, host-key pinned); mention the
  first-run guided tour; point the domain/TLS section at the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:20:32 +02:00
boban 2a1ad6595b chore(install): don't prompt for a domain — install over IP, set it later in the dashboard
The panel domain is a dashboard concern now (System page → panel_domain override + Caddy
on-demand TLS), so the installer no longer asks for it. A fresh interactive install goes
straight to bare-IP/HTTP (prompts only for the HTTP port + admin email). The domain still
works everywhere it needs to: CLUSEV_DOMAIN=… preset for scripted installs is kept, and a
non-interactive re-run (update.sh) still preserves the existing .env domain; the closing
banner and the bare-IP warning now point the operator to Dashboard → System for the domain.

shellcheck clean; bash -n OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:14:29 +02:00
boban dc26dccaa3 feat(onboarding): first-run spotlight tour — dimmed backdrop, sidebar highlights, relaunchable
A guided tour for new operators: a dimmed+blurred overlay with an enterprise description card
(mono eyebrow "01 / 06", display title, body, progress bar, back/skip/next). A step with a
target spotlights the matching sidebar nav item (a box-shadow "hole" lights it while everything
else dims); welcome + finish are centred cards. On narrow viewports (sidebar hidden) it falls
back to centred cards.

- auto-opens once per account (users.onboarding_tour_completed_at is null → autostart); skip or
  finish calls Tour::markSeen() which stamps it, so it never auto-opens again.
- relaunchable any time from Settings (a client-side 'onboarding:start' window event — no DB change).
- @persist'd in the layout + a sessionStorage guard so a wire:navigate or fast reload right after
  dismissal can't re-open it before markSeen persists.
- nav-item now merges its attribute bag (data-tour passes through without duplicating class/href).

Browser-verified: auto-open → spotlight walks Dashboard/Servers/Terminal/Settings → skip stamps +
closes → navigation doesn't reopen → Settings relaunch works; zero console errors. 4 feature tests;
full suite 473 pass; Codex review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:00:19 +02:00
boban cc4cc3db4c security: pin the SSH host key in the terminal sidecar (fail-closed)
The ssh2 sidecar connected with no host-key verification, so a MITM could present its own key
and capture the (decrypted) SSH credential — the phpseclib fleet path pins via TOFU but the
sidecar did not. The resolve endpoint now returns the server's TOFU-pinned host key; the sidecar
verifies the presented key against it (crypto.timingSafeEqual) during the handshake, before any
credential is sent.

parseHostKeyPin distinguishes three states so a corrupted record can't silently unpin:
  null   → no pin (Clusev host / never-contacted server) → accept, TOFU first use
  false  → a pin is present but malformed/undecodable    → FAIL CLOSED (reject)
  Buffer → enforce the pin (reject a mismatch as MITM)

Verified in-browser against a real server: valid key connects + runs; a swapped key is rejected
as MITM; a garbage non-empty key fails closed — all before any credential leaves the sidecar.
Full suite 469 pass; Codex re-review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:33:53 +02:00
boban c3788f5851 security: generate a random install password instead of the literal 'clusev'
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>
2026-07-02 19:33:52 +02:00
boban fd4f6ceb0f chore: remove dead code — sidecar host-PTY path, node-pty, unused lang keys + component
The "Clusev host" terminal resolves to an SSH login now (resolve never returns kind:'host'), so the
sidecar's local-shell path was unreachable:
- docker/terminal/server.js: drop startHost(), the spec.kind==='host' branch, the node-pty require
  and HOST_SHELL/HOST_CWD constants — the sidecar only ever opens an SSH PTY.
- Dockerfile: drop the python3/make/g++ toolchain (only there to compile node-pty's native addon);
  package.json: drop the node-pty dependency. Leaner, faster image.
- compose (dev+prod): drop the now-unused TERMINAL_HOST_CWD env and the .:/workspace:ro mount.
- remove grep-confirmed-unused lang keys (settings 2FA/stub-tab keys, accounts twofa/cannot_remove,
  auth recovery_done/regenerate_confirm, common back/retry/more/loading, servers firewall/fail2ban
  unavailable variants) and the unused x-server-item Blade component.

Verified: lean sidecar rebuilds + the server terminal still connects/runs; full suite 467 pass; all
pages 200 with no raw-key leaks and no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:16:11 +02:00
boban e38409bf31 security: harden proxy trust + model mass-assignment + terminal-token log hygiene
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>
2026-06-26 07:16:10 +02:00
boban f3d2a9592e feat(auth): drop password complexity — minimum is now just 6 characters
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>
2026-06-26 06:29:33 +02:00
boban 6f43b480b1 feat(auth): lower the password minimum from 12 to 6 characters
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>
2026-06-25 21:19:41 +02:00
boban d08670270e feat(accounts): let the operator set a new user's password directly
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>
2026-06-25 20:29:15 +02:00
boban 00a9d8b9a3 feat(ui): shield-only 2FA indicator in the sidebar + terminal search from 2 servers
- 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>
2026-06-25 20:20:46 +02:00
boban 2b1e98c5d4 fix(terminal): make the saved host-login state unmistakable in the modal
The stored secret is never echoed back (security), so on reopen the blank password field
read as "not configured" / "it forgot my password". The login DOES persist (single
HostCredential row, kept across reloads); only the modal was ambiguous.

- when configured, show a green status: "Configured as <user> — password is stored. Leave
  blank to keep it; enter a new one to change it." The Remove button + gear stay, so the
  operator can still change the password or switch access.

No behaviour change — set once, stays until changed/removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:08:50 +02:00
boban e1a886671b refactor(terminal): host login targets the local machine only — drop the HOST/IP field
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>
2026-06-25 20:00:04 +02:00
boban 0ec9f3664a feat(terminal): explain the host-login HOST/IP default in the setup modal
The field is pre-filled with host.docker.internal — a Docker DNS name that resolves to
the machine the containers run on (the Clusev host) — but had no explanation, so it read
as a mandatory magic value. Add a one-line helper under the field: it's the default for
the Clusev host; leave it, or enter another IP/host. The field stays editable on purpose
(some setups want the host's LAN IP or a different machine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:52:13 +02:00
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 707b031bd2 ci: bump actions/checkout + actions/setup-node to v5 (drop Node 20 runtime)
GitHub deprecated the Node 20 action runtime, so checkout@v4 and setup-node@v4 were
force-run on Node 24 with a warning. v5 of both targets Node 24 natively; their inputs
are unchanged (ref/repository/token/fetch-depth/path; the build's node-version stays
'20', that's the project runtime not the action runtime). setup-php@v2 is the current
major and unaffected. ci-staging runs only on tag pushes, so the warning clears on the
next tagged release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:55:52 +02:00
boban e3b5aa5902 fix(terminal): correct mobile sizing — fit after web-font load + open PTY at the visible grid
On a phone the terminal clipped on the right: FitAddon measured the column count before
JetBrains Mono finished loading (narrower fallback metrics → too many columns → the wider real
font then overflowed and the right edge was cut). And the PTY opened at a fixed 80x24, so the
remote shell's first screenful (MOTD/prompt) was generated at 80 columns and wrapped awkwardly
on a ~43-column viewport.

- refit immediately, on the next animation frame, and again on document.fonts.ready (font race)
- refit on the 'ready' frame before sending the size
- pass the browser's cols/rows on the WS URL; the sidecar opens the host PTY / SSH shell at that
  size (clamped 1..1000), so output is born at the visible width

Verified in-browser at 390px: PTY reports 43x23 (matches the box), no horizontal overflow, clean
wrapping, zero console errors; desktop server+host terminals and cross-origin rejection still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:08:12 +02:00
boban 05042c3582 fix(terminal): clean Clusev-host shell prompt — node@clusev:~ not node@<hash>:/workspace
The host terminal is a shell in the sidecar container; it opened in the read-only
repo mount (/workspace) with the prompt showing the random container id, so it read
like a stray container rather than "the Clusev host".

- give the sidecar a stable `hostname: clusev` (dev + prod) → prompt host is `clusev`
- land the host PTY in /home/node (a real, writable home) and set HOME to it, so the
  shell opens at ~ like a normal terminal instead of /workspace

Server (SSH) terminals are unchanged. Verified in-browser: prompt reads node@clusev,
pwd is /home/node, HOME=/home/node, no container hash / no /workspace; server terminal
still connects and runs; zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 06:31:49 +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 d8da46151e chore: release 0.10.0-beta1
Server-Details resource-history graph (CPU/RAM/Disk over time, selectable range +
hover tooltip, range persisted in the URL), nameable SSH keys, and two hardening
fixes (SSH-root-login self-lockout guard, fail2ban systemd-journal backend). See
CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 02:03:40 +02:00
boban 1697a5d7c2 feat(metrics): persist the history-chart range in the URL
Clicking a range now writes ?range=<key> (replaceState — no history spam) and the
chart restores it on init, so reloading the SAME page keeps the chosen range. Opening
the page fresh (no param) still defaults to 24h.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:59:39 +02:00
boban fbd9c7e257 fix(metrics): move the history chart x-axis labels below the plot
The y-axis labels spanned the whole wrapper (inset-y-0), which now included the
x-axis label row — so the "0" tick and the "−1 h"/"jetzt" labels overlapped at the
bottom. Wrap only the plot + y-axis in the relative box and render the x-axis labels
as a separate row beneath it, so "0" sits on the chart's bottom edge and the time
labels are clearly below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:55:49 +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 cf70317534 docs(readme): drop stale static version; add OS-support matrix + real requirements
The README banner showed v0.9.40 while the badge showed v0.9.58 — static fields
that go stale and disagree. Removed the per-release version from both the badge row
and the banner SVG (the release version lives in tags/changelog, not a frozen
image). Added a "Supported operating systems" matrix (Debian/Ubuntu · RHEL · SUSE
full; Arch partial; Alpine metrics-only; anything else metrics-only) and real
minimum requirements (CPU/RAM/disk for the panel host; nothing on managed servers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:35:35 +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