7 Commits (492b1925fb1ef9c63d9aa759bbd11c0a29d50831)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
492b1925fb |
Land a sign-in where it was performed, not always in the customer portal
tests / pest (push) Successful in 7m42s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Successful in 6s
Details
Fortify sends every successful sign-in to config('fortify.home') — /dashboard.
That is why an operator signing in ended up in the customer portal, and once
the console has a hostname to itself it stops being merely confusing:
/dashboard does not exist on that host, so signing in would land on a 404.
Three outcomes now, decided by where the sign-in happened and who signed in:
the console for an operator on the console, the portal everywhere else, and for
a non-operator on the console the session is taken away again at the moment it
was created — a guard that merely refuses each page afterwards leaves that
session in the browser.
Four things the review caught, each of which would have left a hole:
- Fortify never reaches LoginResponse when a two-factor challenge was involved.
Binding only that one left exactly the accounts most likely to be operators
on the old behaviour, so both exits are bound and share one decision.
- The JSON branch ran before the authorization check, so a JSON client kept a
session a browser would have lost.
- The two exits do not share a JSON success contract — an ordinary sign-in
answers {"two_factor":false}, a completed challenge answers an empty 204 —
and merging them breaks a client keying off the status code.
- In shared mode everyone posts to /login, so the request never looks like the
console even when /admin is the destination. The intended URL is read too.
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> |
|
|
|
b860ce2e9b |
Give the three console guards one answer to "is this the console?"
tests / pest (push) Successful in 7m44s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
The hostname guard, the network allowlist and the public-site switch each decided for themselves, by testing the path against admin/*. That works only while the console sits under /admin. It is also a trap: the moment the console moves to the root of its own hostname, PublicSiteGate stops recognising it, and with the public site hidden the console answers 503 to the very person trying to sign in and switch it back on. The guard does not fail loudly — it silently stops matching. AdminArea is now the single answer. It has two modes and no third: a console hostname is configured, in which case the console IS that host and answers at its root; or nothing is configured, in which case the console stays under /admin on any host exactly as before, so upgrading cannot lock anyone out of a system that was working. RestrictAdminHost gains the half it was missing. Binding console routes to a hostname does not stop the customer routes from answering there too, because they are registered without one — so the console's hostname would still serve portal pages wherever the paths did not collide. It now enforces both directions, with the endpoints both sides genuinely share written out as a list rather than inferred. Nothing changes yet for an installation with no ADMIN_HOSTS set, which is every development machine and every fresh checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c1e81808a7 |
feat(traffic): meter the monthly allowance, show it, throttle instead of blocking
Customers can now see what they have used and what is left, and the service slows down rather than stopping when the allowance is gone — a slow Nextcloud gets a top-up, a dead one gets a cancellation. - instance_traffic keeps one row per instance per month. Proxmox counters are cumulative since the VM last started, so usage is the difference between two samples, and a counter that went backwards means a restart, not a refund. - Outbound is what counts: inbound is free at our providers and egress is what Hetzner's 20 TB per server applies to. - CollectInstanceTraffic samples every 15 minutes, warns once per threshold (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via Proxmox — released again as soon as the customer tops up or the month rolls over. - The dashboard gets a full-width band (it throttles the service, so it is not a tile among tiles) with the top-up offer right next to the warning. - Bytes are formatted in SI units now: allowances are computed in SI, so dividing by 1024 made a 1000 GB plan read as "931 GB". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
0219e3c987 |
fix(deploy): recreate the container, keep the root password secret, forget failures
- update.sh recreated the app container only at the very end, so an update that changes the PHP runtime would have installed dependencies and migrated inside the old one. - install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value committed in .env.example — the same root credential on every installation, reachable from any container on the compose network. - Settings cached the fallback after a database failure, so one blip could leave a hidden site publicly visible until someone cleared the cache. Only a successful read is cached now, proven by a test that drops the table and brings it back. Co-Authored-By: Claude Opus 4.8 <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> |
|
|
|
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> |