Commit Graph

215 Commits (5aef5e693caeef34bb5b1f11ef682f07990b4869)

Author SHA1 Message Date
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