Compare commits

...

407 Commits

Author SHA1 Message Date
nexxo f8f0de4892 Let an update install host packages, without handing out root
Asked for directly: "später wenn ich etwas lokal erweitere will ich mich
nicht auf alle adminseiten einloggen müssen und extra rsync installieren".

An update runs as the service account, so anything root-owned on the host is
out of its reach and turns into "log into every server once and paste this".
The obvious fix — sudo on deploy/install-agent.sh — is not a grant at all:
the service account owns the checkout and can rewrite the very script it
would be allowed to run as root.

So the privileged part is written OUT of the checkout by install-agent.sh, to
/usr/local/sbin/clupilot-host-step, root:root 0755, from a quoted
here-document. The service account cannot influence a byte of it. sudoers
names one exact command line including its argument, so a step added to the
helper later is not covered by a grant written before it existed, and the
helper refuses anything not on its own list as well.

update.sh uses it only when rsync is actually missing, after the restart and
never fatally: the archive is collected hours later, and an update that died
over a package would be the bigger problem. A helper older than the steps the
updater wants is reported through the existing one-time-setup hint — silently
doing nothing is worse than an error.

Existing servers still need `sudo bash deploy/install-agent.sh` once, because
that is the run that puts the helper there. After it, they do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:27:56 +02:00
nexxo 0671e8e119 Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then
vanished and was replaced by a completely different "we are updating"
screen. Both were mine. The console drew its own overlay in the app's light
tokens; a moment later the containers went down and Laravel's 503 page took
over with its own standalone styling. One event, two faces, swapping while
an operator watches.

There is now one panel — resources/views/partials/updating-panel.blade.php —
rendered by both. It carries its own inline CSS and cpu- prefixed class
names because the 503 page is served with the application down: no compiled
stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes
from prefers-color-scheme for the same reason.

The operator detail (step, running since, log tail) rides inside the panel
behind a flag, since the same file is shown to customers on the 503 page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:20:40 +02:00
nexxo 15b536f393 Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Setting a NAS up to collect the invoice archive meant three machines and a
dozen commands: generate a keypair there, carry the public half to the server,
write it into authorized_keys with a restriction, install rsync, and get every
path right on the first try. Every one of those steps was a place to be told
"permission denied" with no clue which of the three was wrong. It was, and
several times over.

One button now. The panel asks, the host does it — because the host is where
all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself.
The panel is www-data in a container and owns none of that, so it uses the
mailbox it already uses for updates. The private half is shown exactly once,
alongside the finished rsync command, and is never written to the database: it
exists to be copied into a NAS, and storing it "for convenience" would put a
working credential in every backup of that database.

rrsync does the restricting, not a pinned rsync option string. That string
differs between rsync versions and fails silently — a refusal with no reason
given, which is the shape of the afternoon this replaces. If rrsync is missing
the agent refuses rather than issuing an unrestricted key while the panel says
it is restricted.

rsync now comes with install-agent.sh, which already runs as root once per
machine. It has to be on the HOST: a NAS connects by ssh and sshd starts
`rsync --server` here, so without it the pull fails with "command not found"
from a NAS whose own setup is perfectly correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:32 +02:00
nexxo 186f7f2de7 Keep exported invoices out of the repository
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
storage/archive is runtime output, and on a real server it is a mount point.
Committing it would put customer documents into git and make a deploy fight the
archive for the directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 09:56:00 +02:00
nexxo 132be7fdd5 Leave the archive readable by whoever collects from it
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Reported as "the folder arrives empty". It was not empty. The folder was 0700
and owned by the account that wrote it, so the account that COLLECTS — an rsync
over ssh, a backup agent, anything that is not the writer — could not enter it,
and an unreadable directory looks exactly like an empty one. The invoice had
been sitting in it the whole time.

The cause is that the default follows the process umask, so under a restrictive
one 0755 quietly becomes 0700. The permissions are declared on the disk now
rather than left to inherit, and a test writes under umask 0077 and asserts the
mode — the ordinary umask hides this completely, which is why it was found on a
server and not here.

Two mistakes of mine on the way to it, both worth recording. I read the folder
as the wrong user, got no entries back, and concluded the file had been deleted
— it was a permission failure reading as an unprivileged account, not a missing
file. And the first fix was a chmod on a variable that does not exist in that
method, silenced by an @ so it failed without a word. Flysystem takes its
permissions from configuration; a chmod afterwards was the wrong layer even
before it was the wrong variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:48:03 +02:00
nexxo 79564947d4 Actually issue the invoice when the money arrives
tests / pest (push) Failing after 7m24s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Everything was built — numbering, the frozen snapshot, the PDF, the mail, the
archive and its retention — and none of it was connected to anything. IssueInvoice
was called from nowhere but its own tests. So on a real server no invoice had
ever been created, the handover directory was empty, and rsync was correctly
copying nothing. Reported as "nothing gets copied", which it was.

One mail, not two. The invoice is issued the moment the payment lands, so an
order confirmation and an invoice mail would otherwise go out in the same second
for the same event — a defect rather than a preference. The invoice mail carries
the document and says everything the confirmation said, so it replaces it
whenever there is an invoice; the confirmation stays for when there cannot be
one.

Issuing is allowed to fail without taking the purchase with it. IssueInvoice
refuses while the company details are incomplete, and that refusal is correct —
an invoice without a registered name or a VAT number is not a valid invoice, and
issuing one consumes a number that can never be handed out again. The order
still stands, the machine is still built, and the invoice can be issued later.
Nothing here may throw at the webhook either: Stripe retries anything that is
not a 2xx, and a retry would re-enter provisioning and build a second machine
over a missing VAT number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:25:56 +02:00
nexxo d8ff717cb5 Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Two destinations doing two jobs. A handover directory a NAS collects from wants
a folder per day and wants emptying — thirty days is enough to notice a NAS that
stopped collecting, and keeping more only hides it. An archive on the NAS itself
wants a folder per year and wants nothing deleted, ever. So both are per
destination rather than global.

Deleting is safe here in a way it usually is not, and that is worth saying
plainly: the invoice is a frozen document in the database and its PDF is
rendered from that on demand. Nothing is moved anywhere — it is copied, and
anything removed from a directory can be produced again and re-exported. The
directory is a handover point, not the record.

keep_days is null by default and stays null unless somebody sets it: a retention
rule nobody asked for is a deletion nobody expected. The prune only touches
folders whose NAME is one of the two shapes this application writes, and leaves
everything else alone — a prune that deletes what it does not recognise is how
an archive loses something nobody was watching. It also skips a destination
whose path is missing rather than acting on whatever is underneath an absent
mountpoint.

The shape is matched with a pattern before parsing rather than by handing
nonsense to Carbon: under strict mode createFromFormat throws instead of
returning false, so one folder somebody made by hand would have ended the whole
prune instead of being skipped. The test with a foreign folder in it found that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:08:27 +02:00
nexxo 0528b56a32 Name no vendor in the export settings
tests / pest (push) Failing after 7m36s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
A destination is a kind of connection, not a product. The wording said "Hetzner
Storage Box" and the host field suggested one particular provider's hostname
format, which reads as a requirement rather than an example — and the whole
point of the directory kind is that whatever is behind it is nobody's business
here.

Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made
on the server, and the application writes into the path it produces. SFTP is for
a destination that is not mounted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:49:33 +02:00
nexxo f0ec2fe03f Keep the SFTP credential on the row it belongs to, encrypted
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The first version put it in the credential vault. The vault refuses it, and
correctly: it keeps a registry of fixed, documented credentials so the Secrets
page can describe each one, and a per-destination password is not that. Forcing
a dynamic key into that registry would have made the registry meaningless.

Encrypted onto the row with the same cipher instead — the same key, so it is no
more readable in a database dump than anything the vault holds, and it lives
with the record it describes. A rotated SECRETS_KEY now says so by name rather
than surfacing as an authentication failure against a host that is perfectly
fine.

Also answers the question that prompted this: the host, port and user fields are
there, and appear once SFTP is chosen. A mounted directory has no host on
purpose — the mount is made on the server with mount(8), and the application
only names the directory it writes into. A test asserts both halves, because
"the fields are there, you just cannot see them yet" is not something anybody
should have to take on trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:46:06 +02:00
nexxo b3081a544b Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
One path and one exported_at could not say "on the office NAS, not yet on the
off-site box" — which is the only interesting question once there is more than
one destination, and the reason for a second is precisely that any single one
can fail. A row per destination now, and a row per (invoice, destination) pair.

Two kinds, and the difference is where the protocol lives. 'local' is a
directory this machine can already write to: a NAS mounted over NFS 4.1, a bind
mount, a disk. The application knows nothing about the protocol, the target
changes without code changing with it, and the whole thing stays testable
against a temporary directory. 'sftp' is for something reachable with
credentials — a Hetzner Storage Box being the obvious one — and the password
goes into the secret vault with only its key on the row, because a credential
in a settings table is a credential in every database dump. An empty password
field on an existing target means unchanged, not cleared: the field cannot show
what is stored, so blank-means-delete would wipe it on every unrelated edit.

One job per pair rather than one per invoice. A job writing to both would retry
the one that worked every time the other did not, and report one outcome for
two different things.

The ordering fix the tests found: Laravel's local adapter creates its root when
it is CONSTRUCTED, so the "never create the root" guard was checking a
directory the disk had just made for it. It runs before the disk is built now.
That guard is the difference between an archive and a hole in the ground — mkdir
on an absent mountpoint succeeds, writes onto the container's own disk and
reports success, and an archive that silently is not the archive is worse than
none.

The single path from the previous commit is migrated into a destination called
"Archiv" rather than dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:43:15 +02:00
nexxo ab4b0311d4 Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The application knows nothing about NFS and should not. It writes to a
directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1
belongs to the mount. No protocol library, the target changes without code
changing with it, and the whole thing is testable against a temp directory.

The finding that shaped it: mkdir -p on a path whose mount is NOT there
succeeds. It creates the empty directory beneath the mountpoint, writes the
invoice onto the container's own disk and reports success — so an unmounted NAS
would quietly collect invoices locally while every check said it worked. An
archive that silently is not the archive is worse than none. The root is
therefore never created, only used: a missing root means a missing mount, and
that is a failure. Only the year subdirectory is created, inside a root already
proven to exist.

Written to a .part and renamed. A rename within one filesystem is atomic, so
whatever watches that folder never sees a truncated PDF and takes it for a
finished invoice. The size is read back afterwards rather than the write being
trusted: over a network mount a short write can report success, and an archive
of truncated files looks exactly like one that worked.

Queued, not done during the request. A network mount can block for its whole
timeout, and an invoice must not fail to be issued because a NAS is rebooting.
Dispatched after the commit, or a worker could look for an invoice that has not
landed yet.

And copy-on-issue is not enough on its own: an invoice issued while the office
connection was down never arrives, the queue eventually gives up, and nobody
opens an archive to check whether last Tuesday is in it. An hourly sweep
re-queues whatever is still missing, the failure is recorded on the invoice
rather than only in a log, and the Finance page says how many are outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:32:08 +02:00
nexxo b5de002622 Take the invoice year off the storage clock too
tests / pest (push) Failing after 7m40s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
DisplayTimezoneTest went red on main: the year filter formatted issued_on
without ->local(). A year looks timezone-proof until New Year's night,
when the UTC year is still the previous one and the filter offers a year
the operator has already left. R19 is absolute for exactly that reason.
2026-07-29 02:28:34 +02:00
nexxo 33e5b15099 Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:26:34 +02:00
nexxo 86bf4f26e0 List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Read-only by design, and the absence is the feature: an issued invoice is
corrected by cancelling it and issuing another, which is the only lawful way to
correct one. There is no edit button here whose absence needs explaining, and a
test asserts there is none.

The PDF is a plain route rather than a Livewire action. A download is a
download, and routing one through a component round-trip only adds a way for it
to fail.

Two findings on the way, and both were mine.

The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such
function, so the page worked against the real database and threw against the
test one — a query that only runs on one engine makes the test database a
different product from the one being shipped. The years are derived in PHP now.

And a test put a User on the operator guard, where EnsureAdmin calls isActive()
on it and a 403 becomes a 500. Not a code defect: actingAs() calls
Auth::shouldUse(), so a second actingAs() in the same test with no guard named
lands on whichever guard the FIRST one named. That trap is written up in
CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are
split now and both name their guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:21:15 +02:00
nexxo 92bcdcd186 Send the invoice with the invoice attached
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The PDF is rendered when the mail goes out rather than fetched from anywhere,
because nothing stores it — and rendering from the frozen snapshot means the
attachment is the document as issued no matter when the queue gets to it or how
often it retries. fromData rather than fromPath: there is no path.

Three attempts at testing it, and the first two were wrong in ways worth
recording. Attachment::$data does not exist — the closure lives in a protected
$resolver, and reaching for it was me testing the framework's internals rather
than my own code. And a byte comparison of the PDF cannot work at all: TCPDF
embeds a creation time and a document id, so two renders of one invoice differ.

Split into the two things that can actually go wrong. The mail carries an
attachment with the right filename and type — that is metadata, and it is
asserted as metadata. And the renderer produces a real PDF rather than an empty
one, asserted where the renderer is, because an attachment closure that returns
nothing still makes a perfectly valid mail with a nought-byte file on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:13:41 +02:00
nexxo cd54212b34 Issue an invoice from what somebody bought, and freeze it there
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
One invoice per purchase rather than per order: a customer who buys a plan and
two add-ons in one go has bought once, and three invoices for one purchase is
three times the paperwork for the same money. Each order becomes a line, which
is also how the add-ons get listed individually.

Everything the document says is copied into the snapshot at the moment the
number is assigned — issuer, recipient, lines, rate, the reason for the rate.
A test changes the company name and the customer name afterwards and asserts
the invoice still says what it said. That is the whole reason no PDF is stored.

The number and the invoice commit in one transaction. A number taken and then
lost to a failure is a gap in a series that must not have one, and the refusal
test asserts that a rejected invoice leaves the counter where it was.

It refuses outright until the company details are complete. An invoice without
a registered name, an address or a VAT number is not a valid invoice here, and
issuing one consumes a number that can never be handed out again.

VAT comes from TaxTreatment, which already existed and already handles a
verified EU VAT ID correctly — including the reverse-charge note, without which
a zero-rated invoice says nothing about why no VAT was charged.

The billing address is carried as the lines it was written as. It is one
free-text field today, and guessing which line is the postcode would put it
where the street belongs, on a document nobody can correct afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:09:05 +02:00
nexxo 3fb7e5f08c Decide the VAT rate in one place, before the two disagree
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
The Finance tab wrote a rate into settings while TaxTreatment kept reading one
from config — two sources for one number, added by me two commits ago. It had
not bitten yet only because both happened to say 20.

TaxTreatment now takes the domestic rate from CompanyProfile, which falls back
to the .env-derived config until an operator saves one. Same shape as
ProvisioningSettings, which already solves this for the deployment values, and
the same reason: an operator can change a settings page and cannot change a
config value, so a page that appears to set the rate and does not is worse than
no page at all.

Reverse charge and the seller country stay where they are. That logic already
existed, already handles a verified EU VAT ID correctly, and nothing here
needed to know about it — which is exactly why it should not have been
duplicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:03:29 +02:00
nexxo 4646880b90 Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Second correction to the same block, and this one came from the right place: a
line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is
in that number or still to come. It was unreadable, and unreadable on the one
figure a customer checks.

Lines are at full price now. Discounts are still entered per line — that is
where they belong and where somebody decides them — but they are summed and
shown once, in euros, under a subtotal that the "Gesamt netto" column adds up
to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without
being told which figures are already adjusted.

The gross line-total column is gone and a gross UNIT price takes its place.
Full-price gross totals would sum to 315,60 against a total of 288,60 — the
same contradiction in a different column. Nobody adds up unit prices, and a
consumer reading this wants to know what one of the things costs.

lineTotal() and adjustmentLabel() went with it. Nothing calls them any more,
and a helper kept for a layout that no longer exists is the next person's
wrong turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:59:56 +02:00
nexxo d21e4f0eb2 Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
With one discount applied under the table, the gross column summed to 315,60
against a total of 284,04. Nothing was miscalculated — the discount simply came
after — but a reader who adds up a column found a document that does not agree
with itself, and no way to tell which figure was the real one. On an invoice
that is not a presentation detail.

A discount or surcharge now sits on its line, percentage or fixed amount, and
is inside that line's total. The label under the line is built from the same
value that did the arithmetic, so the words cannot end up describing a
different number than the one printed beside them.

The invoice-wide adjustment stays in the arithmetic for anything that genuinely
applies to the whole document, but nothing uses it by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:52:16 +02:00
nexxo 90131f088a Add a raw .env editor to the Integrations page, with a net under it
Everything the console has no field for is still real — MAIL_*, DB_*,
APP_KEY, whichever key nobody has built a form for yet — and without a way to
touch it from here, the operator needs a shell and the console page misses
its own point. EnvFileEditor is the net, not just the warning:

1. Validates before writing. A line that is neither blank, nor a comment, nor
   KEY=value is rejected outright, and an empty file (syntactically "valid" by
   that rule, but not survivable) is refused too. write() never touches the
   file before checking the new content.
2. Backs up before every write that actually happens — a timestamped copy
   beside .env, before the new content lands. Never pruned automatically; the
   page says so, next to where it says where they land.
3. Names the keys a mistake here can lock an operator out with — APP_KEY,
   DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads.
4. Gated by secrets.manage and the same confirmed password the vault entries
   use — this is the one place on the page that can reach every credential
   the vault otherwise keeps write-only.
5. Says plainly what saving does not do: queue, queue-provisioning, scheduler
   and reverb only read .env at their own startup, and names the restart
   command plus config:clear.
6. Marks which .env keys SecretVault currently overrides, so editing a line
   that a stored vault value already shadows does not look broken.

Two issues surfaced by Codex review and fixed before this commit: the raw
file content stayed in the Livewire component snapshot after the password
confirmation window expired on its own (not only on an explicit re-lock), and
the first save on an installation with no .env yet failed trying to back up a
file that was never there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:51:27 +02:00
nexxo 3fadaa14b0 Merge credentials and infrastructure into one Integrations page
Zugangsdaten and Infrastruktur split the same subject by storage mechanism —
SecretVault vs App\Support\Settings — instead of by what each field
configures, which is not a distinction an operator setting up Stripe or DNS
should have to know or care about. One page now, grouped by integration:
Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A
secret and a plain setting sit side by side within a section; the difference
stays visible — a vault entry is write-only and shows only an outline, a
setting shows its value in full — as a property of the field, not a reason
for a separate page.

Both storage mechanisms are unchanged underneath: this is a presentation
change, not a data migration. The vault keeps its own gate (secrets.manage +
a recently confirmed password); plain settings keep theirs (hosts.manage
alone). Reachable with either capability, since the two are no longer two
pages a role happens to see one, both, or neither of — every section and
every save action still checks its own capability server-side.

admin.secrets and admin.infrastructure redirect here permanently rather than
404ing a bookmark. The nav entry that replaces both is visible to either
capability — Navigation's capability field now accepts an array meaning "any
of these", not only a single ability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:49:39 +02:00
nexxo 835f3eeb64 Give the specimen invoice a placeholder in every field, not only the required ones
tests / pest (push) Failing after 6m58s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The footer is four columns of three lines, as designed — but the specimen only
filled the fields an invoice legally needs, so two of the four columns came out
empty and it read as a missing footer rather than as missing data. Reported
that way, which is the correct reading of what was on the page.

A specimen exists to show the layout. Every field now carries an obvious
placeholder, so the shape is visible before any real detail has been entered,
and what is still a placeholder is obvious at a glance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:48:34 +02:00
nexxo f8c923b48a Stop the invoice printing my own indentation
tests / pest (push) Failing after 8m3s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
TCPDF takes leading whitespace in HTML literally, so every line of the address
and every line description came out indented by however far the Blade template
happened to be nested — and the one line that had no indentation, the VAT
number, sat flush against the margin looking like the mistake. It was the only
correct one.

Also puts the title and the invoice number in one block. As an h1 with a
paragraph beneath it, TCPDF gave the heading a margin of its own and the two
drifted a centimetre apart.

Found by rendering the page and looking at it, which is the only way any of
this is ever found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:45:55 +02:00
nexxo c1896e70fc Render an invoice, with the arithmetic held to integer cents
tests / pest (push) Failing after 7m9s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7
is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own
mapping table lists 115 of 293 methods as stubs, and whose font package ships
no font files at all — the first render died on a missing helvetica.json. The
6.x line carries a hundred and sixty-five fonts, a working writeHTML and real
header and footer hooks, and is what everybody means by TCPDF.

The body is a Blade template handed to writeHTML rather than a hundred Cell()
calls with millimetre coordinates, because this table has to be adjusted the
first time somebody sees it on paper, and nobody adjusts the second kind.

InvoiceMath is integer cents end to end, and rounds once. A percentage applied
line by line and rounded each time drifts from the same percentage applied to
the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents
the honest way, and the customer's calculator is what finds it. Gross is always
derived from net and never the reverse: taking VAT back out of a gross figure
returns a different number often enough to matter. A discount and a surcharge
are one operation with opposite signs, so nothing has to remember which of two
flags means which.

Quantities are thousandths, because hours, gigabytes and part-months are all
real, and are printed with the trailing zeroes stripped — "12,000 Std." reads
as a defect.

Every line shows quantity, unit price net, total net AND total gross: this goes
to businesses and to consumers, and each reads a different column first.

Verifying it took two goes and both failures were mine. `strings` over a PDF set
in a Unicode font finds nothing, because the text is stored as subset glyph ids
— the check has to render in a core font to be a check at all. And two of the
strings I then declared missing were simply expectations I had got wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:41:46 +02:00
nexxo 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>
2026-07-29 01:29:33 +02:00
nexxo e6d2e6dc33 Give the console a Finance tab: company details, VAT rate, invoice series
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Its own tab rather than a section of Settings, as asked. What is set here
appears on a legal document, and burying it beside the site-visibility switch
invites somebody to change one in passing.

Everything the printed invoice needs — registered name, address, Firmenbuch
number and court, VAT ID, bank details, payment terms, logo — with the logo
replaceable, because a company changes its mark. None of it reaches an invoice
that already exists: the values are copied into each document when its number
is assigned, so a corrected address is correct from the next one onwards and
cannot rewrite an old one.

The console refuses to consider itself ready while a name, an address or a VAT
number is missing, and says which. An invoice without them is not a valid
invoice in Austria, and issuing one is worse than refusing to.

The series editor carries the same shape as EditDatacenter and for the same
reason. The prefix is free while the series is empty and frozen once a document
carries it: changing RE to AR renames nothing and leaves a series whose past
says RE and whose future says AR, with no record they belong together. The
counter may be raised — an operator migrating from another system needs exactly
that — but never lowered, because a lower number is one already printed on a
document somebody holds. Both rules are recomputed from the database on save
rather than read back from the properties the browser returns.

CompanyProfile drops keys that are not part of the profile. It is fed from a
form, and a forged key must not be able to write an arbitrary setting into the
same table the site-visibility switch lives in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:18:43 +02:00
nexxo 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>
2026-07-29 01:06:50 +02:00
nexxo 735daf46a4 Lay the foundation for self-issued invoices: series, numbers, frozen documents
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
An invoice is a tax document, so two things drive the shape of this.

It is frozen at issue. Everything the finished document says — issuer address,
VAT number, bank details, customer address, every line, the rate, the totals —
is copied into the invoice row when the number is assigned. The PDF is rendered
from that and never stored, which is what was asked for; rendering it from
today's settings instead would have been simpler and wrong, because an address
changed in 2028 would rewrite an invoice from 2026. The width of this table IS
the feature.

Numbers are gapless and ascending, which is a legal requirement rather than a
preference, and that is why none of it is derived from the invoices table.
"Highest number plus one" hands the same number to two sales that land together
and hands a used number back out after a row is removed — a number already
printed on a document somebody holds. The counter is a column, read and advanced
under a row lock inside the caller's transaction, and taking one outside a
transaction throws rather than quietly working.

Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each
with its own prefix and its own counter, because a credit note is not an
invoice with a minus sign in front of it. Seeded rather than left to the
operator: an installation with no series cannot issue anything, and discovering
that at the first sale is the worst possible moment.

TCPDF added, for the renderer that comes next.

There is deliberately no test for the outside-a-transaction guard, and a note
where it would have been: RefreshDatabase opens a transaction around every
test, so the guard cannot fire and a green test for it would be green for a
reason that has nothing to do with the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:57:06 +02:00
nexxo 864126ec7f Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The SSH private key CluPilot deploys to every host is now stored through
SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH)
at all three real consumers — SshTraefikWriter and HostStep::keyLogin.
kuma.password/kuma.totp stay in .env: they authenticate a separate Python
container at boot, and nothing in this app reads them, so the vault would
have nothing to wire them to.

A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings,
makes the non-secret deployment settings that used to force a shell —
DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config
path, the SSH public key, and the monitoring bridge URL — visible and
editable from the console, each wired to every real consumer. WG subnet/hub
IP and Kuma's own connection details stay in .env: the compose file and a
separate container read those before this application ever does.
2026-07-29 00:52:44 +02:00
nexxo 5f7b8ff81b Cut 1.3.0
tests / pest (push) Failing after 7m41s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:06 +02:00
nexxo a457bea87e Read the DNS and monitoring tokens from the vault, not config
Two of the Zugangsdaten page's three entries were decorative: whatever an
operator typed was encrypted and stored, but HttpHetznerDnsClient and
HttpMonitoringClient still read config() directly, so the stored value never
reached an outgoing request. Both now resolve through SecretVault::get() at
the point of use, matching how the Stripe client already worked.
2026-07-29 00:34:54 +02:00
nexxo 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>
2026-07-29 00:26:22 +02:00
nexxo 7a5a71dc89 Read VPN host state from the synced peer table, not a live hub call
tests / pest (push) Failing after 7m20s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-29 00:18:21 +02:00
nexxo b1ab0f7f31 Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Four templates, one layout. Two of them — the maintenance announcement and its
cancellation — still wore Laravel's markdown component, which brings its own
logo, its own button and its own footer: the product was sending mail that
looked like two different companies, one of which was the framework.

New: an order confirmation, sent when the payment lands rather than when
provisioning finishes. Those are minutes apart at best and can be much longer,
and somebody who has just been charged and heard nothing assumes the worst
about both the charge and the product. It deliberately promises no time for the
cloud itself — provisioning takes what it takes, CloudReady announces the end,
and a promised minute that slips is worse than no promise. Queued after the
transaction, never inside it, and never allowed to fail the webhook: Stripe
retries anything that is not a 2xx, and a retry would re-enter provisioning
over a mail server having a bad minute.

The finished-cloud mail now names what was actually built — address, plan,
storage, location — because somebody who ordered a fortnight ago is checking it
against what they bought, and that is a comparison rather than a sentence. Still
no credential in it.

Two tests earn their place. One holds every template on the shared layout, so a
new one written in a hurry cannot copy-paste its way back to the framework's.
The other proves the finished-cloud mail carries no password — asserted against
the data the mail is given and the HTML it renders, not against the template's
source, because the first version of that test tripped over the word in a
comment and would have passed a template that leaked the value through a
variable. It tested the prose, not the mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:14:04 +02:00
nexxo eb743d34ed Show WireGuard tunnel state per host in the hosts list 2026-07-29 00:10:15 +02:00
nexxo 4d4d1dd5d5 Stop the update agent dying on its own tag arithmetic
tests / pest (push) Failing after 8m4s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Since 1.2.0 the agent has ended with a non-zero status on every tick, before
writing anything. The console showed a check that never finished and a
last-checked time frozen at the moment of the deployment, and the update button
could not be reached at all — the one mechanism that would have delivered the
fix.

Two instances of the same trap, both mine, both under `set -Eeuo pipefail`:

The tag count was `release_version_gt … && echo`. The LAST iteration is almost
always a tag that is not newer, so the loop ended non-zero, pipefail carried it
out of the pipeline, the command substitution inherited it, and set -e ended
the agent. `if` rather than `&&` is the whole fix: an if whose condition is
false still returns 0.

The newest tag came from `git tag … | head -1`. head exits after one line, git
takes SIGPIPE, pipefail does the rest. install-agent.sh has carried a written
warning about exactly this since it was written; I read it and wrote it anyway.

Both now live in deploy/lib/release.sh, because the reason neither was caught
is that no PHP test can reach a bash pipeline.
tests/Feature/ReleaseComparisonTest.php runs the real functions in a real
throwaway repository under the agent's own set -Eeuo pipefail, and asserts the
EXIT CODE as well as the answer — an answer nobody lives to read is not an
answer. The SIGPIPE case needs four hundred tags to reproduce, because a short
list finishes writing before head leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:00:17 +02:00
nexxo ed4167eba3 Close SSH and the Proxmox UI to the world once the tunnel is proven up
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-28 23:57:07 +02:00
nexxo 6998a22527 Repair ownership by looking inside, not at the door
tests / pest (push) Failing after 8m2s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The ownership repair added in 1.1.1 sampled the owner of each top-level
directory and skipped the recursion when it already matched. node_modules was
owned by www-data; node_modules/.vite-temp, left behind by an earlier root
build, was not. So the repair walked past it, `npm run build` failed with
EACCES, and the deployment stopped in maintenance mode — on the release whose
whole point was that this could not happen.

A directory's owner says nothing about its contents. It now walks each tree
once with find and changes only the entries that are wrong, which is also
cheaper than the detect-then-chown-everything it replaces.

Verified against the shape that actually failed: a root-owned file inside a
www-data-owned node_modules, repaired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:53:25 +02:00
nexxo 2b1989f53d Move host DNS names off the public zone into vpn-dns's internal hostsdir
tests / pest (push) Failing after 8m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
2026-07-28 23:44:31 +02:00
nexxo c7a3224466 Cut 1.2.0
tests / pest (push) Failing after 7m51s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:44:18 +02:00
nexxo 5b8c28595a Require a confirmed address before an account can use anything
Registration opened an account that could sign in immediately on an address
nobody had proved was theirs — and anybody can type a stranger's into a
registration form. Both halves were commented out: the Fortify feature and
MustVerifyEmail on the model.

`verified` sits on the whole portal group rather than on the pages that spend
money. An unconfirmed address is not a billing problem to be caught at
checkout; it is an account that may not belong to the person holding it, and
the servers, the users and the backups behind it are worth as much to whoever
typed the address as to whoever owns it.

The mail is a Mailable in this product's design rather than Laravel's
notification, which would arrive with the framework's logo, button and footer
as the first thing anybody ever receives from CluPilot. It also has to leave
through the SYSTEM mailbox like every other account mail, which the
notification path does not do on its own.

The link is signed and expires, and the address is part of what is signed — so
a link issued to a mistyped address stops working the moment the address is
corrected, rather than confirming an address nobody can read. Tests cover the
tampered link, the expired one, the corrected address, and one account trying
to confirm another.

The waiting page is ours (Fortify has views off) and offers the only two things
that move somebody forward: send it again, or sign out and register with the
address they meant. The resend is throttled, because otherwise it is a button
that makes the server send mail to an arbitrary address as fast as it can be
clicked. Somebody already confirmed is redirected away from it — the `verified`
middleware only guards the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:43:20 +02:00
nexxo 07141a96bf Show where an account is signed in, and let it sign the other places out
Completes the device work: the warning mail now has somewhere to send people.
A warning whose only advice is "if this was not you, take action" names a
problem and hands back nothing to do about it.

Both sides get their own component rather than one shared with the guard passed
in. Operators and customers are two guards and two tables (R21) — a component
taking the guard as input is one forged property away from listing and ending
the other side's sessions. Every query in SessionRegistry is scoped by guard as
well as by id for the same reason, and three tests hold that line, because the
unscoped version looks exactly like a working feature while doing it.

TouchLoginSession keeps each row pointing at the session it describes. Both
sign-in paths raise the Login event BEFORE session()->regenerate(), so the row
is written against an id that is dead a moment later — and the once-a-minute
throttle would have held the correction back for the first minute of every
session, hiding the session its reader is sitting in. A changed id is therefore
never throttled.

A session counts as live only if the framework's row exists AND was touched
within the session lifetime. The join alone is not enough: Laravel collects
expired sessions by lottery, so on a quiet installation rows sit there for days
and the list would offer somebody a laptop they shut last month.

Ending the others goes through the product's own modal (R23) and leaves the
asking session alone: signing somebody out of the page they are using to sign
other people out is not what the button says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:38:17 +02:00
nexxo 2609393e3a Recognise the devices an account signs in from, and warn about a new one
tests / pest (push) Failing after 7m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Sessions lived in Redis, where they cannot be enumerated — one key per session,
no index by user — so "where am I signed in" and "end that one" were not
answerable at all. The database driver keeps a row per session and makes ending
one a delete. It costs every currently signed-in person one forced sign-in,
once, which is why it is happening now rather than later.

The warning is only worth anything if it is rare, so the two obvious signals
are deliberately not used. Not the IP: it changes on every train, every café,
every mobile handover, and behind a VPN it is not theirs to begin with. Not the
user-agent: Chrome ships every four weeks and the string changes each time,
which would be twelve warnings a year per customer, describing nothing. A
device is a long-lived signed cookie holding a random token, and nothing else
is consulted. The honest cost — cleared cookies, a private window, a second
browser — is written into the mail itself, because a reader who thinks the
warning is wrong stops reading the next one.

Three things the tests found or hold:

The first device on an account is new and must not warn. Replying to somebody
creating an account by telling them they signed in from a new device is noise
on the one message that has to stay worth reading.

token_hash was globally unique, and the shared-browser test failed on it. Two
people on one machine share one cookie and must each own a device row against
it; the obvious repair — issue a fresh token for the second — would orphan the
first person's row and warn them about their own desk. Unique is now per
identity.

Recording a device may never block a sign-in. A safeguard that locks the owner
out when it breaks has stopped being one, so the listener logs and swallows,
and a test drops the tables to prove it.

Sessions are linked by a uuid carried inside the session payload rather than by
the session id, which Laravel rotates on every sign-in and every regenerate().
Laravel's own sessions.user_id cannot carry the link either: it is filled from
the default guard, while operators and customers are two guards and two tables
(R21) — operator 1 and customer 1 would each be offered the other's sessions.

Also lands the shared mail layout the rest of the templates will use: tables
and inline styles for Outlook, no image and no web font because half of all
clients block remote content, and the button in --accent-active rather than the
brand orange, which is 2.9:1 against white.

Not tagged: the session list is not built yet, and without a tag no server can
install this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:28:34 +02:00
nexxo 669d0471a8 Repair two comment blocks the admin-hosts edit ran together
tests / pest (push) Failing after 8m1s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
An earlier edit removed the ADMIN_HOSTS example line and left its
"Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file
documented neither properly. SECRETS_KEY was never in here at all —
without it the console silently stores no credentials, which is exactly
the kind of thing an example file exists to prevent.
2026-07-28 23:20:43 +02:00
nexxo 75b4a47768 Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits
forever in an inbox: it moves onto the instance (encrypted, same as
nc_admin_ref) and shows on the dashboard until the customer clicks
"Notiert", which deletes it for good.
2026-07-28 23:19:20 +02:00
nexxo 00b9107ee3 Deploy as the application's user, and take back what root already took
tests / pest (push) Failing after 7m19s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The 500 on the VPN config download was never in the VPN code. storage/logs/
laravel.log was owned by root, mode 644, since 27.07 04:16 — the moment a
failed `artisan optimize` wrote its error there during a deployment. From then
on the application could not append to its own log: Monolog threw on every
attempt, and a throw while logging is a 500 on any page that logs, with nothing
written down to say why. The error page said "Er wurde protokolliert". It was
not.

It surfaced on the config download because that is one of the few pages writing
a log line on its way through — on success and on a wrong password alike. Pages
that log nothing were unaffected, which is exactly why it looked like a VPN
fault for days.

`docker compose exec` is root unless told otherwise, and every deployment
script relied on that default — including the agent's allowlist sync, which
runs every minute. docker/entrypoint.sh had it right all along and drops to
www-data for precisely these commands. Now so does everything else, help text
included: telling an operator to run artisan as root is how the file ends up
owned by root in the first place.

update.sh also repairs what an earlier run left behind, before its first
unprivileged step rather than after — a root-owned log file is not
self-healing, the page that trips over it is nowhere near the deployment that
caused it, and with in_app unprivileged a root-owned vendor/ would break the
next composer step outright.

tests/Feature/DeploymentRunsAsTheAppUserTest.php holds the line. It was checked
against the previous commit and finds all nine places that were wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:52:09 +02:00
nexxo 21b7aaca42 Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Asked for directly: a commit on main must change nothing on a server, and only
a tag whose version is higher than the installed one may be installed.

The agent asked two different questions before this. A pinned server compared
tags; everything else counted commits on its branch — so a console following
main offered an update for every push, verified or not, and "Jetzt
aktualisieren" meant "take whatever is on the branch right now". Landing on main
and being released are different events, and only the second is a decision
somebody made.

It is one question now, regardless of what the checkout is attached to: is there
a tag whose version is higher than the deployed one? Compared with sort -V
against the version in the deployment manifest — not the VERSION file, which an
update moves before it migrates, so a run that failed in between would leave the
checkout claiming a version it never finished installing. A run always targets
that tag; the branch path is gone. A request that arrives with nothing to
install is answered rather than obeyed, before anything announces a run.

The console follows: the button is disabled with nothing released, says so in a
line rather than leaving a grey button to be guessed at, names the version it
would install rather than a count, and refuses the action server-side as well —
a Livewire action is a public endpoint, and one place a rule may not live is
only in the disabled attribute of a button.

VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:32:44 +02:00
nexxo b64a9f9238 Give the two-factor page a way back, and a shape
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Clicking "Einrichten" generated a secret and showed the QR with no exit:
leaving meant navigating away, which left an unconfirmed enrolment on the
account that nothing on the console ever displayed. cancelSetup() clears
it — it was never confirmed, so it never protected anything.

The page was also one card with four states stacked into it. Now each
state is its own panel, the enrolment step says which step it is and
carries numbered instructions beside the code, and the recovery codes get
their own card with a copy button instead of being a footnote under
whatever came before them.
2026-07-28 22:25:42 +02:00
nexxo 184350494d Let a half-finished two-factor enrolment be cancelled, and rework the setup page
tests / pest (push) Failing after 7m39s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Found uncommitted in the working tree at the start of this session and carried
in as its own commit so it can be reverted on its own. Not written here: a
cancel action that clears an unconfirmed secret, the wording for every state of
the page, and a rebuilt setup view. The suite is green with it in place,
including the R18-R23 scan tests.
2026-07-28 22:25:27 +02:00
nexxo d65ab71029 Let a datacenter code be corrected while nothing depends on it, and say which building
The code was immutable, which made a typo permanent. It cannot be freely mutable
either, and not only because hosts.datacenter references it: each host's DNS name
was minted from it and registered at the provider, the machine's own hostname was
set from it as it was built, the counter handing out those numbers is keyed by
it, and every past order stores it as plain text with no foreign key to follow.
Renaming a code with hosts in it is not a rename — it is a datacenter called hel
full of machines called fsn-01.

So it is editable exactly while nothing references it, which is the case that
matters: a typo noticed shortly after creating one. Once a host or an order
depends on it the field is disabled and says what is holding it, with the
numbers. The lock is recomputed from the database on every save, never read back
from the property the browser returns.

Alongside it, an optional free-text field for the specific datacenter —
fsn-dc-15. A provider runs several within one location, on separate power and
separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a
choice anybody can make. Nothing keys off it; the code remains the identifier.

Also renames the country loop variables in the edit modal. They were $code and
$name — the component's own properties — and shadowed them for the rest of the
file. Harmless while nothing below the loop read them, which stopped being true
the moment the code became an editable field above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:25:21 +02:00
nexxo 07634c8a1d Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real
update, collected by the same agent on the same systemd timer — so asking what
was new was answered on the next tick, and the console, having nothing else to
show, counted down to it. An operator who asked a question was told an update
would start in 3:44.

A systemd path unit now wakes the agent the moment the request file is written,
for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms
as soon as the service goes inactive, so a request the agent could not consume —
it holds a lock for the length of an update — would restart it in a tight loop.
The timer stays as the fallback, and a drop-in disables systemd's start rate
limit, because two triggers on one oneshot service can otherwise wedge the unit
and leave neither of them able to run.

The countdown is gone from both the settings card and the maintenance overlay.
It could not be made honest: its target was recomputed on every poll, and where
the interval the agent reports is shorter than the timer actually installed on
that host it had already gone by, so the fallback moved it forward again each
time. It ran backwards four seconds and snapped back to a full minute, forever —
reported exactly that way. A queued run says it is starting; a queued check says
nothing beyond its badge, because a check starts nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:25:07 +02:00
nexxo e44f6658db Hand off with the process failure at the top, not the feature list
tests / pest (push) Failing after 7m16s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 21:44:04 +02:00
nexxo 64efe45f0d Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:36:21 +02:00
nexxo bda61ca560 Pin the sidebar header and footer so only the nav list scrolls, and shorten two action labels that wrapped
tests / pest (push) Failing after 7m9s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-28 20:52:12 +02:00
nexxo f05547921d Separate checking for updates from applying them, and show live progress
tests / pest (push) Failing after 7m11s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 20:23:06 +02:00
nexxo 973fcb3f2d Replace native confirm() dialogs with the app's own modal pattern
tests / pest (push) Failing after 7m14s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 19:34:27 +02:00
nexxo c2681f2801 Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:50:45 +02:00
nexxo 164145463c Send an operator with an expired session to sign in, not a 500
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
VpnConfigAccess::reveal() dereferenced Auth::guard('operator')->user()
unguarded, which is null when a modal's session has ended since it was
opened (a Livewire action reaches its component through /livewire/update
on its own, without the page's original route middleware). The same
unguarded pattern was in TwoFactorSetup, InstanceAdminAccess and Settings.

Add App\Livewire\Concerns\ResolvesOperator with currentOperator(), the one
place that resolves the operator and redirects to admin.login when there
isn't one, and use it everywhere the pattern occurred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:09:22 +02:00
nexxo 833f386e73 Document that the console host has to be exclusive to be the console
tests / pest (push) Failing after 7m23s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Without ADMIN_HOST_EXCLUSIVE the default is false, and https://admin.…/
then serves the PORTAL. That reads as a fault and is not one, so the
example file now says which setting decides it and why the host list must
hold nothing but the console's own name once it is on.
2026-07-28 16:52:58 +02:00
nexxo 254674f456 Bound the mail test-send and real send to a timeout instead of hanging
tests / pest (push) Failing after 7m25s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
A blocked outbound port (587 filtered at a firewall, as on the live
server) left both MailboxTester and MailboxTransport blocking until
PHP's default_socket_timeout (60s) or the reverse proxy cut the
request — a 504 with no error the operator ever saw. Mail::build()
honours a 'timeout' config key by calling SocketStream::setTimeout()
(confirmed by reading MailManager::configureSmtpTransport() and by an
end-to-end run against a genuinely unroutable host), so MailboxTester
now passes 'timeout' => 10 through it. MailboxTransport builds its
EsmtpTransport directly rather than through Mail::build(), so it sets
the same call on the transport's stream, at 30s — a queue worker can
afford to wait longer than an operator watching a browser, and a
timed-out send is retried rather than lost.

The test button also now shows a visible "sending" state while the
request is in flight, matching the wire:loading span pattern already
used on admin/host-create's save button — previously only
wire:loading.attr="disabled" fired, leaving a greyed-out button with
no other sign anything was happening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:36:14 +02:00
nexxo 9fc74eedc3 Hand off with the effort rule at the top, not the feature list
tests / pest (pull_request) Failing after 7m21s Details
tests / assets (pull_request) Successful in 23s Details
tests / release (pull_request) Has been skipped Details
tests / pest (push) Failing after 7m18s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Has been skipped Details
2026-07-28 16:13:08 +02:00
nexxo 75cb45bcbc Write down that the effort has to match the task 2026-07-28 16:12:05 +02:00
nexxo 495d314aa5 Preflight every customer conflict before this migration mutates anything
tests / pest (push) Failing after 7m24s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m32s Details
tests / assets (pull_request) Successful in 20s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 16:07:11 +02:00
nexxo f970dda0bb Send an already signed-in operator back to the console, not the portal
guest:operator's authenticated-user redirect had no console-aware
override, so it fell to Laravel's own default: the named 'dashboard'
route, the portal. An operator revisiting admin.login while already
signed in bounced to the portal's own login instead — in exclusive
mode, to a 404, since dashboard isn't a console route there.

redirectUsersTo() now mirrors the existing redirectGuestsTo() override
right above it, keyed on the same AdminArea::isConsole($request)
condition, so the two read as a pair.
2026-07-28 15:55:39 +02:00
nexxo 92dd6cc2d8 Refuse a two-factor completion for a revoked operator
completeLogin() re-checks isActive()/isOperator() immediately before signing
in, not only at the password step. The two-factor challenge previously only
confirmed the operator row still existed (Operator::find() + a null check),
so disabling an operator or revoking every console role in the window between
the password step and the pending code did not stop the login from
completing — the realistic case is an owner locking someone out mid-login,
not a contrived one.

One check, in the static method both OperatorLogin's direct exit and
OperatorTwoFactorChallenge::verify() already share, rather than a second copy
in the challenge. completeLogin() now returns bool instead of void: false
means refused, and neither the guard nor the session was touched. Each caller
turns that into a ValidationException in its own vocabulary — 'email' /
auth.failed for the direct exit (already unreachable in practice, since
authenticate() just checked the same two conditions synchronously; kept for
defense in depth), 'code' / auth.not_an_operator for the challenge, since the
code entered may be genuinely correct and reusing "wrong code" would mislead.
2026-07-28 15:47:39 +02:00
nexxo 58835a1051 Check users directly for the reverse operator-identity collision, not just customers
The three sites that refuse to create or rename an operator onto a
customer's email (Admin\Settings::saveAccount(), ::inviteStaff(), and
clupilot:create-operator) all checked Customer::where('email', ...) as
a proxy for "does this address already have a portal login". A users
row with no matching customers row — an email changed on one side
only, or legacy/orphaned data — passed straight through: this dev
database already had one.

Extracted the three copies into Customer::emailTaken(), which checks
both tables directly, so the three sites cannot drift from each other
again.
2026-07-28 15:36:29 +02:00
nexxo 278c4b9953 Match the logout exemption through AdminArea, not a bare route check
The setup-route exemption already went through AdminArea::routeIs(),
which matches both admin.<name> and admin.via*.<name>. The logout
exemption next to it used a bare $request->routeIs('admin.logout'),
which matches only the canonical name — on a recovery hostname the
logout route is admin.via0.logout, so the check failed there and an
unenrolled operator's logout POST bounced back to enrolment instead
of being let through, on exactly the host that exists because the
canonical one is not working.
2026-07-28 15:27:06 +02:00
nexxo 27292237c3 Move operator two-factor enrolment off admin.settings onto its own page
RequireOperatorTwoFactor exempted the whole of admin.settings while
compulsory two-factor was on, on the theory that it was "the page
where two-factor is set up". That component also carries staff
management, site visibility, network restrictions, account changes,
and the two-factor policy switch itself, so an unenrolled operator got
unrestricted access to all of that, not just enrolment.

Enrolment (secret, QR, confirm, recovery codes, regenerate, disable)
moves to its own route and component, admin.two-factor-setup /
Admin\TwoFactorSetup, reachable by every operator regardless of
capability. That is now the only page the middleware exempts besides
logout, and its redirect points there instead. admin.settings goes
back behind the policy like every other console page; only the policy
switch itself stays there, since only someone who already satisfies
it should be the one changing it.
2026-07-28 15:11:20 +02:00
nexxo 9c851b28b4 Satisfy Pint on the two new guard-boundary tests
Import the classes instead of referencing them fully-qualified, and
use single quotes where no interpolation or escaping is needed.
2026-07-28 15:10:58 +02:00
nexxo 055c6228f4 Stop a suspended customer's sign-out from killing a shared operator session
EnsureCustomerActive used session()->invalidate() to sign a suspended
or closed customer out. In shared-host mode the console and the
portal keep their login key in one session, so invalidate() (flush()
+ migrate(true)) silently signed out an operator elsewhere in that
same browser too. Same fix as the console's own /logout route:
regenerate() gets a fresh session id without flushing attributes
belonging to another guard.
2026-07-28 15:02:00 +02:00
nexxo a5db914696 Key the password-confirmation marker by guard and identity
confirmPassword() already checked the right account per guard, but the
session marker it stamped, auth.password_confirmed_at, was one flat
key shared by every guard. In shared-host mode the portal and console
guards keep their login in the same session, so confirming a
customer's portal password left the marker in place for an
operator-only gate too. One private method now computes the key for
all three callers so they cannot drift apart again.
2026-07-28 15:00:46 +02:00
nexxo e8bd8268fd Stop a disabled operator from bypassing the hidden-site gate
PublicSiteGate let anyone through on a bare Auth::guard('operator')->check(),
which stays true for a disabled operator's live session — disabling
someone does not sign them out, so it never stopped this gate from
showing them the real site indefinitely, even though EnsureAdmin
already treats them as inactive. Now requires the same thing EnsureAdmin
does: an active operator holding a console role.

Chosen to merely ignore an invalid operator session here rather than log
it out. This gate only decides what one request sees; logging out would
ripple into whatever else that session is doing (a concurrent console
tab, mid-task), a bigger side effect than a visibility check exists to
have. EnsureAdmin sets the precedent — it also rejects a disabled
operator per request without touching their session.
2026-07-28 14:42:29 +02:00
nexxo 20329b176b Refuse a portal login for an address that already belongs to an operator
Customer::ensureUser() enforced uniqueness only within users, so a
customer provisioned (or registering publicly) with an operator's email
got a second, colliding identity for the same address — the exact thing
R21 exists to prevent. Checks Operator by address now, not by session
(the deleted assertNotAdmin() checked who was signed in, which could
never be the right question).

Public registration gets the same guard via a plain unique() validation
rule, reusing Laravel's stock "already taken" message rather than a
dedicated one — naming the collision specifically would tell a public
visitor an address belongs to staff, a worse leak than the generic
refusal every other collision here already gives them.

Impersonation answers 409 instead of crashing into it. The Stripe
webhook keeps recording the paid order (same principle as
openContract()'s own comment: the order is proof money changed hands)
and withholds only the colliding login, logged for an operator to
resolve by hand.

Verified the reverse direction Codex flagged as already covered: two
Admin\Settings actions and the clupilot:create-operator command all
already refuse an operator email that collides with a customer's, and
that check is sound today because every users row is created in
lockstep with a customers row of the same email (CreateNewUser and
ensureUser are the only two creation sites, both confirmed by grep) and
nothing in this app can change a user's email afterwards (Fortify's
updateProfileInformation feature is disabled, and no other code calls
that action).
2026-07-28 14:42:16 +02:00
nexxo 913d19d39c Give operators somewhere to actually set up two-factor
console.require_2fa exempted admin.settings from the redirect it forces on
an unconfirmed operator, but that page only ever configured the global
policy — nobody could enrol behind it. A newly invited operator stayed
permanently redirected to a page with no way to satisfy the requirement,
the opposite of what the switch was for.

Adds enable/confirm/regenerate/disable to Admin\Settings, reusing Fortify's
own actions exactly as the portal's Settings already does — they only ever
touch the model they're handed, no guard or auth() call inside them, so
pointing ConfirmsPassword's guard at 'operator' is the only change needed.
The new card is deliberately not gated behind the site.manage capability
that hides the rest of this page's Owner-only sections: the compulsory
switch applies to every operator, so enrolment has to be reachable by every
operator too. Dropped the account card's stale hint pointing at a "separate
security area" that never existed.
2026-07-28 14:21:02 +02:00
nexxo 4ae5281c91 Carry operators into the RBAC move via direct permissions too, not only roles
operatorUserIds() only discovered console users through model_has_roles. A
pre-existing account granted console.view straight through
model_has_permissions, holding no role at all, was invisible to the
worklist — meanwhile the permission's own guard is switched to `operator`
a few lines above, so that account would silently lose console access
while staying behind as a working portal login. Worklist is now the
deduplicated, sorted union of both tables; orderBy('model_id') kept on
each side since carryAcross() remaps in place and the ordering still
matters regardless of which table an id came from.
2026-07-28 14:20:49 +02:00
nexxo 2b2bb439a5 Stop the console logout from destroying a shared portal session
Where the console and the portal share a host (shared/fallback mode —
this VM's own mode), both guards keep their login key in the SAME
session. session()->invalidate() is flush() + migrate(true) — it
discards every attribute in the session, not only the operator's, so
signing out of the console silently signed a customer out of the
portal too if the same browser carried both. Replaced with
session()->regenerate(): a new session id, so there is no fixation
risk, while attributes belonging to any other guard survive untouched.
2026-07-28 13:58:42 +02:00
nexxo 5e22e16291 Send a guest bounced off the console to the console's own sign-in
ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.

Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
2026-07-28 13:32:05 +02:00
nexxo 47812cfca9 Authorize the console's live feed on the operator guard, not the default
POST /broadcasting/auth carries only the 'web' middleware group
(Laravel's own withBroadcasting() registers it that way), so
PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard
before the admin.runs channel's own Auth::guard('operator') check ever
ran — found nobody, since an operator is never signed in on 'web', and
threw straight from there. The guard was correct but unreachable.
Fixed by naming both guards on the channel registration itself.

Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive
mode it was neither admin.* by name nor on the shared list, so it 404'd
on the console host too — degrading silently to wire:poll.4s rather
than breaking outright, which is why nothing screamed.
2026-07-28 13:31:50 +02:00
nexxo 54fb6deb03 Make the RBAC move retry-safe and order-safe, and round-trip permissions
Three faults in the same migration:

- up()'s removal loop was driven by operatorUserIds() (model_has_roles
  WHERE model_type=User) via the $carried array — but carryAcross()
  repoints model_has_roles to Operator for every operator before a
  single users row is deleted. A run that stops partway (the
  customer-conflict abort, or a dropped connection on real MariaDB,
  which this file deliberately runs without a transaction) leaves that
  worklist empty on the next attempt, so `migrate` records the
  migration as applied while every leftover users row still grants a
  live portal login on the operator's own password. Now driven by
  `operators` joined to `users` on email: that finds every
  carried-but-not-yet-deleted row regardless of which run carried it.

- operatorUserIds() had no ORDER BY. carryAcross() remaps four owned
  columns in place, keyed on the old users.id, while operators.id hands
  out new values in the same order the list is walked — unordered, a
  later operator's freshly assigned id can equal an earlier operator's
  still-unprocessed users.id, and the second remap catches a row the
  first one already rewrote. One operator ends up silently owning
  another operator's VPN peer. Added ->orderBy('model_id').

- down() restored model_has_roles but not model_has_permissions, so an
  operator holding a direct (not role-granted) permission lost it on
  rollback. Mirrored the same move carryAcross() does forward.

Also removes seed_roles_and_permissions' now-stale
User::OPERATOR_ROLES/hasAnyRole()/assignRole() bootstrap loop — all
three gone from User per this branch's own work — which only a deep
rollback (down() of the move migration recreates is_admin users) would
ever have reached, but would fatal when it did.
2026-07-28 13:31:34 +02:00
nexxo 6e81d6e93e Delete Customer::assertNotAdmin() — the fault it guarded is now impossible
It checked Auth::guard('operator')->check() instead of the users row it
was handed, so with an operator's own session still attached (shared
mode carries the same cookie through both legs of impersonation) it
threw the moment a customer's email already had a users row — a 500 on
the first impersonation of anyone who had signed into the portal
themselves. Deleting it rather than fixing the check: operators live in
`operators` now, never in `users` (R21), so a users row can no longer
BE an operator account for this to guard against. Also drops the dead
`'is_admin' => false` write — the column reads from nowhere any more.
2026-07-28 13:31:14 +02:00
nexxo 5a40107256 Return to the console by name after impersonation, not a bare path
redirect()->to(AdminArea::home()) resolved against whatever host the
leave() POST itself arrived on — the portal's, once the console has a
hostname of its own — landing the operator on the portal's dashboard
instead of back at the console. route('admin.overview') is domain-bound
to the console and generates the correct cross-host URL, matching what
this line did before this branch. The test named after this behaviour
now actually asserts it.
2026-07-28 13:30:56 +02:00
nexxo d6ea39b69a Say the two-factor rule once, not twice 2026-07-28 12:27:02 +02:00
nexxo 88d9a66f44 Sign the impersonation link against the portal's own host, not the console's 2026-07-28 12:24:53 +02:00
nexxo bdb9c0a04a Write down that the console and the portal share no identity 2026-07-28 12:15:13 +02:00
nexxo e519653f66 Let the owner make two-factor compulsory, without locking themselves out 2026-07-28 12:08:08 +02:00
nexxo 26c84e3695 Hand impersonation over by signed link, not by a cookie that cannot cross hosts 2026-07-28 12:02:22 +02:00
nexxo 73dec7f685 Prove ConfirmsPassword resolves its guard, not the ambient default
actingAs()/Livewire::actingAs() call Auth::shouldUse(), which made every
existing test for this trait pass identically whether confirmPassword() asked
for the right guard explicitly or just read whichever guard happened to be
default — reverting the explicit guard() call to a bare auth()->user() passed
the full suite. This test signs the operator in without actingAs and
deliberately decouples the ambient default guard from their session before
calling confirmPassword(), so only the explicit resolution can succeed.
2026-07-28 11:54:13 +02:00
nexxo 8f8a0ad4f7 Stop the console serving the portal's sign-in page
Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested
the three deleted response classes. Its intent (an operator's sign-in landing
in the console, not the portal) is covered by OperatorLoginTest's "signs an
operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs
the operator in once the code actually checks out", both confirmed present
before deletion; the route-separation half is covered by the new
ConsoleHostSeparationTest cases added in this commit.
2026-07-28 11:45:34 +02:00
nexxo 3f318c3f6a Give the console a front door of its own 2026-07-28 11:45:22 +02:00
nexxo 513cadc5ce Confirm a console password against the console's own record 2026-07-28 11:17:34 +02:00
nexxo f54d00b3e7 Move the value remap and constraint swap before the delete, and fix the seeder 2026-07-28 11:00:09 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo 8d1fa52a5a Give the people who run CluPilot a table of their own 2026-07-28 09:20:54 +02:00
nexxo 2a06b5dfb2 Say EU and stop there, as the approved template does 2026-07-28 09:19:24 +02:00
nexxo e6793c4dae Plan the operator identity work as nine testable steps
Re-measuring before writing found the spec's numbers had moved: seventeen
permissions now, not sixteen, because the mailbox work added mail.manage.
It also turned up something the spec did not anticipate — ConfirmsPassword
resolves the password through auth()->user(), the default guard, so all
five components using it break the moment a second guard exists. That is
now its own task.

Self-review caught two references to things that do not exist: users has
no uuid column, so the impersonation link addresses the customer instead,
and there is no layouts.guest — the auth pages use layouts.portal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:13:42 +02:00
nexxo 2979f008b5 Stop a mismatched-key decrypt from crashing the mailbox test button
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m17s Details
tests / assets (pull_request) Successful in 24s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 07:26:42 +02:00
nexxo a6a9c76660 Trust an explicitly configured mail host or port over the placeholder
env('MAIL_HOST')/env('MAIL_PORT') returning anything, or MAIL_URL supplying
either, now counts as "supplied" and wins over the sentinel comparison — a
relay genuinely on 127.0.0.1:2525 no longer reads as unconfigured. The
sentinel stays as a fallback for when neither signal says anything (including
under config:cache, where env() goes null and this collapses to today's
behaviour).
2026-07-28 07:10:53 +02:00
nexxo 15c81489d4 Stop downgrading an opportunistic smtp scheme to plaintext
MAIL_SCHEME=smtp is Symfony's opportunistic scheme (upgrade to STARTTLS
if offered, don't fail if not) — not a "no encryption" statement.
Mapping it to 'none' silently turned every existing MAIL_SCHEME=smtp
installation's encrypted-when-possible connection into an always-
plaintext one. Maps to 'tls' (STARTTLS required) instead: stricter than
smtp's own default, so a relay lacking STARTTLS now fails loudly rather
than leaking credentials.
2026-07-28 06:47:11 +02:00
nexxo 729f57755a Clear every mailbox's verification when the shared server config changes
saveServer() wrote host/port/encryption without touching any mailbox, so
the console kept showing every mailbox as verified against a server
nothing had tested since. Mirrors EditMailbox::save()'s existing guard on
a single mailbox's own identity, just at server scope: only clears when a
field actually changed, so re-saving unedited values leaves a real
verification alone.

Both places now delegate the actual clear to new Mailbox::
invalidateVerification()/invalidateAllVerifications() methods rather than
touching last_verified_at directly, sharing the mutation. The "did this
change" comparison stays separate in each caller - one diffs a loaded
model's attributes, the other diffs persisted settings against incoming
scalars, and forcing them through one function would add machinery no
actual duplication justifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:06 +02:00
nexxo ebe29f8a5d Stop demanding a cipher, a real port, or a decrypted password a mailbox never uses
MailboxTester required a usable SECRETS_KEY unconditionally, refusing an
unauthenticated relay's test-send on a fresh install with no key yet, even
though real sending never touches the cipher for that mailbox. Gated the
check on authenticates, same as the password-presence check beside it.

Comparison pass across MailboxTester and MailboxTransport turned up two more
of the same shape. MailboxTransport's delegate-cache fingerprint decrypted
$box->password unconditionally, even when unauthenticated and never going to
use it — a mailbox that once authenticated, stored a password, and later had
authenticates unchecked would crash real sending the moment SECRETS_KEY
became unusable. And MailboxTester had no guard against a stored port <1;
Symfony silently reinterprets that as port 25 rather than refusing it, so the
test button could reach whatever happens to listen there and report success
for a configuration MailboxTransport refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:21:28 +02:00
nexxo 5d306fce31 Let a mailbox send without a password when it never needed one
An SMTP relay with a host and a from address but no username or password
- a trusted local or private-network relay - always used to be seeded as
a placeholder, because the takeover required a username to treat an
account as real, and Mailbox::isConfigured() always required a stored
password. Laravel's own SMTP mailer has supported unauthenticated relays
all along.

Add mailboxes.authenticates (default true, so every existing row keeps
its current behaviour) to tell "does not authenticate" apart from
"authenticates, but nobody has typed the password yet" - the same empty
password column used to mean both. The takeover migration now seeds the
real account off the host/address alone and marks it authenticates=false
only when neither a username nor a password resolved; isConfigured(),
MailboxTransport and MailboxTester all read it before requiring or
sending a password; the edit modal gets a checkbox for it with the
password field's hint updated to match, in both languages.
2026-07-28 05:54:21 +02:00
nexxo 40a6b3269d Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be
downgraded to STARTTLS because mail.encryption was derived from the port
alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the
URL's own scheme via the same ConfigurationUrlParser Laravel uses) and
only fall back to the port heuristic when nothing was actually stated -
which is also what keeps this machine's invalid MAIL_SCHEME=tls from
silently steering the result.
2026-07-28 05:53:51 +02:00
nexxo 74ac406fc0 Make isUsable() apply the same 32-byte rule encrypter() does
isUsable() checked only "is SECRETS_KEY nonempty", so a set-but-malformed
key (wrong length, garbage base64) read back as usable even though
encrypter() rejects it two lines below. EditMailbox::save() and
MailboxTester::run() both gate on isUsable() specifically to avoid an
uncaught RuntimeException reaching the operator; a lying isUsable() meant
that guard did not fire in exactly the configuration it exists for.

Both methods now read resolveKey() — the base64: prefix, the raw-base64
path, the 32-byte check — so they cannot disagree about a value either
one is given. SecretVault::isUsable() and the secrets console page's
"no key" banner both delegate down to this and are covered here too, not
assumed to inherit the fix correctly.
2026-07-28 05:09:09 +02:00
nexxo f0c3bd9c2e Resolve MAIL_URL before seeding, so a takeover doesn't blank out a working relay
The seed migration read config('mail.mailers.smtp.host') and its three
siblings directly. An install configuring SMTP through MAIL_URL (the
other form config/mail.php supports) never sets those individual keys —
MailManager only substitutes the URL's components in at transport-build
time, inside its own protected getConfig(). Read raw, that left the
migration seeding config/mail.php's own placeholder defaults, which the
UNCONFIGURED_HOST/UNCONFIGURED_PORT guards then read as genuinely unset:
blank host, port 0, an unconfigured mailbox, for an install that was
sending mail successfully.

resolveSmtpConfig() calls the same Illuminate\Support\ConfigurationUrlParser
MailManager::getConfig() itself delegates to, so this is the same
resolution rather than a second copy of its merge order. Verified against
the real (protected) getConfig() via reflection: the URL wins for
host/port/username/password wherever it actually supplies one, and an
unset MAIL_URL leaves this a no-op.
2026-07-28 05:08:58 +02:00
nexxo e8a3b8d5be Seed the mailbox address from mail.from.address, not the SMTP login
The takeover migration seeded `address` from MAIL_USERNAME and left
`username` null unconditionally, conflating the SMTP login with the
sender address. Whenever the two differ — an API-key username, a
shared relay login — mail would go out From the login instead of the
address the system was actually configured to send from, exactly like
this installation: MAIL_USERNAME is no-reply@clupilot.com but
MAIL_FROM_ADDRESS is hello@clupilot.local.

`address` now reads config('mail.from.address'), falling back to the
SMTP login only when that reads back config/mail.php's own unset
placeholder (env('MAIL_FROM_ADDRESS', 'hello@example.com') — the same
masking problem UNCONFIGURED_HOST already guards against for
MAIL_HOST). `username` is set to the SMTP login only when it actually
differs from the resolved address, since Mailbox::smtpUsername()
already falls back to address when username is null. display_name
now reads config('mail.from.name') instead of a hardcoded literal.

The placeholder domain for the four non-configured mailboxes still
comes from the SMTP login, not from mail.from.address — the two can
name different domains, and only the login's is proven to accept mail
for a given install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:49:37 +02:00
nexxo 3278f063ea Validate purpose mappings against real mailboxes, and clear stale verification on identity changes
savePurposes() only checked that each key was a nonempty string, so a
stale or tampered mailbox key (reachable by posting straight to
/livewire/update) saved successfully while MailboxResolver silently
returned null for it. Every purpose's key must now name a mailbox that
exists; system's must additionally be active, since every unmapped or
inactive OTHER purpose falls through to it.

EditMailbox::save() also cleared last_verified_at only when the
password changed, but smtpUsername() depends on address and username
too (falling back to address when username is blank) — changing
either while leaving the password untouched kept showing a successful
verification for an identity nothing had actually tested.
2026-07-28 04:35:16 +02:00
nexxo c55a5d2b49 Make ssl, tls, and none mean what they say on the wire
EsmtpTransport's autoTls (default true) and requireTls (default false)
are independent of the implicit-TLS constructor argument: 'tls' left
these at their defaults, so a server that omitted STARTTLS sent
mailbox credentials in the clear while reporting success, and 'none'
would silently upgrade the moment a server offered STARTTLS. Both
MailboxTransport and MailboxTester already duplicated the "ssl, or
port 465 ⇒ implicit" half of this decision independently; MailTlsPolicy
is now the one place both read, for both dimensions.

MailboxTester still builds through Mail::build() rather than a hand-built
transport: MailManager::createSmtpTransport() forwards the whole config
array to EsmtpTransportFactory as DSN options, which already reads
auto_tls/require_tls itself, so the same policy reaches this path
through keys the config array already supports.
2026-07-28 04:35:03 +02:00
nexxo 6fc25051e4 Fix the mail scheme example — smtp and smtps, not tls
Symfony's mailer only knows the schemes smtp and smtps; the example's
MAIL_SCHEME=tls (and the "tls | smtps (465)" comment naming it as an
option) made the default mailer unconstructible. .env itself is left
alone — that is the operator's live file.
2026-07-28 03:50:28 +02:00
nexxo 5de1f45703 Close two ways a mail.manage session could take over outgoing mail
I3: nothing stopped an operator unchecking Aktiv on the mailbox that
mail.purpose.system points at. MailboxResolver::active() filters an
inactive mailbox at BOTH the direct lookup and the system fallback, so
that one checkbox could take mail.purpose.system to null and every
purpose without its own mapping down with it — the existing
system_required guard on savePurposes() reads as if this were covered,
but it only blocks the empty-mapping path, not this one.
EditMailbox::save() now refuses active=false against the mailbox 'system'
currently points at, the same way savePurposes() already refuses to leave
'system' empty.

I4: Admin\Mail::saveServer() writes mail.host — the platform's outbound
relay for every purpose mailbox at once — and CloudReady::toMail() puts a
customer's Nextcloud admin password in cleartext in the message body. So a
signed-in mail.manage session was already a credential-interception
primitive, the same threat Admin\Secrets' second gate exists for. Both
saveServer() and EditMailbox::save() (only when a new password is actually
being typed) now also require passwordRecentlyConfirmed(), following the
ConfirmsPassword pattern Secrets.php and Settings.php's two-factor actions
already use — including their per-action UI shape (an inline confirm form
scoped to the gated action), not a whole-page lock, so address, display
name, username, no-reply, active and the purpose mapping stay reachable
without confirming, per the brief's capability split.

Both gated and ungated paths are covered by tests, and both guards were
mutated away and confirmed to fail before being restored.
2026-07-28 03:50:19 +02:00
nexxo 7102d01bcc Keep the purpose mailer through the queue instead of losing it to the default
Mail::to($x)->queue($mail) resolves the DEFAULT mailer, and
Illuminate\Mail\Mailer::queue() (vendor Mailer.php:482) does
`$view->mailer($this->name)->queue($this->queue)` — overwriting
MaintenanceAnnouncementMail/MaintenanceCancelledMail's own 'cp_maintenance'
with the default mailer's name ('array' in tests, 'log'/whatever
MAIL_MAILER is in prod) before the job is even built. Both mails kept
sending through the old .env account instead of MailboxTransport. CloudReady
was never affected — MailChannel.php:66 preserves a notification's own
mailer.

Fix: name the mailer before queueing — Mail::mailer($mail->mailer)->to(...).
$this->name on the resolved Mailer is then already $mail->mailer, so the
vendor overwrite becomes a no-op instead of a silent downgrade. Confirmed by
reading the vendor source, not assumed.

SenderAddressTest's "names the purpose mailer..." test asserted
$mail->mailer on a bare, never-queued mailable, which proves nothing about
what survives queueing — the constructor sets it right regardless of what
MaintenanceNotifier does with it. Replaced it with one that drives
MaintenanceNotifier::deliver() under Queue::fake() and inspects the pushed
SendQueuedMailable's mailable. Verified it fails against the pre-fix code
first (the pushed job carried mailer = 'array'), then verified the fix
makes it pass, then mutated the fix away and watched it fail again.
2026-07-28 03:50:02 +02:00
nexxo 99b7393361 Stop committing a real password, and say the truth about what carried over
MailboxSeedMigrationTest.php had the actual .env MAIL_PASSWORD for
no-reply@clupilot.com hard-coded into a test fixture — replaced with an
obvious dummy. Grepped the whole branch (576cfca..HEAD) and the working
tree for that string and for every other real secret value in .env;
nothing else leaked.

Separately, the seed migration's SECRETS_KEY-missing warning claimed a
password "was NOT copied into the mailbox table" even when a vault row
already existed and its ciphertext WAS carried across a few lines below,
unconditionally on $canEncrypt. An operator debugging a live mail outage
would have been sent to re-enter a password that was already sitting in
the column. The warning now distinguishes the two cases and says which
one actually happened.
2026-07-28 03:49:37 +02:00
nexxo befb67327f Prove a mailbox works by actually sending from it 2026-07-28 02:56:47 +02:00
nexxo a9c777c79a Rename the SECRETS_KEY page test to match what it actually checks
It only ever loaded the page and asserted the banner, never a password
save — but its name claimed the crash-on-save case, which is exactly the
promise that let that bug ship unnoticed once. The test that earns the
crash claim is its new neighbour, added for the same finding.
2026-07-28 02:24:58 +02:00
nexxo 67a3e78cb4 Stop the mailbox password save from crashing without SECRETS_KEY
EditMailbox::save() now checks SecretCipher::isUsable() before touching a
typed password and reports it as a form error, instead of letting
SecretCipher::encrypt() throw all the way to Laravel's debug page. Also
switches the mail.manage migration to Role::findOrCreate, matching every
sibling capability migration, so a squashed replay can't hit
RoleDoesNotExist.
2026-07-28 02:22:36 +02:00
nexxo 4211f3dfab Give the mailboxes a page, and the support sender its own capability 2026-07-28 01:57:11 +02:00
nexxo a12ab148b3 Close the data-loss paths the review found in the mailbox takeover
The vault's mail.password row is no longer deleted by this migration at
all, in either direction: deleting it unconditionally destroyed a real
password whenever MAIL_USERNAME was empty (nowhere to carry it to), and
even on a successful carry-over a later rollback had nothing left to fall
back to. Reproduced both against real MariaDB, along with a migrations-
table drift that crashed a retry on the mailboxes.key unique constraint —
fixed with Mailbox::firstOrNew() and a transaction around each direction.
down() now goes through the new Settings::forget() instead of a raw
delete, so it stops leaving the settings cache holding what it just
removed. A skipped .env password (SECRETS_KEY still unset) now prints an
operator-visible line instead of migrating clean and failing silently
later. Host/port that resolve to config/mail.php's own placeholder
defaults are treated as unset rather than as a real relay.
2026-07-28 01:15:50 +02:00
nexxo 3e4d5472a7 Move the one SMTP account into the mailbox table it outgrew
The seed migration also guards against SECRETS_KEY being unset at migrate
time (encrypting the .env password would otherwise throw and take the
whole migration down, on a fresh install as much as in the test suite),
and the four tests/Feature/Mail files written before this migration reset
the mailbox table in their beforeEach so they keep testing what they did
before mailboxes.key collided with the seeded rows.
2026-07-28 00:24:54 +02:00
nexxo c899d41946 Pin CloudReady's null-mailbox guard and share it with the trait
A mutation the reviewer ran (if ($box !== null) -> if (true)) left every
test green: CloudReady hand-rolled the same null-mailbox guard as
SendsFromMailbox, but nothing exercised its null branch. Added the
missing test, then extracted the decision itself (null mailbox -> no
sender fields, no_reply -> no Reply-To) into one shared method so an
Envelope-shaped and a MailMessage-shaped consumer can no longer disagree.
2026-07-27 23:59:05 +02:00
nexxo 64a33c675e Put a real sender on every mail, and a reply address where one helps 2026-07-27 23:40:34 +02:00
nexxo 961bf62032 Make the DSN agree with send() when a mailbox has no password
Re-review of the previous fix round found the "cannot disagree" part of
finding 4 was still open. resolution() had unified the log/array/null/
mailbox decision, but inside the mailbox branch describe() returned
$box?->address unconditionally while delegate() additionally required
isConfigured() before using it. A mailbox with active=true, a real
address, and no password produced
'mailbox://support/support@clupilot.com' — reading as entirely healthy
— while send() threw. That is exactly the harm the original finding
named: the log says fine, the mail fails.

resolution() now decides "unconfigured" too, not just "which
non-delivering mode": it calls isConfigured() itself, once, and returns
that verdict alongside the box. describe() and delegate() both branch
on the SAME result again, the same way they already did for log/array/
null — a mailbox with no password now reads as
'support@clupilot.com [unconfigured]' rather than a plain address, and
the address stays visible so an operator can tell WHICH mailbox needs
fixing.

Also renamed a test the re-review flagged as promising more than it
checked: "registers a mailer per purpose without touching the database
at boot" only ever verified the config array's shape — the DB-touching
claim is a separate, already-passing test just below it
(defines every purpose mailer as a plain literal...). Renamed to
"registers a mailer for every purpose". No behaviour change.

Added a test creating a mailbox with no password and asserting the DSN
both names the address AND carries the marker, plus that send() still
throws — proving agreement, not just a matching label painted on
separately. Mutated describe()'s unconfigured branch to drop the
marker: the test failed exactly on the missing 'unconfigured' string
(DSN read as a plain, healthy-looking address again). Reverted, reran,
passes.

Full suite: 667 passed. vendor/bin/pint --test clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:17:42 +02:00
nexxo a39670f920 Close the safety-net gaps the review found in MailboxTransport
The reviewer continued the mutation sweep from Task 4's own commit and
found three more unpinned lines in delegate(), plus two real behaviour
gaps, all inside the same 40-line method:

- The fingerprint refresh (the headline feature: a worker's mailer
  must pick up a corrected password without a restart) had no test —
  mutating its check to "delegate === null" left the suite green.
- The implicit-TLS third argument to EsmtpTransport had no test either
  — mutating it to a hardcoded true left the suite green, because the
  only assertion touching it was instanceof EsmtpTransport, which the
  hanging variant also satisfies.
- isLogging() only ever checked for 'log', but phpunit.xml sets
  MAIL_MAILER=array for the whole suite. Task 5 wires real mailables
  to these mailers next; the first feature test that sends one without
  Mail::fake() would have opened a real SMTP connection.
- describe() and delegate() each ran their own copy of that guard —
  which is exactly how deleting it from ONLY delegate() passed the
  whole suite in the first place: __toString() kept reporting "log"
  from its own untouched copy.
- An unconfigured host/port (Settings::get('mail.host', '') and a
  stored null port casting to 0) built a transport pointed at
  smtp://:587 or silently downgraded to plaintext port 25, instead of
  refusing the way a missing mailbox already does.

Fixes: replaced isLogging() with resolution(), the one place that now
decides "log, array, null-default, or a real mailbox" — describe() and
delegate() both branch on ITS result, so a guard broken in one cannot
look fine in the other. Added an explicit RuntimeException for a blank
host or non-positive port, guarded the same way as the missing-mailbox
case just above it.

Tests: added coverage for the fingerprint rebuild (reuses the same
delegate until the password actually changes, proven by object
identity), the exact DSN string for both the STARTTLS (587) and
implicit-TLS (465) cases, 'array' and unset-default both landing on a
non-delivering transport, and the blank host/bad port throwing.
Mutated each of these four in turn on the final code and confirmed:
fingerprint check removed -> the reuse test fails (same object handed
back after the password changed); TLS argument hardcoded true -> the
587 DSN test fails ('smtps://...' where 'smtp://...:587' was
expected); NON_DELIVERING narrowed back to ['log'] -> both the array
and unset-default tests fail (a real EsmtpTransport where a safe
transport was expected); host/port guards removed -> both throw
expectations fail. Reverted each, reran, all twelve tests pass again.

Replaced the two tests that reached into delegate() via Closure::bind
to check "log or SMTP" with one behavioural test: point Settings at a
closed loopback port (127.0.0.1:1, so refusal is instant and entirely
local — no DNS, no dependency on this environment's network egress
policy) and assert Log::shouldReceive('debug')->once() while sending
for real through Mail::mailer('cp_support')->raw(...). Mutated
NON_DELIVERING to drop 'log' specifically and confirmed this test
alone catches it: a real connection attempt to the closed port throws
TransportException (Connection refused) in under 300ms. Reverted,
reran, passes again.

Also: the two tests that only checked the config array's shape and a
snapshot of the resolved address promised more than they verified —
CLAUDE.md R19 names a test that recomputes the implementation as
checking nothing. Added a source-level test that the five cp_* config
entries are parenthesis-free literals (same technique
DisplayTimezoneTest/IconLayoutTest use: a property of the file, not of
one run) as the actual "no query at boot" proof, and renamed the
send-time test to what it verifies now that the fingerprint test above
covers the live-refresh claim properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:02:18 +02:00
nexxo 0be9c61484 Make a deactivated mailbox fall back like a deleted one
Review of Task 4: MailboxResolver::for() only fell back on a missing
mapping or a missing row. Deactivating a mailbox — the operator's
explicit "do not use this" — left it resolvable anyway, so
MailboxTransport reached it, found isConfigured() false, and threw
instead of quietly using the next candidate the way every other
"this mailbox is unusable" case does. Worse, __toString() still
reported the deactivated address as though it were healthy.

named() now runs through a new active() filter at BOTH lookup sites —
the purpose's own mapping and the system fallback — so an inactive
system mailbox is refused exactly like a missing one, not handed out
as the last resort. A missing password stays deliberately unfiltered
here: that is a MailboxTransport concern (loud failure naming the
mailbox), not a resolver fallback trigger — silently rerouting mail
because a password hasn't been typed yet would hide the gap instead
of surfacing it.

Added two tests and mutated active() to a no-op filter to confirm they
catch it: both failed (one expected the system mailbox, got null; the
other expected null, got the inactive mailbox's full attribute dump).
Reverted, reran, both pass again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:57:22 +02:00
nexxo 095d8e694a Resolve the sending mailbox when the mail goes out, not at boot
Five purpose-named mailers (cp_maintenance, cp_provisioning, cp_support,
cp_billing, cp_system) land in config/mail.php as plain static entries —
no query runs to build them. Mail::extend('mailbox', ...) in
AppServiceProvider registers a transport that looks up its mailbox
through MailboxResolver only once a mailer is actually resolved, which
for queued mail is inside the worker at send time, never at boot.

Mutation-tested the brief's own three tests by deleting the MAIL_MAILER
guard from MailboxTransport::delegate(): all three stayed green. They
only exercise __toString()/describe(), a code path separate from the one
that actually sends. Added two tests that reach into delegate() itself
(a bound closure, since it's private) and assert the transport it
actually builds: LogTransport while MAIL_MAILER=log, EsmtpTransport once
it isn't. Re-ran the same mutation against the strengthened suite: it
now fails (EsmtpTransport where LogTransport was expected), then passes
again once reverted.

Verified two things against the installed versions before writing this:
LogTransport takes a Psr\Log\LoggerInterface and Log::channel(null)
resolves to the default channel rather than throwing, and
Mail::extend('mailbox', ...) is read by
MailManager::createSymfonyTransport() via $config['transport'] before it
would fall back to createSmtpTransport() — both matched the brief
exactly, no changes needed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:28:37 +02:00
nexxo 06b84c5cd5 Say which kind of mail leaves from which mailbox 2026-07-27 22:00:16 +02:00
nexxo c5021c4aa7 Make the SECRETS_KEY test actually discriminate from APP_KEY
The old test rotated app.key and expected the password to stay
readable, but Laravel's encrypter is a container singleton resolved
once — config()->set('app.key', ...) never rebuilds it, so the
rotation the test performed was invisible to any code path going
through it. Confirmed by mutation: rewiring Mailbox to Crypt::
encryptString/decryptString (genuinely APP_KEY-keyed, the exact
regression this constraint exists to forbid) left the old test green.

Replace it with two tests: a positive proof that rotating SECRETS_KEY
makes a stored password unreadable (the one that discriminates), and
a separate check that rotating APP_KEY does not, with
forgetInstance('encrypter') so that rotation is actually visible to
whatever the password accessor resolves.

Re-ran the same mutation against the new test: it fails (exit 1, the
SECRETS_KEY test reports the expected DecryptException was not
thrown), then passes again once the mutation is reverted.
2026-07-27 21:49:46 +02:00
nexxo c5252847bb Make the SECRETS_KEY test able to fail
The Task 2 reviewer rewired the model to Laravel's APP_KEY-based Crypt
facade and every test stayed green. Laravel's encrypter is a container
singleton resolved once from app.key, so config()->set('app.key') never
rebuilds it and the rotation the test performed was invisible.

Replaced with a positive proof — rotating SECRETS_KEY must break
decryption — plus the APP_KEY case with forgetInstance(), which is what
makes that direction mean anything.
2026-07-27 21:44:59 +02:00
nexxo aaddffc096 Reuse the existing UUID trait instead of a second copy of the logic
Mailbox hand-rolled the same creating-time UUID assignment that
App\Models\Concerns\HasUuid already provides to 17 other models under
R11 (URLs address records by UUID, not integer PK), and skipped the
route-key binding that comes with it. Swap onto the trait, and cover
both the assignment and the route key with a test matching the
convention already used for Host/Order.

Also match MailboxFactory's @extends annotation to how the other
factories in the repo write it (Pint's fully_qualified_strict_types
rule turns a fully-qualified docblock reference into an import plus a
short name anyway, so this is what it would end up as either way).
2026-07-27 21:31:39 +02:00
nexxo c642090d97 Point the mailbox model at the UUID trait the repo already has
Seventeen models use App\Models\Concerns\HasUuid, and R11 is the reason:
URLs address records by UUID, not by integer primary key. The plan told
the implementer to hand-roll booted() instead, losing getRouteKeyName()
with it. Authoring error, corrected before the remaining tasks copy it.
2026-07-27 21:28:23 +02:00
nexxo 270ec942d6 Give every sending address a record of its own 2026-07-27 21:25:35 +02:00
nexxo 5cbd949bd5 Say plainly that the path comment in the plan is not code
The Task 1 implementer copied '// path/to/file.php' into the files
verbatim, which breaks Pint's blank_line_after_opening_tag and matches
no file in the repo. The annotation was mine; the constraint now says so
before the next seven tasks repeat it.
2026-07-27 21:20:37 +02:00
nexxo 38c5518f45 Drop the stray path comment after the opening PHP tag 2026-07-27 21:19:32 +02:00
nexxo 93d409e18f Pull the secrets encrypter out where two callers can share it 2026-07-27 21:09:10 +02:00
nexxo 0b7961ec36 Keep the SDD scratch directory out of the repository 2026-07-27 21:06:07 +02:00
nexxo 576cfcafac Plan the mailbox work as eight testable steps
Self-review against the spec turned up four gaps and one latent bug in
what already ships.

The bug: Laravel 13's MailManager reads only 'scheme' when building an
SMTP transport (MailManager.php:196) — the old 'encryption' fallback is
gone — and .env carries MAIL_SCHEME=tls, which Symfony does not accept;
it knows smtp and smtps. Nothing has failed because MAIL_MAILER=log
means no connection has ever been opened. The plan stores a human-facing
tls/ssl choice and translates it where it is used, and never copies
MAIL_SCHEME through.

The gaps: the cancellation mail and the provisioning notification were
edited but not tested, secrets.manage was never proven insufficient for
the new page, and a missing SECRETS_KEY had no task making the page say
so rather than throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:03:13 +02:00
nexxo 743ba96d13 Design mailboxes as records, not as one more secret
The console offers a single mail.password pointing at one SMTP mailbox,
which is nowhere near enough for five sending addresses. A mailbox is
address, display name, username and password; five of them as registry
entries would be twenty secrets named mail.support.username, and the
question of which mail sends from which address would still have no home.

Send-only stays usable because every message carries Reply-To on its own
mailbox, so a customer's reply lands in a mailbox someone reads. That is
what makes IMAP unnecessary rather than merely deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:51:03 +02:00
nexxo edaceb9597 Delete the users row once the operator has moved out of it
Decided: whoever is in operators has no business in users. A row left
behind is the same mixing the separation exists to end, only smaller.

The one case that deletes nothing is a row with a customer, a seat or an
order on it. That would mean the same person is operator and paying
customer, and a silent delete would take billing data with it — so the
migration stops and names the address instead. Neither existing account
is in that state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:44:56 +02:00
nexxo ce970a5fac Design the console's own identity, separate from customer accounts
The operator console and the customer portal share one users table, one
login page and one guard. Three reported faults follow from that single
fact: the console serves the portal's sign-in page, that page offers
"Registrieren", and the link 404s because RestrictAdminHost::SHARED does
not list register.

Measured rather than assumed: driving the host guard directly gives
/login through and /register 404 on the console host, and all sixteen
Spatie permissions turn out to be console permissions — so RBAC moves to
the new guard rather than being duplicated across two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:40:22 +02:00
nexxo 96dac236e8 Write the handoff, and rescue the approved templates into the repo
tests / pest (push) Successful in 7m3s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The three templates the design was signed off against lived in a
session-scoped scratchpad and would have disappeared with the session — the one
artefact the whole conversion is measured against. They are in docs/design/ now,
with the measurements that were got wrong at least once written down beside
them, and a note that the way to check is to render and measure rather than to
read the source. That distinction already settled one disagreement: the source
said the metric grid used a 20px gap, rendered it is 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:18:20 +02:00
nexxo 9b8d5dfd1e Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
Three things reported together.

── Editing belongs in a modal (R20)

The seats table grew its input fields into the row. It worked and it looked
broken: the row grew, the columns beside it jumped, and a table half in edit
mode reads as a rendering fault rather than as a form. The project already had
the answer — EditDatacenter, whose own header comment says it avoids exactly
that row-height jump — and the seats table simply did not use it.

EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route
middleware, so it resolves the customer itself and re-reads the record rather
than trusting a hydrated property: a forged addressEditable would otherwise
open the address of an accepted seat, and the address is the person — editing
it hands one employee's access to another with nobody told. Only an invitation
still in flight can have its address corrected, which is the case that actually
comes up.

The actions column is now always drawn. It used to disappear when the only seat
was the owner, on the reasoning that there was nothing to act on. But every
seat can be renamed, and a column that vanishes does not read as "not
applicable here" — it reads as "this product cannot do that", which is how it
was reported, three times. The owner's row says "Geschützt" rather than leaving
an empty cell.

Also caught here: the edit fields carried class-wide #[Validate] attributes, so
an empty edit form made the INVITE button fail on a field the invite form does
not have. Rules for an action belong to the action.

── The update button

"I cannot run an update, it says everything is current." `behind` is a READING
taken by the agent every five minutes, not the state of the world — push a
commit and the console insists it is up to date and refuses to act. The agent
does its own fetch before deciding, so asking against a stale reading costs one
fetch and finds nothing; being locked out costs the deployment. The button is
now offered whenever an agent is alive and no run is in flight, labelled for
what it does rather than for what the last reading said.

── The update never announced that it had finished

Because the thing being watched restarts the thing doing the watching. Mid-run
every wire:poll request fails, and what answers afterwards is a new build being
questioned by the old page's JavaScript — so the card sat on "läuft" until
somebody reloaded by hand. A small Alpine watcher now asks a plain JSON
endpoint (no Livewire, no component state, no assets), treats a failed request
as the restart rather than as a fault worth giving up over, and reloads once
the build it is looking at is no longer the build it started with.

── Support

The page was a decorated placeholder: a button that raised a toast saying the
form was "only hinted at in the prototype", three invented ticket titles living
in the translation file, and no way to see what became of anything. It looked
thin because nothing on it was real.

It now leads with the customer's own requests — what somebody arriving here
wants to know is what they asked and whether anyone answered — with contact
details moved to the side where they belong. The form attaches the plan, the
instance and who is asking automatically: making a customer describe their own
server back to the people who built it is the part of support people hate. FAQ
answers end where the thing can actually be done, and "is it me or is it you"
is answered by a link to the status page.

637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page
view may grow an input field inside a <td>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:55:49 +02:00
nexxo bd779f00e5 Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Successful in 6s Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.

Two of those views looked as though they had handled it. They called

    ->timezone(config('app.timezone'))

which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.

Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.

The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.

The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.

Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.

APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:32:21 +02:00
nexxo 186c2e46d1 Merge the panel design conversion into main
tests / pest (push) Successful in 12m28s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 4s Details
2026-07-27 17:10:35 +02:00
nexxo 4daf37a10b Give the demo account a real password, not one written in the source
The demo customer is a genuine login on a panel that answers from the public
internet. A default password in a seeder would sit on production for as long as
the demo does, and "it is only the demo account" is how the first one gets
taken. It now takes DEMO_PASSWORD if the operator sets one and otherwise mints
a 24-character random password, printed once at seed time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:09:54 +02:00
nexxo 435a202fdd Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:08:05 +02:00
nexxo 85fcc154b8 Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows
the figure, but the REST bridge in front of it only answers "healthy, yes or
no", and waiting for the bridge to grow an endpoint would have left the panel
without the number indefinitely. The sync already asks on a schedule; counting
the answers gives a percentage that is ours and explainable.

An unanswered check is not a failed one — the counter only moves when a real
answer arrived, so our monitoring going down does not become the customer's
outage on the one figure they are asked to trust. An instance nobody has ever
checked shows no availability rather than 100 %.

The downgrade path existed nowhere. Going down is not the mirror of going up:
it can ask an instance to hold more than the target plan allows. Smaller plans
are now listed even when they cannot be taken, with the obstacle in the
customer's own numbers — "you have 31 users, this package allows 10" — because
a greyed-out button that does not name what is in the way is the thing people
ring about. Storage is checked against the last real reading rather than the
contractual allowance, or anyone who once bought a large plan could never leave
it. The limit is re-checked in the action: a rule enforced only in markup is
not enforced.

And the smaller corrections from this round:

- The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP
  and MariaDB versions, which tell a tenant nothing they can act on.
- "EU — Serverstandort im Angebot festgehalten" under a label already reading
  "Serverstandort" was two sentences saying nothing. It is the country.
- Support promised a reply "binnen 4 Std." with nothing behind it. What is true
  everywhere else is an answer on the same working day.
- The actions column appeared even when no row in the table had an action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:41:15 +02:00
nexxo ee5e92c0ed Measure what the template draws
The panel could not show the storage ring or the transfer trend because nothing
measured them. instance_traffic keeps one row per billing period — right for an
allowance, useless as a series — and storage consumption was never sampled at
all, so the card could only ever state what was sold rather than what is used.

instance_metrics holds one row per instance per day, written by the traffic
collector on the visit it already makes. A second scheduler entry against the
same Proxmox API would have doubled the load and the failure modes for no gain.

Disk usage comes from `df` inside the guest rather than from Proxmox's own disk
figure, which reports the allocated image: that would show every customer a full
500 GB from their first day.

The rule the whole thing is built on: a reading that could not be taken is not a
reading of zero. The disk columns are nullable and left untouched when the guest
agent does not answer, so yesterday's figure stands instead of the ring dropping
to empty — which would tell a customer their data had vanished. Missing days
stay missing in the series rather than being filled with zeroes, which would
draw an outage that never happened. Both are covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:29:28 +02:00
nexxo c2a56382e1 Build the dashboard cards to the template, and name what has no source
Card by card against the approved template: label with a chevron, the figure
and its unit on one line, a line of context underneath, a bar across the foot.
The order matches too — storage, seats, transfer, then the fourth slot.

Three of the template's four visuals have no data behind them, and the code now
says so where someone will read it rather than leaving the next person to
wonder why the charts are missing:

- Storage consumption is never sampled. The card states the contractually
  agreed allowance instead of drawing a ring at an invented level.
- instance_traffic keeps one row per period, not a daily series, so there is
  nothing to draw a sparkline from. The share of the allowance is real, so
  that card carries a bar.
- monitoring_targets records a state and a check time but no uptime figure, so
  there is no availability percentage to show. That slot carries the last
  backup — the thing this product is about and can actually prove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:21:27 +02:00
nexxo 676c38643c Follow the approved template, and stop showing invented records
The dashboard was built to my own idea of the layout rather than to the
template that was signed off: a coloured pill where the template has a stamped
mono line, bare figures where it has a label with a chevron, a unit on its own
line instead of beside the number, and no ring at all. There is no point
agreeing a design and then building something adjacent to it. The metric card
is now a component that draws the template's form, and the pages use it.

Two more places were still showing data that does not exist:

- Support listed three tickets to every customer, complete with reference
  numbers and dates. There is no ticket model; it was fixture text. A request
  list also belongs in the console, where an operator can see who filed what.
  Removed until there is something real behind it.
- The datacentre name reached the customer through the cloud tab's copy as
  well. Customer surfaces name the jurisdiction; the building is operator
  information.

And the actions column rendered an empty cell for the owner, who can be neither
re-invited nor revoked. An empty cell reads as a missing feature; it now says
so with a dash and a title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:17:06 +02:00
nexxo 21d8dc310a One definition of the navigation, and the country instead of the building
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.

The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.

Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:09:39 +02:00
nexxo 6376f5e9fa One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set —
its own surfaces, its own orange, its own status triad — so every shared
component had to work in two worlds, and the two halves of the application
drifted apart in ways nobody could see from inside either one. That file now
carries density and nothing else: smaller type, tighter rows, tighter corners.
Colour comes from one place.

The tokens themselves are rebuilt around what the redesign settled on:

- Radii scaled to object size (9/11/16/22) instead of one value everywhere,
  which is what made the interface read as a design-system specification
  rather than as a product.
- Warm, broad, low shadows. A neutral grey drop shadow belongs to a different
  design language and reads as cold on this ground.
- --accent-press, which several rules already referenced and nothing defined.
  An undefined var() does not make a browser skip the declaration; it computes
  to , and for background-color that is transparent — the accent button
  lost its fill on hover and left white text on white.
- No serif. A serif headline is what made every page read as a document, which
  is the impression the whole redesign exists to remove. --font-serif now
  points at the sans so nothing breaks while call sites are cleaned up.

The two shells are one shell with two configurations, and both finally have a
mobile navigation: the sidebar used to simply disappear below 900px, leaving no
way to move around the application at all on a phone.

The customer dashboard is bound to real records — instance, seats, traffic,
backups, contract — instead of the fixtures it shipped with. Where something is
not measured, storage consumption, it says what was contractually agreed rather
than inventing a figure. This is the sheet a customer forwards to their auditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:03:47 +02:00
nexxo be082b7bf9 Stop iOS zooming the page on every tap into a field
tests / pest (push) Successful in 7m9s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Safari zooms when a focused input is smaller than 16px, and then leaves the page
zoomed — so on an iPhone every form throws the layout about, and the way back is
a pinch. The usual fix is the meta-viewport switch, which suppresses the zoom by
disabling pinch-zoom entirely: it takes a real accessibility feature away from
everyone to solve a cosmetic problem for some. Sizing the fields at 16px on
touch devices removes the reason instead.

!important on purpose: it has to beat every text-size utility on every field in
the application, and a rule that loses to `text-sm` on one form is worth
nothing. Checkboxes and radios are left alone — no text to inflate, and sizing
them there breaks the box.

Also -webkit-text-size-adjust, so iOS stops inflating body copy in landscape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:59:12 +02:00
nexxo b85c12e8b9 Give the readiness probe long enough for a restart to finish
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The gateway is restarted twice in a deployment — once by `up -d`, then again
after the hub whose network namespace it lives in — and `docker compose exec`
into a container that is still coming up fails outright rather than waiting.
Twenty seconds did not cover that, so the first run of the new probe reported a
gateway that answers as down, and the resolver stayed out of client configs for
one more deployment.

The health site also now binds the hub address instead of only matching on it.
Nothing was published either way, but the file should do what its comment says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:46:47 +02:00
nexxo 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>
2026-07-27 14:43:23 +02:00
Claude 2bb1d7cc3b Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Successful in 4s Details
An empty 404 is itself information: it says something terminates TLS here and
chose not to answer. The console and the websocket endpoint now close the
connection instead, which is what a hostname that serves nothing looks like.
The websocket endpoint answers a genuine upgrade — verified live, 101 — and
nothing else; a browser opening it gets a closed connection.

Reviewing that turned up two holes that mattered more than the thing being
reviewed.

The compose defaults published the application and Reverb on every interface.
Docker publishes ports ahead of UFW, so those backends were reachable from the
internet with the firewall closed — and reaching a backend directly skips the
proxy, and with it every hostname and address rule keeping the console private.
The defaults are loopback now, and an update rebinds an existing installation
that is behind a proxy actually running and holding 443. A development box
without one is left alone, because taking its port away would look like the
machine had broken.

And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's
allowlist. One click would have put the console on the public internet, which
is the exact state the owner's rule exists to prevent. Switching it off relaxes
the application's check; the proxy keeps its list, always.

The reference proxy config carries the arrangement: QUIC early data off, since
an address-based decision taken on 0-RTT can answer 425 and some browsers do
not retry — an intermittent lockout from the console is the worst kind — and
explicit handling for plain HTTP, whose automatic redirect otherwise announces
both hidden names to anyone who asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:48 +02:00
Claude c4649571df Wait for the tunnel interface, and prove the gateway answers
tests / pest (push) Successful in 7m23s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Successful in 5s Details
Two things reported success while the tunnel console was unreachable.

The services were restarted the moment the hub container counted as started,
but the hub brings wg0 up as part of its start command — so they were binding an
address that did not exist yet. They now wait for the interface.

And readiness was a container-state check. A container can be up and running
while the process inside it listens in a network namespace that was torn down
underneath it: nothing errors, the address simply refuses connections, and
"running" reports everything fine. It asks the gateway now, and only a real
answer counts — because the consequence of getting this wrong is a client
config naming a resolver that is not there, which takes the device's whole name
resolution with it for as long as the tunnel is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:24:29 +02:00
Claude d2d79b4f5f Restart the tunnel services after the hub they live inside
tests / pest (push) Successful in 10m42s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Successful in 6s Details
Both VPN services share the provisioning container's network namespace, and a
process holds the namespace it started in. Restarting the hub — which every
deploy does, to pick up new code — therefore leaves them listening inside a
namespace that no longer exists.

Nothing errors. The containers stay up, Caddy's log still reports it is serving
on the tunnel address, and connections to that address are simply refused. It
looks precisely like a gateway that never worked, which is how it presented on
the live server after the first successful start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:22:19 +02:00
Claude 32ae64d5fd Pin the resolver to a tag that exists
tests / pest (push) Successful in 9m52s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 4s Details
The dnsmasq image was pinned to 2.90-r0, which is not published. The gateway
therefore never started — correctly reported as not ready, so no client was
handed a resolver that was not there, but the tunnel console did not come up
either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:17:20 +02:00
Claude 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>
2026-07-27 10:14:33 +02:00
Claude 99715989aa Dress the placeholder page, and record why the obvious VPN fix cannot work
tests / pest (push) Successful in 8m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The page shown while the site is hidden was still in the old style — a coloured
square and system fonts — on the two hostnames a visitor is most likely to try.
It now uses the site's own plate, typeface and registration marks, still
entirely self-contained because it is shown precisely when the asset build may
not exist. Both it and the error pages lighten the accent in dark mode: the tone
is chosen for contrast against paper, and on the ink plate the small text is the
first thing to stop being readable.

The VPN investigation ended somewhere useful, and not where it started.

The blank page on the phone is the reverse proxy refusing the request — `respond
404` sends no body. It refuses because the phone is not on the tunnel: the
client config lists only the management subnet in AllowedIPs, so a request to
the console's public hostname goes out over the mobile network and arrives from
a carrier address. It also explains "last contact: never", since WireGuard only
performs a handshake when it has traffic to send, and nothing is ever routed
into the tunnel.

The obvious fix — add the server's public address to AllowedIPs — is written
down here as a comment and a test rather than as code, because it cannot work.
The WireGuard endpoint is that same address, so routing it into the tunnel
routes the handshake packets into the tunnel they are trying to establish. The
result is a loop and a connection that never comes up: the same blank page, now
with no way in at all.

Reaching the console over the VPN needs an address INSIDE the subnet — the proxy
answering on the hub address, and a name that resolves to it. That is a
deployment change, not something a config line can express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:30 +02:00
Claude 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>
2026-07-27 09:01:16 +02:00
Claude f6b9181ed8 Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 6s Details
Clicking Save made every button on the console's settings page appear to fire.
Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one
Livewire applies the loading state to ANY request on the component — so one save
put all of them into their disabled state simultaneously. Each names its own
action now.

Two-factor for customers. Fortify's endpoints already existed; only the screen
was missing. Setting up requires re-entering the password first, and every
action re-checks that server-side rather than relying on the button not being on
screen — a Livewire action is reachable by anyone who can post to
/livewire/update. The confirmation marker is Laravel's own session key, so it
and the framework's password.confirm middleware mean the same thing rather than
drifting apart.

The secret never enters a Livewire property. Component state travels to the
browser and back in the snapshot; the QR image is derived from the secret, the
secret is not in it — and there is a test that says so.

The status page moves to the ROOT of its hostname: status.clupilot.com/status
says the same word twice. That needed the domain-bound `/` registered BEFORE the
landing page, because Laravel takes the first match and the landing route is
host-agnostic — so the status host would have served the marketing site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:34 +02:00
Claude 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>
2026-07-27 08:08:57 +02:00
Claude 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>
2026-07-27 07:49:07 +02:00
Claude bc8bbc56a5 Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The console was unreachable, and not for the reason it looked like. Two causes,
both mine.

A failed `artisan optimize` — the one that hit duplicate route names — left a
broken route cache behind, so every request to the console host answered 500.
Rebuilt.

Underneath that: the reverse proxy has its own console allowlist, hard-coded,
and it runs BEFORE the application. Everything added on the console's own
access page was therefore ineffective, and when the owner's address changed
they were turned away by the proxy before reaching the page that would have
fixed it. The proxy now imports a fragment generated from the same list the
console manages, regenerated by the host agent and reloaded when it changes.

Getting that safe took most of the review. It refuses to rewrite an ambiguous
Caddyfile rather than replacing some other site's matcher; it never falls back
to a loopback-only list when the application cannot be reached, because that
list validates cleanly and locks out every remote operator; it retries a reload
that failed instead of assuming it worked; and an installation that upgrades
without rerunning the installer is told, because otherwise the whole mechanism
is invisibly absent.

Prices are entered in euros. The form asked for cents, so €799 was typed as
79900 and one slipped digit was a factor of ten on an invoice. Conversion
happens in one place, on the string rather than through a float — (float)
'79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on
exactly the prices people charge — and it refuses an amount the column cannot
hold instead of failing at the database.

Every error code has a page now, in the site's own language and typeface,
self-contained so it still renders when the asset manifest is the thing that
broke. There was only a bare white 404.

The VPN list stops being a seven-column table nobody could fit: names broke
across two lines and so did the headings. These are attributes of one access,
not quantities compared down a column, so each access is a row — identity on
one line, measurements in mono on the next.

The status page says what it measures rather than what it promises. "New orders
are delivered without failures" read as a marketing claim on a page whose only
job is to be believed, and said nothing about the last 24 hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:51:05 +02:00
Claude 233fc73430 Let the console keep its recovery hostnames without breaking route caching
tests / pest (push) Successful in 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Successful in 4s Details
Registering the console once per accepted hostname reused one set of route
names, and Laravel refuses to serialise two routes under the same name — so
`artisan optimize` failed on the live server the moment the separation was
switched on, mid-deploy.

Only the canonical registration carries the `admin.` names now; the alternates
answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses
someone locked out reaches for — never to have URLs generated for them, so
route() keeps producing the canonical hostname.

That renaming then broke every exact route-name check, which is how the console
navigation decides what is active: reached through a recovery address, nothing
in the sidebar was marked, exactly when something is already going wrong. The
check goes through AdminArea now and matches either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:20:44 +02:00
Claude 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>
2026-07-27 06:15:16 +02:00
Claude 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>
2026-07-27 06:05:40 +02:00
Claude 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>
2026-07-27 05:33:17 +02:00
Claude 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>
2026-07-27 04:52:21 +02:00
Claude 30a80b6c15 Report the estate from the database, and print the traffic that is sold
Three console pages were fiction. The front page claimed 42 customers, 39
instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month
growth curve; the instance list held seven invented machines; the revenue page
reported churn and a trend for a business with no recorded history. All of it
was hard-coded. It read like a running company and measured nothing.

They now read the database. Two figures are gone rather than approximated —
the revenue trend, which needs a monthly history nobody records, and churn,
which needs a base the data cannot supply. ARR stays, labelled as the
projection it is. The green "all systems normal" badge is computed from the
notice list instead of asserted, and the notices themselves come from failed
runs, hosts reporting errors or gone quiet, and monitoring that is down.

Host load is the one number that had to agree with something else: placement
counts the VM disk allocation, ignores a failed instance that never got a VM,
and subtracts the host's reserve. A dashboard doing its own arithmetic would
show a host as comfortable while orders were already being refused on it, so it
uses the host's own accounting — with the filter moved into a scope both share,
and the sum preloaded so listing hosts stays one query.

The instance list drops the Nextcloud version column: that version is not
recorded anywhere, and a column filled with a plausible number is worse than no
column. Statuses the lifecycle writes but nobody had translated no longer
render as "admin.status.failed".

The price sheet also gains the included traffic, which the catalogue has always
carried and the page simply never printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:48:51 +02:00
Claude b844ff377d Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The marketing page was a generic centred hero in the system font stack: it
neither used the design system the console is built on nor looked like the
product it sells. Rebuilt around what CluPilot actually promises — security you
can produce evidence for — so the page reads as a controlled technical
document: a specification plate instead of a hero image, numbered sections, an
audit register with rhythm and proof per measure, and a price sheet rather than
floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self
hosted as static files so the public page renders even without the Vite build.

The prices were written into the page by hand, and the page and the catalogue
had already drifted apart on three of four plans: the site advertised 249 € for
a plan the checkout charges 399 € for. The sheet now reads the catalogue, like
every other caller — including the currency, which is configurable and was also
hard-coded here.

The catalogue fails loudly on purpose; a public website must not. A broken or
overlapping catalogue is caught in the controller alone: the page still
renders, the sheet is replaced by "on request", and nobody is quoted a number
the checkout would not honour.

The sign-in and registration panels shared a copy-pasted orange gradient. They
now share one component and the same ink plate as the site, so the two halves
of the product no longer look like two companies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:52:25 +02:00
nexxo 0066b1c6a0 feat(security): a way back into the console that does not need the console
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
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>
2026-07-27 03:09:13 +02:00
nexxo 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>
2026-07-26 20:27:26 +02:00
nexxo 5c2e481985 fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:04:34 +02:00
nexxo 5d64da1cb3 docs: hiding the site also hides the login page, and that has a consequence
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 3s Details
The gate exempts admin/* so the console keeps working, but /admin sends a guest
to /login and /login is not the console — so with the site hidden and no VPN
yet, an operator cannot sign in to flip the switch back. Found while bringing
up the live server.

The mechanism for it already exists and is the right one: TRUSTED_RANGES. What
does NOT work is exempting the login flow by hostname, which was the obvious
patch — a Host header is chosen by the caller, so one forged header would have
lifted the gate for every route, portal included. Codex caught that; the comment
now says why the narrow-looking option is the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:27:51 +02:00
nexxo 875fd11982 fix(deploy): an answer file with a space in a value killed the install
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The file is sourced, so ADMIN_NAME=Boban Blaskovic is read as a command and
dies with "Blaskovic: command not found" — naming neither the file nor the
variable — and set -e ends the install there. A display name with a space is
entirely ordinary; the example just happened to use "Administrator", one word,
so nobody hit it.

Found while installing on the live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:49:58 +02:00
nexxo 9352935b88 fix(nginx): the live hostnames were missing from the admin denylist
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
The map listed only *.dev.clupilot.com, with the production names commented out
under "add the real public hostnames here before launch". Launching without
them removes the outer of the two layers that keep /admin off the public
internet — the ADMIN_HOSTS check in the app would still answer 404, but the
whole point of the pair is that neither is relied on alone.

admin.clupilot.com stays out of the list on purpose: the list names the hosts
that are PUBLIC, and the console is not one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:43:44 +02:00
nexxo 35307e64cc fix(deploy): the installer could never finish, twice over
tests / pest (push) Successful in 6m55s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 5s Details
Found by running it on a fresh Debian 12 with systemd, no docker, no git and no
users — which is the first time it has ever been run anywhere.

`optional_env` ended in `[[ -n "$2" ]] && set_env …`. With an empty value the
function returns 1, and under `set -e` that ends the install, silently, with a
half-written root-owned .env. Absent is the NORMAL case for those variables —
they are the optional ones — so this killed every installation that did not
happen to supply a Hetzner token. An `if` returns 0 and does not.

Then it waited for `php -v` before migrating. PHP answers in seconds; the
entrypoint is still running `composer install` on a fresh checkout, which takes
minutes. So `artisan migrate` ran against a checkout with no vendor/ and died
on a missing autoload.php. It now waits for vendor/autoload.php, exactly as the
dependent containers already do, and says where to look if it never appears.

After both: exit 0, 59 migrations applied, the plan catalogue seeded and
consistent, the Owner account created with its role, and the portal answering
200 while the console redirects to login and the unconfigured Stripe webhook
fails closed with 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:26 +02:00
nexxo e9240c1324 feat(deploy): releases you can pin to, and a version that tells the truth
VERSION at the repository root is the release number and the only place it is
written down — a file rather than `git describe`, because a source archive, a
shallow CI checkout and a container built without .git have no tags to
describe. Cutting a release is editing that file and merging it; CI creates the
annotated v<VERSION> tag once green, only on the commit that raised the number,
and never moves it. Servers are pinned to these.

Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an
immutable tag, checked out detached: it moves when someone decides it moves.
BRANCH=main follows the edge, where CI tags a commit only after it is pushed.
The mode is remembered, so re-running the updater on a pinned box does not
quietly walk it back onto main.

Going backwards is refused, by commit ancestry rather than by comparing version
strings — a tag can be cut from anywhere, and only ancestry says whether history
is going back. The schema has already moved forward by then, and the migrations
needed to reverse it are not in the older checkout at all.

What the console reports comes from a manifest written atomically after every
step succeeded, never from live git: git says what the files are, not whether
the deployment came up. So a failed update keeps reporting the version that is
actually serving — including its version number, not the newer one the checkout
has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)";
anything else reads "1.0.0-dev (abc1234) · main", because every commit after the
tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is
how a bug gets filed against the wrong code.

Codex reviewed the design before it was written and the result six times after.
Its findings, all real: the version had to come from the manifest too, not just
the commit; branch names need JSON escaping or the manifest silently vanishes; a
`case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a
server already sits on skipped recording the mode, and then skipped detaching;
a manifest that failed to write would never be repaired; an unparseable
timestamp would 500 every admin page; and an empty tag list read as a
connectivity failure.

466 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:21:38 +02:00
nexxo c119e8d96e fix(deploy): say which branches exist instead of failing inside git
The installer clones BRANCH, which defaults to main — and the repository has
no main. Git's own "Remote branch not found" reads like a bad token or an
unreachable server, and the branch is the one thing here that is routinely
wrong. It now asks first and, when the branch is missing, prints the ones that
do exist and how to pass one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:48:49 +02:00
nexxo 4f3e11ae5e docs(deploy): the installer's Stripe steps match what phase 5 actually needs
It still named two webhook events; the application handles six, and the four
new ones are the billing cycle itself — renewals, failed payments, status
changes and endings. It also said nothing about stripe:sync-catalogue, without
which Stripe has no prices and nothing can be sold at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:46:25 +02:00
nexxo 8c3a79e258 fix(billing): hold only what cannot be matched, and let migrations roll back
Two findings from reviewing all five phases together rather than one at a time.

The webhook held every event a handler answered `null` to — but `null` also
means "already recorded" and "deliberately skipped", which is every checkout's
own invoice and every redelivered renewal. Their contract is right there, so
`replayHeldFor()` could never come back for them, and ordinary Stripe traffic
silted up the holding area until the weekly prune. It now holds only what it
genuinely cannot match — and if the contract appeared in the meantime, applies
the event instead of dropping it: the creation race is narrow, but losing a
cancellation to it would leave us serving someone who had left.

Also fixes a rollback that predates this work and blocks `migrate:fresh` on
MariaDB entirely: dropping the unique index on customers.user_id fails while
the foreign key added one migration earlier still depends on it. Verified by
building all 33 migrations from nothing on MariaDB, rolling every one of them
back, and building them again.

458 tests green. Codex clean on the full branch diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:57:11 +02:00
nexxo 0560ae743d feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version,
plus the four subscribed webhook events that were arriving and being ignored.

`stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored
id is skipped, and each call carries an idempotency key derived from our own
row, so a crash between Stripe creating a Price and us recording its id gives
back the same object rather than a second one. That matters more here than
usual — a Stripe Price cannot be edited or deleted, so a duplicate is
permanent. Drafts are not synced at all: a version that has promised nothing
has no business in a price list. --dry-run shows what would be created.

The webhook now handles invoice.paid, invoice.payment_failed,
customer.subscription.updated and customer.subscription.deleted. It never
re-derives an amount: the invoice is the authority for what was charged, and
recomputing it from our catalogue would produce a second, disagreeing answer.
A failed payment is recorded and nothing else — Stripe runs the dunning
schedule, and cutting a customer off on the first failure would punish an
expired card as though it were a refusal to pay.

Only a cycle renewal moves the term on. Stripe also sends paid invoices for
prorations and manual charges, and the checkout's own invoice — which is
already in the register as the purchase, and would otherwise double every
customer's first payment.

Stripe does not guarantee delivery order, which is the source of most of the
care here:
- state changes are judged and written in one transaction with the row held,
  so two deliveries cannot both pass a check made on a stale copy;
- an older event never overwrites a newer one, with a rank breaking ties
  between events sharing a second — their timestamps have one-second
  resolution, and a failed attempt and its successful retry routinely do;
- a term is never shortened, and an ended contract is never revived;
- an event for a contract that does not exist yet is HELD and replayed the
  moment it appears, rather than acknowledged and forgotten. A cancellation
  dropped that way would leave us serving someone who had already left. Held
  rows that never match are pruned after a week.

Register entries are keyed by what they are about — the invoice, the attempt,
the subscription — with a unique index doing the work, because a check two
concurrent deliveries can both pass is not idempotency.

PlanChange is now documented as the preview shown before someone confirms, not
the invoice: Stripe's proration accounts for tax, existing credit and the exact
second the change lands, and ours cannot.

Not run against the live account — `stripe:sync-catalogue` creates objects that
cannot be deleted, so that is the owner's call. The dry run lists 12.

457 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:36:28 +02:00
nexxo 7582df3de6 feat(billing): a proof register, and modules frozen at their booked price
Two things the contract could not answer on its own: what happened, and what
the customer's whole bill is.

`subscription_records` is append-only, one row per commercial event. Flat
columns for everything searched or relied on as evidence — event, customer,
subscription, plan family and version, term, net/tax/gross, currency, tax rate,
reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot.
Not JSON alone: you cannot query it, and "the amount is in there somewhere" is
poor evidence. Copied, not joined, so a row still answers after the customer,
the plan or the version have gone.

The flat columns describe the TRANSACTION. Gross is what was actually taken;
net and tax are that gross split by the rate that applied on the day. A
discount lowers the taxable amount rather than creating negative VAT, and a
free checkout is recorded as free instead of as paid in full. What was agreed
sits beside it in the snapshot, so a charge that differs from the catalogue is
preserved as a question rather than reconciled away.

The register refuses to be rewritten, in bulk as well as one row at a time —
model events do not fire for `query()->update()`, which is exactly the shape a
careless data fix takes.

`subscription_addons` carries the owner's rule: a customer's total is their
subscription plus their modules, and all of it is frozen at what was agreed.
Price protection covering only the plan would let a module quietly double for
someone who booked it two years ago. A module they have NOT booked is a sale
still to be made, at today's price — so the catalogue is consulted only for
what is not in this table. Several bookings of one module are summed rather
than reduced to one arbitrary row, or the page and the bill would disagree.

Concurrency: one order books one module, enforced by a unique index rather than
a lookup two retries can both pass; cancellation is an atomic claim; and each
booking commits with its own register entry, so a failure cannot leave a sale
without evidence and a retry that finds the booking cannot skip the event.

Verified in the browser: with the module raised from 29,00 € to 59,00 € in the
catalogue, the customer who booked it still sees 29,00 € and a total of
208,00 €, while a new customer is quoted 59,00 €.

436 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:07:34 +02:00
nexxo f6ddf45dc0 feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before:
create a plan line, draft a version, price it, publish it into a window, and
pull a plan out of the shop.

Two pages and one modal, because the catalogue has exactly two levels. The
plan list is a name, a rank and a kill switch. Everything that can be got
wrong — capabilities, prices, windows — lives on the versions, where the page
is built around the one rule that matters: a draft is freely editable, a
published version is not touchable at all. So drafting and publishing are
separate acts, and publishing says plainly that it is final.

The console refuses everything the catalogue refuses, on the form rather than
as a stack trace: an overlapping window, a window that ends before it starts,
a version with no price for a term we sell on, and — new here — a version with
no VM template, which would be bought and then fail provisioning every time.
Numbers are bounded on both sides, because a mistyped price otherwise
overflows the column and answers with a 500 instead of saying which field was
wrong. Performance class and features are picked from the keys we have labels
for; free text would be frozen at publication and shown to customers as a raw
translation key forever.

Concurrency, since two admins share one catalogue: draft numbers are allocated
under a lock on the family, publication is an atomic conditional claim, and
discarding a draft is one statement conditional on it still being a draft —
otherwise a draft published in the meantime could be deleted out from under
the customers now contracted to it.

`plans.manage` (Owner and Admin) guards the pages themselves, not only the
buttons, and the modal authorises itself — prices, drafts and unreleased plans
are not something to leave readable to anyone who types the URL.

Also fixes the shared checkbox, which ticked in the browser's own blue on
every page that used one: a native checkbox ignores text colour and needs
accent-color.

Verified in the browser: withdrawing a plan in the console removes it from the
customer's billing page immediately, and restoring it brings it back. The
public landing page carries no catalogue-driven plan list, so there is nothing
there to hide.

421 tests green. Codex review clean after six rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:41:51 +02:00
nexxo 6387c747d0 feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 52b41bb0d5 fix(billing): a paid order opens a contract, and provisioning obeys it
The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.

- OpenSubscription freezes the catalogue onto a subscription when a checkout
  is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
  ReserveResources fail closed with no_subscription rather than falling back
  to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
  published after the sale. Deliberately outside FROZEN: it is how we build
  the machine, not a term the customer is owed, and a dead template must be
  replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
  traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
  reconstructed from what was actually delivered where an instance exists,
  and adopts an existing order-less contract instead of opening a second.
  Orders paid in a currency the catalogue cannot price get none, matching the
  checkout path.

price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).

Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.

Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.

373 tests green. Codex review clean after three rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:10:00 +02:00
nexxo 0dde76ad55 docs: handoff for the plan catalogue rebuild
tests / pest (push) Successful in 6m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Design is decided and Codex-reviewed; none of it is built. Records the live
split-brain bug to fix first, the three-table catalogue, computed availability,
the proof register with frozen add-ons, the Stripe split, what already exists,
and the working rules that cost time this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:43:17 +02:00
nexxo 6119044669 fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
A cancelled subscription still reported an upgrade as allowed and priced it, so
a caller trusting that would have provisioned and billed a customer who had
already left. And any term string other than the two we support was silently
priced monthly while keeping the unknown value — a subscription whose price and
billing period disagree. Both are refused now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:57:53 +02:00
nexxo 35324b986a fix(billing): no free upgrade in the last hours of a term
tests / pest (push) Successful in 6m48s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Flooring the remaining time made an upgrade with under a day left cost nothing,
and an upgrade requested after the period had ended cost nothing while being
applied immediately — the bigger plan for free. Remaining time now rounds up
while any service is left, and an expired period defers the change to the next
term instead of pricing it at zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:55:53 +02:00
nexxo 52b1acbb80 fix(billing): a scheduled downgrade must be applicable, and never cost money
tests / pest (push) Successful in 6m49s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Once the term is over, the downgrade has to be allowed — otherwise the job that
is supposed to carry it out never can, and the change waits forever for a date
that has already passed.

And a grandfathered plan can be cheaper than the smaller plan costs today, which
made the goodwill credit negative: an invoice for the privilege of downgrading.
Clamped to zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:54:16 +02:00
nexxo 460fac01b1 fix(billing): upgrade or downgrade is decided by rank, not by price
tests / pest (push) Successful in 6m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
With grandfathered prices the comparison inverts: a business plan bought when
it cost less than today's team plan would have treated a move to team as an
upgrade — charged immediately, while the customer loses resources. The plan's
rank is frozen with the rest of the snapshot and decides the direction; prices
only decide the amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:52:28 +02:00
nexxo ac250071cb feat(billing): immutable subscription snapshots and pro-rata plan changes
The plan catalogue describes what we sell today; a customer who signed up last
year bought last year's terms. Every commercially relevant condition — price,
quotas, seats, the hardware behind the plan — is now frozen onto a subscription
at signup, and the model refuses to let any of it be rewritten afterwards. A
price rise applies to new subscriptions and cannot reach back into an existing
contract.

PlanChange holds the two rules, computed against the frozen price rather than
today's catalogue:

- Upgrading is immediate and pro rata: the new plan for the days left in the
  paid term, minus what the old plan was worth over those same days. On the
  last day of a month that is one day's difference, not a month's.
- Downgrading waits for the end of the term. A yearly customer bought a year
  and can move down when it is up; a month is a month. A mid-term downgrade is
  a goodwill decision, not a self-service button, and its credit covers only
  the unused part of the difference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:49:48 +02:00
nexxo c36ea17b39 fix(billing): normalise both sides of the VAT comparison; cast the timestamp
tests / pest (push) Successful in 6m45s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
A verifier that returns the number in display form ("DE 811 907 980") would
have failed the comparison against the normalised current value and silently
switched a genuine reverse-charge customer back to domestic VAT. Both sides are
normalised now, and vat_id_verified_at is a real datetime rather than a string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:36:07 +02:00
nexxo 5aef5e693c fix(billing): verification vouches for a value, not for a row
tests / pest (push) Successful in 6m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A timestamp alone said "some number was checked once": editing the field left
it intact, so a customer could swap a verified foreign VAT ID for any
plausible-looking one and keep zero-VAT pricing. The verified value is stored
and compared, which makes the rule self-enforcing — no writer has to remember
to clear a flag, and there are several writers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:34:40 +02:00
nexxo 42ceafb57f fix(billing): a self-declared VAT ID must not zero the tax
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Any non-empty string starting with two characters other than AT switched the
customer to reverse charge — typing "XX123" was a 20 % discount. Reverse charge
now requires a VAT ID that is verified, belongs to an EU member state other than
ours, and looks like a VAT number at all. Unverified is the normal state and
means the domestic rate: over-collecting is correctable, under-collecting is a
tax liability.

Changing the number clears its verification. Verification itself (VIES) is not
built yet, so reverse charge stays off until someone confirms a number — which
is the safe direction to be wrong in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:32:42 +02:00
nexxo 76510a59a3 fix(billing): VAT follows the customer, not a global setting
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Codex was right that this could misstate real charges: an EU business with a
VAT ID registered in another country is billed under reverse charge, and we
were adding 20 % Austrian VAT to their total anyway. TaxTreatment resolves it
from the customer's VAT ID, and the whole page — cart, plan cards, add-on
cards — now states one treatment instead of contradicting itself.

Explicitly NOT handled: cross-border sales to private individuals, which are
taxed at the buyer's national rate under OSS. That needs a maintained rate
table and a tax adviser, not a guess, so those fall back to the domestic rate —
over-collecting rather than under-collecting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:30:17 +02:00
nexxo ac3f429958 fix(billing): round the recurring figure the same way as the total
tests / pest (push) Successful in 7m33s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The note rounded VAT after aggregating while the total rounded per order, so
cent-level prices made the two disagree — and a customer who spots that stops
trusting every other number on the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:27:02 +02:00
nexxo 9fab251fb3 fix(billing): make the one-plan-change rule hold under two clicks
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Both requests could finish their delete before either inserted, leaving exactly
the two pending upgrades the rule exists to prevent. Replacement and insert now
run in one transaction with the customer row locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:25:29 +02:00
nexxo a87a4f512d feat(billing): one plan change at a time, and every price says what it is
Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:23:49 +02:00
nexxo 55b1a1468f fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The handoff stayed in the cache for its full ten minutes, so any replayed
component request could fetch the plaintext again — "shown once" was a figure
of speech. It is consumed on the first read and the token dropped; the payload
reaches the view and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:34:57 +02:00
nexxo d40113be8e fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex:

- The job accepted any stored instance with a host. A closed one's VMID can have
  been reused on the same machine, and the reset would then land on another
  customer's VM. It now requires a live instance, and the action is not offered
  for anything else.
- An unreachable Proxmox or guest agent throws rather than returning an exit
  code, so nothing was ever written to the handoff and the modal polled forever.
  The throw is caught, and a failed() handler covers anything that still escapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:32:53 +02:00
nexxo aae0457e19 feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their
installation, which is a different thing and now a different button.

Nextcloud has no passwordless admin jump, so this does the only honest thing it
offers: it resets OUR managed admin account inside that installation and hands
the credentials over once. The customer's own accounts are untouched, and the
next request sets a new password again.

- New capability instances.adminlogin, Owner and Admin only — stronger than
  impersonation, because it hands over control rather than a session.
- The operator's own password is required every time, rate-limited: taking over
  a customer's installation is not something an unattended browser should manage
  on its own.
- The reset runs on the provisioning worker (it owns the tunnel and the Proxmox
  credentials); the console polls a handoff token, so the credentials never
  enter a Livewire snapshot.
- A silent instance is reported as such instead of appearing to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:30:59 +02:00
nexxo d26200c74b feat(billing): a cart you can actually read and change
tests / pest (push) Successful in 7m10s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"5 purchases pending" told nobody what they had ordered and offered no way to
change their mind. The billing page now lists each pending purchase by name,
what it costs, when it was added, and the total — with a remove button per row
behind a confirmation, like every other destructive action in the console.

The wording lives on the Order model rather than in the view, so the cart, the
invoice list and any later confirmation mail cannot each invent their own name
for the same row. Removal is scoped to the customer's own still-pending orders,
checked in the modal itself: modals are reachable without the page's guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:26:49 +02:00
nexxo a6d7594e5c test: do not reach for Redis from the provisioning queue
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Jobs that pin themselves to the provisioning connection bypass
QUEUE_CONNECTION, so two tests opened a Redis connection — fine on a machine
running the stack, a RedisException in CI. A test run must not depend on
infrastructure being up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:20:46 +02:00
nexxo 819872bb79 fix(ci): install dependencies by cloning, not through GitHub's API
tests / pest (push) Failing after 6m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The dist path is rate-limited for anonymous callers, and a partial failure left
a half-installed vendor/ behind — the suite then failed with 500s that had
nothing to do with the code. Source clones need no quota. A GitHub token would
let us go back to the faster path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:11:52 +02:00
nexxo 7af3e2957b test: do not depend on a built asset manifest
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
CI has no build, so every page rendering through @vite threw a view exception —
dozens of failures with one cause. withoutVite() makes the suite test the
application rather than the state of the asset pipeline; a broken build shows up
in the build job, which is where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:03:58 +02:00
nexxo 2c0884357c fix(pages): the update and placeholder pages must not need a build
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
CI caught it: both rendered through @vite, so a missing manifest threw a view
exception instead of a page. That is not a test problem — the update page is
shown precisely while the application is mid-deploy or freshly installed, which
is exactly when the manifest may be absent. Both carry their own small
stylesheet now and render with nothing built at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:56:23 +02:00
nexxo b55a127a00 fix(ci): drop the cache steps this runner cannot serve
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
actions/cache expects a cache service the runner matching Gitea 1.20 does not
speak, and it failed the job outright. It was an optimisation; the source
fallback that makes the install work is what mattered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:45:11 +02:00
nexxo 7cd3ff33bd fix(ci): survive GitHub's anonymous rate limit
tests / pest (push) Failing after 1m11s Details
tests / assets (push) Failing after 16s Details
tests / release (push) Has been skipped Details
Composer's dist downloads come from GitHub, which throttles anonymous callers —
on a shared address that is a coin flip, and it took the run down after four
minutes of setup. Source clones are the fallback, and both toolchains now cache
their package directories so a repeat run barely touches the network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:39:32 +02:00
nexxo 6367f4a9fd fix(ci): pin actions to versions the runner can execute
tests / pest (push) Failing after 1m0s Details
tests / assets (push) Successful in 52s Details
tests / release (push) Has been skipped Details
The floating tags moved to node24; act_runner 0.2.6 — the version that matches
Gitea 1.20 — supports node20 at most, so every job died before running a single
test. Pinned to the newest releases that still declare node20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:34:37 +02:00
nexxo 53d305340f fix(vpn): a late revocation must not disconnect whoever holds the key now
tests / pest (push) Failing after 49s Details
tests / assets (push) Successful in 51s Details
tests / release (push) Has been skipped Details
Once a key is free, someone can legitimately create a new access with it. The
forced revocation looked the key up and would have deleted that person's access
and pulled their key off the hub. It now acts only on an adoption artifact — a
system peer with no owner and no creator — and leaves anything else alone.

Re-issuing also threw when VPN_CONFIG_KEY was missing, which is exactly the
situation the console tells people to fix by re-issuing. It now hands the new
config over once instead of storing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:24:55 +02:00
nexxo 4f9ca27732 fix(vpn): a replaced key must not stall the reconciliation
Codex found that a sync landing between the key swap and its removal would see
the old key as unknown. Writing the test showed something worse than the
duplicate he predicted: the adoption cannot even happen — the address still
belongs to the live access — so the insert violated the unique index and took
the ENTIRE reconciliation down with it. One stale key would have stopped every
peer's state from updating.

The sync now recognises that case and queues the removal instead of adopting,
and a key with no row of its own is revoked with force: it can only ever be
removed, never kept, so the removal cannot be talked out of it by a row that
should not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:23:15 +02:00
nexxo 556a560506 feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no
explanation — it looked broken. It now shows a dimmed button that says why
(the private key only exists on your device), and every staff access gets
"Re-issue": a new keypair, old key off the hub before the new one goes on,
so two peers never claim the same tunnel address at once. A stored config is
re-encrypted in step, or the owner would download a key the hub no longer
accepts.

Host peers are excluded: their key belongs to the machine's own wg0, and
swapping it here would cut the host off with nothing left to repair it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:20:04 +02:00
nexxo e5aea84539 docs(ci): wire the runner up to the point where only the token is missing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:18:42 +02:00
nexxo 419a1a8552 fix(ci): keep the workflow and runner compatible with Gitea 1.20
github.* is the documented context alias and exists in 1.20; gitea.* came
later. The runner is pinned instead of :latest — the registration protocol
moves with the server, and a newer runner refuses to register against an older
Gitea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:15:45 +02:00
nexxo 8117630a65 feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy:

- artisan down now renders a branded page that says an update is running and
  reloads itself, instead of Laravel's bare 503.
- A page left open across a deploy carries a Livewire snapshot and CSRF token
  the new code rejects — the user got "419 Page Expired" and a dead interface.
  A 419 from a /livewire/ request now reloads the page; the session is still
  valid, so that is all it takes. Hooked at the fetch layer rather than through
  Livewire's request hook, whose failure callback is not invoked for this case
  in the installed version — verified against a real 419 in the browser, not a
  simulated one.
- Vite no longer empties its output directory: wiping it is what left an open
  page without its stylesheet mid-deploy, which is the "design is completely
  broken" symptom. update.sh also rebuilds the caches with optimize:clear +
  optimize instead of leaving them half-warm.

Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only
on a green main) and an opt-in act_runner service under the ci profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:58:27 +02:00
nexxo ac86efd9c3 feat(deploy): unattended install from a single answers file
--env-file makes the whole run non-interactive, which is what a bare server
needs: install git, clone, run, done. The file also carries the operational
secrets (Hetzner DNS, Stripe, SMTP, the Proxmox key path); those are optional,
so a first install can happen before the Stripe account exists and the features
stay dark until the values are filled in.

The installer warns when the file is readable by more than its owner, and
clupilot.env is gitignored — it holds every secret an installation will need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:40:33 +02:00
nexxo 062d337dd0 fix(portal): move notices into the header, so the page stops scrolling twice
The banners sat outside the h-screen shell, so the page scrolled and the content
scrolled — two scrollbars whenever a maintenance window was active.

Maintenance notices are now a bell with a count in the header, next to the user
menu that was otherwise the only thing up there. Impersonation stays visible as
a header chip rather than moving into the dropdown: it is a mode, not a notice,
and acting as someone else without noticing is how mistakes happen.

Also: the VPN status pill wrapped onto two lines in a narrow column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:13:20 +02:00
nexxo 3fad319053 fix(hosts): re-read the host before deciding there is no DNS record
A registration finishing while the purge waited on the run locks wrote
dns_record_id after the purge had already loaded the host, so it skipped the
deletion and then deleted the row holding the only id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:02:18 +02:00
nexxo 7b57ab21cb fix(hosts): retry a failed DNS registration before advancing without a name
The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:16 +02:00
nexxo 952e8e5d2f fix(hosts): number DNS names by the normalised label, not the raw code
Two legacy codes can normalise to the same label (eu_west and eu-west), and
separate counters then handed both of them eu-west-01 — a unique-constraint
failure inside the reservation, which blocks onboarding rather than being a DNS
problem the step can shrug off. Lock, counter and the in-use check all key off
the label now.

(The helper was also called label(), which HostStep already declares as the
step's display name — renamed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:59:58 +02:00
nexxo e96f4c1c1a fix(datacenters): a code becomes a DNS label, so validate it as one
alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:56:50 +02:00
nexxo 69043f9ffc fix(dns): deleting a record that is already gone is success
A lost response, or a retry after a later step failed, made every further
attempt a 404 — and the host impossible to purge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:54:45 +02:00
nexxo 9f8659da32 fix(hosts): a failed DNS deletion must not orphan the record
Swallowing it and carrying on deleted the row that held the only reference to
that record, publishing the machine's management address for good. The purge now
fails instead — and is retried, since it re-reads the host and its other steps
are idempotent — so a broken DNS provider becomes a visible failed job rather
than a silent leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:53:14 +02:00
nexxo edf24257b5 fix(hosts): reserve the DNS name under the lock, never reuse it, clean it up
Codex was right that my never-reuse claim did not hold:

- The lock was released with only a candidate in hand, so two concurrent
  onboardings in one datacenter could pick the same name and overwrite each
  other's record. The name is now persisted on the host while still locked.
- The number was derived from the hosts that happen to exist, so removing the
  highest one handed that number straight back out — a cached name would then
  resolve to a different machine. The counter is stored per datacenter, floored
  by what is actually in use so a wiped settings store cannot reissue live names.
- PurgeHost deleted the row that carried the record id, leaving the machine's
  management address published with nothing left to clean it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:51:25 +02:00
nexxo 5db2f7fda7 feat(hosts): give each host a DNS name under the public zone
fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a
host must not renumber its neighbours onto its name, so the number is stored
rather than derived.

The record points at the host's WireGuard address, not its public IP: the name
exists so an operator can reach a host by name over the VPN, and publishing a
Proxmox host's public address would hand every scanner a target, which is the
one thing this network design avoids.

DNS is convenience, not a prerequisite: a failure logs an event and lets the
onboarding finish rather than stranding a host that is otherwise ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:49:18 +02:00
nexxo 7941935f57 fix(traffic): a sample without counters is not zero traffic
Defaulting missing netin/netout to zero reset the baseline, so the next real
sample added the entire cumulative counter again — a customer throttled for
traffic that was counted twice. Such a sample is skipped and logged now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:44:02 +02:00
nexxo 2415dde288 fix(deploy,traffic): clear caches before restarts, hide the token, widen the lock
- update.sh restarted the long-lived services before clearing the caches, so
  they booted from the old cached config and kept it for the life of the
  process — the release's configuration never reached the workers.
- install.sh put the Gitea token in the clone URL, which is visible in the
  process list to every local user while the clone runs. It goes through a
  short-lived askpass helper now.
- The collector's uniqueness lock expired at exactly the collection interval,
  so a delayed queue could run two collectors on the same baseline and count
  the same delta twice — throttling a customer for traffic they never used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:42:12 +02:00
nexxo 0135f114d3 fix(traffic): keep retrying the throttle release after a Proxmox blip
Tying the release to the creation of the new month's row meant a single failed
call was never retried: the row exists from then on, and the current period
looks unthrottled, so nothing could detect the stale NIC limit. A customer would
have stayed slow for a whole month they had paid for. Checked on every sample
now, with a test that makes the first release fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:33 +02:00
nexxo 25c4c5df5a fix(deploy): restart the worker after the hub key, diff against what was deployed
- The provisioning worker boots before install.sh writes
  CLUPILOT_WG_HUB_PUBKEY and holds the empty value for the life of its process,
  so every host onboarded afterwards would have got an empty PublicKey in its
  wg0.conf.
- A failed update left the checkout at the target, so the rerun compared a
  commit with itself and skipped exactly the dependency and image steps that had
  not finished. The comparison base is now the last commit actually deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:32 +02:00
nexxo 2b70c9226a fix(traffic): release the throttle at month end; close the Livewire bypass
Two from Codex:

- A throttled instance kept its NIC limit into the new month: the new period
  row starts unthrottled, so neither branch of enforce() fired and nothing ever
  took the limit off. A customer would have stayed slow through a month they
  had already paid for.
- PublicSiteGate exempted livewire/* wholesale. That endpoint is shared by the
  console and the portal, so a signed-in customer could keep driving portal
  components — billing included — while the portal was supposed to be offline.
  Operators pass the normal check anyway, which is all the console needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:36:38 +02:00
nexxo 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>
2026-07-25 23:33:47 +02:00
nexxo 4681b135db feat(deploy): run under a dedicated account instead of root
The installer still needs root for apt and the firewall, but it now creates a
clupilot service account that owns the checkout and runs Docker, maps the
container user to it (HOST_UID/GID), and update.sh refuses to run as root —
root-owned files in the checkout are files the application cannot write.

The account has no password login: docker group membership is root-equivalent
on the host, so it is reachable only via sudo -u or an SSH key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:59 +02:00
nexxo 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>
2026-07-25 23:25:06 +02:00
nexxo 773ef4bd5f fix(deploy): migrate behind maintenance mode, resume a failed update, install deps
Codex was right on all three, and the first was a design error on my part: the
checkout is bind-mounted into every container, so 'git merge' swaps the running
code instantly — 'migrations before traffic' was not achievable by ordering.
The updater now goes down → merge → dependencies → migrate → assets → restart →
up, trading a short deliberate outage for never serving code whose schema does
not exist yet. If a step fails the site STAYS down: coming back up with new code
on an old schema is worse than staying dark until someone looks.

It also records the last successfully deployed commit. A run that died halfway
left the checkout ahead, so the next run said 'already up to date' and skipped
the rest forever.

And it installs dependencies when a lockfile moved: vendor/ and node_modules/
live in the bind mount and shadow the image, so rebuilding the image never
updated them — migrations could run against stale packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:15:15 +02:00
nexxo 834abcec40 feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git,
WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner
account and a closed firewall. Re-runnable: it keeps an existing .env and never
regenerates the hub key, which would disconnect every onboarded host.

update.sh pulls and applies. Migrations run before the new containers take
traffic, the image is rebuilt only when its definition changed, and the queue
workers are restarted — they are long-running processes that otherwise keep the
old classes in memory, which cost us an hour during development.

clupilot:create-admin creates or promotes an Owner, so re-running the installer
fixes a lost role instead of failing on a taken address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:13:24 +02:00
nexxo 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>
2026-07-25 23:10:27 +02:00
nexxo 7f60f88542 fix(maintenance): make the window form usable
The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:33 +02:00
nexxo 7ef1b3e5c8 docs(wireguard): document the ownership model and config storage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:14 +02:00
nexxo 17d7dc2ad9 fix(vpn): store the config inside the creation transaction
Encryption and the write ran after the transaction had committed and the peer
had been dispatched to the hub. A failure there left a live access whose config
was never stored — unrecoverable, and the operator would create a second one
not knowing why. Both now happen in the same insert; nothing is dispatched
unless it succeeded.

Re-verified in the browser afterwards: create with storage, wrong password
refused, right password reveals the config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:53:30 +02:00
nexxo 4ffdafc614 fix(vpn): losing the operator roles closes the owner doors by itself
Ownership alone kept download, block and delete reachable for someone whose
roles were removed by any path other than revokeStaff() — a direct role edit,
say. Being staff is now part of ownership, so revocation closes these doors
without depending on whoever removed the role also remembering the tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:13 +02:00
nexxo e5f19f8db3 fix(vpn): count downloads in the database; repair the race test
The download counter was read-modify-written, so two concurrent retrievals
recorded one. It is an audit trail — under-reporting is the failure mode it
must not have.

The creation-race test broke when issuing moved into a transaction: its
simulated competitor writes inside our transaction, so the rollback removes
that row too. It now asserts what actually matters (the loser is told, and
creates nothing) and states what a single-connection sqlite test cannot show.
This test was red in the previous commit — my mistake for committing before
reading the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:48:38 +02:00
nexxo 02750a7c9a fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no
peer to remove and still leave the revoked colleague with a brand-new tunnel.
Issuing now runs in a transaction that locks the owner row — the same row
revokeStaff() locks — so the two cannot interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:46:23 +02:00
nexxo 3bd3e64a3d fix(vpn): pause polling while a private config is on screen
Every five-second poll re-rendered the page, putting the private key into
another HTTP response — the handoff kept it out of the snapshot but not out of
the responses. Traffic figures can wait the minute it takes to copy a key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:44:31 +02:00
nexxo c2b18ecfbc fix(vpn): drop the previous config before showing a new access
Creating a second access with a supplied key skipped the config branch, so the
first access's private configuration stayed on screen under the new name — and
someone would have handed it out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:38 +02:00
nexxo 7e17bc74b2 fix(vpn): give the QR code the specified four-module quiet zone
Two modules is below the minimum, and against a bordered card some readers fail
to locate the symbol at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:40:54 +02:00
nexxo 04ba9dd2a9 fix(vpn): an open modal stops serving the config once access is revoked
Once revealed, the plaintext sits in the handoff cache for ten minutes, and the
modal read it from the token alone. A former owner could keep pulling the key
out of an open modal after revocation — the exact window purgeSecret() exists to
close. Every request now re-checks the policy and drops the handoff when it no
longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:30 +02:00
nexxo db7b8a6ee3 fix(vpn): keep the navigation entry after the capability split
The sidebar still gated the VPN entry on vpn.manage, which the split deleted —
so the page would have vanished from the console for every role, Owner
included. Every operator can hold their own access, so the entry is shown to
all of them and the page itself shows only what the policy allows. Guarded by a
test across all five roles, which is the assertion my rewrite had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:37:35 +02:00
nexxo 371d517261 fix(vpn): dispatch the revocation removal after the transaction commits
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>
2026-07-25 22:36:11 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 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>
2026-07-25 22:31:54 +02:00
nexxo cad245c5df docs(wireguard): document the VPN console and the invariants behind it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:02:46 +02:00
nexxo d1302e17bc fix(vpn): purging a host no longer leaves a phantom access behind
The FK only nulled host_id, so the adopted peer stayed enabled-but-absent: shown
as "wird angewendet" forever, and re-enabling it would have put the deleted
host's key and address back on the hub. PurgeHost now tombstones that row, which
hands it to the same revocation machinery as everything else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:13 +02:00
nexxo c54b718566 fix(vpn): never let an access hijack a host key; name adopted peers correctly
- A host's wg_pubkey may not be in vpn_peers yet, so the duplicate check passed
  and an operator could create an access with it. ApplyVpnPeer would then run
  wg set with a freshly allocated address, rewriting that host's allowed-ip and
  cutting CluPilot's management tunnel to a live machine. Creation now rejects
  keys already held by a host, checked under the allocation lock.
- A sync landing between addPeer() and the step storing wg_pubkey adopted the
  peer as "unknown", and a later sync filled in host_id but left the name, so
  the page showed that host as unknown forever. The name is now filled in when
  the link becomes known — only for sync-adopted rows, so an access an operator
  named keeps its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:58:03 +02:00
nexxo 28e681b9df fix(vpn): lock every hub mutation; resolve duplicate keys inside the lock
- ConfigureWireguard and RemoveWireguardPeer mutate the hub too, and were not
  taking the lock, so a sync could still read the interface around them and
  write stale state. Both now hold wireguard:hub, which is what makes the
  read/mutation guarantee actually hold.
- The duplicate-key check ran before the allocation lock, so two concurrent
  creations with the same key both passed it and the loser hit the unique index
  as an unhandled 500. The check moved inside the lock, with the index kept as
  a caught backstop for any writer that does not take it — reproduced in a test
  by inserting a colliding row from within the allocation call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:55:57 +02:00
nexxo 8eb9d7ad30 fix(vpn): serialize hub reads against hub mutations
A sync could read the hub snapshot just before ApplyVpnPeer removed a peer and
purged its tombstone, then adopt what it had seen as a fresh enabled access —
restoring a revoked one. Both jobs now hold Cache::lock('wireguard:hub') around
read-decide-mutate, so a reconciliation can never straddle a removal. Retry
removals are dispatched after the lock is released, because the sync queue
driver runs them inline and they take the same lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:53:13 +02:00
nexxo 9137eae6df fix(vpn): a stale enable job can no longer resurrect a deleted access
If an add job was retried after the access had been revoked and its tombstone
purged, it found no row and trusted its own enabled=true payload — re-adding a
key that no longer had a row anyone could revoke it with. A missing row can only
mean the access is gone, so the captured payload may now remove but never
enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:57 +02:00
nexxo 1b193ba4ea fix(vpn): join the host pipeline's allocation lock; re-authorize on every poll
Two more from Codex:

- Addresses come from one subnet but live in two tables (hosts.wg_ip and
  vpn_peers.allowed_ip), so a concurrent host onboarding and console creation
  could each see the same address as free and both insert — neither unique
  index sees the other. Creation now waits on Cache::lock('wireguard:allocate'),
  the same lock ConfigureWireguard already holds while reserving a host address.

- mount() runs once, so revoking vpn.manage left an open tab polling every five
  seconds and still receiving fresh peer state. Authorization now also runs on
  hydration, which is what the poll goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:49:09 +02:00
nexxo 300ea60f19 fix(docker): build assets on start when the manifest is missing
With the Vite dev server off by default, a fresh checkout had no public/build
(gitignored, and the image does not build it), so every @vite page failed with
ViteManifestNotFoundException. The entrypoint now builds once when the manifest
is absent, non-fatally — a broken build must not wedge the container into a
restart loop where the logs are unreachable.

Proven by deleting public/build and restarting: assets rebuilt automatically,
page 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:45:27 +02:00
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
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>
2026-07-25 21:41:03 +02:00
nexxo 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>
2026-07-25 21:38:12 +02:00
nexxo a5a261fa37 test: pin APP_URL/ADMIN_HOSTS so the suite does not follow deployment env
Setting the real APP_URL made route() build https://app.dev.clupilot.com/...,
which ADMIN_HOSTS does not list, so 30 admin tests 404'd. The suite must not
depend on operator hostnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:42 +02:00
nexxo 3e82b51f89 fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains
The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:03 +02:00
nexxo 8edb2a69e5 docs(wireguard): bring the management hub up and prove it end-to-end
The hub lives in queue-provisioning (the only worker on the provisioning queue,
and LocalWireguardHub shells out to wg in-process). Generated the hub keypair,
wrote wg0.conf, and filled CLUPILOT_WG_ENDPOINT/HUB_PUBKEY.

Verified against the running stack rather than mocks: registered a stand-in
Proxmox peer through the app's own addPeer(), got a handshake, opened hub->peer
TCP over the tunnel (what the SSH steps actually need — a handshake alone does
not prove routed TCP), confirmed wg-quick save persisted the peer, then removed
it through removePeer(). wg0 returns after a container restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
nexxo 7247edf9d8 feat(security): keep the operator console off the public hostnames
Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:01:02 +02:00
nexxo 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>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +02:00
nexxo 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>
2026-07-25 20:45:54 +02:00
nexxo db3ef1642b chore: drop stray vim swap file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:25:40 +02:00
nexxo 46d821a047 fix(monitoring): cap monitoring retries at the run budget; dependency-free liveness probe
- MONITORING_ATTEMPTS is capped by the run's max_attempts, so a large value can
  no longer burn the retry budget and fail the very provisioning the degradation
  exists to protect (covered by a new test at the budget boundary)
- /health is now dependency-free (200 in ~2ms with Kuma absent, verified: the
  container healthcheck stays healthy); Kuma state is exclusively in /ready

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:19:32 +02:00
nexxo d1db23398d fix(kuma-bridge): a rejected login is not-ready (was falsely 'up'); clearer connect vs login errors
Verified with a reachable Kuma and deliberately wrong credentials:
/ready -> 503 'Kuma login rejected: Incorrect username or password.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:16:24 +02:00
nexxo 353df9f054 fix(kuma-bridge): detect Kuma outages and never trust the cached monitor list
Verified against a real Kuma by stopping it mid-test:
- get_monitors() reads the library's CACHED monitorList event, so it reported
  'up' long after Kuma died. Reachability now does a real HTTP round-trip plus a
  live socket check.
- the cached list could yield an id for a monitor that no longer exists; a match
  is now confirmed with a live get_monitor before it is returned, so CluPilot can
  never record a target that is never checked.
- liveness (/health, always 200 while serving) split from readiness (/ready, 503
  when Kuma is unreachable) — the container healthcheck must not restart a
  healthy bridge just because a dependency is down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:13:34 +02:00
nexxo 82916167cf fix(monitoring): MONITORING_ATTEMPTS is now exact (off-by-one); accept int or dict monitor id
- attempt is 0-based, so compare one-based: ATTEMPTS=2 really means two tries
- bridge accepts add_monitor returning {monitorID}|{id}|int (v1.2.1 returns a
  dict — re-verified: first create on a fresh Kuma is HTTP 200 with the id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:08:07 +02:00
nexxo 5301ce4309 feat(monitoring): Uptime Kuma support via a REST bridge + resilient monitoring policy
Kuma has no write REST API (monitor CRUD is Socket.IO only), so docker/kuma-bridge
translates the exact generic REST contract HttpMonitoringClient already speaks —
no PHP client change needed. Opt-in compose profile 'monitoring'.

Verified end-to-end against a real Uptime Kuma 1.x:
  create -> id, create again -> same id (idempotent), list, status, delete
  and PHP MonitoringClient -> bridge -> Kuma round-trip.
The e2e run caught a real bug: add_monitor takes 'maxretries', not 'retries'.

Monitoring is now observability, not a delivery gate:
- RegisterMonitoring retries MONITORING_ATTEMPTS times on an outage, then
  continues degraded with a visible 'info' event
- RunAcceptanceChecks no longer fails when monitoring isn't green (a fresh Kuma
  monitor legitimately reports 'pending' — confirmed in the e2e run); it records
  the gap instead. MONITORING_REQUIRED=true restores strict gating.
- docs/monitoring-uptime-kuma.md documents setup, failure behaviour, alternatives

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:04:58 +02:00
nexxo f06f00c428 chore(config): documented env placeholders + SSH keys from file paths
- .env.example gains a full, commented CluPilot operations block (Stripe, Hetzner
  DNS, Traefik, WireGuard hub, SSH identity, monitoring, prod mail) with a note
  that everything is optional for local dev
- config/provisioning.php: CLUPILOT_SSH_{PUBLIC,PRIVATE}_KEY_PATH read the key
  from a file (a multi-line PEM cannot live in .env), falling back to inline vars
- documents that Uptime Kuma's REST API is read-only (monitor CRUD is Socket.IO),
  so it needs a bridge rather than being drop-in for the generic REST client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:37:25 +02:00
nexxo 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo d05932105d feat(portal): bind the cloud card to the real instance (plan/seats/domain/quota) so the maintenance badge matches it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:14:42 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- 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>
2026-07-25 19:10:44 +02:00
nexxo 0b2d762b88 fix(auth): scope registration throttle to its own route (per-IP), not all Fortify endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:41:30 +02:00
nexxo 35a86c413c fix(auth): throttle Fortify endpoints (registration had no limiter — signup-spam guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:44 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 22cbe9ac39 fix(auth/admin): atomic user+customer signup; Rule::in for country validation (comma-safe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:33:09 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 507ab485a0 fix(admin): derive legacy datacenter location allowlist from DB, not client property
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:27:41 +02:00
nexxo 3a794b05dd fix(admin): promote only role-less legacy admins; allow editing datacenters with legacy free-form location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:26:09 +02:00
nexxo fd7cba7403 fix(admin): authorize datacenter modal mounts; stale threshold uses step maxDuration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:23:44 +02:00
nexxo 2b73a895c9 fix(admin): don't flag scheduled-backoff runs as stale; edit-datacenter country validation from config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:21:22 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo 88957f8f57 feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish
  enterprise look), cross-linked; brand panel uses accent gradient + tokens
- Fortify registration enabled; /register full-page Livewire + RegisterTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:16:27 +02:00
nexxo be99f413f7 feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:11:17 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- 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>
2026-07-25 18:09:17 +02:00
nexxo 87a81f5f40 fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:44 +02:00
nexxo e09965f918 fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:38:44 +02:00
nexxo 28221549d1 fix(admin): defer maintenance sent_at to real delivery; read-only send-time dedup (no lost mail)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:35:52 +02:00
nexxo 0cc5be5479 fix(admin): atomic send-time delivery claim so backlog duplicates ship exactly once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:34:02 +02:00
nexxo 5b85806605 fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:43 +02:00
nexxo 72731112f4 fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:30:17 +02:00
nexxo 8bb93137a3 refactor(admin): extract MaintenanceNotifier; atomic provisioning retry; catch-up cancellation for race-delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:28:02 +02:00
nexxo 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>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo 62b4d85ef7 fix(admin): release notification claim on dispatch failure so retries aren't blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:17:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 493b81aadf fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:14:01 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo 6593dae946 fix(admin): retry path for missed maintenance emails; lock Owner role to serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:04:31 +02:00
nexxo 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>
2026-07-25 16:01:45 +02:00
nexxo e5c74c6bdd fix(admin): reject customer email on operator account update; do not claim mail delivery on dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:58:24 +02:00
nexxo d68d8e1f25 fix(rbac/mail): migrate legacy is_admin users to Owner in-migration; send maintenance mail in customer locale
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:56:22 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo 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>
2026-07-25 15:47:42 +02:00
nexxo 4336c3bb3f feat(brand): CluPilot logo mark + SVG favicon across layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:31:33 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo f6efa4f200 fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:02:37 +02:00
nexxo 8bcb5ec268 fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect
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>
2026-07-25 15:00:38 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo 01383e7e5e fix(portal): race-safe owner-seat initialization (firstOrCreate + catch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:55:09 +02:00
nexxo 3bf0dfb548 fix: seat limit + suspend guard per Codex; harden factory email uniqueness
- 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>
2026-07-25 14:53:15 +02:00
nexxo cbbb523f9c fix(portal): cancel the active instance explicitly, not just the newest record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:48:57 +02:00
nexxo c011a11b48 fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:47:04 +02:00
nexxo 074b0c041b fix(portal): enforce customer lifecycle per Codex review
- EnsureCustomerActive middleware: suspended/closed customers lose portal
  access (admins + active impersonation exempt)
- cancellation: reject non-active instances; service-end anchored on the
  subscription start date, not calendar month-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:45:03 +02:00
nexxo 3b288486cd feat(portal): seats management — invite/role/revoke against plan limit
- 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>
2026-07-25 14:40:18 +02:00
nexxo df10448e5d feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- 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>
2026-07-25 14:36:52 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo b44d25404d test: widen DatacenterFactory code space (fix faker unique() pool exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:23:49 +02:00
nexxo 54d9a05235 feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:22:15 +02:00
nexxo e3ab3cb501 feat(portal): service framing + gradient line charts + billing button fix
- 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>
2026-07-25 14:18:08 +02:00
nexxo a3423a2cee docs: Phase D spec — service framing, redesign & CRUD completeness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:13:23 +02:00
nexxo 80348d6a3f fix: unique customer↔user link + reject inactive datacenters on host create
- 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>
2026-07-25 13:56:07 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- 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>
2026-07-25 13:53:06 +02:00
nexxo 4e86e135a6 fix(admin): harden impersonation + datacenter code per Codex review
- normalize datacenter code before uniqueness validation
- ensureUser: race-safe create + refuse linking admin accounts
- impersonate start/leave are POST (CSRF-protected)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:50:16 +02:00
nexxo 879697c6ea feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:46:48 +02:00
nexxo 515f26234e feat(portal): billing page — current plan, upgrades, extra storage, add-ons
New /billing page + nav; plans gained price_cents, storage_addon + addons
catalogue in config. Purchases create a pending Order intent (fulfillment
mocked). Dashboard storage upsell links here. DE+EN. 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:36:52 +02:00
nexxo eabcb98f91 feat(admin): compact redesign of /admin/provisioning current-run panel
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>
2026-07-25 13:31:49 +02:00
nexxo 7c1a153446 fix(ui): fixed sidebars (admin + portal) + storage banner shimmer
App-shell layout: sidebar is a full-height fixed column on lg, only main
scrolls. Re-added the left->right shimmer sweep on the dashboard storage
banner (reduced-motion safe, token colours).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:28:05 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00
nexxo 5fb4e637fc docs: panel improvements (phase C) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:20:55 +02:00
nexxo d7c2edb1df fix(engine-b): idempotent local DNS record (firstOrCreate on record_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:08:13 +02:00
nexxo 96aceeeaa1 fix(engine-b): stop customer dashboard polling once provisioning failed
Show the failure state but only poll while pending/running/waiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:06:31 +02:00
nexxo 66e8a4bbf6 fix(engine-b): clean re-clone on lost task ref; idempotent firewall rules
- Clone recovery: once the lock clears but the task ref is lost, destroy the
  possibly-incomplete VM and re-clone instead of advancing onto it.
- applyFirewall clears existing rules before adding, so retries don't accumulate
  duplicate Proxmox firewall entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:04:48 +02:00
nexxo 9162446b1f fix(engine-b): acceptance gate verifies real health, not just breadcrumbs
- Nextcloud: parse occ status JSON (installed + not in maintenance).
- Admin: query occ user:info for the account.
- Monitoring: MonitoringClient::isHealthy checks the provider, not the local row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:00:49 +02:00
nexxo 491800e09c fix(engine-b): grant VM.Backup to the Proxmox automation role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:57:11 +02:00
nexxo 157496f0c5 fix(engine-b): write Traefik route to the serving host over SSH, not locally
TraefikWriter now targets the host that serves the traffic (DNS points at it):
SshTraefikWriter SSHes in and writes the file-provider YAML there. Interface
takes the traffic host + guest backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:55:24 +02:00
nexxo 483a79a822 fix(engine-b): treat unreachable cert endpoint as not-ready (poll, not retry-burn)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:52:37 +02:00
nexxo 03c4b6508f fix(engine-b): Proxmox agent exec repeated command fields; Traefik write throws on failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:50:57 +02:00
nexxo 8825f9abfe fix(engine-b): Proxmox config uses PUT; guest-agent exec is form-encoded
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:48:58 +02:00
nexxo 778bb7f117 fix(engine-b): always scrub credentials once delivered; idempotent monitoring lookup
- CompleteProvisioning scrubs admin_password whenever credentials_sent exists,
  so a crash between recording and scrubbing can't leave the password in context.
- HttpMonitoringClient looks up an existing monitor by URL before creating one,
  so a retry after a crashed POST doesn't create a duplicate external monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:46:34 +02:00
nexxo 661b52dbe0 fix(engine-b): clone recovery waits for lock; register rows before breadcrumb
- Clone recovery polls until the VM lock clears instead of advancing onto a
  possibly-incomplete clone.
- RegisterBackup/RegisterMonitoring create the local row BEFORE the run-resource
  breadcrumb, so a crash between them can't leave the row permanently missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:44:22 +02:00
nexxo 3c5a18b5d7 fix(engine-b): apply plan CPU/RAM; idempotent backup + monitoring registration
- ConfigureCloudInit sets cores/memory from the plan (not the template default).
- RegisterBackup uses a deterministic Proxmox job id (ignore-exists) + firstOrCreate.
- RegisterMonitoring is idempotent by URL + firstOrCreate — no duplicate schedules
  or monitors after a crash/retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:42:10 +02:00
nexxo 5cf1964d7d fix(engine-b): default-deny firewall, disk-based capacity, synchronous credential mail
- applyFirewall sets policy_in=DROP so only 80/443 are exposed.
- Capacity/placement account for disk_gb (the real VM allocation), not the
  smaller Nextcloud user quota — no systematic overcommit.
- CloudReady sent synchronously again; the step retries on mail failure with the
  password preserved (no lost credential in a failed queue job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:39:15 +02:00
nexxo c79874ccfe fix(engine-b): keep admin password ciphertext in the queued mail; valid backup schedule
- CloudReady receives the ENCRYPTED password (decrypts only when rendering), so
  the queued payload never holds plaintext.
- RegisterBackup uses a valid Proxmox calendar expression (02:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:35:49 +02:00
nexxo b4cf94ee1a fix(engine-b): real backup/monitoring registration + OC_PASS scoping
- CreateCustomerAdmin puts OC_PASS on the docker invocation (survives the &&).
- RegisterBackup creates a real vzdump job via ProxmoxClient::createBackupJob.
- RegisterMonitoring registers via a new MonitoringClient service (interface +
  fake + http). Both persist the external id [E] before the breadcrumb, so
  acceptance reflects a real registration, not a fabricated row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:33:45 +02:00
nexxo 64e3ca421d fix(engine-b): async stripe payments, capacity honesty, durable credential mail
- Accept checkout.session.async_payment_succeeded (async methods) and dedupe on
  the checkout session id, not the event id.
- committedGb still counts a failed instance while its VM exists (has vmid);
  releases quota only when no VM was created — no overcommit vs no leak.
- CloudReady is queued (durable). Credential delivery is documented at-least-once
  (a rare duplicate welcome email beats a lost credential; exactly-once = v1.1 outbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:29:47 +02:00
nexxo e0e4c4e18d fix(engine-b): run guest-agent commands through /bin/sh -c
The Proxmox guest agent execs a program directly; our compound commands
(cd/&&/pipes/redirects/env) need an explicit shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:25:24 +02:00
nexxo f43ade79b0 fix(engine-b): address Codex round 6 (crash-window idempotency)
- Clone: recover via vmExists() when the task ref was lost, instead of
  re-cloning the reserved vmid (which Proxmox rejects).
- Deploy: persist the DB password ENCRYPTED before compose up and reuse it on
  retry, so a crash can't regenerate a mismatching credential.
- Complete: gate credential delivery on a credentials_sent breadcrumb so a
  retry after a crash doesn't re-send the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:23:28 +02:00
nexxo e2b8f25e78 fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)
- Failed run releases the instance (status=failed) so its quota stops counting
  against host capacity.
- CompleteProvisioning activates instance+order only AFTER onboarding tasks +
  credential delivery succeed.
- ConfigureNetwork polls until the guest has an address (no wrong Traefik route).
- customers.email unique + race-safe customer resolution in the intake action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:19:31 +02:00
nexxo c56aa14f9d fix(engine): don't reset started_at on poll so step deadlines accumulate
RunRunner::onPoll no longer resets started_at, so a poll step's own deadline
(WaitForGuestAgent 270s, ConfigureDnsAndTls cert 840s) actually fires instead
of resetting every poll. maxDuration raised above each own-deadline so the
step's fail wins over the generic timeout. Shared core fix (A + B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:15:26 +02:00
nexxo fffcecb152 fix(engine-b): address Codex round 3 (webhook email/replay/rotation, db secret)
- Require a real customer email (no unknown@ fallback merging customers).
- Stripe signature: enforce 5-min replay tolerance + accept any rotating v1.
- DeployApplicationStack no longer persists the DB password in the run context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:12:24 +02:00
nexxo e6bd6c9354 fix(engine-b): address Codex round 2 (fail-closed webhook, placement lock, clone/admin idempotency)
- Stripe webhook fails closed when the signing secret is missing (outside local/testing).
- ReserveResources places + creates the instance under a per-datacenter lock so
  concurrent orders can't overcommit a host.
- CloneVirtualMachine reserves the vmid + breadcrumb BEFORE cloning, so a crash
  can't mint a new vmid and orphan the first clone.
- CreateCustomerAdmin checks user existence and resets the password instead of
  re-running user:add on retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:09:21 +02:00
nexxo 42fa92cf20 fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk)
- Stripe: only checkout.session.completed with payment_status=paid (dedupes the
  paired payment_intent event and blocks unpaid async sessions).
- Capture the guest IP (ConfigureNetwork) and point Traefik at the VM, not the
  Proxmox host, so ACME/HTTP-01 can reach Nextcloud.
- Resize the disk to an absolute target (not '+…') so a retry can't double it.
(Codex #4 timeout was a false positive — started_at resets per step.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:31 +02:00
nexxo 3e19778046 chore(engine-b): provisioning step/mail i18n (DE+EN) + demo seed data
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:59:06 +02:00
nexxo a9fbb395f9 feat(engine-b): live provisioning progress (admin console + customer dashboard)
Admin /admin/provisioning bound to real runs+steps (poll+admin.runs). Embedded
CustomerProvisioning card shows the logged-in customer their own run live
(per-customer StepAdvanced channel, email-bridged authz). Shared BuildsRunSteps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:57:35 +02:00
nexxo 65ba3d6588 feat(engine-b): Stripe webhook -> idempotent customer provisioning intake
Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout
creates customer+order+run (stripe_event_id unique => duplicate webhooks start
one run) and dispatches. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:52:46 +02:00
nexxo 446da58061 feat(engine-b): 15-step customer pipeline + DNS/Traefik services
validate->reserve(placement)->clone->cloudinit->start->guestagent->network->
deploy->nextcloud->admin->dns/tls->backup->monitoring->acceptance->complete.
HetznerDnsClient + TraefikWriter (interface+fake+real), CloudReady notification,
hosts.node (set by RegisterCapacity). Secrets transient/encrypted, never
plaintext. 21 tests incl. mocked end-to-end + crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:26 +02:00
nexxo e5aeb3989e feat(engine-b): ProxmoxClient VM-lifecycle extension (interface + fake + http)
nextVmid/cloneVm/setCloudInit/resizeDisk/startVm/vmStatus/guestAgentPing/
guestExec/taskStatus/applyFirewall. Fake is deterministic with configurable
task/guest failure hooks; Http polls UPID + guest exec-status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:41:48 +02:00
nexxo 770a6cf7cd feat(engine-b): CustomerStep base + shared resource trait + config
ManagesRunResources trait (host+customer), CustomerStep base (order/instance/
plan/guest helpers), config plans + 15-step customer pipeline + dns/traefik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:39:56 +02:00
nexxo 406251dfde feat(engine-b): customer domain models + migrations
customers, orders (ProvisioningSubject, stripe_event_id unique), instances
(subdomain unique, encrypted nc_admin_ref), dns_records, backups,
monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb +
datacenter placement. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:38:01 +02:00
nexxo b9742f61ee docs: customer pipeline (Subsystem B) implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:35:28 +02:00
nexxo 5e6d6b11b6 docs: engine customer-pipeline spec (the missing Section 3, aligned to the built core)
Detailed build contract for Subsystem B (15 customer-provisioning steps),
written against the real Subsystem-A contracts: ProvisioningStep/StepResult
(advance/retry/poll/fail), RunResource idempotency breadcrumbs, ProvisioningRun
context, the config/provisioning.php pipeline registry, and the read-only
ProxmoxClient it must extend. Covers new models (customers/orders/instances/…),
ProxmoxClient VM-lifecycle additions, Hetzner-DNS + Traefik services, Stripe
idempotent intake, live progress binding to the existing admin/customer views,
build order and DoD. Unblocks B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:28 +02:00
nexxo c7fb1ce56d fix(engine): dispatch run continuation after releasing the lock
Prevents a second worker from consuming the follow-up job and bailing on the
still-held run lock, which would stall the run until the next scheduler tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:09:24 +02:00
nexxo 294c9aef2f fix(engine): store wg_pubkey right after addPeer so failed peers are cleanable
Keeps the wg_peer resource gated on a verified handshake (idempotency) while
ensuring PurgeHost can always remove the hub peer, even on terminal failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:07:41 +02:00
nexxo 9e664654a2 fix(engine): record wg peer only after verified handshake; async host purge
- ConfigureWireguard checks setup command results and only records the wg_peer
  resource after the handshake verifies, so a transient setup failure re-runs
  the full setup instead of getting stuck on the idempotent path.
- Host removal now deactivates immediately and queues PurgeHost, which deletes
  runs under the runner lock (waiting out a long step) — no 15s LockTimeout error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:13 +02:00
nexxo a6a41719bb fix(engine): long SSH command timeout; wg peer removal on privileged worker
- PhpseclibRemoteShell sets a command timeout (default 2000s) so apt
  full-upgrade/install don't hit phpseclib's ~10s default and fail.
- Host removal dispatches RemoveWireguardPeer to the provisioning queue, which
  runs in the worker that owns wg0 (the web container can't manage WireGuard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:01:02 +02:00
nexxo 5979470dda fix(engine): install wireguard-tools + bring up wg0; lock-coordinate host removal
- Image installs wireguard-tools/iproute2; the provisioning worker brings up
  wg0 before queue:work so LocalWireguardHub can manage peers.
- ConfirmRemoveHost deletes each run under its run:<uuid> lock so an in-flight
  worker can't keep mutating the server or write to a deleted run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:57:51 +02:00
nexxo 93191f5de9 fix(engine): fail on unresolvable pipeline step; make worker the WG hub
- RunRunner catches step-resolution errors (removed/renamed pipeline) and
  fails the run terminally instead of looping forever every tick.
- queue-provisioning worker gains NET_ADMIN + tun + persistent wireguard
  volume + published UDP port so LocalWireguardHub can manage wg0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:53:40 +02:00
nexxo 00b8897b9d fix(engine): compute default WireGuard hub IP from the CIDR network
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:49:43 +02:00
nexxo 8a392bfd06 fix(engine): parse WireGuard CIDR for address allocation (any prefix length)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:48:16 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 91e308efb0 fix(engine): one host per public IP (unique constraint + validation)
Prevents two onboarding runs from fighting over the same physical server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:30 +02:00
nexxo 48ed5e7d34 fix(engine): fail if the WireGuard peer can't be persisted (wg-quick save)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:41:11 +02:00
nexxo f240401a42 fix(engine): honour configured WireGuard prefix length in wg0.conf
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:39:46 +02:00
nexxo 18d1ed2424 fix(engine): derive WireGuard hub IP from the configured subnet
hub_ip is now defined in config (default: subnet .1), so a custom
CLUPILOT_WG_SUBNET no longer pings the wrong handshake target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:38:17 +02:00
nexxo ced7e6103d fix(engine): durable queueing for long provisioning steps
- Dedicated 'provisioning' queue connection (retry_after 2400) + worker
  (--timeout=2100); AdvanceRunJob tries=1, timeout=2100 on that queue.
- Run lock TTL raised to 2100s so a long step can't be run concurrently by a
  duplicate tick-dispatched job.
- StartHostOnboarding creates host+run in a transaction, dispatches after commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:36:49 +02:00
nexxo fbe87d99d5 fix(engine): honour backoff timing + don't double-count Proxmox storage
- RunRunner returns early for a waiting run whose next_attempt_at is in the
  future, so stale/duplicate jobs can't bypass backoff.
- RegisterCapacity takes the largest VM-capable datastore instead of summing
  overlapping pools (local + local-lvm), preventing placement overcommit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:32:14 +02:00
nexxo 8a2a2ee695 fix(engine): reset step timer on retry so timed-out steps recover
A retried step (manual or after a timeout) now gets a fresh started_at, so
RunRunner no longer immediately re-detects the timeout and burns the retry
budget without executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:11 +02:00
nexxo 62c4412623 fix(engine): address Codex round 2 (poll budget, host error state, wg race)
- StepResult::poll — polling steps (reboot) wait without consuming the retry
  budget; the step owns its deadline. Reboot maxDuration > deadline.
- Failed runs move a Host subject to 'error' via ProvisioningSubject hook
  (no host stuck 'onboarding').
- ConfigureWireguard allocates + reserves the wg_ip under a global lock;
  unique index on hosts.wg_ip as a backstop against duplicate addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:26:37 +02:00
nexxo 65717bd3bd fix(engine): address Codex review (auth token bootstrap, tunnel recheck, ssh)
- CreateAutomationToken now bootstraps the pveum role/user/token over the
  authenticated SSH session (a fresh host has no API token yet); ProxmoxClient
  is read-only in A.
- ConfigureWireguard re-verifies the handshake on the idempotent replay path,
  never advancing over a dead tunnel.
- PhpseclibRemoteShell treats a missing SSH exit status as failure (255).
- connectWithKey verifies the pinned host key fingerprint on later logins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:20:59 +02:00
nexxo 36a564d5c8 chore(seed): demo Proxmox hosts for the operator console (local/testing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:15:12 +02:00
nexxo 8d12a40a42 feat(admin): host onboarding UI (add / live stepper / retry / remove)
Real hosts list, add-host form (StartHostOnboarding), host detail with live
progress stepper (Reverb + wire:poll fallback), retry failed run, remove via
wire-elements/modal (deregister only). DE+EN. 8 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:12:41 +02:00
nexxo ac3d17cd6a feat(engine): 11-step host onboarding pipeline (SSH -> WG -> Proxmox)
Idempotent steps: validate, ssh-trust (deploy key + scrub password),
prepare base, wireguard (hub peer), install proxmox-ve, reboot-into-pve
(retry-poll), configure, automation token, verify api, register capacity,
complete. StartHostOnboarding action. 22 tests incl. mocked end-to-end +
crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:04:39 +02:00
nexxo c06ca0ae5d feat(engine): SSH / WireGuard / Proxmox service layer
RemoteShell (phpseclib + fake), WireguardHub (local wg + fake), ProxmoxClient
(REST + fake). Interfaces bound in AppServiceProvider; tests swap fakes via
fakeServices() helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:58:02 +02:00
nexxo 4f724d57c9 feat(engine): orchestrator core (state machine + tick + lock)
StepResult, ProvisioningStep contract, PipelineRegistry, RunRunner (per-run
lock, advance/retry/fail, backoff, timeout, append-only events + Reverb
StepAdvanced), AdvanceRunJob (provisioning queue), minutely Tick, admin.runs
channel. 21 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:54:52 +02:00
nexxo 11cdcdd4da feat(engine): core provisioning data model + hosts
hosts, provisioning_runs (polymorphic), append-only provisioning_step_events,
run_resources (idempotency breadcrumbs). Models with encrypted api_token_ref,
json context helpers, UUID routing (R11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:49:43 +02:00
nexxo 7ae81d8127 docs: host-onboarding implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:46:58 +02:00
nexxo 772e9d35ff docs: host-onboarding (Subsystem A) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:44:11 +02:00
nexxo bf5813057b docs: provisioning engine v1.0 build handoff (standalone fleet + cluster-per-DC target)
Focused TDD build plan for the engine: topology decision baked in (Option 1
standalone fleet as the software model, cluster-per-datacenter as the growth
target — hosts get datacenter + nullable cluster fields, placement filters by
datacenter). Covers data model, DB-state-machine orchestrator, 15 customer steps,
new ProxmoxClient, Stripe idempotent intake, and wiring the existing admin/customer
progress views to real data via Reverb. References the state handoff for workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:31:51 +02:00
nexxo 49b5b7a523 docs: state/continuation handoff (2026-07-25)
Full project state for a fresh session: env facts, stack, docker workflow, what's
built (landing + customer portal + admin console), verification workflow (pest /
R12 puppeteer / R15 codex), hard-won gotchas, open items, and the recommended next
block (provisioning engine v1.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:16:17 +02:00
nexxo b032d8808b feat(landing): public marketing homepage at / (+ legal placeholders)
Public www landing page (your design) as a self-contained Blade view at / — own
CSS/JS, system fonts (no CDN, R14), hero, marquee, typewriter, product mock with
scroll-zoom, security section, honest comparison, pricing gallery, FAQ, CTA. It is
intentionally outside the app token/component system (marketing page, not control
panel). Wired a sign-in link to route('login').

Robustness/a11y hardening from the Codex (R15) loop:
- reduced-motion: pin hero/reveal elements to their visible end state.
- <noscript> fallback so reveal content is visible without JS.
- login link kept visible on mobile (moved out of the hidden nav group).
- stats render their real values in HTML (correct without JS).
- footer legal links now resolve to real /legal/* routes (placeholder pages).
- reconciled contradictory data-migration pricing (add-on from €390).

48 Pest tests green; R12 browser: landing 0 console errors. Codex (R15) clean.

NOTE: the /legal/* pages (Impressum, Datenschutz, AGB) are placeholders — real
legal content must be supplied before this homepage goes public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:59:39 +02:00
nexxo 1aa7535fc4 feat(admin): dark Tactical-Terminal operator console
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.

Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).

18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:35:03 +02:00
nexxo c4cff8f67c feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support
Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an
English route (R13), localized DE/EN (R16), Chart.js islands and token styling:
- Cloud: instance details + specs + storage-over-time line chart + actions.
- Users: user table (roles/groups/status badges) + users-by-group doughnut.
- Backups: 14-day size bar chart + backup history table + restore.
- Invoices: invoice table + next-charge card + monthly-spend bar chart (locale-
  aware month labels, Number::currency amounts).
- Support: contact cards + tickets table + FAQ accordion (Alpine).
- Sidebar links all tabs with routeIs() active state; global toast in app shell.
- All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format).

12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs
HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:08:33 +02:00
nexxo 499adaff00 feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use
  token: strings resolved from CSS design tokens at runtime (R3). Respects
  reduced-motion; IBM Plex chart defaults.
- Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts.
  Staggered 'rise' reveal keyframe. Global toast in the app shell.
- Overview rebuilt to the customer template: storage doughnut ring, availability
  sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist
  (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed.
- Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so
  the EN dashboard is not mixed-language (R16).
- 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js;
  R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:59:19 +02:00
nexxo 2b08b072fe fix(docker): stop injecting .env as real env vars (test isolation)
env_file: .env exported the dev DB/cache config as real container env vars,
which overrode phpunit's forced test env — so the Pest suite ran RefreshDatabase
against the dev MariaDB and wiped it on every run. Laravel already reads .env
from the bind mount; only vite needs VITE_HMR_HOST/VITE_PORT at process level,
now injected explicitly. Tests now use sqlite :memory: and never touch dev data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:28:51 +02:00
nexxo 254a7d46a0 feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify):
- laravel/fortify with TOTP two-factor + recovery codes; User uses
  TwoFactorAuthenticatable; 2FA credentials hidden from serialization.
- Views off — pages are full-page class-based Livewire components (R1/R2);
  Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login +
  2FA only (no public register/reset/passkeys). Seeder gated to local/testing.

Component kit (Blade, token-based, a11y):
- button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine,
  auto-advance/paste, -safe submit), progress-stepper, nav-item, icon
  (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu).

Screens (localized DE/EN, R16):
- Login (form -> login.store), Two-factor challenge (OTP + recovery fallback),
  Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures,
  activity). Routes English (R13).

Tests + verification:
- Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render).
- R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and
  the authenticated /dashboard all HTTP 200 with ZERO console errors; login
  flow verified end-to-end.
- Test isolation fixed (force test env over injected .env).
- Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:20:25 +02:00
nexxo 53c2a12d6d feat(portal): design foundation — Tailwind v3, tokens, self-hosted fonts
- Downgrade Tailwind v4 -> v3 (user decision): postcss.config.js,
  tailwind.config.js mapping framework-neutral CSS-var tokens onto utilities.
- portal-tokens.css: light enterprise palette, single orange accent, IBM Plex
  type scale, radius/shadow/motion/focus (design handoff §6). AA-safe accent
  text/fill tokens (accent-active/-press/-text) — #f97316 alone fails AA.
- Self-hosted IBM Plex Sans+Mono via @fontsource, Vite-bundled (R14, no CDN).
- app.css: v3 layers, base type, uniform :focus-visible, reduced-motion.
- layouts/portal.blade.php base layout; welcome page retokenised (guarded
  login CTA, DE/EN via lang/common) — no v4-only classes.
- Reviewed with Codex (R15): 5 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:43:01 +02:00
738 changed files with 80553 additions and 611 deletions

View File

@ -38,7 +38,7 @@ REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -63,23 +63,198 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost
# ── Mail (dev → log) ─────────────────────────────────────────────────────
# ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=smtp # smtp | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
STRIPE_WEBHOOK_SECRET=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.com
# Host names (fsn-01.node.…) are NOT published here — that would leak the
# internal WireGuard subnet. They go into this directory instead, which the
# vpn-dns container reads (dnsmasq --hostsdir) over the dns-hosts volume; see
# docker-compose.yml. Only change if that volume is mounted somewhere else.
CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
# Erzeugen: head -c 32 /dev/urandom | base64
SECRETS_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
# ── Wo die Konsole antwortet ─────────────────────────────────────────────
#
# ADMIN_HOSTS: Hostnamen, unter denen die Konsole erreichbar ist. Der ERSTE
# ist der kanonische — für ihn werden URLs erzeugt; die weiteren sind
# Ausweichadressen, über die jemand hereinkommt, der ausgesperrt ist.
#
# ADMIN_HOST_EXCLUSIVE: darf auf diesen Namen NUR die Konsole antworten?
#
# true Die Konsole liegt an der WURZEL ihres Hostnamens
# (https://admin.…/), und Portal wie Website antworten dort mit 404.
# Dann gehört hier ausschliesslich der Konsolen-Name hinein — eine
# IP oder localhost mit aufzunehmen schaltet Portal und Website auf
# genau dieser Adresse ab. Das ist die Betriebsform.
#
# false Die Konsole liegt unter /admin auf jedem gelisteten Namen, und
# diese Namen bedienen weiterhin auch Portal und Website. Nur für
# eine Maschine ohne eigene DNS-Namen sinnvoll.
#
# Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die
# Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist.
#
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# eine blanke IP braucht).
APP_HOST=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
# SITE_HOST=www.clupilot.com,clupilot.com
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
# auch aus einer Mail heraus eine www-Adresse.
SITE_HOST=
ADMIN_HOSTS=admin.dev.clupilot.com
ADMIN_HOST_EXCLUSIVE=true
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000
HOST_GID=1000
APP_PORT=80
# Behind a reverse proxy — which is every production install — these must stay
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
# reachable from the internet regardless of the firewall, and reaching a backend
# directly skips every hostname and address rule the proxy enforces.
# For local development without a proxy, set APP_PORT=80.
APP_PORT=127.0.0.1:8080
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
VITE_PORT=5173
REVERB_HOST_PORT=8080
REVERB_HOST_PORT=127.0.0.1:8081
DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6
# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC.
APP_DISPLAY_TIMEZONE=Europe/Vienna

118
.gitea/workflows/tests.yml Normal file
View File

@ -0,0 +1,118 @@
# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner).
#
# Deliberately not mirrored to GitHub: this repository describes how our
# infrastructure is provisioned, and the same workflow runs at home. If we ever
# move, this file goes along unchanged.
name: tests
on:
push:
branches: [main, 'feat/**']
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- name: Set up PHP
# Pinned: the floating v2 tag now requires node24, which the runner
# matching Gitea 1.20 cannot execute. 2.34.1 is the newest that still
# declares node20.
uses: shivammathur/setup-php@2.34.1
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip
coverage: none
- name: Install PHP dependencies
# Source clones, not dist archives: dist downloads go through GitHub's
# API, which throttles anonymous callers and left a half-installed
# vendor/ behind — the tests then failed with 500s that had nothing to
# do with the code. Cloning is slower and does not need anyone's quota.
# Swap back to --prefer-dist once a GitHub token is configured.
run: composer install --no-interaction --prefer-source --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Tests
# phpunit.xml pins its own sqlite/array drivers, so no services needed.
run: ./vendor/bin/pest --colors=always
assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-node@v4.1.0
with:
node-version: '22'
- name: Install JS dependencies
run: npm ci --no-fund --no-audit
# A build failure here is what used to surface as "the design is broken"
# only after deploying — catch it before it ships.
- name: Build assets
run: npm run build
release:
# Only a green run produces the tag the console offers as an update.
needs: [pest, assets]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag this commit as tested
run: |
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

12
.gitignore vendored
View File

@ -3,6 +3,10 @@
.env
.env.backup
.env.production
# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a
# timestamped copy beside .env before every write from the console's raw
# editor. Never pruned automatically; see the warning on that page.
.env.bak-*
.phpactor.json
.phpunit.result.cache
/.codex
@ -20,6 +24,10 @@
/public/storage
/storage/*.key
/storage/pail
# Exported invoice PDFs — runtime output, and on a real server this is a mount
# point. Committing it would put customer documents into the repository and
# make a deploy fight the archive for the directory.
/storage/archive
/vendor
_ide_helper.php
Homestead.json
@ -28,3 +36,7 @@ Thumbs.db
# Gitea push token (parent home dir; never track)
.env.gitea
clupilot.env
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
.superpowers/

269
CLAUDE.md Normal file
View File

@ -0,0 +1,269 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.
---
## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr
**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.**
Verboten:
1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)`
liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`.
2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt
UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus
gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`.
Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen
Uhr abliest.
Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich.
### Warum das durchgerutscht ist
Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die
Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt
aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in
Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist
schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.
Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und
Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein
eingetragenes „21:00" wurde zu 23:00 Ortszeit.
Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben
falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft
nichts.
### Wie es jetzt gebaut ist
- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe
`Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt.
- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem
Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße
Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf —
Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung
ersetzt die erste für alle.
- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand
eine ändert, ohne die andere zu sehen.
### Erzwungen durch
`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil
darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.
---
## R21 — Konsole und Portal teilen keine Identität
**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
Verboten:
1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
mit Konsolenzugang ist die Vermischung in klein.
2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
führt nur noch `livewire/*` und `up`.
3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
`App\Livewire\Auth\OperatorLogin`.
### Warum das aufgeschrieben wurde
Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
zweier Personengruppen in einer Tabelle.
### Erzwungen durch
`tests/Feature/IdentitySeparationTest.php`
---
## R22 — Der Aufwand richtet sich nach der Aufgabe, nicht nach dem Verfahren
**Prüfen ist ein Mittel, kein Selbstzweck. Wer für eine Tabellenumstellung
zwölf Stunden und zehn Prüfrunden braucht, hat die Aufgabe nicht verstanden.**
Verbindliche Obergrenzen:
1. **Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff.** Danach
wird ein offener Befund **geparkt** — mit schriftlicher Begründung — und
nicht weitergeschliffen. Ein zweiter Re-Review desselben Befundes ist
verboten.
2. **Codex/R15: höchstens zwei Runden ohne P1.** Danach gehen die Restbefunde
als Folgepunkte in den Merge Request. Bis zur Nullmeldung zu schleifen ist
kein Qualitätsmerkmal, sondern fehlende Priorisierung.
3. **Ein Review sieht nur den Diff.** Dateien, die seit dem letzten grünen
Testlauf nicht angefasst wurden, werden nicht erneut geprüft — weder von
einem Prüfer noch beim Aufräumen. Was unverändert ist, war beim letzten
grünen Lauf schon in Ordnung.
4. **Mutationstests nur an der Grenze**, an der ein Fehler Daten verliert,
Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
5. **Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review.** Eine
Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
6. **Unabhängige Arbeit läuft gleichzeitig.** Analysen (Review, Codex,
Testlauf) blockieren einander nicht und werden parallel gestartet.
### Warum das aufgeschrieben wurde
Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat
zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber
die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den
Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.
---
## R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog
**Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in
einem Fenster, das der Browser zeichnet.**
Verboten:
1. **`wire:confirm`.** Das Attribut ruft `window.confirm()` auf, bevor die
Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin,
kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht
gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
2. **`confirm(` aus eigenem JavaScript.** Derselbe native Dialog, nur ohne die
Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe
Problem unter anderem Namen.
### Warum das aufgeschrieben wurde
Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf
admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich
übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst
durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten
dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu
ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte
Identitäten nach R21), ein Benutzerzugang entzogen.
### Wie es jetzt gebaut ist
- Sechs eigene Bestätigungs-Modals nach dem Muster von `ConfirmRemoveHost` und
den anderen bestehenden Lösch-Bestätigungen — `App\Livewire\Admin\
ConfirmSaveSecret`, `ConfirmForgetSecret`, `ConfirmReissueVpnPeer`,
`ConfirmDisableTwoFactor` in der Konsole; `App\Livewire\
ConfirmDisableTwoFactor` und `ConfirmRevokeSeat` im Portal. Geöffnet über
`$dispatch('openModal', { component: '…', arguments: { … } })`, derselbe
Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen
benannt statt „OK").
- Das Modal mutiert nichts selbst. Der eigentliche Vorgang (`save()`,
`forget()`, `reissue()`, `disableTwoFactor()`, `revoke()`) bleibt
unverändert auf der Seiten-Komponente stehen, mit ihren eigenen
`guard()`-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein
Event aus (z. B. `secret-save-confirmed`), das die Seiten-Komponente per
`#[On(...)]` auffängt und an die bestehende Methode weiterreicht — das hält
die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt
sie im Modal zu verdoppeln.
- Der bisherige Bestätigungssatz bleibt wortgleich als `_body` stehen; nur der
neue `_title` (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam
hinzu, `_confirm` heißt jetzt wie überall sonst im Repo die kurze
Knopfbeschriftung statt eines ganzen Satzes.
### Erzwungen durch
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.3.3

View File

@ -0,0 +1,459 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA, invoice numbering and we own capability, because Stripe does not know
* how big the VM should be. So these handlers move period boundaries and status,
* and write the register. They never re-derive an amount: the invoice is the
* authority for what was charged, and recomputing it here from our own
* catalogue would produce a second, disagreeing answer.
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(private RecordCommercialEvent $record) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen.
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
return $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
netCents: $isRenewal ? $subscription->price_cents : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

132
app/Actions/BookAddon.php Normal file
View File

@ -0,0 +1,132 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
return SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
}
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
$addon = SubscriptionAddon::create([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
]);
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Stop charging for a module, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
return DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
// Nor an operator's — R21: one address must not represent both
// identities. No dedicated message: Laravel's stock "already
// taken" line (translated in both languages already) is what
// the two rules above fall back to as well, and telling a
// public visitor specifically that an address belongs to
// STAFF would be a worse leak than the generic refusal every
// other collision here already gives them.
Rule::unique(Operator::class, 'email'),
],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the GROSS total
* Stripe actually charged. The two can legitimately differ (VAT, and later
* coupons), and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start));
}
private function open(Order $order, string $term, Carbon $start): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
'status' => 'active',
],
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took — gross, including
// whatever tax or discount applied on the day. The contract price
// is what was agreed; this is what was paid, and the register has
// to be able to state both.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $subscription;
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\TaxTreatment;
use Illuminate\Support\Carbon;
/**
* Writes one row into the proof register.
*
* Every commercial event goes through here so that the flat columns are filled
* the same way each time an evidence table where the amount means one thing
* in one row and another in the next is not evidence.
*
* Amounts are stated three ways on purpose. Net is what the catalogue and the
* contract deal in; gross is what was actually charged; the tax between them
* depends on the customer and on the day, and recomputing it later from a rate
* that has since changed would produce a different answer to the one on the
* invoice.
*/
class RecordCommercialEvent
{
/**
* @param array<string, mixed> $extra merged into the JSON snapshot
* @param array<string, string|null> $stripe event / invoice / subscription ids
*/
public function __invoke(
string $event,
Subscription $subscription,
int $netCents,
?Carbon $at = null,
array $extra = [],
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
$tax = TaxTreatment::for($customer);
$agreedNet = $netCents;
$expectedGross = $tax->grossCents($agreedNet);
// The flat columns describe the TRANSACTION, not the contract: gross is
// what was taken from the customer, and net and tax are that gross
// split by the rate that applied on the day.
//
// Split, not subtracted from the agreed price. A discount lowers the
// taxable amount; it does not create negative VAT, and recording
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
// make the register wrong in exactly the case someone audits.
if ($chargedGrossCents !== null) {
$gross = $chargedGrossCents;
$net = (int) round($gross / (1 + $tax->rate));
} else {
$net = $agreedNet;
$gross = $expectedGross;
}
$version = $subscription->planVersion;
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,
// The order this event belongs to — a booked module has its own,
// and pointing it at the original plan purchase would file every
// add-on under the wrong transaction.
'order_id' => $order?->id ?? $subscription->order_id,
'plan_version_id' => $subscription->plan_version_id,
// Copied rather than joined: these have to still answer the question
// after the customer, the plan or the version have gone.
'customer_name' => $customer?->name,
'plan_key' => $subscription->plan,
'plan_version' => $version?->version,
'term' => $subscription->term,
'net_cents' => $net,
'tax_cents' => $gross - $net,
'gross_cents' => $gross,
'currency' => $subscription->currency,
'tax_rate' => round($tax->rate * 100, 2),
'reverse_charge' => $tax->reverseCharge,
'stripe_event_id' => $stripe['event'] ?? null,
'stripe_invoice_id' => $stripe['invoice'] ?? null,
'stripe_subscription_id' => $stripe['subscription'] ?? null,
'occurred_at' => $at,
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
// The long tail: everything nobody has thought to ask about yet.
// The flat columns above are what gets queried.
'snapshot' => array_merge([
'subscription' => $subscription->only(Subscription::FROZEN),
'plan' => [
'key' => $subscription->plan,
'version' => $version?->version,
'version_id' => $subscription->plan_version_id,
'family' => $version?->family?->name,
'capabilities' => $version?->capabilities(),
],
'period' => [
'start' => $subscription->current_period_start?->toIso8601String(),
'end' => $subscription->current_period_end?->toIso8601String(),
],
'addons' => $subscription->addons()->active()->get()
->map(fn ($addon) => [
'key' => $addon->addon_key,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
'booked_at' => $addon->booked_at?->toIso8601String(),
])->all(),
'customer' => [
'name' => $customer?->name,
'email' => $customer?->email,
'vat_id' => $customer?->vat_id,
],
// Kept even when they agree. A charge that differs from the
// catalogue plus tax is exactly the thing someone will ask
// about later, and reconciling it silently would erase the
// question along with the answer.
'amounts' => [
// What was agreed, beside what was actually taken. The
// flat columns state the transaction; this states whether
// it came out at the contract price, which is the question
// someone asks a year later.
'agreed_net_cents' => $agreedNet,
'expected_gross_cents' => $expectedGross,
'charged_gross_cents' => $gross,
'matches_catalogue' => $gross === $expectedGross,
],
], $extra),
]);
}
}

View File

@ -0,0 +1,311 @@
<?php
namespace App\Actions;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Services\Billing\IssueInvoice;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
try {
$customer->ensureUser(); // portal login (also enables admin impersonation)
} catch (IdentityCollisionException) {
// Same principle as openContract() below: the order is the record
// that money changed hands, so a paid order still gets recorded
// and provisioned. Only the colliding portal LOGIN is withheld —
// R21 — until an operator resolves the address conflict by hand
// (rename the operator, or the customer). Until then this
// customer has no self-service sign-in and cannot be
// impersonated either; both call ensureUser() the same way.
Log::error('Portal login withheld: this customer email already belongs to an operator account.', [
'customer_id' => $customer->id, 'email' => $customer->email,
]);
}
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
$this->confirmByMail($order, $customer);
return $order;
}
/**
* Invoice the payment, and tell the customer once.
*
* ONE mail, not two. The invoice is issued at the moment the money lands,
* so an order confirmation and an invoice mail would otherwise go out in
* the same second for the same event which is a defect, not a preference.
* The invoice mail carries the document and says everything the
* confirmation said, so it replaces it whenever there is an invoice; the
* confirmation remains for the case where there cannot be one.
*
* Sent here rather than when provisioning finishes: those are minutes apart
* at best and can be much longer, and somebody who has just been charged
* and heard nothing assumes the worst about both the charge and the
* product. CloudReady announces the end; this announces the beginning.
*
* After the transaction, never inside it: a queued job can be picked up by
* a worker before the commit lands, and it would then read an order that
* does not exist yet.
*
* Never allowed to fail the webhook. Stripe retries anything that is not a
* 2xx, and a retry here would re-enter provisioning a second machine
* built over a mail server or a missing VAT number.
*/
private function confirmByMail(Order $order, Customer $customer): void
{
$address = $customer->email;
if (! is_string($address) || $address === '') {
return;
}
$invoice = null;
try {
// Refuses while the company details are incomplete, which is the
// correct outcome — an invoice without a registered name or a VAT
// number is not a valid invoice, and issuing one consumes a number
// that can never be handed out again. The purchase is unaffected:
// the order stands, the machine gets built, and the invoice can be
// issued from the console once the details are there.
$invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));
} catch (Throwable $e) {
Log::warning('Could not issue an invoice for this order', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
try {
Mail::to($address)->queue($invoice !== null
? new InvoiceMail($invoice, (string) $customer->name)
: new OrderConfirmationMail($order, (string) $customer->name));
} catch (Throwable $e) {
Log::warning('Could not queue the purchase confirmation', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*/
private function resume(Order $order): void
{
if ($order->subscription()->exists()) {
return;
}
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Registers a fresh host and kicks off its onboarding run. The one-time root
* password lives encrypted in the run context and is scrubbed after SSH trust
* is established.
*/
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
// Host + run are created atomically; a partial insert would otherwise
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
'datacenter' => $input['datacenter'],
'public_ip' => $input['public_ip'],
'status' => 'pending',
]);
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['root_password' => Crypt::encryptString($input['root_password'])],
]);
return [$host, $run];
});
AdvanceRunJob::dispatch($run->uuid); // after commit — the run definitely exists
return $host;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ArchiveInvoice;
use App\Models\Invoice;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
/**
* The sweep behind the copy-on-issue.
*
* Copying at issue is right and it is not enough. An invoice issued while the
* office connection was down never reaches the archive, the queue eventually
* gives up, and nothing about the invoice tells anybody nobody opens an
* archive to check whether last Tuesday is in it. This picks up whatever is
* still missing, however long ago it was issued.
*
* Deliberately re-queues rather than writing here: the job already knows how to
* write one, and two code paths that both write an invoice into an archive is
* one more than the number that can be kept correct.
*/
class ArchiveUnexportedInvoices extends Command
{
protected $signature = 'clupilot:archive-invoices {--limit=200}';
protected $description = 'Queue a copy of every invoice that is not on the archive yet';
public function handle(): int
{
$targets = ExportTarget::query()->where('active', true)->get();
if ($targets->isEmpty()) {
$this->line('No export target configured — nothing to do.');
return self::SUCCESS;
}
$queued = 0;
foreach ($targets as $target) {
// Per destination, because "arrived" is a fact about a pair. An
// invoice on the office NAS and not on the off-site box is exactly
// the case this exists to catch.
$pending = Invoice::query()
->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $target->id)
->whereNotNull('exported_at'))
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
foreach ($pending as $invoice) {
ArchiveInvoice::dispatch($invoice, $target);
$queued++;
}
}
$this->line("Queued {$queued} copy/copies for {$targets->count()} destination(s).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\Settings;
use Illuminate\Console\Command;
/**
* The way back in when the console has locked its owner out.
*
* 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.
*/
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
public function handle(): int
{
$action = (string) $this->argument('action');
$value = trim((string) $this->argument('value'));
return match ($action) {
'show' => $this->show(),
'allow' => $this->allow($value),
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// Always the allowlist — never 0.0.0.0/0, not even when the console's
// own restriction is switched off.
//
// The owner's rule is absolute: the console is reachable over the
// management VPN, or from an address they have listed, and from nowhere
// else. Letting the console's "open" switch also open the PROXY would
// put the console on the public internet with one click, which is
// exactly the state that rule exists to prevent. Switching it off
// relaxes the application's own check; the proxy keeps its list.
$ranges = RestrictConsoleNetwork::allowedRanges();
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
$own = (array) Settings::get('console.allowed_ips', []);
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
return self::SUCCESS;
}
private function allow(string $value): int
{
if ($value === '') {
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
}
// An entry that matches nothing would be stored, reported as success,
// and leave whoever is recovering still locked out.
if (! RestrictConsoleNetwork::isNetwork($value)) {
return $this->refuse("Not an address or a range: {$value}");
}
$list = (array) Settings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
Settings::set('console.allowed_ips', array_values($list));
}
$this->info("{$value} may now reach the console.");
return $this->show();
}
private function deny(string $value): int
{
Settings::set('console.allowed_ips', array_values(array_filter(
(array) Settings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
)));
$this->info("{$value} removed.");
return $this->show();
}
private function setRestricted(bool $on): int
{
// No lock-out check here on purpose: this command IS the recovery path,
// and it is only reachable by someone who already has the server.
Settings::set('console.network_restricted', $on);
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
return $this->show();
}
private function refuse(string $message): int
{
$this->error($message);
return self::FAILURE;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*
* Renamed from clupilot:create-admin now that the account this creates is an
* Operator, not a User with is_admin set the old name stays reachable as an
* alias so existing install scripts and muscle memory keep working.
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-operator
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $aliases = ['clupilot:create-admin'];
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->option('email') ?: $this->ask('Email');
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
$password = $this->option('password') ?: $this->secret('Password');
$validator = Validator::make(
['email' => $email, 'name' => $name, 'password' => $password],
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
// An operator address that also belongs to a customer would block that
// customer from ever getting a portal login. Checked directly against
// `users`, not only `customers`: see Customer::emailTaken().
if (Customer::emailTaken($email)) {
$this->error('That address already belongs to a customer.');
return self::FAILURE;
}
$operator = Operator::query()->firstOrNew(['email' => $email]);
$existed = $operator->exists;
$operator->fill([
'name' => $name,
'password' => Hash::make($password),
])->save();
$operator->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
? "Existing account {$email} promoted to Owner and password reset."
: "Owner account {$email} created.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there a stray file, a directory somebody made by
* hand, a folder from a different layout is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->whereNotNull('keep_days')
->where('keep_days', '>', 0)
// Only where this machine can see the files. An SFTP destination is
// somebody else's disk with somebody else's retention on it.
->where('driver', ExportTarget::LOCAL)
->get();
foreach ($targets as $target) {
$this->prune($target);
}
return self::SUCCESS;
}
private function prune(ExportTarget $target): void
{
$root = rtrim($target->path, '/');
if (! is_dir($root)) {
// The mount is gone. Saying nothing and doing nothing is right:
// this is a cleanup, and a cleanup must never be the thing that
// reports a broken mount — nor act on whatever is underneath it.
$this->warn("{$target->name}: path is not there, skipping ({$root})");
return;
}
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
$removed = 0;
foreach ((array) scandir($root) as $entry) {
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
continue;
}
$date = $this->dateFrom($entry);
// Not a dated folder: left alone, deliberately. A prune that
// deletes what it does not recognise is how an archive loses
// something nobody was watching.
if ($date === null || $date->gte($cutoff)) {
continue;
}
if ($this->option('dry-run')) {
$this->line("would remove {$root}/{$entry}");
$removed++;
continue;
}
$this->removeTree($root.'/'.$entry);
$removed++;
}
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
}
/**
* Only the exact shapes this application writes.
*
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
* what it does with nonsense: under strict mode createFromFormat throws
* rather than returning false, so a folder somebody made by hand would end
* the whole prune instead of being skipped. The pattern also says plainly
* which two shapes are recognised, which is the thing a reader wants to
* know here.
*/
private function dateFrom(string $name): ?Carbon
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
}
if (preg_match('/^\d{4}$/', $name)) {
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
}
return null;
}
private function removeTree(string $directory): void
{
foreach ((array) scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory.'/'.$entry;
is_dir($path) ? $this->removeTree($path) : @unlink($path);
}
@rmdir($directory);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Services\Dns\HetznerDnsClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Cleans up host DNS records this app published in the PUBLIC Hetzner zone
* before RegisterHostDns moved to the internal vpn-dns hostsdir a host's
* WireGuard address in public DNS hands every scanner the internal subnet.
*
* The ids are whatever is still sitting in hosts.dns_record_id (PurgeHost
* already clears this column for any host removed normally; what remains
* belongs to hosts that are still active).
*
* Never deletes anything on its own: this runs against a live DNS account, a
* migration is the wrong place to make that call unattended, and a stale id
* could by now point at a record this app no longer owns. Without --force
* this only reports what it would remove.
*/
class PruneHostDns extends Command
{
protected $signature = 'clupilot:prune-host-dns {--force : Actually delete the records}';
protected $description = 'List (and, with --force, delete) public Hetzner DNS records this app published for a host';
public function handle(HetznerDnsClient $dns): int
{
$hosts = Host::query()->whereNotNull('dns_record_id')->orderBy('name')->get();
if ($hosts->isEmpty()) {
$this->info('No host carries a public dns_record_id. Nothing to do.');
return self::SUCCESS;
}
$force = (bool) $this->option('force');
$failed = 0;
foreach ($hosts as $host) {
$label = $host->name !== '' && $host->name !== null ? $host->name : $host->uuid;
$recordId = $host->dns_record_id;
if (! $force) {
$this->line(" would delete {$label} record={$recordId}");
continue;
}
try {
$dns->deleteRecord($recordId);
$host->update(['dns_record_id' => null]);
$this->info(" deleted {$label} record={$recordId}");
} catch (Throwable $e) {
$failed++;
$this->error(" failed {$label} record={$recordId}: {$e->getMessage()}");
}
}
if (! $force) {
$this->newLine();
$this->comment(sprintf('Dry run — %d record(s) found, nothing changed. Re-run with --force to delete.', $hosts->count()));
return self::SUCCESS;
}
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$productId = $stripe->createProduct(
$family->name,
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
// Keyed on our row, so a crash between Stripe creating
// the product and us storing its id gives back the same
// product on the next run rather than a second one.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
}
}
$this->newLine();
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$created} object(s) would be created. Run without --dry-run to create them."
: "{$created} object(s) created in Stripe.");
return self::SUCCESS;
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
/**
* Scheduler tick (every minute): dispatch an advance job for every run that is
* due. Immediate dispatch on advance handles the fast path; this catches
* waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/
class TickProvisioning
{
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void
{
ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
})
->get()
->each(fn (ProvisioningRun $run) => AdvanceRunJob::dispatch($run->uuid));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Refused: this address already belongs to the other identity table.
*
* R21 operators and customers are two groups of people, never one row that
* could pass for both. Keyed on the address itself, not on who happens to be
* signed in when the check runs (see the deleted Customer::assertNotAdmin(),
* which checked the caller's session and mis-fired for exactly that reason).
*/
class IdentityCollisionException extends RuntimeException
{
public function __construct(public readonly string $email)
{
parent::__construct("Refusing to create or link a portal login for {$email}: that address already belongs to an operator (R21).");
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
//
use AuthorizesRequests;
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
/**
* Looking at a customer's portal as that customer.
*
* Not a shared cookie. Once the console has a hostname of its own, a session
* cookie set on the console host never reaches the portal host SESSION_DOMAIN
* is null and cookies are host-bound. So the handover is a signed, single-use
* link: it works regardless of cookie scope, it expires, and it leaves a record
* of who looked at whom.
*/
class ImpersonationController extends Controller
{
/** Sixty seconds is a handover, not a credential. */
private const WINDOW = 60;
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$operator = Auth::guard('operator')->user();
// impersonate.enter carries no domain of its own — a portal route,
// reached wherever the portal already answers. Its SIGNATURE is
// computed over the full absolute URL: UrlGenerator::signedRoute()
// hashes the generated URL string, host included, and
// hasCorrectSignature() re-hashes $request->url() — the ACTUAL request
// URL — on the way back. Left alone, "the generated URL" means
// whichever host issued THIS request: the console's, once it has an
// exclusive hostname of its own. RestrictAdminHost then 404s every
// non-console route reached through the console's exclusive host,
// including this one — so the link would die before the customer ever
// saw it, the exact cross-host failure this controller exists to fix.
//
// Forced to the portal's own configured root instead, derived from
// config('app.url') rather than hardcoded, and restored immediately
// after: nothing else generated later in this request should inherit
// the override.
$portalRoot = rtrim((string) config('app.url'), '/');
URL::forceRootUrl($portalRoot);
try {
$signed = URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
// Addressed by CUSTOMER uuid, not user: `users` has no uuid
// column, and naming the integer key in a URL is what R11
// forbids. The portal end resolves the account the same way
// this end would have.
['customer' => $customer->uuid, 'operator' => $operator->uuid],
);
} finally {
URL::forceRootUrl(null);
}
return redirect()->away($signed);
}
/** The portal end of the handover. Signed, and good exactly once. */
public function enter(Request $request, string $customer, string $operator): RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
// Single use: the signature's own hash is the key, and it lives exactly
// as long as the link could. Redis, not the database — the marker is as
// short-lived as the link and should not need tidying up.
$marker = 'impersonate:'.hash('sha256', (string) $request->query('signature'));
abort_unless(Cache::add($marker, true, self::WINDOW), 403);
// Stale data, not a normal outcome: a customer row sharing its email
// with an operator should never have been possible to reach this far
// (see Customer::ensureUser() and CreateNewUser, R21) — refuse rather
// than hand the operator's own address a portal session.
try {
$target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser();
} catch (IdentityCollisionException) {
abort(409);
}
$who = Operator::where('uuid', $operator)->firstOrFail();
Auth::guard('web')->login($target);
session(['impersonator_id' => $who->id]);
return redirect()->route('dashboard');
}
/** Back out of the customer's portal. The operator session was never touched. */
public function leave(): RedirectResponse
{
session()->forget('impersonator_id');
Auth::guard('web')->logout();
// By NAME, not AdminArea::home(): this route is reached on the
// PORTAL's host, and home() is a bare path ('/' in exclusive mode) —
// resolved against whatever host the request is already on, which is
// the portal's, not the console's. admin.overview is domain-bound to
// the console in exclusive mode, so route() generates the correct
// cross-host, absolute URL back to it; in shared mode it resolves
// against the current host same as home() would, so nothing changes
// there.
return redirect()->route('admin.overview');
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The public price sheet.
*
* Prices, storage and seat counts are read from the catalogue rather than
* written into the page. They were hard-coded here once, and the page and the
* catalogue had already drifted apart on three of four plans a visitor was
* quoted 249 for a plan that charged 399 at checkout. The marketing site is
* a reader of the catalogue like every other caller.
*
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
* outage for commerce, not something to paper over. But a public website is not
* commerce a mistyped availability window must not take the company's front
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*/
class LandingController extends Controller
{
/** Presentation that belongs to the marketing page, not to the catalogue. */
private const COPY = [
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
];
/** Catalogue feature keys, in the words a customer uses. */
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
'subdomain' => 'Adresse auf clupilot.cloud',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
'priority_support' => 'Bevorzugter Support',
'premium_sla' => 'Vereinbarte Reaktionszeiten',
'extended_retention' => 'Verlängerte Aufbewahrung',
'audit_log' => 'Protokollierung der Zugriffe',
'onboarding' => 'Begleitete Einführung',
];
/** The plan carrying the recommendation mark. */
private const RECOMMENDED = 'team';
public function __invoke(): View
{
$plans = $this->plans();
return view('landing', [
'plans' => $plans,
'featureRows' => $this->featureRows($plans),
'recommended' => self::RECOMMENDED,
]);
}
/**
* Every feature any sellable plan carries, in the catalogue's narrative
* order the row headings of the price sheet.
*
* Built from what is on sale rather than from the full list, so a feature
* no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, string>
*/
private function featureRows(array $plans): array
{
$offered = array_merge(...array_column($plans, 'features')) ?: [];
return array_values(array_filter(
self::FEATURES,
fn (string $label) => in_array($label, $offered, true),
));
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable $e) {
// Deliberately swallowed: see the class docblock. Logged at error
// level because a shop that cannot list its plans is not selling.
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
return [];
}
$plans = [];
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
'audience' => self::COPY[$key]['audience'] ?? '',
'note' => self::COPY[$key]['note'] ?? '',
'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
'recommended' => $key === self::RECOMMENDED,
];
}
return $plans;
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
/**
* A price as the sheet prints it, currency included.
*
* The symbol comes from the catalogue rather than from the template: the
* currency is configurable (CLUPILOT_CURRENCY), and a page that says ""
* while the checkout charges francs is the same two-sources-of-truth
* mistake as a hard-coded amount, only harder to notice.
*
* Whole units when the amount is whole a price sheet reads "179", not
* "179,00".
*/
private function money(int $cents, string $currency): string
{
$amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.')
: number_format($cents / 100, 2, ',', '.');
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
// currency ends up on a line of its own under the number.
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
? ($gb / 1000).' TB'
: $gb.' GB';
}
/**
* @param array<int, string> $keys
* @return array<int, string>
*/
private function features(array $keys): array
{
// Unknown keys are dropped rather than printed raw: a feature added to
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => self::FEATURES[$key] ?? null,
$keys,
)));
}
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
/**
* The public service status page.
*
* It lived at /legal/status, next to the imprint and the terms, which is a
* filing mistake: nothing about the current health of the platform is a legal
* document. It has its own address now.
*
* Every line on it is derived from a record. Where there is no signal the
* component says "not monitored" rather than "operational" a status page that
* reports green because it has nothing to look at is worse than no status page,
* because someone will believe it.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** A backup older than this stops counting as current. */
private const BACKUP_STALE_AFTER_HOURS = 48;
/**
* How old a monitoring verdict may be before it stops counting.
*
* The sync job runs every five minutes; four missed runs is a monitoring
* pipeline that has stopped, and a verdict from then says nothing about now.
*/
private const MONITORING_STALE_AFTER_MINUTES = 20;
/** How far back a provisioning failure still says something about now. */
private const PROVISIONING_WINDOW_HOURS = 24;
public function __invoke(): View
{
$components = [
$this->portal(),
$this->instances(),
$this->provisioning(),
$this->backups(),
];
// The worst individual state is the state of the whole thing. Averaging
// it would let one outage disappear behind three healthy components.
$overall = match (true) {
in_array('down', array_column($components, 'state'), true) => 'down',
in_array('degraded', array_column($components, 'state'), true) => 'degraded',
in_array('unknown', array_column($components, 'state'), true) => 'unknown',
default => 'operational',
};
return view('status', [
'components' => $components,
'overall' => $overall,
'checkedAt' => Carbon::now(),
]);
}
/**
* The portal and the website.
*
* Answering this request is the measurement. There is no honest way for a
* page to report that the server serving it is down.
*
* @return array<string, mixed>
*/
private function portal(): array
{
return [
'key' => 'portal',
'state' => 'operational',
'detail' => null,
];
}
/**
* Customer instances, from what monitoring last saw.
*
* @return array<string, mixed>
*/
private function instances(): array
{
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
}
// Only targets belonging to instances that are actually in service. A
// healthy check on a decommissioned instance says nothing about a live
// one, and counting it would let coverage look complete when it is not.
$onActive = fn ($query) => $query->whereHas(
'instance',
fn ($instance) => $instance->where('status', 'active'),
);
// Only verdicts the sync job has actually refreshed. A row that has
// never been checked, or was last checked long enough ago that the
// answer means nothing, is not evidence of health — and this column was
// for a long time exactly that: written 'up' at provisioning and never
// touched again.
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
// Down first, then coverage. An instance nobody is watching is not a
// healthy instance — it is an unanswered question, and reporting the
// absence of a check as the absence of a problem is the one thing this
// page must never do.
return [
'key' => 'instances',
'state' => match (true) {
$down > 0 && $down >= $watched => 'down',
$down > 0 => 'degraded',
$watched < $total => 'unknown',
default => 'operational',
},
'detail' => match (true) {
$down > 0 => ['down' => $down, 'total' => $watched],
$watched < $total => ['down' => $total - $watched, 'total' => $total],
default => null,
},
];
}
/**
* Whether new instances are being delivered.
*
* @return array<string, mixed>
*/
private function provisioning(): array
{
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
$failed = ProvisioningRun::query()
->where('status', ProvisioningRun::STATUS_FAILED)
->where('updated_at', '>=', $since)
->count();
return [
'key' => 'provisioning',
'state' => $failed === 0 ? 'operational' : 'degraded',
'detail' => $failed > 0 ? ['failed' => $failed] : null,
];
}
/**
* Whether the last backup of every instance is recent.
*
* @return array<string, mixed>
*/
private function backups(): array
{
// Counted from the INSTANCES that need protecting, not from the backup
// rows that happen to exist. An active instance with no schedule at all
// has no row — so counting rows would leave it out of the arithmetic
// entirely and report the estate as protected because the backups that
// do exist are fine.
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
}
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
$protected = Instance::query()
->where('status', 'active')
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
->count();
$unprotected = $total - $protected;
return [
'key' => 'backups',
'state' => match (true) {
$unprotected === 0 => 'operational',
$unprotected >= $total => 'down',
default => 'degraded',
},
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
];
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin
* console. Checked on the operator guard explicitly, never on the default
* (`web`) guard's user a portal account can no longer hold a role at all,
* so trusting the default guard here would just mean nobody ever gets in.
*/
public function handle(Request $request, Closure $next): Response
{
$operator = Auth::guard('operator')->user();
abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403);
return $next($request);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. An active impersonation session is exempt so
* operators can still inspect a suspended customer's portal. There is no
* operator special case here any more: a `web`-guard user can never be an
* operator, so the only thing left to exempt is an impersonation session.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
// NOT session()->invalidate(): where the console and the portal
// share a host (shared/fallback mode — this VM's own mode), both
// guards keep their login key in the SAME session. invalidate()
// is flush() + migrate(true) — it discards every attribute in the
// session, not just the 'web' guard's, so it would silently sign
// an operator elsewhere in that same browser out of the console
// too. regenerate() issues a new session id (no fixation risk)
// without touching the other attributes, so the 'operator'
// guard's own login key — untouched by the Auth::logout() call
// above, which only logs out the default ('web') guard —
// survives untouched. Same fix as routes/admin.php's own
// /logout route, mirrored the other way round.
$request->session()->regenerate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
// The console is exempt wherever it currently lives. This used to be
// the literal paths 'admin' and 'admin/*', which stops matching the
// moment the console moves to the root of its own hostname — and the
// result would be 503 on the sign-in page of the console, which is the
// only place the switch can be turned back on. Asking AdminArea keeps
// the exemption attached to the console rather than to a path that
// happened to be true once.
if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
/**
* Same standard as EnsureAdmin: an active operator holding a console role.
* A bare `check()` stays true for a disabled operator's live session
* disabling someone did not sign them out, so it never stopped this gate
* from waving them through indefinitely.
*
* Deliberately checked, not enforced by logging out: this gate only
* decides what ONE request sees, and destroying the session would ripple
* into whatever else that session is doing (a concurrent console tab,
* mid-task) a much bigger side effect than a visibility check exists to
* have. EnsureAdmin sets the precedent: it also 403s a disabled operator
* per request without touching their session.
*/
private function isActiveOperator(): bool
{
$operator = Auth::guard('operator')->user();
return $operator !== null && $operator->isActive() && $operator->can('console.view');
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Two-factor for operators, when the owner has made it compulsory.
*
* Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is
* where the operator sets two-factor up gating it would leave nobody able
* to satisfy the requirement. Nothing else is.
*
* This used to exempt the whole of admin.settings, on the theory that it was
* "the page where two-factor is set up". That component also carries staff
* management, site visibility, network restrictions, account changes, and
* the two-factor policy switch itself so an unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* Enrolment has its own route and component for exactly this reason: one
* page to exempt is one decision, not eight actions each needing to
* remember to check.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->user();
if ($operator === null || $operator->two_factor_confirmed_at !== null) {
return $next($request);
}
// The two pages that must stay reachable: where two-factor is
// enrolled, and signing out. Both through AdminArea::routeIs(), not
// a bare $request->routeIs() — a recovery hostname's routes are
// named admin.viaN.*, and a bare check matches only the canonical
// name, which would leave an unenrolled operator unable to sign out
// on exactly the host that exists because the canonical one is not
// working.
if (AdminArea::routeIs('admin.two-factor-setup') || AdminArea::routeIs('admin.logout')) {
return $next($request);
}
return redirect()->route('admin.two-factor-setup')
->with('status', __('admin_settings.two_factor_required'));
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The console answers only on its own hostname and that hostname answers
* only the console.
*
* Both halves matter. The first keeps the console off the public domains. The
* second is what actually separates the two products: binding the console
* routes to a hostname does not stop the CUSTOMER routes from answering there
* as well, because they are registered without a hostname. Without this,
* app.clupilot.com and admin.clupilot.com would still serve each other's pages
* wherever the paths did not collide.
*
* 404 in both directions, never 403: a stranger must not learn that a console
* lives here, and an operator on the wrong host should see the same nothing a
* stranger does.
*
* A handful of endpoints are shared by both sides and must answer on either
* host Livewire's own endpoints and the authentication actions. They are
* listed explicitly below, because the failure mode of getting that list wrong
* is "the console is broken" rather than "the rule is wrong", and a list you
* can read is easier to correct than a rule you have to infer.
*/
class RestrictAdminHost
{
/**
* Endpoints both the console and the portal need, on whichever host the
* caller is currently on.
*
* `login`, `logout` and `two-factor-challenge` used to be here, from when
* one sign-in page served both. They are gone: the console has its own at
* `admin.login`, and leaving the portal's here is what put a registration
* link in front of an operator with a 404 behind it, because `register`
* was never in this list and could not be, since the console has no
* registration by design.
*
* Livewire's component endpoint is guarded separately and more strictly:
* the console's own middleware is registered as persistent, so an action
* posted to /livewire/update is re-checked against the component's real
* route. Letting the path through here does not let anything through there.
*/
private const SHARED = [
'livewire/*',
'up',
// The console's own live-provisioning feed authorizes here (see
// routes/channels.php, admin.runs) — a route Laravel registers
// without any admin.* name, so isConsoleRoute() alone would not
// exempt it, and in exclusive mode it would 404 on the console host
// exactly like a stranger's request would.
'broadcasting/auth',
];
public function handle(Request $request, Closure $next): Response
{
// Not host-bound (development, a fresh checkout): the console keeps its
// /admin prefix on any host and nothing is separated. Upgrading must
// not lock anyone out of a system that was working.
if (! AdminArea::isHostBound()) {
return $next($request);
}
$isConsoleRoute = $this->isConsoleRoute($request);
$onConsoleHost = AdminArea::covers($request->getHost());
// The console, reached through a hostname that is not the console's.
if ($isConsoleRoute && ! $onConsoleHost) {
abort(404);
}
// The portal or the public site, reached through the console's
// hostname. Only once exclusivity is switched on: a machine that lists
// its own IP in ADMIN_HOSTS so the console is reachable without DNS
// still has to serve the portal from that same address.
if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
abort(404);
}
return $next($request);
}
/**
* Console routes are the ones named `admin.*`.
*
* By name rather than by path: the path is exactly what changes between
* host-bound and fallback mode, and a rule written against it would be
* wrong in one of them.
*/
private function isConsoleRoute(Request $request): bool
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Who may reach the operator console, by network address.
*
* RestrictAdminHost answers "under which NAME does the console respond" and a
* Host header is chosen by the caller, so it can never answer "who is asking".
* This one can: the client address behind a trusted proxy is not something the
* client gets to pick.
*
* The management VPN always counts. Beyond that the owner keeps a list an
* office line, a home connection so being away from the VPN does not mean
* being locked out. That list lives in the console rather than in a proxy
* config file, because the person who needs to change it is the person sitting
* in the console.
*
* 404, never 403: a stranger must not learn that a console lives here.
*
* This is not a substitute for a firewall. It runs after PHP has started, so it
* bounds who can USE the console, not who can make the server work. Where a
* network-level ACL is also possible, both belong there.
*/
class RestrictConsoleNetwork
{
public function handle(Request $request, Closure $next): Response
{
// Scoped to the console, wherever the console currently is. Asking
// AdminArea rather than testing the path: once the console moves to the
// root of its own hostname there is no `/admin` left to match, and a
// guard that silently stops matching is worse than no guard.
if (! AdminArea::isConsole($request)) {
return $next($request);
}
if (! self::isRestricted()) {
return $next($request);
}
if (! self::allows((string) $request->ip())) {
abort(404);
}
return $next($request);
}
/** Whether the owner has switched the restriction on at all. */
public static function isRestricted(): bool
{
return Settings::bool('console.network_restricted', false);
}
/**
* Every network that may reach the console.
*
* The VPN is always in it and is not removable it is the one path that
* exists independently of whatever the owner has typed into the list, and
* without it a bad entry would leave nobody able to fix the entry.
*
* @return array<int, string>
*/
public static function allowedRanges(): array
{
$vpn = (array) config('admin_access.trusted_ranges', []);
$own = (array) Settings::get('console.allowed_ips', []);
return array_values(array_unique(array_filter(array_merge(
$vpn,
array_map('trim', array_map('strval', $own)),
))));
}
public static function allows(string $ip): bool
{
return self::covers($ip, self::allowedRanges());
}
/**
* Would this address still get in, given that list?
*
* Split out so the "you are about to lock yourself out" decision can be
* made and tested without a request: it is the one check whose failure
* mode is that nobody can reach the page that would fix it.
*
* @param array<int, string> $extra the owner's list AFTER the change
*/
public static function wouldStillAllow(string $ip, array $extra): bool
{
$vpn = (array) config('admin_access.trusted_ranges', []);
return self::covers($ip, array_merge($vpn, $extra));
}
/**
* Whether a value is an address or a CIDR range at all.
*
* Shared by the console and the recovery command on purpose: an entry that
* matches nothing is stored happily and reports success, and the operator
* then switches the restriction on believing they are covered. That is the
* exact situation the recovery command exists to get out of.
*/
public static function isNetwork(string $value): bool
{
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = str_contains((string) $address, ':') ? 128 : 32;
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
}
/** @param array<int, string> $ranges */
private static function covers(string $ip, array $ranges): bool
{
$ranges = array_values(array_filter($ranges));
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Http\Middleware;
use App\Models\LoginSession;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeps a session row pointing at the session it describes.
*
* Laravel rotates the session id on every sign-in and on every regenerate().
* A row that stored the id once stops matching within a request or two and
* the list would then show sessions that cannot be ended and hide the one the
* reader is sitting in. The uuid inside the payload is what survives; the id is
* refreshed from it here.
*
* Also the only thing that keeps "last seen" honest. Without it the list
* reports the moment somebody signed in, which for a browser left open for a
* fortnight is the least useful timestamp available.
*
* Written at most once a minute. This runs on every authenticated request, and
* a write per request would turn a page of thumbnails into forty updates of the
* same row.
*/
class TouchLoginSession
{
private const THROTTLE_SECONDS = 60;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->hasSession()) {
return $response;
}
$uuid = $request->session()->get(LoginSession::KEY);
if (! is_string($uuid) || $uuid === '') {
return $response;
}
$current = $request->session()->getId();
// A bare update rather than a model round-trip: this is on the path of
// every authenticated request, and the row is not needed, only moved.
DB::table('login_sessions')
->where('uuid', $uuid)
->where(function ($q) use ($current) {
// A CHANGED id is never throttled. Both sign-in paths raise the
// Login event before calling session()->regenerate(), so the row
// is written against an id that is dead a moment later. Held
// back by the throttle, the row would point at nothing for the
// first minute of every session — the one it describes missing
// from the list, and "end this one" deleting a session that no
// longer exists.
$q->where('session_id', '!=', $current)
->orWhereNull('session_id')
->orWhereNull('last_seen_at')
->orWhere('last_seen_at', '<=', now()->subSeconds(self::THROTTLE_SECONDS));
})
->update([
'session_id' => $current,
'ip_address' => $request->ip(),
'last_seen_at' => now(),
'updated_at' => now(),
]);
return $response;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Jobs;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Services\Billing\InvoiceArchive;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Put one invoice on ONE destination, and keep trying if that one is away.
*
* One job per pair rather than one per invoice: the reason for having a second
* destination is that the first can fail, and a job that writes to both would
* retry the one that worked every time the other did not and would report a
* single outcome for two different things.
*
* Queued rather than done during the request that issued the invoice: a network
* mount can block for its whole timeout, and an invoice must not fail to be
* issued because a NAS in an office is rebooting.
*/
class ArchiveInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
/** A destination that is gone is usually gone for a while. */
public array $backoff = [60, 300, 900, 3600];
public function __construct(public Invoice $invoice, public ExportTarget $target) {}
public function handle(InvoiceArchive $archive): void
{
if (! $this->target->active) {
return;
}
$record = InvoiceExport::query()->firstOrNew([
'invoice_id' => $this->invoice->id,
'export_target_id' => $this->target->id,
]);
// Already there. A retry after a timeout that in fact succeeded must
// not write it a second time.
if ($record->exists && $record->exported_at !== null) {
return;
}
$record->attempts = (int) $record->attempts + 1;
$record->save();
$archive->store($this->invoice, $this->target);
$record->forceFill(['exported_at' => now(), 'error' => null])->save();
}
public function failed(?Throwable $e): void
{
// Against the pair, not the invoice: "on the office NAS, not on the
// off-site box" is the thing somebody needs to be told.
InvoiceExport::query()->updateOrCreate(
['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id],
['error' => mb_substr((string) $e?->getMessage(), 0, 250)],
);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Listeners;
use App\Mail\NewDeviceSignInMail;
use App\Services\Devices\DeviceRecognition;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* One listener for both ways in.
*
* The console signs in through App\Livewire\Auth\OperatorLogin and the portal
* through Fortify two code paths that share nothing. Both raise Laravel's
* Login event, and it carries the guard, which is the one thing that tells
* operators and customers apart (R21). Hooking the event rather than the two
* controllers means neither can be changed in a way that quietly stops
* recording sign-ins.
*
* Nothing here may prevent a sign-in. Recognising a device is a safeguard, and
* a safeguard that locks the owner out when it breaks has stopped being one
* so a failure is logged and swallowed. This is a deliberate exception to
* failing loudly, and the reason is written here so nobody removes it as an
* oversight.
*/
class RecordSignInDevice
{
public function __construct(
private readonly DeviceRecognition $devices,
private readonly Request $request,
) {}
public function handle(Login $event): void
{
try {
$outcome = $this->devices->recognise($event->user, $event->guard, $this->request);
$this->devices->attachSession($outcome->device, $this->request);
if (! $outcome->shouldWarn) {
return;
}
$address = $event->user->getAttribute('email');
if (! is_string($address) || $address === '') {
return;
}
// Queued: a sign-in must not wait on a mail server, and this one
// runs while somebody is looking at a spinner on the login button.
Mail::to($address)->queue(new NewDeviceSignInMail(
name: (string) ($event->user->getAttribute('name') ?? ''),
device: $outcome->device,
guard: $event->guard,
));
} catch (Throwable $e) {
Log::warning('Could not record the device for a sign-in', [
'guard' => $event->guard,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->authorize('plans.manage');
$version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail();
abort_if($version->isPublished(), 403);
$this->uuid = $uuid;
$this->version = $version->version;
$this->plan = $version->family->name;
}
public function delete(): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $this->uuid)->first();
// The catalogue decides, in one conditional statement. Checking here and
// deleting afterwards would leave a window in which the version is
// published between the two — and a published version must never go.
if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) {
$this->dispatch('notify', message: __('plans.discard_too_late'));
}
$this->dispatch('plan-draft-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-plan-draft');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
* belongs to a host cuts CluPilot's own management path to that machine, so the
* modal says so plainly rather than hiding the option.
*/
class ConfirmDeleteVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public ?string $hostName = null;
public function mount(string $uuid): void
{
// Modals are reachable without the route middleware, so this is the
// real guard, not a convenience check.
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
$this->hostName = $peer->host?->name;
}
public function delete(): void
{
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
if ($peer !== null) {
$this->authorize('delete', $peer);
// A tombstone that still carries a usable private key is a liability.
$peer->purgeSecret();
// Soft-delete, so the row survives as a tombstone until the hub has
// actually dropped the peer. A hard delete here would let the next
// sync adopt the still-present peer back as a live access —
// silently restoring what was just revoked. ApplyVpnPeer purges the
// tombstone once removal succeeded; SyncVpnPeers retries if not.
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
}
$this->dispatch('vpn-peer-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-vpn-peer');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator's own two-factor login is removed (R23).
* No mutation here TwoFactorSetup::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('two-factor-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-disable-two-factor');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator signs every other browser out (R23). The
* console's own, because operators and customers are two identities (R21) and
* one shared modal would raise an event both sides listen for.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-end-other-sessions');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a stored override is removed (R23), reverting the key
* to whatever the server file provides. No mutation here it dispatches
* back to Secrets, whose forget() is unchanged.
*/
class ConfirmForgetSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-forget-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-forget-secret');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a staff access gets a fresh keypair (R23). The reissue
* itself still runs on Vpn::reissue(), unchanged this only gates it and,
* once confirmed, dispatches back so the page can show the new config.
*/
class ConfirmReissueVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $peer);
abort_if($peer->kind !== VpnPeer::KIND_STAFF, 404);
$this->uuid = $uuid;
$this->name = $peer->name;
}
public function confirm(): void
{
$this->dispatch('vpn-peer-reissue-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-reissue-vpn-peer');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server. The host is
* deactivated immediately and purged asynchronously so removal never blocks on
* (or races) an in-flight provisioning step.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->uuid = $uuid;
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
}
public function remove()
{
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) {
// Deactivate now (out of placement, visibly removed), finalize on the
// provisioning worker which waits for the runner lock.
$host->update(['status' => 'disabled']);
PurgeHost::dispatch($host->uuid);
}
return $this->redirectRoute('admin.hosts', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-remove-host');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before .env the file that holds every credential in the
* system is overwritten (R23).
*
* The content being written lives only in Integrations' own $envContent; this
* modal never sees it and cannot mutate anything itself. Confirming
* dispatches back to the page component, whose saveEnv() keeps its own
* guardSecrets() (secrets.manage + a recently confirmed password) unchanged
* same pattern as ConfirmSaveSecret/ConfirmForgetSecret.
*/
class ConfirmSaveEnv extends ModalComponent
{
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('env-save-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-env');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a credential takes effect (R23).
*
* The value being saved lives only in Secrets' own (deferred) `entered`
* property this modal never sees it and cannot mutate anything itself.
* Confirming dispatches back to the page component, whose save() keeps its
* own guard() (capability + recent password) unchanged.
*/
class ConfirmSaveSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-save-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-secret');
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15. Optional, free text, and
* nothing keys off it: see EditDatacenter and the migration that adds it.
*
* Offered here as well as in the edit modal so that creating a datacenter
* and saying which one it is are not two separate errands.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
public function save(): void
{
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code));
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);
Datacenter::create([
'code' => $data['code'],
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'facility', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* Country is picked from a list so it can't be mistyped.
*
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it which is the case that matters, a
* typo noticed shortly after creating one and locked with the reason named
* once anything does.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
// Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]);
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Services\Secrets\SecretCipher;
use LivewireUI\Modal\ModalComponent;
/**
* Adding or changing one destination.
*
* The password never lives here. It goes into the secret vault under a key
* derived from the target, and only that key is stored on the row a
* credential in a settings table is a credential in every database dump. An
* empty password field on an existing target means "leave it alone", not
* "clear it": the field cannot show what is stored, so treating blank as a
* deletion would wipe the credential every time somebody renamed the target.
*/
class EditExportTarget extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $driver = ExportTarget::LOCAL;
public string $path = '';
/** 'year' for an archive somebody keeps, 'day' for a handover point. */
public string $layout = ExportTarget::BY_YEAR;
/** Empty means keep for ever, which is the default and the safe one. */
public ?int $keepDays = null;
public string $host = '';
public ?int $port = 22;
public string $username = '';
public string $password = '';
public bool $hasPassword = false;
public bool $active = true;
public function mount(?string $uuid = null): void
{
$this->authorize('site.manage'); // modals bypass the route middleware
if ($uuid === null) {
return;
}
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $target->name;
$this->driver = $target->driver;
$this->path = $target->path;
$this->layout = $target->layout;
$this->keepDays = $target->keep_days;
$this->host = (string) $target->host;
$this->port = $target->port ?: 22;
$this->username = (string) $target->username;
$this->active = (bool) $target->active;
$this->hasPassword = trim((string) $target->secret_key) !== '';
}
public function save()
{
$this->authorize('site.manage');
$rules = [
'name' => 'required|string|max:120',
'driver' => 'required|in:local,sftp',
'path' => 'required|string|max:255',
'layout' => 'required|in:year,day',
// Nothing, or a real number of days. Zero would read as "delete
// immediately" to somebody who typed it meaning "never".
'keepDays' => 'nullable|integer|min:1|max:3650',
'active' => 'boolean',
];
if ($this->driver === ExportTarget::SFTP) {
$rules += [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
'username' => 'required|string|max:120',
// Required only when there is nothing stored yet. Blank on an
// existing target means "unchanged" — the field cannot show
// what is stored, so treating blank as a deletion would wipe
// the credential on every unrelated edit.
'password' => $this->hasPassword ? 'nullable|string|max:255' : 'required|string|max:255',
];
}
$data = $this->validate($rules);
$target = $this->uuid !== ''
? ExportTarget::query()->where('uuid', $this->uuid)->firstOrFail()
: new ExportTarget;
$target->fill([
'name' => $data['name'],
'driver' => $data['driver'],
'path' => rtrim($data['path'], '/') ?: '/',
'layout' => $data['layout'],
'keep_days' => $data['keepDays'] ?: null,
'host' => $this->driver === ExportTarget::SFTP ? $this->host : null,
'port' => $this->driver === ExportTarget::SFTP ? $this->port : null,
'username' => $this->driver === ExportTarget::SFTP ? $this->username : null,
'active' => $data['active'] ?? true,
]);
$target->save();
// Encrypted onto the row rather than into the vault. The vault keeps a
// registry of fixed, documented credentials and refuses anything else,
// which is right for it — a per-destination password is not one of
// those, and forcing it in would make the registry meaningless. Same
// cipher, same key, so it is no more readable in a dump than the rest.
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
$target->forceFill([
'secret_key' => app(SecretCipher::class)->encrypt($this->password),
])->save();
}
$this->dispatch('notify', message: __('finance.targets_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-export-target');
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Livewire\Admin;
use App\Models\InvoiceSeries;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a Rechnungskreis, with the parts that cannot move held still.
*
* The same shape as EditDatacenter and for the same reason: some fields stop
* being editable once something depends on them. Here the something is a
* document in somebody's accounts.
*
* The prefix is part of every number already issued. Changing it from RE to AR
* does not rename anything it leaves a series whose documents say RE-2026-
* and whose next document says AR-2026-, with no record that they belong
* together. Free while the series is empty, which is the case that matters: a
* prefix chosen wrongly on the day the shop opens.
*
* The counter may be raised but never lowered. Lowering it re-issues a number
* that is already printed on a document somebody holds, and gapless-and-
* ascending is a legal requirement rather than a preference.
*/
class EditInvoiceSeries extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $prefix = '';
public int $digits = 4;
public bool $yearlyReset = true;
public int $nextNumber = 1;
public bool $active = true;
/** How many documents this series has already issued. */
public int $issued = 0;
/** The lowest number this counter may still be set to. */
public int $floor = 1;
public function mount(string $uuid): void
{
$this->authorize('site.manage'); // modals are reachable without the route middleware
$series = InvoiceSeries::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $series->name;
$this->prefix = $series->prefix;
$this->digits = (int) $series->digits;
$this->yearlyReset = (bool) $series->yearly_reset;
$this->nextNumber = (int) $series->next_number;
$this->active = (bool) $series->active;
$this->issued = $series->invoices()->count();
$this->floor = (int) $series->next_number;
}
public function save()
{
$this->authorize('site.manage');
$series = InvoiceSeries::query()->where('uuid', $this->uuid)->firstOrFail();
// Recomputed from the database, never read back from the properties the
// browser returns: these two rules are what keep already-issued numbers
// from being re-used, and a forged field must not lift either.
$issued = $series->invoices()->count();
$floor = (int) $series->next_number;
$rules = [
'name' => 'required|string|max:120',
'digits' => 'required|integer|min:3|max:10',
'active' => 'boolean',
'nextNumber' => ['required', 'integer', 'min:'.$floor],
];
if ($issued === 0) {
$rules['prefix'] = [
'required', 'string', 'max:12', 'regex:/^[A-Z0-9-]+$/',
Rule::unique('invoice_series', 'prefix')->ignore($series->id),
];
// Only while nothing has been issued: switching an established
// series between yearly and continuous puts a gap in one of them.
$rules['yearlyReset'] = 'boolean';
}
if ($issued === 0) {
$this->prefix = strtoupper(trim($this->prefix));
} else {
$this->prefix = $series->prefix;
$this->yearlyReset = (bool) $series->yearly_reset;
}
$data = $this->validate($rules);
$series->update([
'name' => $data['name'],
'digits' => $data['digits'],
'next_number' => $data['nextNumber'],
'active' => $data['active'],
...($issued === 0 ? [
'prefix' => $data['prefix'],
'yearly_reset' => $data['yearlyReset'],
] : []),
]);
$this->dispatch('notify', message: __('finance.series_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-invoice-series');
}
}

View File

@ -0,0 +1,209 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Models\InvoiceSeries;
use App\Services\Deployment\UpdateChannel;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Everything an invoice needs before one can be issued.
*
* Its own tab rather than a section of Settings: the company's registered
* details, the VAT rate and the Rechnungskreise are not preferences, they are
* what appears on a legal document, and burying them under "Einstellungen"
* beside the site-visibility switch invites somebody to change one in passing.
*
* None of it reaches an invoice that already exists. Every value is copied into
* the invoice when its number is assigned, so an address corrected here is
* correct from the next document onwards and cannot rewrite an old one.
*/
#[Layout('layouts.admin')]
class Finance extends Component
{
use WithFileUploads;
/** @var array<string, mixed> */
public array $company = [];
public float $taxRate = 20.0;
/**
* A freshly minted collection key, shown once and never stored.
*
* Held in the component for the length of one page view. The private half
* exists to be copied into a NAS and nowhere else writing it into the
* database "for convenience" would put a working credential in every backup
* of that database.
*
* @var array<string, string>|null
*/
public ?array $archiveKey = null;
/** True between asking the host for a key and the host answering. */
public bool $waitingForKey = false;
/** New logo upload, validated on save. */
public $logo = null;
public string $logoPath = '';
public function mount(): void
{
$this->authorize('site.manage');
$this->company = CompanyProfile::all();
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
$this->taxRate = CompanyProfile::taxRate();
}
public function saveCompany(): void
{
$this->authorize('site.manage');
$data = $this->validate([
'company.name' => 'required|string|max:190',
'company.address' => 'required|string|max:190',
'company.postcode' => 'required|string|max:20',
'company.city' => 'required|string|max:120',
'company.country' => 'required|string|max:120',
'company.phone' => 'nullable|string|max:60',
'company.email' => 'nullable|email|max:190',
'company.website' => 'nullable|string|max:190',
'company.register_number' => 'nullable|string|max:60',
'company.register_court' => 'nullable|string|max:120',
'company.vat_id' => 'required|string|max:40',
'company.bank_name' => 'nullable|string|max:120',
'company.iban' => 'nullable|string|max:42',
'company.bic' => 'nullable|string|max:15',
'company.payment_days' => 'required|integer|min:0|max:180',
'company.payment_terms' => 'nullable|string|max:500',
// PNG or WEBP only, and small. A PDF embeds whatever it is given,
// and a four-megabyte photograph would be embedded in every invoice
// ever rendered from then on.
'logo' => 'nullable|image|mimes:png,webp|max:1024',
'taxRate' => 'required|numeric|min:0|max:100',
]);
if ($this->logo !== null) {
$previous = $this->logoPath;
$this->logoPath = $this->logo->store('company', 'public');
$this->company['logo_path'] = $this->logoPath;
// The old file goes only once the new one is stored. The other
// order loses the logo entirely if the upload fails, and every
// invoice rendered afterwards is missing it.
if ($previous !== '' && $previous !== $this->logoPath) {
Storage::disk('public')->delete($previous);
}
$this->logo = null;
}
CompanyProfile::put($data['company']);
Settings::set('company.tax_rate', (float) $data['taxRate']);
$this->dispatch('notify', message: __('finance.company_saved'));
}
/**
* Ask the host to mint an SSH key that may only read this archive.
*
* Everything an operator otherwise does by hand across three machines
* generate a keypair, install the public half restricted to one directory,
* carry the private half to the NAS. None of it can happen here: the panel
* is www-data in a container and the keys, the home directory and rrsync
* are all on the host. So it asks, and the agent does it.
*/
public function createPullAccess(string $uuid): void
{
$this->authorize('site.manage');
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
if ($target->driver !== ExportTarget::LOCAL) {
// Only a directory on this host has a home directory and an
// authorized_keys to put anything in. A destination somewhere else
// is somebody else's machine.
$this->dispatch('notify', message: __('finance.pull_local_only'));
return;
}
$accepted = app(UpdateChannel::class)->requestArchiveKey(
(string) (auth('operator')->user()?->email ?? ''),
$target->path,
'clupilot-archiv-'.$target->uuid,
);
$this->archiveKey = null;
$this->waitingForKey = $accepted;
$this->dispatch('notify', message: __($accepted
? 'finance.pull_requested'
: 'admin_settings.update_already_requested'));
}
/** Polled while waiting. Takes the key the moment the host has left one. */
public function collectArchiveKey(): void
{
$this->authorize('site.manage');
if (! $this->waitingForKey) {
return;
}
$key = app(UpdateChannel::class)->takeArchiveKey();
if ($key !== null) {
$this->archiveKey = $key;
$this->waitingForKey = false;
}
}
public function removeLogo(): void
{
$this->authorize('site.manage');
if ($this->logoPath !== '') {
Storage::disk('public')->delete($this->logoPath);
}
$this->logoPath = '';
$this->company['logo_path'] = '';
CompanyProfile::put(['logo_path' => '']);
$this->dispatch('notify', message: __('finance.logo_removed'));
}
public function render()
{
return view('livewire.admin.finance', [
'series' => InvoiceSeries::query()->orderBy('kind')->get(),
// What is still missing before an invoice may be issued at all. An
// invoice without a name, an address or a VAT number is not a valid
// invoice here, and issuing one is worse than refusing to.
'missing' => CompanyProfile::missingForInvoicing(),
// Counted from the join table, never by asking the destinations.
// A stat() on a network mount that has gone away blocks for the
// mount's whole timeout, and this is a page an operator opens when
// something is already wrong.
'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [
'model' => $t,
'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(),
'failed' => InvoiceExport::query()->where('export_target_id', $t->id)
->whereNotNull('error')->whereNull('exported_at')->count(),
]),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$this->authorize('hosts.manage');
$data = $this->validate();
$host = $action->run($data);
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->host = $host;
}
/** Live refresh whenever any run advances (admins-only channel). */
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
$this->host->refresh();
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
$this->authorize('hosts.manage');
$reserve = max(0, min(90, $reserve));
$this->host->update(['reserve_pct' => $reserve]);
$this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
}
/**
* Drain / return a host: toggle between active and disabled. Disabled takes
* it out of placement (maintenance) without purging it distinct from the
* destructive "remove host". Never touches a host mid-onboarding.
*/
public function toggleMaintenance(): void
{
$this->authorize('hosts.manage');
if ($this->host->status === 'active') {
$this->host->update(['status' => 'disabled']);
} elseif ($this->host->status === 'disabled') {
$this->host->update(['status' => 'active']);
}
}
public function retry(): void
{
$this->authorize('hosts.manage');
$run = $this->currentRun();
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {
$run->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
$this->host->update(['status' => 'onboarding']);
AdvanceRunJob::dispatch($run->uuid);
}
}
private function currentRun(): ?ProvisioningRun
{
return $this->host->runs()->latest('id')->first();
}
/** @return array<int, array{label: string, state: string}> */
private function buildSteps(?ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.host', []);
$current = $run?->current_step ?? 0;
$status = $run?->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
public function render()
{
$run = $this->currentRun();
/** @var Collection $events */
$events = $run
? $run->events()->latest('id')->limit(30)->get()
: collect();
return view('livewire.admin.host-detail', [
'run' => $run,
'steps' => $this->buildSteps($run),
'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.admin')]
class Hosts extends Component
{
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $datacenter = '';
#[Url]
public string $status = '';
public function updated(): void
{
// no-op; keeps the query string in sync as filters change
}
public function clearFilters(): void
{
$this->reset('search', 'datacenter', 'status');
}
public function render()
{
$hosts = Host::query()
->withCount('instances')
// The VPN column reads SyncVpnPeers' synced row, not a live hub
// call — the console container has no wg0 (see VpnPeer::vpnPeer()
// and SyncVpnPeers' docblock). One eager-loaded query for the
// whole page, not one per host.
->with('vpnPeer')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
})
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy('datacenter')->orderBy('name')
->get();
return view('livewire.admin.hosts', [
'hosts' => $hosts,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
]);
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Administrator access to a customer's Nextcloud.
*
* Not impersonation: that borrows a portal session. This resets our managed
* admin account inside the customer's installation and hands the credentials
* over once the only thing stock Nextcloud offers, and honest about what it
* does rather than pretending to be a passwordless jump.
*
* The operator's own password is required, every time. Taking control of a
* customer's installation is not something an unattended browser should be able
* to do on its own.
*/
class InstanceAdminAccess extends ModalComponent
{
use ResolvesOperator;
public string $uuid;
public string $subdomain = '';
public string $password = '';
/** Opaque handle; the credentials never enter the component snapshot. */
public ?string $token = null;
public bool $waiting = false;
public function mount(string $uuid): void
{
$this->authorize('instances.adminlogin');
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->subdomain = $instance->subdomain;
}
public function hydrate(): void
{
$this->authorize('instances.adminlogin');
}
public function request(): void
{
$this->authorize('instances.adminlogin');
$this->resetErrorBag('password');
if (! $operator = $this->currentOperator()) {
return;
}
$key = 'instance-admin:'.$operator->id;
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, $operator->password)) {
RateLimiter::hit($key, 300);
$this->addError('password', __('instances.wrong_password'));
return;
}
RateLimiter::clear($key);
$this->reset('password');
// The reset runs on the provisioning worker — it owns the tunnel. The
// token is where it will leave the result.
$this->token = Str::random(40);
$this->waiting = true;
IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, $operator->id);
}
public function render()
{
$payload = null;
if ($this->token !== null) {
$raw = ConfigHandoff::get($this->token);
if ($raw !== null) {
// Consumed on the first read: leaving it in the cache would make
// "shown once" a figure of speech — any replayed component
// request could fetch the password again for ten minutes. The
// payload lives in a local variable, so it reaches the view and
// nothing else.
ConfigHandoff::forget($this->token);
$this->token = null;
$this->waiting = false;
$payload = json_decode($raw, true);
}
}
return view('livewire.admin.instance-admin-access', [
'credentials' => isset($payload['password']) ? $payload : null,
'failed' => isset($payload['error']) ? $payload['error'] : null,
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use Illuminate\Support\Facades\Lang;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every instance on the estate, from the instances table.
*
* This page used to list seven invented instances on four invented hosts,
* complete with a Nextcloud version column. The version is not recorded
* anywhere, so the column is gone rather than filled in with something
* plausible and the storage column now shows the quota that was actually
* sold, not a made-up "used of total", because used disk is not collected.
*/
#[Layout('layouts.admin')]
class Instances extends Component
{
use WithPagination;
public function render()
{
$instances = Instance::query()
->with(['customer', 'host'])
->orderByDesc('id')
->paginate(25);
return view('livewire.admin.instances', [
'instances' => $instances,
'rows' => $instances->getCollection()->map(fn (Instance $i) => [
'address' => $i->custom_domain ?: $i->subdomain,
'customer' => $i->customer?->name ?? '—',
'host' => $i->host?->name ?? '—',
'vmid' => $i->vmid ?? '—',
'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—',
'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—',
'status' => $status = $i->status ?? 'provisioning',
// A status the lifecycle adds later must show as itself, never
// as "admin.status.whatever" in front of the owner.
'status_label' => Lang::has('admin.status.'.$status)
? __('admin.status.'.$status)
: $status,
])->all(),
]);
}
}

View File

@ -0,0 +1,402 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Connected services, one page, grouped by what they are for Zahlungen,
* DNS, Monitoring, VPN/WireGuard, SSH-Identität not by which of the two
* mechanisms below happens to hold a given value.
*
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
* one console area split across two pages by storage mechanism, reported as
* exactly the wrong axis to split on. An operator setting up an integration
* should not have to know whether a given field happens to be encrypted.
*
* Both mechanisms are unchanged underneath this is a presentation change,
* not a data migration:
*
* - SecretVault: a curated, encrypted key/value store for credentials that
* actually stop the business when they leak or expire. Gated by
* `secrets.manage` (Owner only) AND a recently confirmed password the
* realistic threat is an unlocked machine, not a stranger. A stored value
* is write-only: the page only ever shows an outline of it.
* - App\Support\Settings: plain deployment configuration. Gated by
* `hosts.manage` (Owner + Admin) alone none of it opens anything by
* itself. Shows its value in full, because there is nothing to protect.
*
* mount() is reachable with EITHER capability, because the two halves are no
* longer two pages an operator's role happens to see one, both, or neither
* of they are sections of the SAME page now. Each section, and each save
* action, still checks its OWN capability (guardSecrets()/guardInfra())
* server-side; an Admin who can reach this page for the DNS zone still gets
* 403 the moment they try to touch a vault entry.
*
* The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
* and the same confirmed password, because it is the one place on this page
* that can reach every credential the vault deliberately keeps write-only
* see EnvFileEditor for the safeguards that make that survivable.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
use ConfirmsPassword {
forgetPasswordConfirmation as private lockAgain;
}
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
public array $entered = [];
public ?array $check = null;
// Plain settings — App\Support\ProvisioningSettings' full list.
public string $dnsZone = '';
public string $wgEndpoint = '';
public string $wgHubPubkey = '';
public string $traefikDynamicPath = '';
public string $sshPublicKey = '';
public string $monitoringUrl = '';
// .env editor.
public string $envContent = '';
/**
* Loaded lazily, on the first render after unlocking never in mount(),
* so a merely-reachable page never pulls the whole credential file into
* component state for an operator who has not confirmed anything yet.
*/
public bool $envLoaded = false;
/**
* True from the moment saveEnv() successfully asks the agent to restart
* the workers, until render() observes the request is no longer pending.
*
* Drives the small inline "wird neu gestartet" indicator on this page
* not the full-screen maintenance overlay in layouts/admin.blade.php,
* which is for an actual deployment. A worker restart is four containers
* coming back in a few seconds, this page (and the console around it)
* never goes offline for it, and `app` the container answering this
* very poll is never among them. wire:poll on this page's own
* component is therefore enough to watch it finish; nothing here needs
* the client-side watcher an in-flight deployment does.
*/
public bool $envRestartWatching = false;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
if (Gate::allows('hosts.manage')) {
$this->dnsZone = ProvisioningSettings::dnsZone();
$this->wgEndpoint = ProvisioningSettings::wgEndpoint();
$this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
$this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
$this->sshPublicKey = ProvisioningSettings::sshPublicKey();
$this->monitoringUrl = ProvisioningSettings::monitoringUrl();
}
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
{
$this->guardInfra();
$data = $this->validate([
'dnsZone' => ['nullable', 'string', 'max:255'],
'wgEndpoint' => ['nullable', 'string', 'max:255'],
'wgHubPubkey' => ['nullable', 'string', 'max:255'],
'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
'sshPublicKey' => ['nullable', 'string', 'max:1000'],
'monitoringUrl' => ['nullable', 'url', 'max:255'],
]);
Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
$this->dispatch('notify', message: __('integrations.settings_saved'));
}
// ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----
public function save(string $key): void
{
$this->guardSecrets();
$field = self::field($key);
$value = trim((string) ($this->entered[$field] ?? ''));
if ($value === '') {
$this->addError('entered.'.$field, __('secrets.empty'));
return;
}
try {
app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
} catch (Throwable $e) {
$this->addError('entered.'.$field, $e->getMessage());
return;
}
$this->entered[$field] = '';
$this->check = null;
$this->dispatch('notify', message: __('secrets.saved'));
}
/** ConfirmSaveSecret dispatches this back (R23) — see that class. */
#[On('secret-save-confirmed')]
public function onSaveConfirmed(string $key): void
{
$this->save($key);
}
public function forget(string $key): void
{
$this->guardSecrets();
app(SecretVault::class)->forget($key);
$this->check = null;
$this->dispatch('notify', message: __('secrets.removed'));
}
/** ConfirmForgetSecret dispatches this back (R23) — see that class. */
#[On('secret-forget-confirmed')]
public function onForgetConfirmed(string $key): void
{
$this->forget($key);
}
public function test(string $key): void
{
$this->guardSecrets();
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key (a dot means nesting to Livewire). */
public static function field(string $key): string
{
return str_replace('.', '_', $key);
}
// ---- The .env editor — Part B. secrets.manage + confirmed password. ----
/**
* Validate, back up, write then do the two things saving this file used
* to leave as homework: clear the config cache (this container can do
* that itself, no host privileges needed) and ask the host-side agent to
* restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
* it cannot be done from in here; see that class for why).
*
* EnvFileEditor does the write itself and the actual refusing; this only
* translates its one exception into a form error instead of a 500.
*/
public function saveEnv(): void
{
$this->guardSecrets();
try {
$backup = app(EnvFileEditor::class)->write($this->envContent);
} catch (InvalidEnvContentException $e) {
$this->addError('envContent', $e->invalidLine !== null
? __('integrations.env_invalid', ['line' => $e->invalidLine])
: __('integrations.env_empty'));
return;
}
// Unconditional, and deliberately not gated on
// app()->configurationIsCached() first: Illuminate's own ConfigClear
// command is already exactly that guard — it deletes the cache file
// if one exists and does nothing if there is none, so adding a check
// here would only save a single is_file() call in the common case at
// the cost of a second source of truth to keep in sync with it. This
// dev machine has no cache right now (checked: no
// bootstrap/cache/config.php), but deploy/update.sh and
// deploy/install-agent.sh both run `config:cache` as standard
// practice, so a real installation usually does — and while one is
// active, Laravel skips loading .env on every request AT ALL
// (LoadEnvironmentVariables bootstraps straight from the cached
// values instead). Left uncleared there, the value just written would
// stay invisible — not merely to the four workers below, to this
// application too — until the cache was rebuilt by hand.
Artisan::call('config:clear');
$channel = app(UpdateChannel::class);
$agentAlive = $channel->state()['agent_seen'];
$by = Auth::guard('operator')->user()?->email ?? 'console';
// '' means there was no previous file to protect — EnvFileEditor's
// own docblock explains why that is not an error.
$backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');
if (! $agentAlive) {
// Honest, not silent: the values ARE saved, but nothing is going
// to pick them up on its own. Pretending otherwise here is worse
// than the manual-restart card this replaces — see
// admin_settings.update_no_agent for the same shape on updates.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));
return;
}
if (! $channel->requestRestart($by)) {
// The single request slot is already taken by something else
// (a check, an update, or another restart just asked for) — rare,
// but real, and not something to paper over with a message that
// says "restarting" when nothing was actually queued.
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));
return;
}
$this->envRestartWatching = true;
$this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
}
/** ConfirmSaveEnv dispatches this back (R23) — see that class. */
#[On('env-save-confirmed')]
public function onEnvSaveConfirmed(): void
{
$this->saveEnv();
}
/**
* Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
* also drop the Stripe check result the render()-side check just below
* handles $envContent/$envLoaded, and handles it for BOTH ways a session
* can end up locked: this explicit click, and the confirmation window
* simply expiring with nobody clicking anything. A clear here alone would
* only ever cover the first.
*/
public function forgetPasswordConfirmation(): void
{
$this->lockAgain();
$this->check = null;
}
private function guardInfra(): void
{
$this->authorize('hosts.manage');
}
/** Capability AND a recently confirmed password, on every vault/.env action. */
private function guardSecrets(): void
{
$this->authorize('secrets.manage');
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
$vault = app(SecretVault::class);
$canSecrets = Gate::allows('secrets.manage');
$canInfra = Gate::allows('hosts.manage');
$unlocked = $this->passwordRecentlyConfirmed();
// Loaded here rather than in mount(): a page that is merely reachable
// must not already hold the whole credential file in component state
// for an operator who has not confirmed a password this session.
if ($canSecrets && $unlocked && ! $this->envLoaded) {
$this->envContent = app(EnvFileEditor::class)->read();
$this->envLoaded = true;
}
// The mirror case, and the one forgetPasswordConfirmation() alone
// does not cover: the confirmation window can also expire on its
// own, with nobody clicking "Wieder sperren" — $unlocked simply goes
// false on whatever the next render happens to be. Without this, the
// textarea disappears from the page but $envContent — the ENTIRE
// credential file — stays sitting in the component snapshot, reachable
// to anyone with the browser this was left open on (Codex review,
// P1). Checked on every render, not only the explicit lock action.
if (! $unlocked && $this->envLoaded) {
$this->envContent = '';
$this->envLoaded = false;
}
$restart = app(UpdateChannel::class)->state();
// The transition the small inline indicator exists to show: a restart
// THIS component asked for is no longer pending. Read off
// 'requested_at' rather than 'restarting': the latter is gated on
// agent_seen (see UpdateChannel::state()), so if the agent went
// quiet in the few seconds this was being waited on, 'restarting'
// would already read false EVEN THOUGH THE REQUEST FILE IS STILL
// SITTING THERE, unread — and this would announce "restarted"
// for a restart that never ran. 'requested_at' answers the only
// question that matters here — is a request still on disk — with no
// opinion about the agent either way. Fires once — the instant it
// does, envRestartWatching drops so the next poll (or the one after,
// on a slower host with no on-demand wake) does not dispatch it
// again. wire:poll on the .env section is what keeps calling
// render() while this is being waited on; see $envRestartWatching's
// own docblock for why that is enough and the full-page deployment
// overlay is not needed here.
if ($this->envRestartWatching && $restart['requested_at'] === null) {
$this->envRestartWatching = false;
$this->dispatch('notify', message: __('integrations.env_restart_done'));
}
return view('livewire.admin.integrations', [
'canSecrets' => $canSecrets,
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'restart' => $restart,
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => __($meta['label']),
'envKey' => $meta['env_key'],
'testable' => isset($meta['check']),
'source' => $vault->source($key),
'outline' => $unlocked ? $vault->outline($key) : null,
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
// The SSH identity is a multi-line PEM key: a single-line
// password field would mangle it on paste.
'multiline' => $key === 'ssh.private_key',
])
->keyBy('key'),
]);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Invoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every document that has been issued, newest first.
*
* Read-only by design, and that is the feature. An issued invoice cannot be
* edited or deleted here or anywhere else a wrong one is corrected by issuing
* a cancellation and a new document, which is the only lawful way to correct
* one. There is no edit button to explain the absence of.
*/
#[Layout('layouts.admin')]
class Invoices extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $year = '';
public function mount(): void
{
$this->authorize('site.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedYear(): void
{
$this->resetPage();
}
public function render()
{
$invoices = Invoice::query()
->with(['customer', 'series'])
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('number', 'like', $term)
->orWhereHas('customer', fn ($c) => $c->where('name', 'like', $term)));
})
->when($this->year !== '', fn ($q) => $q->whereYear('issued_on', (int) $this->year))
->orderByDesc('issued_on')
->orderByDesc('id')
->paginate(25);
return view('livewire.admin.invoices', [
'invoices' => $invoices,
// Straight from the rows rather than a fixed range: a list of years
// with nothing in them is a filter that mostly returns nothing.
//
// Derived in PHP rather than with YEAR() in raw SQL. That function
// is MySQL's; SQLite has no such thing, and a query that only runs
// on one engine turns the test database into a different product
// from the real one.
'years' => Invoice::query()
->orderByDesc('issued_on')
->pluck('issued_on')
// ->local() even for a year (R19): on New Year's night the UTC
// year is still the previous one, so the filter would offer a
// year the operator is no longer in.
->map(fn ($date) => $date?->local()->format('Y'))
->filter()
->unique()
->values(),
]);
}
}

201
app/Livewire/Admin/Mail.php Normal file
View File

@ -0,0 +1,201 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The sending addresses, and which kind of mail leaves from which.
*
* The server sits at the top because there is one of it; the mailboxes are a
* list because there are several; the mapping is last because it only makes
* sense once both exist. The test-send button lives with the mailboxes: it
* proves one specific mailbox can actually send, which is the only thing
* that makes the rest of this page more than a form.
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
use ConfirmsPassword;
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> purpose => mailbox key */
public array $purposes = [];
public string $testRecipient = '';
/** @var array{ok: bool, error: ?string}|null */
public ?array $testResult = null;
public ?string $testedKey = null;
/**
* Whether credentials can be stored at all on this installation.
*
* Public so the page can say it rather than throwing on the first password
* a mailbox tries to decrypt the same courtesy the secrets page already
* extends. A missing SECRETS_KEY is a setup state, not an error.
*/
public bool $usable = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
$this->authorize('mail.manage');
$this->host = (string) Settings::get('mail.host', '');
$this->port = (int) Settings::get('mail.port', 587);
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
foreach (MailPurpose::ALL as $purpose) {
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
}
}
public function saveServer(): void
{
$this->authorize('mail.manage');
// $this->host is the platform's outbound relay for every purpose
// mailbox at once — pointing it at an attacker's server intercepts
// everything CluPilot sends. The capability decides who may open this
// page; a recent password decides whether THIS session may repoint
// it, the same second gate Admin\Integrations' vault entries use and
// for the same reason: the realistic threat is an unlocked machine, not a
// stranger. savePurposes() and test() stay on the capability alone —
// this is the "changing an address" split's one exception.
abort_unless($this->passwordRecentlyConfirmed(), 403);
// Rules on the ACTION, not on the property: a #[Validate] attribute on
// a Livewire property applies class-wide, and savePurposes() below
// would drag these along.
$this->validate([
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'encryption' => ['required', 'in:tls,ssl,none'],
]);
// Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test
// against THIS server config specifically — the shared-server mirror
// of EditMailbox::save()'s guard on a single mailbox's own address/
// username/authenticates. Compared against the STORED values, not
// just "did the operator touch the field": re-opening the card and
// saving without editing anything must not wipe a legitimate
// verification. Read before Settings::set() overwrites them below,
// and deliberately not merged with EditMailbox's own comparison — that
// one diffs a single loaded model's in-memory attributes, this one
// diffs persisted Settings against incoming scalars for a config that
// has no single row to load at all; forcing one comparison function
// over both shapes would add an abstraction with nothing genuinely
// shared to justify it. What IS shared is the actual clear itself:
// both this method and EditMailbox::save() delegate to a Mailbox::
// invalidate*() method rather than touching the column directly.
$serverChanged = $this->host !== (string) Settings::get('mail.host', '')
|| (int) $this->port !== (int) Settings::get('mail.port', 587)
|| $this->encryption !== (string) Settings::get('mail.encryption', 'tls');
Settings::set('mail.host', $this->host);
Settings::set('mail.port', (int) $this->port);
Settings::set('mail.encryption', $this->encryption);
if ($serverChanged) {
Mailbox::invalidateAllVerifications();
}
$this->dispatch('notify', message: __('mail_settings.server_saved'));
}
public function savePurposes(): void
{
$this->authorize('mail.manage');
// Reachable by posting straight to /livewire/update, past whatever
// the <select> in the view would ever submit — so "is a nonempty
// string" is not enough. Every purpose's key must name a mailbox
// that actually exists; system's additionally must be ACTIVE, because
// MailboxResolver::for() falls every unmapped (or inactive) OTHER
// purpose through to system, and there is nothing left to fall back
// to when system itself is the broken one.
$activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
$allKeys = Mailbox::query()->pluck('key')->all();
$rules = [
// system is the fallback, so it is the one that may not be empty
// — there is nothing left to fall back to.
'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
if ($value !== '' && ! in_array($value, $activeKeys, true)) {
$fail(__('mail_settings.system_inactive'));
}
}],
];
foreach (MailPurpose::ALL as $purpose) {
if ($purpose === MailPurpose::SYSTEM) {
continue;
}
// Blank is a legitimate choice for every OTHER purpose —
// MailboxResolver already falls an unmapped (or inactive)
// purpose through to system on its own, so only a key that names
// NO mailbox at all (a deleted row, or a tampered payload) is
// refused here.
$rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
if ($value !== '' && ! in_array($value, $allKeys, true)) {
$fail(__('mail_settings.purpose_unknown_mailbox'));
}
}];
}
$this->validate($rules, [
'purposes.system.required' => __('mail_settings.system_required'),
]);
foreach (MailPurpose::ALL as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
public function test(string $uuid): void
{
$this->authorize('mail.manage');
$this->validate(
['testRecipient' => ['required', 'email']],
['testRecipient.required' => __('mail_settings.test_recipient_required')],
);
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->testedKey = $box->key;
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
}
public function render()
{
$this->usable = app(SecretCipher::class)->isUsable();
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceWindow;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Maintenance extends Component
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $publicDescription = '';
#[Validate('nullable|string|max:2000')]
public string $internalNotes = '';
#[Validate('required|date')]
public string $startsAt = '';
#[Validate('required|date')]
public string $endsAt = '';
/** @var array<int> */
public array $hostIds = [];
/** Toggle every host of one datacenter (select-all / deselect-all). */
public function selectDatacenter(string $code): void
{
$this->authorize('maintenance.manage');
$ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all();
$current = array_map('strval', $this->hostIds);
$this->hostIds = empty(array_diff($ids, $current))
? array_values(array_diff($current, $ids)) // all selected → clear them
: array_values(array_unique([...$current, ...$ids]));
}
/**
* Set the end from the start. Typing a full timestamp by hand is the
* fiddliest part of this form, and the end is almost always "start plus a
* round number of minutes".
*/
public function setDuration(int $minutes): void
{
$this->authorize('maintenance.manage');
$start = $this->parsed($this->startsAt);
if ($start === null) {
// No start yet: assume the next half hour, which is what someone
// scheduling a window in a hurry means anyway.
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
$this->startsAt = LocalTime::toField($start);
}
$this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes));
}
/** Minutes between start and end, or null while either is unusable. */
public function durationMinutes(): ?int
{
$start = $this->parsed($this->startsAt);
$end = $this->parsed($this->endsAt);
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
return null;
}
return (int) $start->diffInMinutes($end);
}
/** What the operator typed, as UTC. Half-typed input is normal here. */
private function parsed(string $value): ?\Illuminate\Support\Carbon
{
return LocalTime::fromField($value);
}
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
$this->persist('draft');
}
public function publish(): void
{
$this->authorize('maintenance.manage');
$window = $this->persist('scheduled');
if ($window !== null) {
app(MaintenanceNotifier::class)->announce($window);
}
}
private function persist(string $state): ?MaintenanceWindow
{
$data = $this->validate([
'title' => 'required|string|max:255',
'publicDescription' => 'nullable|string|max:2000',
'internalNotes' => 'nullable|string|max:2000',
'startsAt' => 'required|date',
'endsAt' => 'required|date',
'hostIds' => 'array',
'hostIds.*' => 'integer|exists:hosts,id',
]);
$starts = LocalTime::fromField($data['startsAt']);
$ends = LocalTime::fromField($data['endsAt']);
if ($ends->lessThanOrEqualTo($starts)) {
$this->addError('endsAt', __('maintenance.end_after_start'));
return null;
}
// A window always needs a host — otherwise a hostless draft can never be
// published (there is no edit-hosts action) and is stuck.
if (empty($this->hostIds)) {
$this->addError('hostIds', __('maintenance.need_host'));
return null;
}
if ($state === 'scheduled' && $ends->isPast()) {
$this->addError('endsAt', __('maintenance.end_future'));
return null;
}
// Create the window and attach hosts atomically so a bad id can't leave
// an orphaned scheduled window behind.
$window = DB::transaction(function () use ($data, $starts, $ends, $state) {
$window = MaintenanceWindow::create([
'title' => $data['title'],
'public_description' => $data['publicDescription'] ?: null,
'internal_notes' => $data['internalNotes'] ?: null,
'starts_at' => $starts,
'ends_at' => $ends,
'state' => $state,
'created_by' => Auth::guard('operator')->id(),
'published_at' => $state === 'scheduled' ? now() : null,
]);
$window->hosts()->sync(array_map('intval', $data['hostIds'] ?? []));
return $window;
});
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
return $window;
}
public function publishExisting(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state !== 'draft') {
return;
}
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
$this->dispatch('notify', message: __('maintenance.need_host'));
return;
}
$window->update(['state' => 'scheduled', 'published_at' => now()]);
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.published'));
}
/**
* Re-run announcements for a published window. Idempotent (ledger-guarded),
* so it only fills gaps the retry path when a transient queue outage left
* some affected customers un-notified.
*/
public function resend(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Only announce for a window that has not yet ended (derived state).
if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) {
return;
}
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.notified'));
}
public function cancel(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Cannot cancel what is already cancelled or has already completed.
if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) {
return;
}
$wasPublished = $window->state === 'scheduled';
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
// Customers who received an announcement are told it's cancelled. The
// MessageSending listener suppresses still-pending announcements, and the
// MessageSent listener catches the race (an announcement that delivers
// just as we cancel) by queuing a catch-up cancellation for it.
if ($wasPublished) {
app(MaintenanceNotifier::class)->notifyCancellation($window);
}
$this->dispatch('notify', message: __('maintenance.cancelled'));
}
public function render()
{
$windows = MaintenanceWindow::query()
->withCount('hosts')
->orderByDesc('starts_at')
->get()
->map(fn (MaintenanceWindow $w) => [
'uuid' => $w->uuid,
'title' => $w->title,
'starts_at' => $w->starts_at,
'ends_at' => $w->ends_at,
'state' => $w->derivedState(),
'hosts' => $w->hosts_count,
'affected' => $w->affectedCustomers()->count(),
'is_draft' => $w->state === 'draft',
'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true),
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
]);
return view('livewire.admin.maintenance', [
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
'durationMinutes' => $this->durationMinutes(),
]);
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's front page every figure on it comes from the database.
*
* It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
* named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
* business and reported nothing. Anything without a real source has been
* removed rather than approximated the revenue trend line especially, because
* there is no revenue history to draw it from, and a plausible line is worse
* than no line.
*/
#[Layout('layouts.admin')]
class Overview extends Component
{
// current_step is an index into the pipeline, not a name. The provisioning
// page already owns the translation from one to the other; sharing it keeps
// the two pages from naming the same step differently.
use BuildsRunSteps;
/** A host is presumed missing once it has not checked in for this long. */
private const HOST_SILENT_AFTER_MINUTES = 30;
public function render()
{
$locale = app()->getLocale();
$customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
$customersSuspended = Customer::query()->where('status', 'suspended')->count();
$instancesByStatus = Instance::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
$hostsByStatus = Host::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
return view('livewire.admin.overview', [
'kpis' => [
[
'label' => __('admin.kpi.customers'),
'value' => (string) $customersOpen,
'sub' => $customersSuspended > 0
? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
: null,
],
[
'label' => __('admin.kpi.instances'),
'value' => (string) ($instancesByStatus['active'] ?? 0),
'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
],
[
'label' => __('admin.kpi.hosts'),
'value' => (string) ($hostsByStatus['active'] ?? 0),
'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
],
[
'label' => __('admin.kpi.mrr'),
'value' => $this->monthlyRevenue($locale),
'sub' => __('admin.kpi.mrr_sub'),
],
],
'newInstances' => $this->newInstancesPerMonth($locale),
'hostLoad' => $this->hostLoad(),
'runs' => $this->openRuns(),
'notices' => $notices = $this->notices(),
'noticeCount' => count($notices),
]);
}
/**
* What the estate bills per month, net.
*
* Off the contracts, not the catalogue: a price rise must not inflate
* reported revenue for every grandfathered customer overnight. Add-ons
* included and yearly terms divided totalMonthlyCents() owns both rules.
*
* Grouped by currency and never summed across them. Adding francs to euros
* produces a number that is wrong in a way nobody notices.
*/
private function monthlyRevenue(string $locale): string
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
}
});
if ($byCurrency === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($byCurrency)
->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Instances created per month over the last twelve.
*
* Deliberately "created", not "in operation": reconstructing how many were
* running at the end of some past month would need a history nobody keeps,
* and the honest version of that chart is this one with a truthful label.
*
* @return array<string, mixed>
*/
private function newInstancesPerMonth(string $locale): array
{
$start = Carbon::now()->startOfMonth()->subMonths(11);
$counts = Instance::query()
->where('created_at', '>=', $start)
->get(['created_at'])
->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));
$labels = [];
$data = [];
for ($i = 0; $i < 12; $i++) {
$month = $start->copy()->addMonths($i);
$labels[] = $month->local()->locale($locale)->isoFormat('MMM');
$data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
}
return [
'empty' => array_sum($data) === 0,
'config' => [
'type' => 'bar',
'data' => [
'labels' => $labels,
'datasets' => [[
'label' => __('admin.new_instances'),
'data' => $data,
'backgroundColor' => 'token:accent',
'borderRadius' => 3,
'maxBarThickness' => 26,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
// Whole instances only — a y-axis offering "1.5 instances"
// is the giveaway that a chart was never looked at.
'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
];
}
/**
* Storage committed on each host, against the capacity placement may use.
*
* Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
* a quicker sum here. Placement counts the VM disk allocation, ignores a
* failed instance that never got a VM, and subtracts the host's reserve
* a dashboard doing its own arithmetic would show a host as comfortable
* while placement was already refusing to put anything on it, and the
* disagreement would only surface when an order failed.
*
* @return array<int, array<string, mixed>>
*/
private function hostLoad(): array
{
return Host::query()
// Preloaded so listing hosts is one query, not one per host.
->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
->orderBy('name')
->get()
->map(function (Host $host) {
$usable = $host->freeGb();
$committed = $host->committedGb();
// Same ratio usedPct() states, computed from the two values
// already in hand rather than asking the host to sum again.
$pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;
return [
'name' => $host->name,
'pct' => $pct,
'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
'level' => match (true) {
$usable === 0 => 'unknown',
$pct >= 90 => 'high',
$pct >= 75 => 'warn',
default => 'ok',
},
];
})
->all();
}
/**
* Provisioning runs that have not finished.
*
* @return array<int, array<string, mixed>>
*/
private function openRuns(): array
{
return ProvisioningRun::query()
->whereIn('status', [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
ProvisioningRun::STATUS_PAUSED,
])
// Nested through the morph, so naming the customer does not cost a
// query per run.
->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
->latest('id')
->limit(6)
->get()
->map(fn (ProvisioningRun $run) => [
'subject' => $this->describeSubject($run),
'step' => $this->currentStepLabel($run),
'status' => $run->status,
])
->all();
}
/**
* Everything currently wrong, from the records that know it.
*
* There is no invented alert here. If this list is empty, nothing in the
* database is reporting a problem which is a statement worth being able
* to trust.
*
* @return array<int, array<string, string>>
*/
private function notices(): array
{
$notices = [];
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
if ($failedRuns > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])];
}
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])];
}
$silent = Host::query()
->where('status', 'active')
->where(fn ($q) => $q
->whereNull('last_seen_at')
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
->pluck('name');
foreach ($silent as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
])];
}
// Distinct instances, not targets: one instance can be watched by
// several checks, and counting checks would report three outages where
// one machine is down. And only verdicts the sync job has refreshed —
// an unchecked row is not a healthy one, nor a failing one.
$down = MonitoringTarget::query()
->where('status', 'down')
->where('checked_at', '>=', Carbon::now()->subMinutes(20))
->distinct()
->count('instance_id');
if ($down > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
}
return $notices;
}
private function describeSubject(ProvisioningRun $run): string
{
$subject = $run->subject;
return match (true) {
$subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
$subject instanceof Host => $subject->name,
default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
};
}
}

View File

@ -0,0 +1,262 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;
/**
* The versions of one plan: what it can do, what it costs, and when it sells.
*
* The page is built around the one rule that matters here a draft can be
* edited freely, a published version cannot be touched at all. So drafting and
* publishing are separate acts, and publishing says plainly that it is final.
*/
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
public string $uuid;
/** The draft being written. */
public int $quotaGb = 100;
public int $trafficGb = 1000;
public int $seats = 5;
public int $ramMb = 4096;
public int $cores = 2;
public int $diskGb = 120;
public string $performance = 'standard';
public ?int $templateVmid = 9000;
/** @var array<int, string> */
public array $features = [];
/**
* In EUROS, as typed. The catalogue stores cents; asking the owner to type
* them too turned €799 into "79900", where one slipped digit is a factor of
* ten on an invoice. Money::toCents does the conversion, once.
*/
public string $monthlyPrice = '49,00';
public string $yearlyPrice = '588,00';
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
public string $availableUntil = '';
public ?string $publishing = null;
public function mount(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
// Start the draft from what this plan is today, so the common case —
// "same plan, new price" — is a single edit rather than nine.
$latest = $family->versions()->orderByDesc('version')->first();
if ($latest !== null) {
$this->quotaGb = $latest->quota_gb;
$this->trafficGb = $latest->traffic_gb;
$this->seats = $latest->seats;
$this->ramMb = $latest->ram_mb;
$this->cores = $latest->cores;
$this->diskGb = $latest->disk_gb;
$this->performance = (string) ($latest->performance ?? 'standard');
$this->templateVmid = $latest->template_vmid;
$this->features = $latest->features ?? [];
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
}
}
private function family(): PlanFamily
{
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
}
/** Write a new draft. Nothing is promised until it is published. */
public function draft(): void
{
$this->authorize('plans.manage');
// Every bound is explicit, and generous rather than tight. A mistyped
// figure has to come back as a message on the field; without an upper
// limit it overflows the column instead and the owner gets a 500 with
// no idea which number was wrong.
$data = $this->validate([
'quotaGb' => 'required|integer|min:1|max:1000000',
'trafficGb' => 'required|integer|min:0|max:10000000',
'seats' => 'required|integer|min:1|max:100000',
'ramMb' => 'required|integer|min:512|max:4194304',
'cores' => 'required|integer|min:1|max:512',
// The disk has to hold the customer's quota plus the system itself.
'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
// One of the classes we actually have a label for. Free text means
// a typo is published, frozen, and shown to customers forever as a
// raw translation key.
'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
// Required, not nullable: a version without a blueprint can be
// published and sold, and then fails provisioning every time.
'templateVmid' => 'required|integer|min:100|max:999999999',
'features' => 'array',
// Same reason as the performance class: a request can carry
// anything, and an unknown key is frozen at publication and shown
// to customers as a raw translation key.
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
// Euros with at most two decimals, either separator. The upper
// bound lives in Money's pattern: nine whole digits is far past
// anything we would charge and far short of overflowing the column.
// Checked as a rule, not afterwards: a price fault has to be
// reported alongside every other fault on the form, and a check
// that runs after validate() never runs at all once some other
// field has already thrown.
'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
if (Money::toCents((string) $value) === null) {
$fail(__('plans.price_invalid'));
}
}],
'yearlyPrice' => ['required', 'string', $price],
]);
$monthlyCents = Money::toCents($data['monthlyPrice']);
$yearlyCents = Money::toCents($data['yearlyPrice']);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
[
'quota_gb' => $data['quotaGb'],
'traffic_gb' => $data['trafficGb'],
'seats' => $data['seats'],
'ram_mb' => $data['ramMb'],
'cores' => $data['cores'],
'disk_gb' => $data['diskGb'],
'performance' => $data['performance'],
'template_vmid' => $data['templateVmid'],
'features' => array_values($data['features']),
],
// Both terms, because publishing refuses a version that is not
// priced for each — better to find that out here than in front of
// a customer.
[
Subscription::TERM_MONTHLY => $monthlyCents,
Subscription::TERM_YEARLY => $yearlyCents,
],
);
$this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
}
/** Open the publish form for one draft. */
public function choose(string $versionUuid): void
{
$this->publishing = $versionUuid;
$this->availableFrom = LocalTime::toField(now());
$this->availableUntil = '';
}
public function cancelPublish(): void
{
$this->reset('publishing', 'availableFrom', 'availableUntil');
}
/**
* Publish: fix the terms and put them on sale.
*
* Everything that can refuse this an unpriced version, a window that
* overlaps another refuses before anything is written, so a rejected
* publish leaves an editable draft rather than a stranded one.
*/
public function publish(): void
{
$this->authorize('plans.manage');
$this->validate([
'availableFrom' => 'required|date',
'availableUntil' => 'nullable|date|after:availableFrom',
]);
$version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->publish(
$version,
LocalTime::fromField($this->availableFrom),
LocalTime::fromField($this->availableUntil),
);
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->cancelPublish();
$this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
}
/**
* Close a running version's window the ordinary way to take a plan off
* sale for good, since a published version can never be deleted.
*/
public function close(string $versionUuid): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
}
#[On('plan-draft-deleted')]
public function refresh(): void
{
// Livewire re-renders; the listener exists so the modal can say so.
}
public function render()
{
$family = $this->family();
$now = now();
return view('livewire.admin.plan-versions', [
'family' => $family,
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
'now' => $now,
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),
]);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The plan lines we sell, and whether each is currently on sale.
*
* Deliberately shallow: a family is a name and a rank, and everything that can
* be got wrong capabilities, prices, windows lives one level down on the
* versions, where publishing makes it permanent.
*/
#[Layout('layouts.admin')]
class Plans extends Component
{
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
public string $key = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|integer|min:0|max:255')]
public int $tier = 1;
public function mount(): void
{
// The page itself, not only its buttons. Hiding the nav entry and
// guarding the mutations still leaves the whole catalogue — prices,
// drafts, unreleased plans — readable to any operator who types the URL.
$this->authorize('plans.manage');
}
public function save(): void
{
$this->authorize('plans.manage');
$this->key = strtolower(trim($this->key));
$data = $this->validate([
// The key is permanent once created — orders, instances and every
// contract snapshot store it as a string — so it has to be a plain
// identifier, not something anyone will want to prettify later.
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
'name' => 'required|string|max:255',
'tier' => 'required|integer|min:0|max:255',
]);
PlanFamily::create([
'key' => $data['key'],
'name' => $data['name'],
'tier' => $data['tier'],
// Nothing to sell until a version is published, so it starts able
// to sell and simply has nothing available.
'sales_enabled' => true,
]);
$this->reset('key', 'name', 'tier');
$this->tier = 1;
$this->dispatch('notify', message: __('plans.created'));
}
/**
* The kill switch: stop selling this plan now, without touching a window.
*
* Existing customers keep their contract and their machine this only
* decides whether anyone new can buy it.
*/
public function toggleSales(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->first();
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
}
public function render()
{
$now = now();
$families = PlanFamily::query()
->with(['versions.prices'])
->withCount('versions')
->orderBy('tier')
->get()
->map(fn (PlanFamily $family) => [
'uuid' => $family->uuid,
'key' => $family->key,
'name' => $family->name,
'tier' => $family->tier,
'sales_enabled' => $family->sales_enabled,
'versions_count' => $family->versions_count,
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
// What is queued up next, so a scheduled launch is visible
// before a customer discovers it.
'next' => $family->versions
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
->sortBy('available_from')
->first(),
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
]);
return view('livewire.admin.plans', [
'families' => $families,
// The shop's own answer, so this page cannot disagree with what a
// customer actually sees.
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
]);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class Provisioning extends Component
{
use BuildsRunSteps;
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
// A round-trip re-renders with fresh run state.
}
/** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void
{
$this->authorize('provisioning.retry');
// Atomically claim the run: only the request that transitions it out of
// FAILED proceeds, so two concurrent retries can't double-dispatch.
$claimed = ProvisioningRun::query()
->where('uuid', $uuid)
->where('status', ProvisioningRun::STATUS_FAILED)
->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
if ($claimed === 0) {
return; // already retried by a concurrent request
}
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null) {
return;
}
// Move the subject out of its error state so the console reflects the retry.
$subject = $run->subject;
if ($subject instanceof Host) {
$subject->update(['status' => 'onboarding']);
} elseif ($subject instanceof Order) {
$subject->update(['status' => 'provisioning']);
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
}
AdvanceRunJob::dispatch($run->uuid);
$this->dispatch('notify', message: __('admin.run_retried'));
}
/**
* A run that claims to be in progress but hasn't advanced in a while looks
* stuck unless it is in a legitimate scheduled backoff (next_attempt_at in
* the future, e.g. RunRunner's retry delay of up to 300 s).
*/
private function isStale(ProvisioningRun $run): bool
{
if (! in_array($run->status, ['running', 'waiting'], true)) {
return false;
}
if ($run->next_attempt_at !== null && $run->next_attempt_at->isFuture()) {
return false; // waiting out a scheduled retry/poll — not stuck
}
// Compare against the CURRENT step's own allowed duration — some steps
// (VM clone, Proxmox install) legitimately run for many minutes — plus a
// small grace. Only past its own deadline without advancing is it stuck.
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
$maxSeconds = $class !== null ? app($class)->maxDuration() : 120;
return $run->updated_at !== null && $run->updated_at->lt(now()->subSeconds($maxSeconds + 30));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
if ($subject instanceof Order) {
return $subject->customer?->name ?? 'Order';
}
if ($subject instanceof Host) {
return $subject->name;
}
return class_basename($run->subject_type);
}
public function render()
{
$runs = ProvisioningRun::query()->latest('id')->limit(30)->get();
$active = $runs->first(fn (ProvisioningRun $r) => in_array(
$r->status, ['pending', 'running', 'waiting'], true
)) ?? $runs->first();
return view('livewire.admin.provisioning', [
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
'rows' => $runs->map(function (ProvisioningRun $r) {
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
return [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
'activity' => $r->updated_at?->diffForHumans() ?? '—',
'stale' => $this->isStale($r),
];
})->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
}
/** Compact "current run" readout for the right column. */
private function panelFor(ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$total = max(count($pipeline), 1);
$current = min($run->current_step, $total - 1);
$completed = $run->status === ProvisioningRun::STATUS_COMPLETED;
$done = $completed ? $total : $current;
return [
'uuid' => $run->uuid,
'subject' => $this->subjectLabel($run),
'pipeline' => $run->pipeline,
'attempt' => $run->attempt,
'status' => $this->runState($run), // running|done|failed
'failed' => $run->status === ProvisioningRun::STATUS_FAILED,
'current' => $completed ? null : (isset($pipeline[$current]) ? __(app($pipeline[$current])->label()) : null),
'next' => isset($pipeline[$current + 1]) && ! $completed ? __(app($pipeline[$current + 1])->label()) : null,
'error' => $run->error,
'done' => $done,
'total' => $total,
'percent' => (int) round($done / $total * 100),
'activity' => $run->updated_at?->diffForHumans() ?? '—',
'started' => $run->started_at?->diffForHumans() ?? null,
'stale' => $this->isStale($run),
];
}
}

View File

@ -0,0 +1,203 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Revenue, from the contracts and the commercial register.
*
* What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
* curve, four payments from customers who do not exist. All of it is gone.
*
* Two figures that used to be shown are not shown any more, because there is
* nothing to compute them from and an estimate would be indistinguishable from
* a measurement:
*
* - the MRR trend, which needs a monthly revenue history nobody records;
* - churn, which needs cancellations over a period, and cancelled_at alone
* does not say what the base was.
*
* ARR is shown, but only as what it is: this month's recurring revenue times
* twelve, labelled as a projection rather than as measured turnover.
*/
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$totals = $this->recurringTotals();
return view('livewire.admin.revenue', [
'kpis' => [
[
'label' => __('admin.rev.mrr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
'sub' => __('admin.rev.mrr_sub'),
],
[
'label' => __('admin.rev.arr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
'sub' => __('admin.rev.arr_sub'),
],
[
'label' => __('admin.rev.arpu'),
'value' => $this->format(
$totals,
fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
$locale,
),
'sub' => __('admin.rev.arpu_sub'),
],
[
'label' => __('admin.rev.contracts'),
'value' => (string) array_sum(array_column($totals, 'contracts')),
'sub' => __('admin.rev.contracts_sub'),
],
],
'planCharts' => $this->planCharts(),
'payments' => $this->recentPayments($locale),
]);
}
/**
* Recurring revenue per currency, off the contracts.
*
* Never summed across currencies. Frozen contract prices, not the
* catalogue, so a price rise does not retroactively inflate what
* grandfathered customers are reported to pay. Add-ons included and yearly
* terms divided totalMonthlyCents() owns both of those rules.
*
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
*/
private function recurringTotals(): array
{
$totals = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$totals) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
$totals[$currency]['cents'] += $subscription->totalMonthlyCents();
$totals[$currency]['contracts']++;
// A customer with two contracts is one customer — ARPU
// divided by contracts would understate it.
$totals[$currency]['customers'][(int) $subscription->customer_id] = true;
}
});
return $totals;
}
/**
* @param array<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
* @param callable(array{cents:int, customers:array<int,bool>, contracts:int}): float $amount
*/
private function format(array $totals, callable $amount, string $locale): string
{
if ($totals === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($totals)
->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Recurring revenue split by plan one chart per currency.
*
* By what customers are ON, not by what is on sale: a withdrawn plan still
* bills, and leaving it out would understate the total the chart sits next
* to.
*
* Per currency, because a doughnut adds its slices together. Two currencies
* in one ring produces a total that is not an amount of anything, and it
* looks exactly as convincing as a correct one.
*
* @return array<int, array{currency:string, config:array<string,mixed>}>
*/
private function planCharts(): array
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$plan = (string) $subscription->plan;
$byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
+ $subscription->totalMonthlyCents();
}
});
$charts = [];
foreach ($byCurrency as $currency => $byPlan) {
arsort($byPlan);
$charts[] = [
'currency' => $currency,
'config' => [
'type' => 'doughnut',
'data' => [
'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
'datasets' => [[
'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
];
}
return $charts;
}
/**
* The last payments actually recorded.
*
* Both kinds count: an ordinary billing cycle is written as `renewal`, and
* `invoice_paid` is reserved for everything else Stripe charges for a
* proration, a manual invoice. Listing only the second would show a
* payments panel with no ordinary payments in it.
*
* Gross, because that is what left the customer's account. The register
* holds net and tax separately for the books.
*
* @return array<int, array<string, string>>
*/
private function recentPayments(string $locale): array
{
return SubscriptionRecord::query()
->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
->orderByDesc('occurred_at')
->limit(8)
->get()
->map(fn (SubscriptionRecord $r) => [
'customer' => $r->customer_name ?: '—',
'amount' => Number::currency(
(int) ($r->gross_cents ?: $r->net_cents) / 100,
in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
locale: $locale,
),
'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
])
->all();
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Services\Devices\SessionRegistry;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* "Where am I signed in", for an operator.
*
* Deliberately not the portal's component with a guard passed in: two
* identities, two guards, two tables (R21), and a component that took the guard
* as input would be one forged property away from ending a customer's sessions
* from the console. The view is shared, because markup is not identity.
*/
class Sessions extends Component
{
use ResolvesOperator;
private const GUARD = 'operator';
public function end(string $uuid): void
{
if (! $operator = $this->currentOperator()) {
return;
}
app(SessionRegistry::class)->end(self::GUARD, (int) $operator->getKey(), $uuid);
$this->dispatch('notify', message: __('sessions.ended'));
}
/** Raised by the confirmation modal (R23), never called from the markup. */
#[On('sessions-end-others-confirmed')]
public function endOthers(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$registry = app(SessionRegistry::class);
$count = $registry->endOthers(self::GUARD, (int) $operator->getKey(), $registry->currentUuid());
$this->dispatch('notify', message: __('sessions.ended_others', ['n' => $count]));
}
public function render()
{
$operator = $this->currentOperator();
$registry = app(SessionRegistry::class);
$current = $registry->currentUuid();
return view('livewire.sessions', [
'sessions' => $operator
? $registry->for(self::GUARD, (int) $operator->getKey(), $current)
: collect(),
'confirmComponent' => 'admin.confirm-end-other-sessions',
]);
}
}

View File

@ -0,0 +1,484 @@
<?php
namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Operator settings: the signed-in operator's own account plus (Owner-only)
* staff management. All staff mutations are capability-gated server-side and
* protect the last-Owner and self-role invariants transactionally.
*
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup see
* RequireOperatorTwoFactor for why. Only the POLICY switch
* (saveTwoFactorPolicy(), below) stays here: only someone who already
* satisfies the policy should be the one changing it.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
use ChangesOwnPassword;
use ResolvesOperator;
/** This page changes the signed-in OPERATOR's password, never a portal one. */
protected function passwordAccountGuard(): string
{
return 'operator';
}
// My account
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Invite staff
#[Validate('required|string|max:255')]
public string $staffName = '';
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function mount(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$this->name = $operator->name;
$this->email = $operator->email;
$this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:operators,email,'.$operator->id,
]);
// An operator email must never collide with a customer's — that would
// block the customer from ever obtaining a portal login (ensureUser).
// Checked directly against `users`, not only `customers`: see
// Customer::emailTaken().
if (Customer::emailTaken($data['email'])) {
$this->addError('email', __('admin_settings.is_customer'));
return;
}
$operator->update($data);
$this->dispatch('notify', message: __('admin_settings.account_saved'));
}
public function inviteStaff(): void
{
$this->authorize('staff.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:operators,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
]);
// Only an Owner may create another Owner.
if ($data['staffRole'] === 'Owner' && ! $operator->hasRole('Owner')) {
$this->addError('staffRole', __('admin_settings.owner_only'));
return;
}
// Never turn a customer's portal login into an operator.
if (Customer::emailTaken($data['staffEmail'])) {
$this->addError('staffEmail', __('admin_settings.is_customer'));
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
$newStaff = Operator::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
]);
$newStaff->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
}
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
if (! in_array($role, Operator::OPERATOR_ROLES, true)) {
return;
}
if (! $operator = $this->currentOperator()) {
return;
}
$result = DB::transaction(function () use ($id, $role, $operator) {
// Serialize on the Owner role so the global owner count can't be
// raced to zero by concurrent demotions of different owners.
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null) {
return 'gone';
}
if ($target->id === $operator->id) {
return 'self';
}
// Only existing operators may be re-roled. The Customer check below
// can no longer fire in practice — operators and customers are
// different tables now, so a tampered id cannot resolve to one from
// the other — but it costs nothing to leave as a second line.
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
return 'not_staff';
}
// Granting or revoking Owner is Owner-only.
if (($role === 'Owner' || $target->hasRole('Owner')) && ! $operator->hasRole('Owner')) {
return 'owner_only';
}
// Never demote the last Owner.
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([$role]);
return 'ok';
});
$this->flash($result);
}
public function revokeStaff(int $id): void
{
$this->authorize('staff.manage');
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null || ! $target->isOperator()) {
return 'gone';
}
if ($target->id === Auth::guard('operator')->id()) {
return 'self';
}
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([]);
// Taking away the console but leaving the tunnel would be the worst
// of both: no access to the panel, still a route into the
// management network. Same revocation path as the VPN page uses.
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
$peer->purgeSecret();
$peer->delete();
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$this->flash($result);
}
private function flash(string $result): void
{
$msg = match ($result) {
'ok' => __('admin_settings.role_updated'),
'revoked' => __('admin_settings.staff_revoked'),
'self' => __('admin_settings.not_self'),
'owner_only' => __('admin_settings.owner_only'),
'last_owner' => __('admin_settings.last_owner'),
'not_staff' => __('admin_settings.not_staff'),
default => null,
};
if ($msg !== null) {
$this->dispatch('notify', message: $msg);
}
}
private function ownerCount(): int
{
return Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
}
/** A new entry for the console allowlist: a single address or a CIDR range. */
public string $consoleIp = '';
/**
* Add a network that may reach the console without the VPN.
*
* Validated as an address or CIDR before it is stored: a typo that silently
* matches nothing is how someone locks themselves out while believing they
* have not.
*/
public function addConsoleIp(): void
{
$this->authorize('site.manage');
$value = trim($this->consoleIp);
if (! RestrictConsoleNetwork::isNetwork($value)) {
$this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
return;
}
$list = (array) AppSettings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
AppSettings::set('console.allowed_ips', array_values($list));
}
$this->consoleIp = '';
$this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
}
/**
* Remove one unless it is the reason you can see this page.
*
* Refusing here rather than warning afterwards: by the time the page
* reloads, the request that would show the warning has already been
* rejected. The VPN is never in this list, so an operator on the VPN can
* always clear it out.
*/
public function removeConsoleIp(string $value): void
{
$this->authorize('site.manage');
$list = array_values(array_filter(
(array) AppSettings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
));
$ip = (string) request()->ip();
if (RestrictConsoleNetwork::isRestricted()
&& ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
return;
}
AppSettings::set('console.allowed_ips', $list);
$this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
}
/**
* Switch the restriction on or off.
*
* Turning it ON is refused unless the address doing the switching is
* already covered otherwise the click that secures the console is also
* the click that locks its owner out of it.
*/
public function toggleConsoleRestriction(): void
{
$this->authorize('site.manage');
$on = ! RestrictConsoleNetwork::isRestricted();
if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
return;
}
AppSettings::set('console.network_restricted', $on);
$this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
}
/** Whether two-factor is compulsory for every operator on the console. */
public bool $requireTwoFactor = false;
/**
* Make two-factor compulsory for every operator, or make it voluntary again.
*
* The lock-out guard, same shape as console.allowed_ips: the page that
* could switch this back off sits behind the switch.
*/
public function saveTwoFactorPolicy(): void
{
$this->authorize('site.manage');
if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
$this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));
return;
}
AppSettings::set('console.require_2fa', $this->requireTwoFactor);
$this->dispatch('notify', message: __('admin_settings.saved'));
}
/**
* Ask the host-side agent to update this installation a real run.
*
* Deliberately a request rather than an action: see UpdateChannel. The
* button cannot report success, because success means this container is
* restarted out from under the response so it reports that the update was
* asked for, and the page shows the outcome once the agent has written it.
*
* The sibling of requestCheck() below: this one applies, downtime and all
* but only a released version above the one installed. There is no longer
* such a thing as "update to whatever is on the branch".
*/
public function requestUpdate(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
// Checked here as well as in the markup. The button is disabled when
// there is nothing released to install, and the agent refuses such a
// request too — but a Livewire action is a public endpoint, and the one
// place a rule may not live is only in the disabled attribute of a
// button. Queueing a run for nothing costs a maintenance window.
if (! app(UpdateChannel::class)->state()['available']) {
$this->dispatch('notify', message: __('admin_settings.update_nothing_released'));
return;
}
$accepted = app(UpdateChannel::class)->request($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_requested'
: 'admin_settings.update_already_requested'));
}
/**
* Ask the host-side agent to look for updates never to apply one.
*
* For the operator who only wants an answer: no maintenance window, no
* restart, just the next tick's reading reported back.
*/
public function requestCheck(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestCheck($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_check_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();
$staff = Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->with('roles')
->orderBy('name')
->get()
->map(fn (Operator $u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === Auth::guard('operator')->id(),
]);
return view('livewire.admin.settings', [
'update' => app(UpdateChannel::class)->state(),
'updateLog' => app(UpdateChannel::class)->lastLog(),
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => $operator?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
// Who may reach the console, and from where the viewer is asking —
// shown so the consequence of a change is visible before it is made.
'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => Operator::OPERATOR_ROLES,
'canManageStaff' => $operator?->can('staff.manage') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
]);
}
}

View File

@ -0,0 +1,199 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Livewire\Concerns\ResolvesOperator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Where an operator sets their own two-factor login up.
*
* Its own page, not a card on Admin\Settings. RequireOperatorTwoFactor must
* be able to exempt ENROLMENT alone while compulsory two-factor is on the
* earlier shape exempted the whole of Admin\Settings on the theory that it
* was "the page where two-factor is set up", but that component also carries
* staff management, site visibility, network restrictions, account changes,
* and the two-factor policy switch itself. An unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* One route to exempt is one decision; eight actions on a shared page each
* remembering to check is eight chances to forget one.
*
* Reachable by EVERY operator, not gated behind any capability the
* compulsory switch (Admin\Settings::saveTwoFactorPolicy()) applies to every
* member of staff, so enrolment has to be reachable by every operator too.
*
* Every action is re-checked, server-side, against a recent password
* confirmation on every single call not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
#[Layout('layouts.admin')]
class TwoFactorSetup extends Component
{
use ConfirmsPassword;
use ResolvesOperator;
/** Two-factor is confirmed against the operator's own record, not a portal one. */
protected function confirmationGuard(): string
{
return 'operator';
}
/** Shown once, right after setting two-factor up. */
public ?array $recoveryCodes = null;
public string $twoFactorCode = '';
/**
* Start two-factor setup: generate the secret, then show the QR code.
*
* Not confirmed yet Fortify keeps `two_factor_confirmed_at` null until a
* code from the app has been accepted, so someone who scans nothing and
* closes the tab is not left locked out of their own account.
*/
public function enableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(EnableTwoFactorAuthentication::class)($operator);
}
/**
* Step back out of a half-finished setup.
*
* Without this the only way out of the QR step was to navigate away, which
* left an unconfirmed secret sitting on the account invisible, because
* nothing shows a pending enrolment anywhere else. Clearing it costs
* nothing: it was never confirmed, so it never protected anything.
*/
public function cancelSetup(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->twoFactorCode = '';
$this->resetErrorBag('twoFactorCode');
$this->dispatch('notify', message: __('two_factor_setup.cancelled'));
}
/** Accept a code from the app, which is what actually turns it on. */
public function confirmTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
try {
app(ConfirmTwoFactorAuthentication::class)(
$operator,
$this->twoFactorCode,
);
} catch (ValidationException $e) {
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('two_factor_setup.code_wrong'));
return;
}
$this->twoFactorCode = '';
// Shown once, here, because this is the only moment they exist in a
// form anyone will read. They stay retrievable afterwards, but nobody
// writes down what they were not shown.
$this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true);
$this->dispatch('notify', message: __('two_factor_setup.on'));
}
public function regenerateRecoveryCodes(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(GenerateNewRecoveryCodes::class)($operator);
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
}
public function disableTwoFactor(): void
{
$this->requireConfirmedPassword();
if (! $operator = $this->currentOperator()) {
return;
}
app(DisableTwoFactorAuthentication::class)($operator);
$this->recoveryCodes = null;
$this->dispatch('notify', message: __('two_factor_setup.off'));
}
/**
* The disable button opens ConfirmDisableTwoFactor instead of calling
* disableTwoFactor() directly (R23); its confirm button dispatches back
* here, and disableTwoFactor() still runs its own password check.
*/
#[On('two-factor-disable-confirmed')]
public function onDisableConfirmed(): void
{
$this->disableTwoFactor();
}
/**
* Every two-factor action goes through this.
*
* Server-side, on each call, and not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
private function requireConfirmedPassword(): void
{
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
// Unlike the actions above, render() has no requireConfirmedPassword()
// gate in front of it — Livewire calls it on every update, including a
// bare property sync, so it is its own reachable path for a session
// that ended after the page loaded.
if (! $operator = $this->currentOperator()) {
return view('livewire.admin.two-factor-setup', [
'twoFactorPending' => false,
'twoFactorOn' => false,
'twoFactorQr' => null,
'passwordConfirmed' => false,
]);
}
return view('livewire.admin.two-factor-setup', [
// Never the secret itself — only whether it exists, and the SVG
// Fortify renders from it. The secret in a Livewire property would
// travel to the browser and back in the component snapshot.
'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null,
'twoFactorOn' => $operator->two_factor_confirmed_at !== null,
'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null
? $operator->twoFactorQrCodeSvg()
: null,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

402
app/Livewire/Admin/Vpn.php Normal file
View File

@ -0,0 +1,402 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* VPN access management. The console container has no wg0, so live state is
* read from the database and refreshed by SyncVpnPeers on the provisioning
* queue the poll below only nudges that job, it never talks to WireGuard.
*/
#[Layout('layouts.admin')]
class Vpn extends Component
{
public string $name = '';
/** Optional: a peer that generated its own key never hands us the private half. */
public string $publicKey = '';
/** Opaque handle for the freshly created config; see ConfigHandoff. */
public ?string $configToken = null;
public ?string $newConfigName = null;
/** Owner of the new access. Defaults to the operator creating it. */
public ?int $ownerId = null;
/** Keep the config so its owner can fetch it again behind their password. */
public bool $storeConfig = false;
public bool $showQr = false;
public function mount(): void
{
$this->authorize('viewAny', VpnPeer::class);
$this->ownerId = Auth::guard('operator')->id();
}
/**
* mount() runs once; every later request including the five-second poll
* only hydrates. Without this, revoking vpn.manage would not take effect
* until the operator happened to reload the page, and the open tab would
* keep serving fresh peer state.
*/
public function hydrate(): void
{
$this->authorize('viewAny', VpnPeer::class);
}
public function create(): void
{
$this->authorize('create', VpnPeer::class);
$this->validate([
'name' => 'required|string|max:255',
'publicKey' => 'nullable|string|max:64',
// An access belongs to a person, and only to an operator: a customer
// account must never own a way into the management network — and
// now cannot even present as one, since operators live on their
// own table.
'ownerId' => ['required', Rule::exists('operators', 'id')],
]);
// Storing needs a key. Without one we would either write the credential
// in the clear or pretend we stored it — both worse than saying no.
if ($this->storeConfig && ! ConfigVault::available()) {
$this->addError('storeConfig', __('vpn.vault_unavailable'));
return;
}
$ownKey = trim($this->publicKey) !== '';
if ($this->storeConfig && $ownKey) {
$this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));
return;
}
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
$this->addError('publicKey', __('vpn.invalid_key'));
return;
}
$keypair = $ownKey ? null : Keypair::generate();
$publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;
$hub = app(WireguardHub::class);
// Same lock the host pipeline holds while it reserves an address
// (ConfigureWireguard). Addresses come from one subnet but live in two
// tables, so neither unique index can catch the other's insert — the
// shared lock is what keeps a host and an access off the same tunnel IP.
try {
// Filled inside the transaction; only the handoff needs it afterwards.
$plainConfig = null;
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => Auth::guard('operator')->id(),
]);
});
});
} catch (QueryException) {
// Backstop: the unique index caught a writer that did not take this
// lock. Report it like any other duplicate instead of a 500.
$this->addError('publicKey', __('vpn.duplicate_key'));
return;
}
if (is_string($peer)) {
$field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
$this->addError($field, __('vpn.'.$peer));
return;
}
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
// Whatever was on screen belongs to the previous access. Leaving it up
// after creating another one would present the wrong private config
// under the new name — and someone would hand it out.
$this->dismissConfig();
// Only a key we generated can be turned into a ready-to-use config.
if ($plainConfig !== null) {
$this->configToken = ConfigHandoff::put($plainConfig);
$this->newConfigName = $peer->name;
}
$this->reset('name', 'publicKey', 'storeConfig');
$this->ownerId = Auth::guard('operator')->id();
$this->dispatch('notify', message: __('vpn.created'));
}
public function toggle(string $uuid): void
{
// Looked up globally and then judged by the policy: filtering it out of
// the query here would turn "not allowed" into a button that silently
// does nothing, which is exactly how the portal used to lie to people.
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('block', $peer);
$peer->update(['enabled' => ! $peer->enabled]);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
$this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
}
#[On('vpn-peer-deleted')]
public function peerDeleted(): void
{
$this->dispatch('notify', message: __('vpn.deleted'));
}
/**
* The reissue button opens ConfirmReissueVpnPeer instead of calling
* reissue() directly (R23); its confirm button dispatches back here.
*/
#[On('vpn-peer-reissue-confirmed')]
public function onReissueConfirmed(string $uuid): void
{
$this->reissue($uuid);
}
/**
* Replace an access's keypair.
*
* The way back for an access whose config was never stored: nobody can hand
* out a private key that no longer exists, so the honest option is a new
* one. The old key stops working the moment the hub is updated, which is
* also what makes this the right tool for a lost or leaked device.
*/
public function reissue(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('update', $peer);
if ($peer->kind !== VpnPeer::KIND_STAFF) {
$this->dispatch('notify', message: __('vpn.reissue_staff_only'));
return;
}
$keypair = Keypair::generate();
$oldKey = $peer->public_key;
$config = $this->clientConfig($keypair, $peer->allowed_ip);
$peer->forceFill([
'public_key' => $keypair->publicKey,
'present' => false,
// Only re-stored when the vault can actually be used: without the key
// this would throw, and the console tells people to re-issue
// precisely when a stored config has become unreadable.
'config_secret' => $peer->hasStoredConfig() && ConfigVault::available()
? ConfigVault::encrypt($config)
: null,
])->save();
// Old key off the hub first, then the new one on: the other order would
// briefly leave two peers claiming the same tunnel address.
ApplyVpnPeer::dispatch($oldKey, null, false, force: true);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
$this->dismissConfig();
$this->configToken = ConfigHandoff::put($config);
$this->newConfigName = $peer->name;
$this->dispatch('notify', message: __('vpn.reissued'));
}
public function dismissConfig(): void
{
ConfigHandoff::forget($this->configToken);
$this->reset('configToken', 'newConfigName', 'showQr');
}
/**
* The list this operator is allowed to see: their own accesses, plus
* everything when they hold vpn.view.all. Filtering in the query as well as
* in the actions, so a forged uuid cannot reach a peer the page never showed.
*/
private function visiblePeers()
{
$query = VpnPeer::query();
if (! Auth::guard('operator')->user()?->can('vpn.view.all')) {
$query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', Auth::guard('operator')->id());
}
return $query;
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
/**
* wg-quick takes the interface name from the filename, and Linux caps
* interface names at 15 characters so the label is slugged and cut rather
* than handing the operator a file that fails to come up.
*/
public function configFilename(): string
{
$slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';
return Str::limit($slug, 15, '').'.conf';
}
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
public function refreshPeers(): void
{
if (Cache::add('vpn:sync-dispatched', true, 8)) {
SyncVpnPeers::dispatch();
}
}
/**
* What the client sends through the tunnel: the management subnet, and only
* that.
*
* It is tempting to add the server's own public address here, because the
* console lives at a public hostname and without that route the phone sends
* the request out over the mobile network instead it arrives from a
* carrier address, the proxy refuses it, and the operator sees a blank page
* while the VPN app says "connected".
*
* That fix cannot work. The WireGuard endpoint is the SAME address. Routing
* it into the tunnel routes the handshake packets into the tunnel too, and
* a tunnel cannot carry the packets that establish it the result is a
* loop and a connection that never comes up at all. Strictly worse: the
* same symptom, now with no way in.
*
* The console has to be reachable at an address that is INSIDE the subnet
* for the VPN to be the way in. That is a deployment change (the proxy
* answering on the hub address, and a name that resolves to it), not
* something this config line can express.
*/
private static function allowedIps(string $endpoint): string
{
return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
}
private function clientConfig(Keypair $keypair, string $ip): string
{
$hub = app(WireguardHub::class);
return implode("\n", [
'[Interface]',
'PrivateKey = '.$keypair->privateKey,
'Address = '.$ip.'/32',
// Only present when the console is actually reachable inside the
// tunnel. Setting it otherwise would hand the device a resolver
// that does not exist and take its name resolution with it.
...(config('admin_access.vpn_ready')
? ['DNS = '.config('provisioning.wireguard.hub_address', '10.66.0.1')]
: []),
'',
'[Peer]',
'PublicKey = '.$hub->publicKey(),
'Endpoint = '.$hub->endpoint(),
'AllowedIPs = '.self::allowedIps($hub->endpoint()),
'PersistentKeepalive = 25',
'',
]);
}
public function render()
{
$hub = app(WireguardHub::class);
return view('livewire.admin.vpn', [
'newConfig' => $config = ConfigHandoff::get($this->configToken),
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
'operators' => Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->orderBy('name')->get(['id', 'name', 'email']),
'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false,
'vaultAvailable' => ConfigVault::available(),
'hubEndpoint' => $hub->endpoint(),
'hubPublicKey' => $hub->publicKey(),
'lastSync' => VpnPeer::query()->max('observed_at'),
]);
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\VpnPeer;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\QrCode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Retrieving a stored VPN config again, behind the owner's own password.
*
* The password is asked for on EVERY retrieval, not once per session: Laravel's
* password.confirm middleware keeps a 15-minute session stamp, which would
* authorise unlimited later downloads from an unattended browser not what
* "enter your password to download it" means.
*/
class VpnConfigAccess extends ModalComponent
{
use ResolvesOperator;
public string $uuid;
public string $name = '';
public string $password = '';
/** Opaque handle; the plaintext never enters the component snapshot. */
public ?string $token = null;
public bool $showQr = false;
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
}
/**
* Every later request re-checks: once revealed, the plaintext sits in the
* handoff cache for ten minutes, and an open modal would otherwise keep
* handing it out after the access was revoked or reassigned exactly the
* window purgeSecret() exists to close.
*/
public function hydrate(): void
{
$peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first();
if ($peer === null || Gate::denies('downloadConfig', $peer)) {
ConfigHandoff::forget($this->token);
$this->token = null;
abort(403);
}
}
public function reveal(): void
{
// A retry must not still be showing the previous attempt's complaint.
$this->resetErrorBag('password');
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
if (! $operator = $this->currentOperator()) {
return;
}
// Rate-limited per user, so a stolen session cannot grind the password.
$key = 'vpn-config:'.$operator->id;
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, $operator->password)) {
RateLimiter::hit($key, 300);
Log::warning('VPN config download refused: wrong password', [
'user_id' => $operator->id, 'peer' => $peer->uuid,
]);
$this->addError('password', __('vpn.wrong_password'));
return;
}
RateLimiter::clear($key);
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
if ($plaintext === null) {
// Wrong or rotated key, or a tampered record. Never guess.
$this->addError('password', __('vpn.config_unreadable'));
return;
}
// Counted in the database, not read-modify-written here: two concurrent
// retrievals would otherwise record one. This is an audit trail, so
// under-reporting is the one failure mode it must not have.
VpnPeer::query()->whereKey($peer->getKey())->update([
'last_downloaded_at' => now(),
'download_count' => DB::raw('download_count + 1'),
]);
Log::info('VPN config downloaded', ['user_id' => $operator->id, 'peer' => $peer->uuid]);
$this->reset('password');
$this->token = ConfigHandoff::put($plaintext);
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
public function filename(): string
{
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
}
public function render()
{
$config = ConfigHandoff::get($this->token);
return view('livewire.admin.vpn-config-access', [
'config' => $config,
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
]);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class Login extends Component
{
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's own sign-in.
*
* Not Fortify: Fortify binds to exactly one guard, and hanging both the portal
* and the console off it would be the shared login this separation exists to
* end. The two-factor MECHANICS are still Fortify's the same
* TwoFactorAuthenticatable trait and the same TOTP provider only the flow is
* ours.
*/
#[Layout('layouts.portal')]
class OperatorLogin extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public function authenticate(): void
{
// Rules on the action: a #[Validate] attribute on a property applies
// class-wide and would be dragged into any other action added later.
//
// password is NOT 'required': a failed attempt resets it below, and a
// retry (or a brute-force loop) submitting on top of that empty value
// must still count against the rate limiter instead of stopping short
// at a validation error keyed 'password' — which would never accumulate
// toward the throttle at all, five wrong-password submissions in a row
// would never actually throttle. An empty password fails Hash::check()
// like any other wrong one and gets the same generic message.
$this->validate([
'email' => ['required', 'email'],
'password' => ['string'],
]);
$key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$operator = Operator::where('email', $this->email)->first();
// One message for every failure — a different message for "no such
// account" tells a stranger which addresses are operators.
if ($operator === null
|| ! Hash::check($this->password, $operator->password)
|| ! $operator->isActive()
|| ! $operator->isOperator()) {
RateLimiter::hit($key, 60);
$this->reset('password');
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
RateLimiter::clear($key);
if ($operator->two_factor_confirmed_at !== null) {
// Not signed in yet. The id is parked in the session and the guard
// stays untouched until the code checks out.
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]);
$this->redirectRoute('admin.two-factor', navigate: false);
return;
}
$this->completeLoginAndRedirect($operator, $this->remember);
}
/**
* Shared with the two-factor challenge, so both exits behave identically.
*
* Re-checks isActive()/isOperator() here, not only at the password step:
* an operator can be disabled, or lose every console role, in the window
* between entering a password and completing a PENDING two-factor
* challenge an owner revoking access while someone is mid-login is the
* realistic case, not a contrived one, and the challenge on its own only
* ever confirmed the row still existed. One check, in the one place both
* entry points already share, rather than a second copy in the challenge
* that the next change forgets to keep in sync.
*
* @return bool False when the operator no longer qualifies: the login is
* refused, not completed neither the guard nor the
* session is touched, and the caller decides how to say so.
*/
public static function completeLogin(Operator $operator, bool $remember): bool
{
if (! $operator->isActive() || ! $operator->isOperator()) {
return false;
}
Auth::guard('operator')->login($operator, $remember);
session()->regenerate();
$operator->forceFill(['last_login_at' => now()])->save();
return true;
}
private function completeLoginAndRedirect(Operator $operator, bool $remember): void
{
if (! self::completeLogin($operator, $remember)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$this->redirect(AdminArea::home(), navigate: false);
}
public function render()
{
return view('livewire.auth.operator-login');
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The operator's two-factor step.
*
* Fortify's TOTP provider does the arithmetic there is no second
* implementation of the algorithm here, only a second flow, because Fortify's
* own flow is bound to the web guard.
*/
#[Layout('layouts.portal')]
class OperatorTwoFactorChallenge extends Component
{
public string $code = '';
public string $recoveryCode = '';
public bool $usingRecovery = false;
public function mount(): void
{
// Reachable only between password and code. Landing here directly is a
// dead end, not a way in.
abort_if(! session()->has('operator.2fa.id'), 403);
}
public function verify(): void
{
$operator = Operator::find(session('operator.2fa.id'));
abort_if($operator === null, 403);
$key = 'operator-2fa:'.$operator->id.'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
if (! $this->passes($operator)) {
RateLimiter::hit($key, 60);
$this->reset('code', 'recoveryCode');
throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]);
}
RateLimiter::clear($key);
$remember = (bool) session('operator.2fa.remember', false);
session()->forget(['operator.2fa.id', 'operator.2fa.remember']);
if (! OperatorLogin::completeLogin($operator, $remember)) {
// Disabled, or lost every console role, in the window between
// the password step and this one. The code just entered may be
// perfectly correct — the account behind it no longer is, so
// reusing "wrong code" would mislead. The pending challenge is
// spent either way (already forgotten above): no retry loop
// against a session that can never complete now.
throw ValidationException::withMessages(['code' => __('auth.not_an_operator')]);
}
$this->redirect(AdminArea::home(), navigate: false);
}
private function passes(Operator $operator): bool
{
if ($this->usingRecovery) {
$codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: [];
if (! in_array($this->recoveryCode, $codes, true)) {
return false;
}
// A recovery code is single use — leaving it valid turns a one-time
// escape hatch into a second password.
$operator->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(array_values(
array_diff($codes, [$this->recoveryCode])
))),
])->save();
return true;
}
return app(TwoFactorAuthenticationProvider::class)
->verify(decrypt($operator->two_factor_secret), $this->code);
}
public function render()
{
return view('livewire.auth.operator-two-factor-challenge');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Full-page signup view (R1/R2). The POST is handled by Fortify's registration
* feature (register.store App\Actions\Fortify\CreateNewUser).
*/
#[Layout('layouts.portal')]
class Register extends Component
{
public function render()
{
return view('livewire.auth.register');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class TwoFactorChallenge extends Component
{
public function render()
{
return view('livewire.auth.two-factor-challenge');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Where somebody waits between registering and confirming.
*
* Signed in but not through: the account exists, and nothing in the portal
* opens until the address is confirmed. The page therefore has to offer the
* only two things that can move somebody forward send it again, or sign out
* and register with the address they meant.
*
* Fortify would own this page, but 'views' => false: the app renders its own
* screens (R1/R2) and Fortify keeps only the POST actions.
*/
#[Layout('layouts.portal')]
class VerifyEmail extends Component
{
/**
* Six a minute is generous for a person and useless for anything else.
* Without it this is a button that makes the server send mail to an
* arbitrary address as fast as it can be clicked.
*/
private const PER_MINUTE = 6;
/**
* Somebody already through has no step left, and telling them they do is
* how a confirmed account goes looking for a mail it does not need. The
* `verified` middleware only guards the other direction it keeps the
* unconfirmed out of the portal and has nothing to say about this page.
*/
public function mount()
{
if (Auth::user()?->hasVerifiedEmail()) {
return $this->redirectRoute('dashboard', navigate: false);
}
return null;
}
public function resend(): void
{
$user = Auth::user();
// Already through — a stale tab, or the link opened in another window.
// Sending another confirmation for an address that no longer needs one
// is how somebody ends up chasing a mail they do not need.
if ($user === null || $user->hasVerifiedEmail()) {
$this->redirectRoute('dashboard', navigate: true);
return;
}
$key = 'verify-resend:'.$user->getKey();
if (RateLimiter::tooManyAttempts($key, self::PER_MINUTE)) {
$this->dispatch('notify', message: __('verify_email.notice_hint'));
return;
}
RateLimiter::hit($key, 60);
$user->sendEmailVerificationNotification();
$this->dispatch('notify', message: __('verify_email.notice_sent'));
}
public function signOut()
{
Auth::guard('web')->logout();
session()->invalidate();
session()->regenerateToken();
return $this->redirectRoute('login', navigate: false);
}
public function render()
{
return view('livewire.auth.verify-email', [
'email' => Auth::user()?->getEmailForVerification() ?? '',
]);
}
}

58
app/Livewire/Backups.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Backups extends Component
{
public function render()
{
$locale = app()->getLocale();
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->local()->locale($locale)->isoFormat($fmt);
// 14 days of backup sizes for the bar chart.
$sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4];
$rows = [];
foreach (range(0, 6) as $i) {
$rows[] = [
'when' => $date(Carbon::parse('2026-07-24')->subDays($i)->toDateString(), 'dd, D. MMMM').', 03:1'.($i % 3 + 1),
'size' => $gb(18.4 - $i * 0.02),
'status' => 'ok',
];
}
return view('livewire.backups', [
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'line',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

204
app/Livewire/Billing.php Normal file
View File

@ -0,0 +1,204 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Billing extends Component
{
use ResolvesCustomer;
/**
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
* mocked for now the order is created with status 'pending'.
*/
public function purchase(string $type, ?string $key = null): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->latest('id')->first();
$currentPlan = $instance?->plan ?? 'start';
$plans = app(PlanCatalogue::class)->sellable();
$addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) {
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
default => [null, 0, null],
};
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing
// prices would call a grandfathered plan an upgrade to a smaller
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
// Re-checked here and not only in the view: the button can be
// hidden and the action still called — a stale tab, a second
// window, anyone with the component name. A limit enforced only in
// markup is not enforced.
'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
'traffic' => true,
'addon' => isset($addons[$key]),
default => false,
};
if (! $valid || $plan === null) {
return;
}
$datacenter = $instance?->host?->datacenter
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
// A cart cannot hold two plan changes: they contradict each other and no
// checkout could resolve which one the customer meant. Choosing another
// replaces the pending one — add-ons still stack.
//
// Replacement and insert run together with the customer row locked:
// two clicks in flight could otherwise both delete and then both
// insert, leaving exactly the two upgrades this rule exists to prevent.
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
// Up and down are the same kind of change and contradict each
// other just as much, so either replaces a pending one of both.
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
? Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->whereIn('type', ['upgrade', 'downgrade'])
->delete()
: 0;
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => $type,
'addon_key' => $addonKey,
'amount_cents' => $amount,
'currency' => 'EUR',
'datacenter' => $datacenter,
'status' => 'pending',
]);
return $replaced;
});
if ($replaced > 0) {
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
}
$this->dispatch('notify', message: __('billing.purchased'));
}
#[\Livewire\Attributes\On('order-removed')]
public function orderRemoved(): void
{
$this->dispatch('notify', message: __('billing.cart.removed'));
}
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue someone browsing before they have bought.
*
* @param array<string, mixed> $catalogue
* @return array<string, mixed>
*/
private function currentTerms(?Subscription $subscription, array $catalogue): array
{
if ($subscription === null) {
return $catalogue;
}
return [
'tier' => $subscription->tier,
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
'currency' => $subscription->currency,
'quota_gb' => $subscription->quota_gb,
'traffic_gb' => $subscription->traffic_gb,
'seats' => $subscription->seats,
'performance' => $subscription->performance,
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
];
}
public function render()
{
$customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first();
$plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start';
// Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
// Smaller plans, each with the reason it cannot be taken today. Shown
// WITH the reason rather than hidden: a customer who cannot downgrade
// needs to know what to delete, and a plan that silently disappears
// reads as "not offered any more".
$downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
->all();
$upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
return view('livewire.billing', [
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
'instance' => $instance,
'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades,
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state
// different tax treatments on the same page.
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
// Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get()
: collect(),
]);
}
}

89
app/Livewire/Cloud.php Normal file
View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Cloud extends Component
{
use ResolvesCustomer;
public function render()
{
$locale = app()->getLocale();
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
// The card is built from the customer's real service instance, so the
// maintenance badge below can be scoped to exactly that instance's host.
$customer = $this->customer();
$shown = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')->first();
$maintenance = MaintenanceWindow::forInstance($shown)->first();
// The instance carries what was actually provisioned, and the contract
// what was bought. Neither is the catalogue, which only describes what
// we would sell someone new — never what this customer already has.
$contract = $shown?->subscription;
$planKey = $shown?->plan ?? 'team';
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
// Usage metering is not wired yet — scale the illustrative curve into the
// instance's quota so the chart and the "x / y GB" label never disagree.
$curveMax = max($growth);
if ($curveMax > $quota) {
$growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth);
}
$used = $growth[count($growth) - 1];
$domain = $shown?->custom_domain
?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com');
return view('livewire.cloud', [
'instance' => [
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
'domain' => $domain,
'status' => $shown->status ?? 'active',
'plan' => __('cloud.plan_line', [
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
// The line ends in "/mo", so a yearly contract has to be
// divided down before it goes in.
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]),
'location' => __('cloud.datacenter'),
'storageUsed' => $used,
'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
],
'storageChart' => [
'type' => 'line',
'data' => [
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
'datasets' => [[
'label' => 'GB',
'data' => $growth,
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.10',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
'maintenance' => $maintenance,
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\ProvisioningRun;
/**
* Builds the {label, state} array a progress stepper renders from a run's
* pipeline + current step + status. Shared by admin and customer views.
*/
trait BuildsRunSteps
{
/** @return array<int, array{label: string, state: string}> */
protected function buildRunSteps(?ProvisioningRun $run): array
{
if ($run === null) {
return [];
}
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$current = $run->current_step;
$status = $run->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
protected function currentStepLabel(ProvisioningRun $run): string
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
return $class ? __(app($class)->label()) : '—';
}
protected function runState(ProvisioningRun $run): string
{
return match ($run->status) {
ProvisioningRun::STATUS_COMPLETED => 'done',
ProvisioningRun::STATUS_FAILED => 'failed',
default => 'running',
};
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Livewire\Concerns;
use App\Actions\Fortify\PasswordValidationRules;
use App\Actions\Fortify\UpdateUserPassword;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
/**
* Changing your own password, from wherever you are signed in.
*
* There was no way to do it at all: Fortify's updatePasswords feature was off,
* so an account created with a generated password kept it until someone opened
* a shell on the server. Shared by the console and the customer portal because
* the rules are the same in both, and having two copies of a password rule is
* how one of them ends up weaker.
*
* Goes through Fortify's own action rather than hashing here, so the password
* rules stay in the single place that already defines them on the `web`
* guard, which is the only one UpdateUserPassword knows: it is typed to
* `User` and pins its own `current_password` rule to `web`. An operator
* validates against the SAME PasswordValidationRules and writes its own hash
* here instead, rather than routing through an action written for a
* different model on a different guard.
*/
trait ChangesOwnPassword
{
use PasswordValidationRules;
public string $currentPassword = '';
public string $newPassword = '';
public string $newPasswordConfirmation = '';
/**
* Which guard's account this component changes the password of.
*
* Overridden to 'operator' by the console's Settings page. A default of
* 'web' keeps the portal working unchanged.
*/
protected function passwordAccountGuard(): string
{
return 'web';
}
public function updateOwnPassword(): void
{
// The current password is asked for, and checked here as well as in the
// action: an attacker on an unlocked machine should not be able to lock
// the owner out of their own account with two keystrokes.
$this->validate([
'currentPassword' => 'required|string',
'newPassword' => 'required|string',
'newPasswordConfirmation' => 'required|string',
]);
$guard = $this->passwordAccountGuard();
$account = Auth::guard($guard)->user();
if (! Hash::check($this->currentPassword, $account->password)) {
$this->addError('currentPassword', __('admin_settings.password_wrong'));
return;
}
if ($guard === 'web') {
try {
app(UpdateUserPassword::class)->update($account, [
'current_password' => $this->currentPassword,
'password' => $this->newPassword,
'password_confirmation' => $this->newPasswordConfirmation,
]);
} catch (ValidationException $e) {
foreach ($e->errors() as $field => $messages) {
$this->addError(match ($field) {
'current_password' => 'currentPassword',
'password' => 'newPassword',
default => $field,
}, $messages[0]);
}
return;
}
} else {
try {
Validator::make([
'password' => $this->newPassword,
'password_confirmation' => $this->newPasswordConfirmation,
], ['password' => $this->passwordRules()])->validate();
} catch (ValidationException $e) {
$this->addError('newPassword', $e->errors()['password'][0]);
return;
}
$account->forceFill(['password' => Hash::make($this->newPassword)])->save();
}
$this->reset('currentPassword', 'newPassword', 'newPasswordConfirmation');
$this->dispatch('notify', message: __('admin_settings.password_changed'));
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
/**
* A password re-entry gate in front of the dangerous parts of a page.
*
* Used by anything where holding a signed-in session should not be enough on
* its own: setting up two-factor authentication, and reading or changing stored
* credentials. The threat is not a stranger it is a colleague, or anyone, at
* an unlocked machine.
*
* The marker is keyed on the confirming GUARD and that guard's own ACCOUNT id
* (passwordConfirmationSessionKey()) it used to be one flat key shared by
* every guard, the same one Laravel's own `password.confirm` middleware
* uses. That worked only as long as one session ever held one identity. In
* shared-host mode (this VM's own mode) the portal and console guards
* coexist in the SAME session, so a customer confirming their portal
* password and an operator confirming an operator-only action both wrote and
* read that identical flat key confirming as one identity silently
* satisfied the gate for the other (Codex R15, P1a). Scoping to guard AND
* identity closes both halves at once: a marker cannot cross a guard
* boundary, and cannot survive switching to a different account on the same
* guard either. No framework route in this app runs Laravel's own
* password.confirm middleware (checked) if that changes, it needs the same
* scoping or this gap reopens.
*
* Rate-limited, because a confirmation form is a password oracle: without a
* limit it is an unauthenticated-feeling place to guess at a known account.
*/
trait ConfirmsPassword
{
public string $confirmablePassword = '';
/** How long one confirmation lasts. Laravel's own default. */
protected function confirmationWindow(): int
{
return (int) config('auth.password_timeout', 10800);
}
public function passwordRecentlyConfirmed(): bool
{
$at = (int) session($this->passwordConfirmationSessionKey(), 0);
return $at > 0 && (time() - $at) < $this->confirmationWindow();
}
/**
* Which guard's record this component confirms against.
*
* Overridden to 'operator' by the console components. A default of 'web'
* keeps the portal working unchanged and an override is explicit, where
* sniffing the request would be one more thing to get wrong on a page that
* gates access to credentials.
*/
protected function confirmationGuard(): string
{
return 'web';
}
public function confirmPassword(): void
{
$account = auth()->guard($this->confirmationGuard())->user();
$key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('confirmablePassword', __('auth.throttle', [
'seconds' => RateLimiter::availableIn($key),
]));
return;
}
if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) {
RateLimiter::hit($key, 60);
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
$this->reset('confirmablePassword');
return;
}
RateLimiter::clear($key);
$this->reset('confirmablePassword');
// Regenerated first: a confirmation raises what this session can do, and
// a session id that was already known to someone else must not be the
// one that gets the extra authority.
session()->regenerate();
session([$this->passwordConfirmationSessionKey() => time()]);
}
/** Give up the confirmation early — leaving a page should not keep it open. */
public function forgetPasswordConfirmation(): void
{
session()->forget($this->passwordConfirmationSessionKey());
}
/**
* The one place this marker's session key is computed.
*
* passwordRecentlyConfirmed(), confirmPassword() and
* forgetPasswordConfirmation() all go through this rather than each
* building the string themselves, so they cannot drift apart from one
* another which is exactly how the guard-agnostic key this replaces
* came to be read by one guard and written by another.
*/
private function passwordConfirmationSessionKey(): string
{
$guard = $this->confirmationGuard();
$identity = auth()->guard($guard)->user()?->getAuthIdentifier() ?? 'guest';
return "auth.password_confirmed_at.{$guard}.{$identity}";
}
/**
* A value shown only in outline: the last four characters, and nothing else.
*
* Enough to tell two keys apart, useless to anyone reading over a shoulder
* or scrolling back through a screen recording.
*/
protected function outline(?string $value, int $keep = 4): ?string
{
if ($value === null || $value === '') {
return null;
}
return Str::length($value) <= $keep
? str_repeat('•', Str::length($value))
: str_repeat('•', 8).Str::substr($value, -$keep);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Customer;
/**
* Resolves the customer behind the signed-in portal user.
*
* Operator accounts (admins) have no Customer, so customer-scoped actions must
* NOT silently no-op that reads as a dead button. requireCustomer() surfaces
* an explicit message instead and returns null so the caller can bail.
*/
trait ResolvesCustomer
{
protected function customer(): ?Customer
{
$user = auth()->user();
if (! $user) {
return null;
}
// Prefer the explicit link; fall back to email for legacy unlinked accounts.
return Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
}
/** Same as customer(), but tells the user why nothing happened. */
protected function requireCustomer(): ?Customer
{
$customer = $this->customer();
if ($customer === null) {
$this->dispatch('notify', message: __('dashboard.no_customer_action'));
}
return $customer;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Operator;
use Illuminate\Support\Facades\Auth;
/**
* The one place a console Livewire component resolves "who is signed in"
* and decides what happens when the answer is nobody.
*
* A Livewire action reaches its component through its own request to
* /livewire/update, separate from the request that first rendered the page
* a modal especially, reachable without the page's own route middleware (see
* R20 in CLAUDE.md). An operator whose session has ended by the time an
* action runs still reaches this code, and Auth::guard('operator')->user()
* comes back null. Four components each dereferenced it unguarded
* ($operator->password, ->id, ->two_factor_recovery_codes, ), turning that
* into a 500 instead of a sign-in prompt.
*
* currentOperator() cannot throw its way out of the null case. Livewire only
* turns $this->redirect() into a browser navigation once the calling action
* returns NORMALLY: the redirect is queued as component state and picked up
* during Livewire's own dehydrate step (Livewire\Features\SupportRedirects),
* which runs strictly after the called method returns an uncaught
* exception during the call skips it entirely and Livewire ships back a bare
* error response instead (confirmed by reading
* Livewire\Mechanisms\HandleComponents::update() and
* Livewire\Mechanisms\HandleRequests::handleUpdate(), which catches nothing
* but \TypeError). So this returns null and every caller checks for it:
*
* if (! $operator = $this->currentOperator()) {
* return;
* }
*
* That one-line guard is the only thing left for a caller to get wrong; the
* redirect target and the mechanism live here once, so the four cannot drift
* apart the way the original unguarded dereferences did.
*/
trait ResolvesOperator
{
protected function currentOperator(): ?Operator
{
$operator = Auth::guard('operator')->user();
if ($operator === null) {
$this->redirect(route('admin.login'));
}
return $operator;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*/
class ConfirmCancelPackage extends ModalComponent
{
use ResolvesCustomer;
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->customer();
// Target the ACTIVE package explicitly — a newer failed/deprovisioned
// record must not shadow an older active instance (v1: one active/customer).
$instance = $customer?->instances()->where('status', 'active')->latest('id')->first();
if ($instance === null) {
// Explain rather than silently closing: no customer behind this login
// (e.g. an operator account) or no active package to cancel.
$this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package'));
return null;
}
// Typed confirmation must match the instance subdomain.
if (trim($this->confirmName) !== (string) $instance->subdomain) {
$this->addError('confirmName', __('settings.cancel_mismatch'));
return;
}
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => $this->currentPeriodEnd($instance),
]);
return $this->redirectRoute('settings', navigate: true);
}
/**
* End of the current monthly billing period, anchored on the subscription
* start (the order date) rather than assuming calendar-month billing.
*/
private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon
{
$start = $instance->order?->created_at ?? $instance->created_at ?? now();
// Always add from the immutable start so the billing-day anchor is kept
// (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28).
$months = 1;
while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) {
$months++;
}
return $start->copy()->addMonthsNoOverflow($months);
}
public function render()
{
$instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first();
return view('livewire.confirm-cancel-package', [
'subdomain' => $instance?->subdomain ?? '',
]);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use LivewireUI\Modal\ModalComponent;
/**
* Close the whole CluPilot account (R5). Guarded: only allowed when no active
* package remains an active subscription must be cancelled first. Sets
* closed_at; financial records are retained. Requires typing "LOESCHEN".
*/
class ConfirmCloseAccount extends ModalComponent
{
use ResolvesCustomer;
public string $confirmWord = '';
public function closeAccount()
{
$customer = $this->customer();
// No customer behind this login (e.g. an operator account) — say so
// instead of leaving the button looking broken.
if ($customer === null) {
$this->addError('confirmWord', __('dashboard.no_customer_action'));
return null;
}
if ($this->hasActivePackage($customer)) {
$this->addError('confirmWord', __('settings.close_blocked'));
return;
}
if (mb_strtoupper(trim($this->confirmWord)) !== __('settings.close_keyword')) {
$this->addError('confirmWord', __('settings.close_mismatch'));
return;
}
$customer->update(['closed_at' => now(), 'status' => 'closed']);
return $this->redirectRoute('settings', navigate: true);
}
private function hasActivePackage(Customer $customer): bool
{
// Only genuinely-live packages block closure — a failed/cancelled/
// deprovisioned instance does not retain a service.
return $customer->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists();
}
public function render()
{
$customer = $this->customer();
return view('livewire.confirm-close-account', [
'blocked' => $customer !== null && $this->hasActivePackage($customer),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a customer's own two-factor sign-in is removed (R23).
* No mutation here Settings::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('twofa-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-disable-two-factor');
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a customer signs every other browser out (R23).
*
* Mutates nothing. Sessions::endOthers() still does the work, with its own
* identity resolution the check stays at the one place that already had it.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-end-other-sessions');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Order;
use LivewireUI\Modal\ModalComponent;
/**
* Taking a not-yet-paid purchase back out of the cart.
*
* Scoped to the signed-in customer's own orders: modals are reachable without
* the page's own guards, so the ownership check belongs here, not in the view
* that happens to link to it.
*/
class ConfirmRemoveOrder extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $label = '';
public int $amountCents = 0;
public function mount(string $uuid): void
{
$order = $this->order($uuid);
abort_if($order === null, 404);
$this->uuid = $uuid;
$this->label = $order->label();
$this->amountCents = $order->amount_cents;
}
public function remove(): void
{
$order = $this->order($this->uuid);
// Gone or already paid for: say so instead of pretending it worked.
if ($order === null) {
$this->dispatch('notify', message: __('billing.cart.gone'));
$this->closeModal();
return;
}
$order->delete();
$this->dispatch('order-removed');
$this->closeModal();
}
/** @return Order|null the customer's own, still-pending order */
private function order(string $uuid): ?Order
{
$customer = $this->customer();
if ($customer === null) {
return null;
}
return Order::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->where('status', 'pending')
->first();
}
public function render()
{
return view('livewire.confirm-remove-order');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a seat's access ends (R23). Mirrors EditSeat: a modal
* is reachable without the page's route middleware, so the seat is resolved
* fresh from the signed-in customer rather than trusted from the caller.
*/
class ConfirmRevokeSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$seat = $this->customer()?->seats()->where('uuid', $uuid)->first();
// Mirrors the button's own visibility: the row never offers "revoke"
// for the owner seat, so a stale or forged uuid gets 404 here too.
abort_if($seat === null || $seat->role === 'owner', 404);
$this->uuid = $uuid;
$this->name = (string) ($seat->name ?: $seat->email);
}
public function confirm(): void
{
$this->dispatch('seat-revoke-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-revoke-seat');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Livewire\Component;
/**
* Embedded live provisioning progress for the logged-in customer's order.
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
* provisioning has completed.
*/
class CustomerProvisioning extends Component
{
use BuildsRunSteps;
private function currentRun(): ?ProvisioningRun
{
$user = auth()->user();
if ($user === null) {
return null;
}
$customer = Customer::query()->where('email', $user->email)->first();
if ($customer === null) {
return null;
}
return ProvisioningRun::query()
->where('subject_type', Order::class)
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first();
}
public function render()
{
$run = $this->currentRun();
// Show the card while provisioning is in flight OR terminally failed; but
// only POLL while it can still change (terminal failed must not poll forever).
$show = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
$polling = $run !== null && in_array($run->status, [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
], true);
return view('livewire.customer-provisioning', [
'active' => $show,
'polling' => $polling,
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
'steps' => $show ? $this->buildRunSteps($run) : [],
]);
}
}

373
app/Livewire/Dashboard.php Normal file
View File

@ -0,0 +1,373 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The customer's Betriebsblatt: what they have, and the evidence that it is
* being looked after.
*
* Every figure on this page comes from a record. Where something is not
* measured yet storage consumption, restore tests the page says nothing
* rather than showing a plausible number: this is the sheet a customer forwards
* to their auditor, and one invented line on it is worse than a short register.
*/
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
use ResolvesCustomer;
public function render()
{
$customer = $this->customer();
// The instance that is actually in service. A cancelled one still has
// rows, and would otherwise keep filling this page after it is gone.
$instance = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
$domain = $this->domain($instance);
return view('livewire.dashboard', [
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $domain,
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'seatBreakdown' => $this->seatBreakdown($customer),
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
// coming — a customer who has given notice still pays until the
// term ends and still needs the figure on their master record.
'planPrice' => $contract === null ? null : [
'cents' => (int) $contract->price_cents,
'currency' => (string) ($contract->currency ?: 'EUR'),
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
// The initial admin password, if it is still waiting to be
// acknowledged. Read straight off $instance (already scoped to
// THIS customer above) rather than a fresh unscoped lookup, and
// never stored as a public property — see acknowledgeCredentials().
'credentials' => $this->credentials($instance, $domain),
'asOf' => Carbon::now(),
]);
}
/**
* Deletes the instance's stored admin password and stamps the
* acknowledgement the customer has confirmed they noted it down.
*
* Re-resolves both the customer AND the instance from the authenticated
* session rather than trusting $uuid alone: this method is reachable by
* anyone who can post to /livewire/update, not only through this card's
* button, so ownership is checked here again rather than relying on the
* card simply not being shown to anyone else.
*/
public function acknowledgeCredentials(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->where('uuid', $uuid)->whereNotNull('admin_password')->first();
if ($instance === null) {
return;
}
$instance->update([
'admin_password' => null,
'credentials_acknowledged_at' => now(),
]);
$this->dispatch('notify', message: __('dashboard.credentials.acknowledged'));
}
/**
* @return array{uuid: string, url: string, user: string, password: string}|null
*/
private function credentials(?Instance $instance, ?string $domain): ?array
{
if ($instance === null || $instance->admin_password === null) {
return null;
}
return [
'uuid' => $instance->uuid,
'url' => 'https://'.($domain ?: $instance->subdomain),
'user' => (string) $instance->nc_admin_ref,
'password' => $instance->admin_password,
];
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.
*
* @return array<string, int> role => count, roles with nobody omitted
*/
private function seatBreakdown(?Customer $customer): array
{
if ($customer === null) {
return [];
}
return Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->selectRaw('role, COUNT(*) as n')
->groupBy('role')
->pluck('n', 'role')
->filter()
->all();
}
/**
* How full the instance is, as measured not as sold.
*
* Null until the sampler has managed a reading. The card then states the
* contractual allowance, which is true, rather than a ring at a level
* nobody measured.
*
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
*/
private function disk(Instance $instance): ?array
{
$metric = InstanceMetric::latestDisk($instance);
if ($metric === null || ! $metric->disk_total_bytes) {
return null;
}
// What it grew by this week, which is the line the template carries
// under the ring. Null when there is no reading a week back to compare
// against — an instance three days old has no weekly trend, and
// inventing "+0 GB" would read as "nothing happened".
$weekAgo = InstanceMetric::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->where('day', '<=', now()->subDays(7)->toDateString())
->orderByDesc('day')
->first();
return [
'used' => $metric->disk_used_bytes,
'total' => $metric->disk_total_bytes,
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
];
}
/** The address the customer actually reaches their cloud at. */
private function domain(?Instance $instance): ?string
{
if ($instance === null) {
return null;
}
return $instance->custom_domain
?: ($instance->subdomain
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
: null);
}
/**
* Where the data physically sits.
*
* Named from the datacenter register rather than from a translation string,
* because this is the line a customer copies into a processing record it
* has to be the place their instance really runs on, not the place we
* usually sell.
*
* @return array{name: string, note: null}|null
*/
private function location(?Instance $instance): ?array
{
$code = $instance?->host?->datacenter;
if ($code === null || $code === '') {
return null;
}
$datacenter = Datacenter::query()->where('code', $code)->first();
// The country, not the site. "Falkenstein" is how an operator places an
// instance; a customer's processing record says which jurisdiction the
// data sits in, and naming the building means editing customer-facing
// copy every time the estate grows.
return [
'name' => $datacenter?->location ?: $code,
'note' => null,
];
}
/**
* Seats in use against seats bought.
*
* Invited seats count as used: the licence is committed the moment the
* invitation goes out, and showing them as free is how a customer finds out
* at the worst moment that they cannot add the person in front of them.
*
* @return array{used: int, total: int|null}
*/
private function seats(?Customer $customer, ?Subscription $contract): array
{
return [
'used' => $customer === null ? 0 : Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->count(),
'total' => $contract?->seats,
];
}
/**
* The proof register what was done for this customer, and when.
*
* Only entries backed by a record. A row here is something the customer can
* be asked to evidence, so "we back up nightly" is not one; "the last backup
* completed at 03:12 and reported ok" is.
*
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
*/
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
{
if ($instance === null) {
return [];
}
$proofs = [];
// A failing job outranks a succeeding one, whatever their dates.
//
// Ordering by last_ok_at alone hides exactly the case this register
// exists for: last night's job failed and so has no success time at
// all, an older row that DID succeed sorts above it, and the sheet
// reports backups as fine. On the page a customer shows their auditor,
// a problem has to beat a date.
$backups = $instance->backups()->get();
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
?? $backups->sortByDesc('last_ok_at')->first();
if ($backup !== null) {
$proofs[] = [
'key' => 'backup',
'at' => $backup->last_ok_at,
// The job's own verdict, not "there is a row, so it worked".
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
'note' => $backup->schedule,
];
}
// A certificate that stopped renewing is the failure a customer meets as
// "my browser says my own cloud is unsafe", so it is stated either way.
$proofs[] = [
'key' => 'certificate',
'at' => null,
'state' => $instance->cert_ok ? 'ok' : 'attention',
'note' => null,
];
if ($maintenance !== null) {
$proofs[] = [
'key' => 'maintenance',
'at' => $maintenance->starts_at,
'state' => 'planned',
'note' => $maintenance->title,
];
}
if ($instance->service_ends_at !== null) {
$proofs[] = [
'key' => 'service_ends',
'at' => $instance->service_ends_at,
'state' => 'attention',
'note' => null,
];
}
// Newest first, but an entry with no time of its own — the certificate —
// stays at the top rather than sinking to the bottom of the register.
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
return $proofs;
}
/**
* What is owed next, from the contract rather than from a price list.
*
* The plan and the modules are kept apart. A customer with modules booked
* would otherwise read the plan's price as their bill and be short every
* month and putting the combined figure next to the word "Paket" on the
* master record would be just as wrong in the other direction.
*
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
* plan's price_cents is the price of a whole term. On a yearly contract the
* modules therefore multiply by twelve to land on the same invoice.
*
* Nothing at all once the customer has given notice. Cancelling leaves the
* subscription `active` until the term runs out that is deliberate, the
* service keeps running but `current_period_end` is then the day service
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
* they check their invoices against, for a renewal that will never happen is
* the worst single line this page could carry. The end date is still stated;
* it is in the proof register as "contract ends".
*
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
*/
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
{
if ($contract === null || $contract->status === 'cancelled') {
return null;
}
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
return null;
}
$months = $contract->isYearly() ? 12 : 1;
$addons = $contract->addons
->whereNull('cancelled_at')
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
return [
'plan' => (int) $contract->price_cents,
'addons' => (int) $addons,
'total' => (int) $contract->price_cents + (int) $addons,
'currency' => (string) ($contract->currency ?: 'EUR'),
'due' => $contract->current_period_end,
'term' => (string) $contract->term,
];
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a mailbox, in a modal (R20).
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* itself and re-reads the record instead of trusting a property the browser
* hydrated. save() re-authorises independently of mount() for the same reason
* Secrets::guard() checks on every action rather than once: a Livewire action
* is reachable by anyone who can post to /livewire/update, and a mount that
* succeeded earlier in the session proves nothing about the request now.
*/
class EditMailbox extends ModalComponent
{
use ConfirmsPassword;
public string $uuid = '';
public string $address = '';
public string $displayName = '';
public string $username = '';
/** Always starts empty: a stored password never travels to the browser. */
public string $password = '';
public bool $noReply = false;
public bool $active = true;
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
public bool $authenticates = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(string $uuid): void
{
$this->authorize('mail.manage');
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $box->uuid;
$this->address = $box->address;
$this->displayName = (string) $box->display_name;
$this->username = (string) $box->username;
$this->noReply = $box->no_reply;
$this->active = $box->active;
$this->authenticates = $box->authenticates;
}
public function save(): void
{
$this->authorize('mail.manage');
$this->validate([
'address' => ['required', 'email', 'max:255'],
'displayName' => ['nullable', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
]);
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
// Deactivating the mailbox "system" currently points at is not just
// this purpose losing its sender: MailboxResolver::active() filters an
// inactive mailbox at BOTH the direct lookup AND the system fallback,
// so every OTHER purpose that has no mailbox of its own falls through
// to nothing too. savePurposes() already refuses to leave system
// empty; this closes the other way to reach the same broken state.
if (! $this->active && $box->key === (string) Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM))) {
$this->addError('active', __('mail_settings.cannot_deactivate_system'));
return;
}
// Setting a new SMTP password is one of the two actions the
// mail.manage split still leaves able to intercept mail outright (the
// other is Admin\Mail::saveServer()) — gated the same second way
// Admin\Integrations' vault entries are: a capability decides who
// may open this modal at all, a recent password decides whether
// THIS session may point the
// outgoing account at new credentials.
if ($this->password !== '') {
abort_unless($this->passwordRecentlyConfirmed(), 403);
// A password can only be STORED where SECRETS_KEY exists to
// encrypt it under — the same condition the page's own banner
// already names. Checked here rather than left for
// Mailbox::setPasswordAttribute() to throw through: an uncaught
// RuntimeException would render Laravel's debug page, whose
// request-payload inspector can show the very password just
// typed. The page must say so, not crash on it.
if (! app(SecretCipher::class)->isUsable()) {
$this->addError('password', __('mail_settings.no_key'));
return;
}
}
$oldAddress = $box->address;
$oldUsername = $box->username;
$oldAuthenticates = $box->authenticates;
$box->address = $this->address;
$box->display_name = $this->displayName ?: null;
$box->username = $this->username ?: null;
$box->no_reply = $this->noReply;
$box->active = $this->active;
$box->authenticates = $this->authenticates;
// Either one changes the identity smtpUsername() authenticates as:
// username directly, or address as username's fallback when none is
// set (including username being CLEARED back to that fallback).
// authenticates itself is the fourth: whether that identity gets
// sent at all. Leaving last_verified_at standing after any of the
// three would keep showing a successful verification for a
// connection nothing has actually tested since. Mailbox::
// invalidateVerification() is the same clear Admin\Mail::saveServer()
// uses for the server card, in its single-row shape — see that
// method's own comment for why the two do NOT also share the "did
// this actually change" comparison above this block.
if ($box->address !== $oldAddress || $box->username !== $oldUsername || $box->authenticates !== $oldAuthenticates) {
$box->invalidateVerification();
}
// Empty means "leave it alone", not "delete it" — otherwise every edit
// of an address would silently drop the password.
if ($this->password !== '') {
$box->password = $this->password;
$box->invalidateVerification();
}
$box->save();
$this->password = '';
$this->dispatch('notify', message: __('mail_settings.saved'));
$this->dispatch('mailbox-saved');
$this->closeModal();
}
public function render()
{
return view('livewire.edit-mailbox', [
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

108
app/Livewire/EditSeat.php Normal file
View File

@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Seat;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one seat, in a modal.
*
* Editing used to happen in the row itself. It worked and it looked wrong: the
* row grew, the columns beside it jumped, and a table half in edit mode reads
* as a rendering fault rather than as a form. Anything with fields of its own
* gets a modal see R20.
*
* A modal is reachable without passing the page's route middleware, so the
* customer is resolved here rather than trusted from the caller: the uuid comes
* from the browser and must never reach across customers.
*/
class EditSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid = '';
public string $name = '';
public string $email = '';
/** Owners cannot be suspended or removed; everyone can be renamed. */
public bool $isOwner = false;
/**
* Whether the address may still be corrected.
*
* Only while the invitation is in flight. Once someone has accepted, the
* address IS the person: editing it would hand one employee's access to
* another with nobody told a transfer of access wearing the clothes of a
* rename.
*/
public bool $addressEditable = false;
public function mount(string $uuid): void
{
$seat = $this->seat($uuid);
abort_if($seat === null, 404);
$this->uuid = $uuid;
$this->name = (string) $seat->name;
$this->email = (string) $seat->email;
$this->isOwner = $seat->role === 'owner';
$this->addressEditable = $seat->status === 'invited';
}
public function save()
{
$customer = $this->requireCustomer();
$seat = $this->seat($this->uuid);
if ($customer === null || $seat === null) {
return $this->closeModal();
}
$rules = ['name' => 'nullable|string|max:255'];
// Re-read from the record, never from the hydrated property: a forged
// addressEditable would otherwise open the address of an accepted seat.
if ($seat->status === 'invited') {
$rules['email'] = 'required|email|max:255';
}
$data = $this->validate($rules);
$changes = ['name' => trim($data['name'] ?? '') ?: null];
if ($seat->status === 'invited') {
$address = trim($data['email']);
if ($address !== $seat->email) {
if ($customer->seats()->where('email', $address)->whereKeyNot($seat->id)->exists()) {
$this->addError('email', __('users.duplicate'));
return null;
}
$changes['email'] = $address;
}
}
$seat->update($changes);
$this->dispatch('notify', message: __('users.saved'));
return $this->redirectRoute('users', navigate: true);
}
private function seat(string $uuid): ?Seat
{
return $this->customer()?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
return view('livewire.edit-seat');
}
}

58
app/Livewire/Invoices.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Invoices extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
$date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL');
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all();
$rows = [
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
];
return view('livewire.invoices', [
'rows' => $rows,
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Raise a support request.
*
* The customer is asked for three things and nothing else: what it is about,
* a subject, and the question. Everything an operator would otherwise have to
* ask for which customer, which instance, which plan the system already
* knows and attaches itself. Making somebody describe their own server back to
* the people who built it is the part of support that annoys people most.
*/
class NewSupportRequest extends ModalComponent
{
use ResolvesCustomer;
public string $category = 'technical';
public string $subject = '';
public string $body = '';
public function save()
{
$customer = $this->requireCustomer();
if ($customer === null) {
return $this->closeModal();
}
$data = $this->validate([
'category' => ['required', Rule::in(SupportRequest::CATEGORIES)],
'subject' => 'required|string|min:3|max:150',
'body' => 'required|string|min:10|max:5000',
]);
$instance = $customer->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')
->first()
?? $customer->instances()->latest('id')->first();
SupportRequest::create([
'customer_id' => $customer->id,
'instance_id' => $instance?->id,
'subject' => $data['subject'],
'category' => $data['category'],
'body' => $data['body'],
'status' => 'open',
// The person, not the account: on a shared login the account name
// says nothing about who is actually asking.
'reported_by' => auth()->user()?->name ?: auth()->user()?->email,
]);
$this->dispatch('notify', message: __('support.sent'));
return $this->redirectRoute('support', navigate: true);
}
public function render()
{
return view('livewire.new-support-request', [
'categories' => SupportRequest::CATEGORIES,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More