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>
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>
- 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>
- 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>
- 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>
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>
- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
late in a full suite run)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- seats table + model; Users page manages team members (owner auto-created)
- invite respects plan seat allowance + dedupe; role change + revoke guarded
so the last owner can never be removed or demoted
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- customers gain profile + branding + closed_at; instances gain cancellation
fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- plans gain seats + performance class; customer views show storage/seats/
performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
MRR / backups / invoices bar charts converted to gradient line
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- customers.user_id unique so impersonation/billing can't cross customers
- HostCreate validates datacenter is active (exists rule + active=1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- migration seeds fsn/hel + any host-referenced code so existing installs
keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Progress bar + X/total, highlighted current step, faint next step, failure
inline — replaces the long repetitive per-step 'done' list. Tactical-Terminal
tokens, DE+EN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>