Commit Graph

12 Commits (186c2e46d1c7bbf57fd35fc99bf8e4e0eec64d29)

Author SHA1 Message Date
Claude 2bb1d7cc3b Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Successful in 4s Details
An empty 404 is itself information: it says something terminates TLS here and
chose not to answer. The console and the websocket endpoint now close the
connection instead, which is what a hostname that serves nothing looks like.
The websocket endpoint answers a genuine upgrade — verified live, 101 — and
nothing else; a browser opening it gets a closed connection.

Reviewing that turned up two holes that mattered more than the thing being
reviewed.

The compose defaults published the application and Reverb on every interface.
Docker publishes ports ahead of UFW, so those backends were reachable from the
internet with the firewall closed — and reaching a backend directly skips the
proxy, and with it every hostname and address rule keeping the console private.
The defaults are loopback now, and an update rebinds an existing installation
that is behind a proxy actually running and holding 443. A development box
without one is left alone, because taking its port away would look like the
machine had broken.

And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's
allowlist. One click would have put the console on the public internet, which
is the exact state the owner's rule exists to prevent. Switching it off relaxes
the application's check; the proxy keeps its list, always.

The reference proxy config carries the arrangement: QUIC early data off, since
an address-based decision taken on 0-RTT can answer 425 and some browsers do
not retry — an intermittent lockout from the console is the worst kind — and
explicit handling for plain HTTP, whose automatic redirect otherwise announces
both hidden names to anyone who asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:48 +02:00
nexxo a87a4f512d feat(billing): one plan change at a time, and every price says what it is
Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:23:49 +02:00
nexxo e5aea84539 docs(ci): wire the runner up to the point where only the token is missing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:18:42 +02:00
nexxo e2b4cdbac4 feat(site): switch the public website and portal off from the console
While the product is still being built, the marketing site and the customer
portal should not be reachable — but they must stay reachable for us.

- A toggle in the console (site.manage, Owner/Admin) stored in a new
  app_settings table, because this has to be flippable without a deploy.
- Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search
  engines to index the placeholder as the site's content, which is far harder
  to undo than to prevent.
- Anyone on the management VPN, and any signed-in operator, sees the real site.
  The console, Livewire's endpoint, the Stripe webhook and the health check are
  always reachable — otherwise the switch could only be flipped once.
- robots.txt is generated by the app and follows the switch. It had to stop
  being a static file: nginx short-circuited it and Laravel's stock file said
  'Disallow:' with an empty value, so crawlers were never told anything.
- Settings reads fall back to the default when the table is unavailable. The
  gate reads one on every request, so a deploy running new code before migrate
  would otherwise answer the entire site with a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:10:27 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 0d9b62eb50 feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.

Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
  not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
  belongs to a person, and ownership is what grants sight of it. Every operator
  reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
  it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
  imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
  different things at once.

Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
  admin who needs access revokes this one and issues their own, which keeps the
  record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
  password.confirm keeps a 15-minute session stamp, which would authorise
  unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
  also hand over the management network. Purged on revocation and when a staff
  member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
  seconds); the component carries an opaque handle instead.

Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:31:54 +02:00
nexxo 3e82b51f89 fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains
The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:03 +02:00
nexxo 7247edf9d8 feat(security): keep the operator console off the public hostnames
Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:01:02 +02:00
nexxo 1f42c05648 feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The
primary control is the reverse proxy, but nginx here is a catch-all
(server_name _), so /admin was served on EVERY hostname — a proxy
misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404
(not 403: a public domain must not disclose that a console exists).

Prepended to the  group instead of the admin route group on purpose: route
middleware is reordered by Laravel's priority list, which runs  first — a
guest would then be redirected to /login and learn the console is there. Covered
by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is
locked out by upgrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:45:54 +02:00
nexxo 5301ce4309 feat(monitoring): Uptime Kuma support via a REST bridge + resilient monitoring policy
Kuma has no write REST API (monitor CRUD is Socket.IO only), so docker/kuma-bridge
translates the exact generic REST contract HttpMonitoringClient already speaks —
no PHP client change needed. Opt-in compose profile 'monitoring'.

Verified end-to-end against a real Uptime Kuma 1.x:
  create -> id, create again -> same id (idempotent), list, status, delete
  and PHP MonitoringClient -> bridge -> Kuma round-trip.
The e2e run caught a real bug: add_monitor takes 'maxretries', not 'retries'.

Monitoring is now observability, not a delivery gate:
- RegisterMonitoring retries MONITORING_ATTEMPTS times on an outage, then
  continues degraded with a visible 'info' event
- RunAcceptanceChecks no longer fails when monitoring isn't green (a fresh Kuma
  monitor legitimately reports 'pending' — confirmed in the e2e run); it records
  the gap instead. MONITORING_REQUIRED=true restores strict gating.
- docs/monitoring-uptime-kuma.md documents setup, failure behaviour, alternatives

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:04:58 +02:00
nexxo f06f00c428 chore(config): documented env placeholders + SSH keys from file paths
- .env.example gains a full, commented CluPilot operations block (Stripe, Hetzner
  DNS, Traefik, WireGuard hub, SSH identity, monitoring, prod mail) with a note
  that everything is optional for local dev
- config/provisioning.php: CLUPILOT_SSH_{PUBLIC,PRIVATE}_KEY_PATH read the key
  from a file (a multi-line PEM cannot live in .env), falling back to inline vars
- documents that Uptime Kuma's REST API is read-only (monitor CRUD is Socket.IO),
  so it needs a bridge rather than being drop-in for the generic REST client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:37:25 +02:00
nexxo 2ae7595aff chore: bootstrap CluPilot control-plane (Laravel 13, Docker stack)
- Docker dev stack: app (php-fpm+nginx+vite via supervisor), reverb, queue,
  scheduler, mariadb 11.4, redis 7 — env-driven ports/UID (HOST_UID=1000).
- Laravel 13.8 + Livewire 3.8 (pinned v3 per R2), Tailwind v4, Reverb, Echo,
  phpseclib, wire-elements/modal.
- .env wired to service names; DE default locale (R16); browser Reverb host
  env-driven; self-hosted-fonts-ready vite.config (R14).
- Entrypoint bootstraps vendor/node_modules on fresh checkout; bin/clupilot
  helper (UID/port env-driven) + shell aliases.
- Reviewed with Codex (R15): 7 findings fixed, 1 verified false-positive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:21:30 +02:00