232 Commits (feat/mailboxes)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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. |
|
|
|
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. |
|
|
|
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. |
|
|
|
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. |
|
|
|
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. |
|
|
|
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.
|
|
|
|
befb67327f | Prove a mailbox works by actually sending from it | |
|
|
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. |
|
|
|
4211f3dfab | Give the mailboxes a page, and the support sender its own capability | |
|
|
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. |
|
|
|
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. |
|
|
|
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. |
|
|
|
64a33c675e | Put a real sender on every mail, and a reply address where one helps | |
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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>
|
|
|
|
06b84c5cd5 | Say which kind of mail leaves from which mailbox | |
|
|
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). |
|
|
|
270ec942d6 | Give every sending address a record of its own | |
|
|
38c5518f45 | Drop the stray path comment after the opening PHP tag | |
|
|
93d409e18f | Pull the secrets encrypter out where two callers can share it | |
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
492b1925fb |
Land a sign-in where it was performed, not always in the customer portal
tests / pest (push) Successful in 7m42s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Successful in 6s
Details
Fortify sends every successful sign-in to config('fortify.home') — /dashboard.
That is why an operator signing in ended up in the customer portal, and once
the console has a hostname to itself it stops being merely confusing:
/dashboard does not exist on that host, so signing in would land on a 404.
Three outcomes now, decided by where the sign-in happened and who signed in:
the console for an operator on the console, the portal everywhere else, and for
a non-operator on the console the session is taken away again at the moment it
was created — a guard that merely refuses each page afterwards leaves that
session in the browser.
Four things the review caught, each of which would have left a hole:
- Fortify never reaches LoginResponse when a two-factor challenge was involved.
Binding only that one left exactly the accounts most likely to be operators
on the old behaviour, so both exits are bound and share one decision.
- The JSON branch ran before the authorization check, so a JSON client kept a
session a browser would have lost.
- The two exits do not share a JSON success contract — an ordinary sign-in
answers {"two_factor":false}, a completed challenge answers an empty 204 —
and merging them breaks a client keying off the status code.
- In shared mode everyone posts to /login, so the request never looks like the
console even when /admin is the destination. The intended URL is read too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
de6821b53e |
Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 5s
Details
Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |