Commit Graph

122 Commits (08670ecbee77ef7b2de29e0d86e3f22d045b7e56)

Author SHA1 Message Date
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
Two lifecycle holes Codex found in the new VPN management:

1. Delete removed the row before the hub knew. If the removal job was delayed
   or failed, SyncVpnPeers saw a peer it did not recognise and adopted it back
   as a live access — silently restoring what an operator had just revoked, and
   with no row left to revoke it again. Deletion is now a soft-delete
   tombstone: the row survives until the hub confirms the peer is gone, the
   sync retries the removal instead of adopting, and the tombstone is purged
   only once the peer is really absent. It also keeps the key and the tunnel
   address reserved meanwhile, so neither is handed out twice.

2. ApplyVpnPeer applied the state captured at dispatch. A retried block job
   could therefore undo a later unblock, and nothing would repair it — the sync
   only observes state, it never re-applies intent. The job now resolves the
   current desired state from the row and treats its payload as a fallback for
   the case where the row is already gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:41:03 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +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 46d821a047 fix(monitoring): cap monitoring retries at the run budget; dependency-free liveness probe
- MONITORING_ATTEMPTS is capped by the run's max_attempts, so a large value can
  no longer burn the retry budget and fail the very provisioning the degradation
  exists to protect (covered by a new test at the budget boundary)
- /health is now dependency-free (200 in ~2ms with Kuma absent, verified: the
  container healthcheck stays healthy); Kuma state is exclusively in /ready

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:19:32 +02:00
nexxo 82916167cf fix(monitoring): MONITORING_ATTEMPTS is now exact (off-by-one); accept int or dict monitor id
- attempt is 0-based, so compare one-based: ATTEMPTS=2 really means two tries
- bridge accepts add_monitor returning {monitorID}|{id}|int (v1.2.1 returns a
  dict — re-verified: first create on a fresh Kuma is HTTP 200 with the id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:08:07 +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 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo d05932105d feat(portal): bind the cloud card to the real instance (plan/seats/domain/quota) so the maintenance badge matches it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:14:42 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:10:44 +02:00
nexxo 0b2d762b88 fix(auth): scope registration throttle to its own route (per-IP), not all Fortify endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:41:30 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 22cbe9ac39 fix(auth/admin): atomic user+customer signup; Rule::in for country validation (comma-safe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:33:09 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 507ab485a0 fix(admin): derive legacy datacenter location allowlist from DB, not client property
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:27:41 +02:00
nexxo 3a794b05dd fix(admin): promote only role-less legacy admins; allow editing datacenters with legacy free-form location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:26:09 +02:00
nexxo fd7cba7403 fix(admin): authorize datacenter modal mounts; stale threshold uses step maxDuration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:23:44 +02:00
nexxo 2b73a895c9 fix(admin): don't flag scheduled-backoff runs as stale; edit-datacenter country validation from config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:21:22 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo 88957f8f57 feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish
  enterprise look), cross-linked; brand panel uses accent gradient + tokens
- Fortify registration enabled; /register full-page Livewire + RegisterTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:16:27 +02:00
nexxo be99f413f7 feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:11:17 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:09:17 +02:00
nexxo 87a81f5f40 fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:44 +02:00
nexxo e09965f918 fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:38:44 +02:00
nexxo 28221549d1 fix(admin): defer maintenance sent_at to real delivery; read-only send-time dedup (no lost mail)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:35:52 +02:00
nexxo 0cc5be5479 fix(admin): atomic send-time delivery claim so backlog duplicates ship exactly once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:34:02 +02:00
nexxo 5b85806605 fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:43 +02:00
nexxo 72731112f4 fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:30:17 +02:00
nexxo 8bb93137a3 refactor(admin): extract MaintenanceNotifier; atomic provisioning retry; catch-up cancellation for race-delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:28:02 +02:00
nexxo ae1f78f534 fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo 62b4d85ef7 fix(admin): release notification claim on dispatch failure so retries aren't blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:17:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 493b81aadf fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:14:01 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo 6593dae946 fix(admin): retry path for missed maintenance emails; lock Owner role to serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:04:31 +02:00
nexxo bc2e95ba18 fix(admin): block role escalation of non-staff; validate maintenance host ids atomically; rollback ledger on dispatch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:01:45 +02:00
nexxo e5c74c6bdd fix(admin): reject customer email on operator account update; do not claim mail delivery on dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:58:24 +02:00
nexxo d68d8e1f25 fix(rbac/mail): migrate legacy is_admin users to Owner in-migration; send maintenance mail in customer locale
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:56:22 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo c5d60340b7 feat(admin): staff RBAC (spatie) + admin settings page
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:47:42 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo f6efa4f200 fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:02:37 +02:00
nexxo 8bcb5ec268 fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect
The DB now refuses to orphan a host; datacenter delete pre-checks for a friendly
message and catches the constraint as the race backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:00:38 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo 01383e7e5e fix(portal): race-safe owner-seat initialization (firstOrCreate + catch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:55:09 +02:00