Three gaps, all of the "half-built" kind.
Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.
The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.
The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel had no way to update the installation, and could not have one on its
own: it runs as www-data inside a container, and the update restarts that
container. A button that shelled out would kill its own web server
mid-response, and would need the application to hold host-level credentials —
a far worse thing to own than an out-of-date checkout.
So the button is a mailbox. The panel writes a request into the checkout; a
systemd timer on the host, running as the service account, consumes it, runs
deploy/update.sh and writes back what happened. The same timer answers the
question the panel cannot answer alone — is there anything to update — which
needs a git fetch, and therefore credentials the application does not have.
The parts that were wrong before review, all of which would have shipped as a
button that looks fine and does nothing:
- The agent was only installed by install.sh, which the installed base never
runs again. Every existing server would have shown the button permanently
disabled. It has its own root entry point now, install.sh delegates to it,
and update.sh says so when the unit is missing.
- On a release-pinned server the checkout is detached, so it followed
"origin/HEAD" and then ran an update that deliberately stays on the pinned
tag: updates advertised, nothing applied. It now compares TAGS in release
mode and passes the target release through.
- On a branch other than main it advertised that branch's commits and then
deployed main's, because update.sh defaults to main.
- A request written while the agent was down was executed whenever the agent
next started, days later.
- A single status file meant the routine five-minute check overwrote a failed
update with "idle" before anyone saw it.
- An agent that had been stopped left the button enabled forever, because a
status file written once counted as an agent.
- The agent's error messages were German strings rendered into an English
interface; it reports codes now and the panel translates them.
Also: the add-address button in the console-access panel was as tall as the
field plus its hint, because the hint was a sibling inside the flex row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The allowlist is managed in the console, which is fine until the address you
manage it from changes — and then the page that would fix the problem is the
page the problem blocks. Every gate needs a door that does not depend on
itself, and on a server that door is a shell:
php artisan clupilot:console-access show
php artisan clupilot:console-access allow 203.0.113.7
php artisan clupilot:console-access open
The address check moved to RestrictConsoleNetwork::isNetwork() so the command
and the console apply the same rule. Codex caught the version that did not: a
typo like 203.0.113.9/99 was stored, reported as success, and matched nothing —
leaving whoever was recovering still locked out, now believing they were not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RestrictAdminHost answers under which NAME the console responds — and the
caller picks the Host header, so it can never answer who is asking. This does:
a network gate on the client address, which behind a trusted proxy is not
something the client chooses.
The management VPN is always in the list and cannot be removed — it is the one
way in that survives a bad entry. Beyond that the owner keeps their own
addresses in the console, because being away from the VPN should not mean being
locked out, and the person who needs to change that list is the person sitting
in front of it.
Two refusals rather than warnings, since by the time a warning renders the
request that would show it has already been rejected: switching the restriction
ON is refused unless the address doing the switching is already covered, and
removing the entry you are sitting behind is refused. Entries are validated as
an address or CIDR — a typo that matches nothing is how someone locks
themselves out while believing they have not.
404, never 403.
Registered as persistent Livewire middleware, and verified in a browser that it
holds there: with the settings page open, narrowing the list turned the next
action from 200 into 404. Codex read the path guard as skipping
/livewire/update; Livewire in fact replays middleware against a duplicate of
the request carrying the original component's path, so the guard matches and
the client address is preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- 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>