9 Commits (186c2e46d1c7bbf57fd35fc99bf8e4e0eec64d29)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
3699c7cdb9 |
Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
The console has been unreachable over the VPN, and the cause was the readiness probe rather than the tunnel. The gateway binds to the WireGuard hub address alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only fail, so VPN_READY stayed false, and on that basis the application withheld the `DNS = 10.66.0.1` line from every client config it issued. A phone then built the tunnel, asked its normal resolver for the console hostname, got the public address and had its connection closed — indistinguishable from "this site does not exist", which is exactly what it looked like. Probing over TLS on the bare address would not have worked either: the site matches on the console's hostname, so a request without SNI is offered no certificate. The gateway now answers a plain-HTTP health port on the hub address, which removes TLS, SNI and name resolution from the question and answers only what is being asked — is this gateway listening, in this network namespace, right now. Caddy refuses to start when the certificate is unreadable, so a health port that answers still proves the whole file loaded. Also here, all found while looking: - Icons pushed their label onto a second line and rendered a size larger than asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and `.size-5` have equal specificity, so stylesheet order decided — and it emits size-4 first. Every icon written as 16px was silently 20px. Recorded as R18. - Four error pages printed `errors.404.hint` in place of a sentence: the lang files give those codes a null hint and Laravel returns the key for a null line. - The Developer role had no label in either language, so the dropdown showed `admin_settings.role_developer`. - The secrets area held one key, which is not worth a password gate. It now carries the credentials that actually stop the business when they expire — DNS, monitoring, SMTP — and the test button appears only where a checker exists, instead of reporting on Stripe whatever was being looked at. - The update button said nothing about when a queued run would start or where a running one had got to; both are now shown, and a failure names its step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
935c9ae6ac |
Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
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> |
|
|
|
de6821b53e |
Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 5s
Details
Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
276f926d57 |
Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
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> |
|
|
|
9ccd4f59d8 |
feat(security): the console decides who may reach it, and the owner keeps the list
tests / pest (push) Successful in 6m53s
Details
tests / assets (push) Successful in 18s
Details
tests / release (push) Successful in 3s
Details
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |