Commit Graph

10 Commits (40383261bddd3480330162b9d75a3f13a322af07)

Author SHA1 Message Date
boban 096ba3ca98 harden(compose): scope host-gateway route to the web app only (audit finding)
A multi-agent security audit of this branch flagged that the new
`extra_hosts: host.docker.internal:host-gateway` sat on the shared
x-app/x-image anchor, so reverb/queue/scheduler inherited it too. Only
the web `app` container serves the (admin-gated) "Clusev host" Docker
view; the workers run background code with no role gate. Combined with
the image's ability to decrypt the root HostCredential and open root SSH
via HostCredential::toServer()+FleetService, every worker held a network
path to a root shell on the host — defeating the manage-fleet gate that
only exists in the HTTP layer.

Not reachable as shipped (no job/scheduled code touches the host
credential), but a least-privilege regression. Move extra_hosts off the
anchor onto ONLY the `app` service (dev + prod). Verified: app resolves
host.docker.internal, queue/scheduler do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 01:15:35 +02:00
boban fd6eba5c29 fix(mariadb): stop logging protocol-normal "Aborted connection" warnings
MariaDB logged an [Warning] Aborted connection ... (Got an error/timeout
reading communication packets) for every DB connection PHP closed without
the COM_QUIT handshake — i.e. whenever a long-lived worker (queue/schedule/
reverb) was recycled, a php-fpm worker retired, or a container restarted.
These are protocol-normal, not a fault: Aborted_clients was 46 over ~24
days uptime (~2/day) with a non-persistent PDO pool and no connection leak.

They surface only because the mariadb:11 image ships log_warnings=2. Add
docker/mariadb/tuning.cnf (mounted in dev + prod) setting log_warnings=1 —
genuine errors still log, the expected-disconnect noise stops — plus a
higher net_read_timeout (120s) so a momentarily-stalled worker read isn't
aborted as aggressively. Verified: forcing app+queue restarts now produces
zero new "Aborted connection" lines. The DB is internal-network only, so
dropping the level-2 access-denied notes carries no real exposure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:34:04 +02:00
boban c253e79c0c feat(docker): show the Clusev host's own containers (host target)
The Docker page only ever targeted fleet servers, so an operator whose
containers run ON the Clusev host itself (the clusev stack + anything
else on that machine) saw nothing — the page has "nothing to do with
the fleet" from their point of view. It now has a target switch:

  • Clusev host (default when a host SSH login exists) — lists the
    containers on the machine Clusev runs on, reached over the local
    Docker gateway with the existing encrypted HostCredential, the same
    login the host terminal uses.
  • the active fleet server — the previous behaviour, still available.

Mechanics:
- HostCredential::toServer() builds a TRANSIENT Server (never persisted)
  carrying the host login; DockerService/FleetService run unchanged
  against it. The logs modal resolves serverId 0 to this host server.
- VerifiesHostKey only pins (writes ssh_host_key) for persisted fleet
  servers now; a transient host connection no longer spawns a junk
  fleet row on every request. The host is the local gateway — no
  network path to MITM — so skipping the pin there is safe.
- The `app` service (dev + prod compose) gains the
  host.docker.internal:host-gateway mapping the terminal sidecar already
  had, so the PHP layer can reach the host's sshd. Without it the app
  container cannot resolve the host at all — the real reason host Docker
  never worked.
- Absent runtime still renders the clean "not installed" state; a
  host target with no login yet shows a setup hint.

Verified end-to-end in a browser: the page defaults to the host and
lists the full running stack (clusev-app/mariadb/redis/… + others) with
Logs/Restart/Stop actions. 748 tests green (5 new host-target tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:43:36 +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 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 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 58a94d1e9a feat(release): systemd watcher units + dev run-mount + flag-gated install
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:28:27 +02:00
boban e1fb24203d feat(ops): add a scheduler service (schedule:work) — runs wg-sample + prune-audit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:05:14 +02:00
boban 53f7309c0b feat: scaffold — Dockerized Laravel 13 + Livewire 3 + Tailwind 4 + Reverb
Working dev stack on :80 (Freigabe step 5: app boots, page renders, Vite HMR runs):
- Multi-stage Dockerfile (php8.3-fpm + nginx + supervisor + node). Dev runs
  php-fpm + nginx + vite in ONE app container; prod target bakes vendor + assets.
- docker-compose.yml (dev, bind-mount) + docker-compose.prod.yml (GHCR image).
  Env-driven ports/UID (APP_PORT, VITE_PORT, REVERB_HOST_PORT, DB_PORT,
  HOST_UID/HOST_GID) — nothing hardcoded. MariaDB only on 127.0.0.1:3306.
- Laravel 13, Livewire 3.8 (class-based, no Volt), wire-elements/modal 2,
  phpseclib 3, laravel/reverb 1. composer platform pinned to php 8.3.
- Tailwind v4 @theme tokens ("Tactical Terminal") in app.css; Vite remote HMR
  (host 0.0.0.0, hmr.host 10.10.90.136).
- Dashboard as a full-page Livewire route (/) in layouts/app; §5 folder skeleton.
- Docs: rules.md R1-R11 (incl. R11 UUID-in-URLs) + CLAUDE.md updated for
  Laravel 13, clusev/ root, port 80, vite-in-app, env-driven config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:31:50 +02:00