11 Commits (cdda38c6b244f6bfbbd107772ce1daa9952cd420)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
77bd30ca56 |
Answer on every name for the website, and send them all to one
tests / pest (push) Failing after 7m23s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
SITE_HOST takes a comma-separated list now, the first name canonical. It serves the site; every other name in the list redirects there permanently, path and query intact. An apex domain and its www are two names for one thing, and answering on both without picking one splits search rankings and cookies between them. The redirect is a host-bound catch-all rather than middleware. Middleware that only some routes carry is the same half-measure this file already made once, and a catch-all registered without a host would swallow every path in the application — which is what the third test is there to stop. The test harness needed fixing too, and the fix is the finding. Router::dispatch on a standalone router does not rebind the container's request, so a route reading one — through the helper OR through an injected parameter, both of which resolve from the container — gets the test's request rather than the one being answered. The redirect silently lost its query string in the test while working in production. dispatchOn() binds it where the framework would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
21762284a7 |
Bind the portal to its own hostname too, so the website cannot serve it
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.
APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.
There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.
Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.
The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
49a3ca2e33 |
Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The landing page was registered host-agnostically, so app.clupilot.com served it: marketing copy, a pricing table and a "sign up" call to action, at the address where a customer's servers live. Reported exactly that way. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. The legal pages are deliberately not bound. An imprint has to be reachable from wherever the reader is standing, and a mail footer links to it from whichever host sent the mail. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
457eeeeaef |
Serve the console inside the tunnel, without publishing the internal network
tests / pest (push) Successful in 10m12s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Successful in 5s
Details
The owner's phone shows a blank page on the VPN. That page is the reverse proxy refusing the request, and it refuses because the phone is not on the tunnel: the client config routes only the management subnet, so a request to the console's public hostname leaves over the mobile network and arrives from a carrier address. It is also why the peer reads "last contact: never" — WireGuard only handshakes when it has traffic to send, and nothing is ever routed in. The obvious fixes are both wrong. Adding the server's public address to AllowedIPs routes the WireGuard endpoint into the tunnel it is trying to establish. A public DNS record pointing at 10.66.0.1 publishes the internal subnet to anyone enumerating the domain — the owner raised that himself, and he was right. So the console is served INSIDE the tunnel instead. A small resolver and a small gateway share the hub container's network namespace, which is where wg0 lives, and answer on 10.66.0.1 directly. A client therefore needs no route beyond the subnet it already has, the host's docker bridge is never exposed, and the request reaches the application with its real 10.66.0.x source — which is what the console's own allowlist checks. The gateway reuses the certificate the public Caddy already renews: a certificate is bound to the name, not to the address serving it, so nothing new is issued and nothing internal reaches a public zone. Most of this commit is the arithmetic of not lying about it. Review found fourteen ways the arrangement could report itself working while it was not: enabling the compose profile after the services were started rather than before; starting a gateway with empty certificate paths, which can only crash; treating "container created" as "container running"; carrying a stale readiness marker through a failed restart; reusing the previous hostname's certificate after a rename; leaving the profile enabled when the hostname was cleared, in a script that exits early precisely when nothing else would fix it; reading the renewal timestamp as a user who cannot traverse Caddy's storage; and a `find | head` that aborts the whole installer under pipefail the moment two issuers hold a certificate for the same name. One of them was mine and worth naming: to let the container read the certificate, I had made the TLS private key world-readable. On a multi-user host that hands the console's identity to every local account. The client is told about the resolver only when both services are confirmed running, because a config naming a resolver that does not exist takes the device's entire name resolution with it for as long as the tunnel is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a58faf3f85 |
Manage the Stripe key from the console, behind a password and a test
tests / pest (push) Successful in 8m56s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
Changing a key meant a shell, a file edit and a cache rebuild — and the person who owns the Stripe account is not necessarily the person who owns the server. Two gates, not one. The capability decides who may open the page; every operator has console.view, and that must not mean "can read the payment key". The password decides whether this SESSION may see or change anything, because the realistic threat is not a stranger but an unlocked machine, and a session is exactly what that hands over. Both are re-checked server-side on every action — a Livewire action is reachable by anyone who can post to /livewire/update. The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY is ordinary maintenance and would otherwise destroy every stored credential, discovered when Stripe stops answering. It is read where it is used, not overlaid onto config at boot — an overlay adds a query to every request including the public site, and leaves queue workers holding whatever was true when they started. It is never shown again, only outlined, and never enters a Livewire property that would carry it to the browser and back in the snapshot. A registry, not an env editor: a form that can set any environment variable is a privilege-escalation primitive, and one bad value bricks the installation with no way back through that same form. The test button is the part that matters. It reports which Stripe account the key belongs to, whether it is LIVE or test — the most expensive mistake here is pasting one where the other belongs, and both look identical in a form — and which webhook endpoints exist with the events each subscribes to. A key can be perfectly valid while the endpoint listens for the wrong five events, and nothing fails until a payment goes unrecorded. The webhook signing secret deliberately stays in .env. It is read on every incoming payment event; in the database, a database problem becomes silently failing signature checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6082164f8b |
Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
STATUS_HOST binds /status to a single hostname. Every other host redirects there rather than 404ing: a status page is the one address people keep in a bookmark and reach for when something is already wrong, so breaking old links to prove a point about separation would be exactly backwards. Unset, nothing changes. The /legal/status redirect goes through route() instead of a literal path. A relative redirect lands on whatever host the visitor is already on — which, once the page has a hostname of its own, is precisely where it no longer answers. The installer told the operator to point Stripe's webhook at the customer portal. Stripe posts server-to-server and never sees the portal; the endpoint is the api hostname, which the installer never even asked for. It asks now, and prints the address that actually receives the events. Getting this wrong does not fail loudly — payments simply stop being recorded. It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the console onto its own hostname before the DNS for it exists makes the console unreachable under any other name, and that is not a thing to have on by default. 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> |
|
|
|
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> |
|
|
|
0d9b62eb50 |
feat(vpn): ownership, a Developer role, and password-gated config retrieval
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> |
|
|
|
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> |
|
|
|
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> |